feat(module-federation): Add react support for dynamic federation (#20024)

This commit is contained in:
Nicholas Cunningham 2023-11-13 10:53:21 -07:00 committed by GitHub
parent c7c845dbc4
commit 6475c41ec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 372 additions and 29 deletions

View File

@ -68,6 +68,11 @@
"enum": ["eslint"],
"default": "eslint"
},
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false
},
"skipFormat": {
"description": "Skip formatting files.",
"type": "boolean",

View File

@ -28,6 +28,12 @@
"type": "string",
"enum": ["as-provided", "derived"]
},
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false,
"x-priority": "internal"
},
"style": {
"description": "The file extension to be used for style files.",
"type": "string",

View File

@ -2,6 +2,7 @@ import { Tree, stripIndents } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
fileExists,
killPorts,
killProcessAndPorts,
newProject,
@ -10,6 +11,7 @@ import {
runCLIAsync,
runCommandUntil,
runE2ETests,
tmpProjPath,
uniq,
updateFile,
updateJson,
@ -165,9 +167,11 @@ describe('React Module Federation', () => {
`apps/${app}/module-federation.server.config.ts`
);
['build', 'server'].forEach((target) => {
['development', 'production'].forEach((configuration) => {
['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:${target}:${configuration}`);
expect(cliOutput).toContain('Successfully ran target');
await killPorts(readPort(app));
});
});
});
@ -866,6 +870,91 @@ describe('React Module Federation', () => {
}
}, 500_000);
});
describe('Dynamic Module Federation', () => {
beforeAll(() => {
tree = createTreeWithEmptyWorkspace();
proj = newProject();
});
afterAll(() => cleanupProject());
it('should load remote dynamic module', async () => {
const shell = uniq('shell');
const remote = uniq('remote');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote} --dynamic=true --project-name-and-root-format=as-provided --no-interactive`
);
// Webpack prod config should not exists when loading dynamic modules
expect(
fileExists(`${tmpProjPath()}/${shell}/webpack.config.prod.ts`)
).toBeFalsy();
expect(
fileExists(
`${tmpProjPath()}/${shell}/src/assets/module-federation.manifest.json`
)
).toBeTruthy();
const manifest = readJson(
`${shell}/src/assets/module-federation.manifest.json`
);
expect(manifest[remote]).toBeDefined();
expect(manifest[remote]).toEqual('http://localhost:4201');
// update e2e
updateFile(
`${shell}-e2e/src/e2e/app.cy.ts`,
`
import { getGreeting } from '../support/app.po';
describe('${shell}', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
getGreeting().contains('Welcome ${shell}');
});
it('should navigate to /${remote} from /', () => {
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
1
)}').click();
cy.url().should('include', '/${remote}');
getGreeting().contains('Welcome ${remote}');
});
});
`
);
// Build host and remote
const buildOutput = runCLI(`build ${shell}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
const shellPort = readPort(shell);
const remotePort = readPort(remote);
if (runE2ETests()) {
// Serve Remote since it is dynamic and won't be started with the host
const remoteProcess = await runCommandUntil(
`serve-static ${remote} --no-watch --verbose`,
() => {
return true;
}
);
const hostE2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(remoteProcess.pid, remotePort);
await killProcessAndPorts(hostE2eResultsSwc.pid, shellPort);
}
}, 500_000);
});
});
function readPort(appName: string): number {

View File

@ -0,0 +1,105 @@
export type ResolveRemoteUrlFunction = (
remoteName: string
) => string | Promise<string>;
declare const window: {
[key: string]: any;
};
declare const document: {
head: {
appendChild: (script: HTMLScriptElement) => void;
};
createElement: (type: 'script') => any;
};
declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
declare const __webpack_share_scopes__: { default: unknown };
let remoteUrlDefinitions: Record<string, string>;
let resolveRemoteUrl: ResolveRemoteUrlFunction;
const remoteModuleMap = new Map<string, unknown>();
const remoteContainerMap = new Map<string, unknown>();
let initialSharingScopeCreated = false;
export function setRemoteUrlResolver(
_resolveRemoteUrl: ResolveRemoteUrlFunction
) {
resolveRemoteUrl = _resolveRemoteUrl;
}
export function setRemoteDefinitions(definitions: Record<string, string>) {
remoteUrlDefinitions = definitions;
}
export async function loadRemoteModule(remoteName: string, moduleName: string) {
const remoteModuleKey = `${remoteName}:${moduleName}`;
if (remoteModuleMap.has(remoteModuleKey)) {
return remoteModuleMap.get(remoteModuleKey);
}
const container = remoteContainerMap.has(remoteName)
? remoteContainerMap.get(remoteName)
: await loadRemoteContainer(remoteName);
const factory = await container.get(moduleName);
const Module = factory();
remoteModuleMap.set(remoteModuleKey, Module);
return Module;
}
const fetchRemoteModule = (url: string, remoteName: string): Promise<any> => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
const proxy = {
get: (request) => window[remoteName].get(request),
init: (arg) => {
try {
window[remoteName].init(arg);
} catch (e) {
console.error(`Failed to initialize remote ${remoteName}`, e);
reject(e);
}
},
};
resolve(proxy);
};
script.onerror = () => reject(new Error(`Remote ${remoteName} not found`));
document.head.appendChild(script);
});
};
async function loadRemoteContainer(remoteName: string) {
if (!resolveRemoteUrl && !remoteUrlDefinitions) {
throw new Error(
'Call setRemoteDefinitions or setRemoteUrlResolver to allow Dynamic Federation to find the remote apps correctly.'
);
}
if (!initialSharingScopeCreated) {
initialSharingScopeCreated = true;
await __webpack_init_sharing__('default');
}
const remoteUrl = remoteUrlDefinitions
? remoteUrlDefinitions[remoteName]
: await resolveRemoteUrl(remoteName);
let containerUrl = remoteUrl;
if (!remoteUrl.endsWith('.mjs') && !remoteUrl.endsWith('.js')) {
containerUrl = `${remoteUrl}${
remoteUrl.endsWith('/') ? '' : '/'
}remoteEntry.js`;
}
const container = await fetchRemoteModule(containerUrl, remoteName);
await container.init(__webpack_share_scopes__.default);
remoteContainerMap.set(remoteName, container);
return container;
}

View File

@ -0,0 +1,5 @@
export {
loadRemoteModule,
setRemoteDefinitions,
setRemoteUrlResolver,
} from './dynamic-federation';

View File

@ -3,12 +3,20 @@ import * as React from 'react';
import NxWelcome from "./nx-welcome";
<% } %>
import { Link, Route, Routes } from 'react-router-dom';
<% if (dynamic) { %>
import { loadRemoteModule } from '@nx/react/mf';
<% } %>
<% if (remotes.length > 0) { %>
<% remotes.forEach(function(r) { %>
<% if (dynamic) { %>
const <%= r.className %> = React.lazy(() => loadRemoteModule('<%= r.fileName %>', './Module'))
<% } else { %>
const <%= r.className %> = React.lazy(() => import('<%= r.fileName %>/Module'));
<% } %>
<% }); %>
<% } %>
export function App() {
return (
<React.Suspense fallback={null}>

View File

@ -3,8 +3,11 @@ import { ModuleFederationConfig } from '@nx/webpack';
const config: ModuleFederationConfig = {
name: '<%= projectName %>',
remotes: [
<% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %>
],
<% if (static) {
remotes.forEach(function(r) { %> "<%= r.fileName %>", <% });
}
%>
],
};
export default config;

View File

@ -6,8 +6,11 @@
const moduleFederationConfig = {
name: '<%= projectName %>',
remotes: [
<% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %>
],
<% if (static) {
remotes.forEach(function(r) { %> "<%= r.fileName %>", <% });
}
%>
],
};
module.exports = moduleFederationConfig;

View File

@ -15,8 +15,11 @@ const config: ModuleFederationConfig = {
*
*/
remotes: [
<% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %>
],
<% if (static) {
remotes.forEach(function(r) { %> "<%= r.fileName %>", <% });
}
%>
],
};
export default config;

View File

@ -1 +1,10 @@
import('./bootstrap');
<% if (dynamic) { %>
import { setRemoteDefinitions } from '@nx/react/mf';
fetch('/assets/module-federation.manifest.json')
.then((res) => res.json())
.then(definitions => setRemoteDefinitions(definitions))
.then(() => import('./bootstrap').catch(err => console.error(err)));
<% } else { %>
import('./bootstrap').catch(err => console.error(err));
<% } %>

View File

@ -13,6 +13,9 @@ module.exports = {
*
*/
remotes: [
<% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %>
<% if (static) {
remotes.forEach(function(r) { %> "<%= r.fileName %>", <% });
}
%>
],
};
};

View File

@ -1 +1,10 @@
import('./bootstrap');
<% if (dynamic) { %>
import { setRemoteDefinitions } from '@nx/react/mf';
fetch('/assets/module-federation.manifest.json')
.then((res) => res.json())
.then(definitions => setRemoteDefinitions(definitions))
.then(() => import('./bootstrap').catch(err => console.error(err)));
<% } else { %>
import('./bootstrap').catch(err => console.error(err));
<% } %>

View File

@ -39,6 +39,7 @@ export async function hostGeneratorInternal(
const options: NormalizedSchema = {
...(await normalizeOptions<Schema>(host, schema, '@nx/react:host')),
typescriptConfiguration: schema.typescriptConfiguration ?? true,
dynamic: schema.dynamic ?? false,
};
const initTask = await applicationGenerator(host, {
@ -71,6 +72,8 @@ export async function hostGeneratorInternal(
skipFormat: true,
projectNameAndRootFormat: options.projectNameAndRootFormat,
typescriptConfiguration: options.typescriptConfiguration,
dynamic: options.dynamic,
host: options.name,
});
tasks.push(remoteTask);
remotePort++;

View File

@ -1,15 +1,21 @@
import { NormalizedSchema } from '../schema';
import { generateFiles, joinPathFragments, names } from '@nx/devkit';
import { join } from 'path';
import {
Tree,
generateFiles,
joinPathFragments,
names,
readProjectConfiguration,
} from '@nx/devkit';
export function addModuleFederationFiles(
host,
host: Tree,
options: NormalizedSchema,
defaultRemoteManifest: { name: string; port: number }[]
) {
const templateVariables = {
...names(options.name),
...options,
static: !options?.dynamic,
tmpl: '',
remotes: defaultRemoteManifest.map(({ name, port }) => {
return {
@ -19,17 +25,23 @@ export function addModuleFederationFiles(
}),
};
const projectConfig = readProjectConfiguration(host, options.name);
const pathToMFManifest = joinPathFragments(
projectConfig.sourceRoot,
'assets/module-federation.manifest.json'
);
// Module federation requires bootstrap code to be dynamically imported.
// Renaming original entry file so we can use `import(./bootstrap)` in
// new entry file.
host.rename(
join(options.appProjectRoot, 'src/main.tsx'),
join(options.appProjectRoot, 'src/bootstrap.tsx')
joinPathFragments(options.appProjectRoot, 'src/main.tsx'),
joinPathFragments(options.appProjectRoot, 'src/bootstrap.tsx')
);
generateFiles(
host,
join(__dirname, `../files/common`),
joinPathFragments(__dirname, `../files/common`),
options.appProjectRoot,
templateVariables
);
@ -40,25 +52,35 @@ export function addModuleFederationFiles(
// New entry file is created here.
generateFiles(
host,
join(__dirname, `../files/${pathToModuleFederationFiles}`),
joinPathFragments(__dirname, `../files/${pathToModuleFederationFiles}`),
options.appProjectRoot,
templateVariables
);
if (options.typescriptConfiguration) {
function deleteFileIfExists(host, filePath) {
if (host.exists(filePath)) {
host.delete(filePath);
}
}
function processWebpackConfig(options, host, fileName) {
const pathToWebpackConfig = joinPathFragments(
options.appProjectRoot,
'webpack.config.js'
fileName
);
const pathToWebpackProdConfig = joinPathFragments(
options.appProjectRoot,
'webpack.config.prod.js'
);
if (host.exists(pathToWebpackConfig)) {
host.delete(pathToWebpackConfig);
}
if (host.exists(pathToWebpackProdConfig)) {
host.delete(pathToWebpackProdConfig);
deleteFileIfExists(host, pathToWebpackConfig);
}
if (options.typescriptConfiguration) {
processWebpackConfig(options, host, 'webpack.config.js');
processWebpackConfig(options, host, 'webpack.config.prod.js');
}
if (options.dynamic) {
processWebpackConfig(options, host, 'webpack.config.prod.js');
processWebpackConfig(options, host, 'webpack.config.prod.ts');
if (!host.exists(pathToMFManifest)) {
host.write(pathToMFManifest, '{}');
}
}
}

View File

@ -33,6 +33,7 @@ export async function setupSsrForHost(
project.root,
{
...options,
static: !options?.dynamic,
remotes: defaultRemoteManifest.map(({ name, port }) => {
return {
...names(name),

View File

@ -25,6 +25,7 @@ export interface Schema {
unitTestRunner: 'jest' | 'vitest' | 'none';
minimal?: boolean;
typescriptConfiguration?: boolean;
dynamic?: boolean;
}
export interface NormalizedSchema extends Schema {

View File

@ -74,6 +74,11 @@
"enum": ["eslint"],
"default": "eslint"
},
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false
},
"skipFormat": {
"description": "Skip formatting files.",
"type": "boolean",

View File

@ -28,6 +28,7 @@ exports[`remote generator should create the remote with the correct config files
exports[`remote generator should create the remote with the correct config files 3`] = `
"module.exports = {
name: 'test',
exposes: {
'./Module': './src/remote-entry.ts',
},
@ -65,6 +66,7 @@ exports[`remote generator should create the remote with the correct config files
const config: ModuleFederationConfig = {
name: 'test',
exposes: {
'./Module': './src/remote-entry.ts',
},

View File

@ -2,6 +2,9 @@ import {ModuleFederationConfig} from '@nx/webpack';
const config: ModuleFederationConfig = {
name: '<%= projectName %>',
<% if (dynamic) { %>
library: { type: 'var', name: '<%= projectName %>'},
<% } %>
exposes: {
'./Module': './src/remote-entry.ts',
},

View File

@ -1,5 +1,8 @@
module.exports = {
name: '<%= projectName %>',
<% if (dynamic) { %>
library: { type: 'var', name: '<%= projectName %>'},
<% } %>
exposes: {
'./Module': './src/remote-entry.ts',
},

View File

@ -0,0 +1,17 @@
import { Tree } from '@nx/devkit';
export function addRemoteToDynamicHost(
tree: Tree,
remoteName: string,
remotePort: number,
pathToMfManifest: string
) {
const current = tree.read(pathToMfManifest, 'utf8');
tree.write(
pathToMfManifest,
JSON.stringify({
...JSON.parse(current),
[remoteName]: `http://localhost:${remotePort}`,
})
);
}

View File

@ -20,6 +20,7 @@ import { Schema } from './schema';
import setupSsrGenerator from '../setup-ssr/setup-ssr';
import { setupSsrForRemote } from './lib/setup-ssr-for-remote';
import { setupTspathForRemote } from './lib/setup-tspath-for-remote';
import { addRemoteToDynamicHost } from './lib/add-remote-to-dynamic-host';
export function addModuleFederationFiles(
host: Tree,
@ -72,6 +73,7 @@ export async function remoteGeneratorInternal(host: Tree, schema: Schema) {
const options: NormalizedSchema<Schema> = {
...(await normalizeOptions<Schema>(host, schema, '@nx/react:remote')),
typescriptConfiguration: schema.typescriptConfiguration ?? false,
dynamic: schema.dynamic ?? false,
};
const initAppTask = await applicationGenerator(host, {
...options,
@ -125,6 +127,20 @@ export async function remoteGeneratorInternal(host: Tree, schema: Schema) {
);
}
if (options.host && options.dynamic) {
const hostConfig = readProjectConfiguration(host, schema.host);
const pathToMFManifest = joinPathFragments(
hostConfig.sourceRoot,
'assets/module-federation.manifest.json'
);
addRemoteToDynamicHost(
host,
options.name,
options.devServerPort,
pathToMFManifest
);
}
if (!options.skipFormat) {
await formatFiles(host);
}

View File

@ -26,6 +26,7 @@ export interface Schema {
tags?: string;
unitTestRunner: 'jest' | 'vitest' | 'none';
typescriptConfiguration?: boolean;
dynamic?: boolean;
}
export interface NormalizedSchema extends ApplicationNormalizedSchema {

View File

@ -28,6 +28,12 @@
"type": "string",
"enum": ["as-provided", "derived"]
},
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false,
"x-priority": "internal"
},
"style": {
"description": "The file extension to be used for style files.",
"type": "string",

View File

@ -1,6 +1,7 @@
import {
addDependenciesToPackageJson,
GeneratorCallback,
joinPathFragments,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
@ -14,6 +15,7 @@ export function updateModuleFederationProject(
appProjectRoot: string;
devServerPort?: number;
typescriptConfiguration?: boolean;
dynamic?: boolean;
}
): GeneratorCallback {
const projectConfig = readProjectConfiguration(host, options.projectName);
@ -33,6 +35,19 @@ export function updateModuleFederationProject(
}`,
};
// If host should be configured to use dynamic federation
if (options.dynamic) {
const pathToProdWebpackConfig = joinPathFragments(
projectConfig.root,
`webpack.prod.config.${options.typescriptConfiguration ? 'ts' : 'js'}`
);
if (host.exists(pathToProdWebpackConfig)) {
host.delete(pathToProdWebpackConfig);
}
delete projectConfig.targets.build.configurations.production?.webpackConfig;
}
projectConfig.targets.serve.executor =
'@nx/react:module-federation-dev-server';
projectConfig.targets.serve.options.port = options.devServerPort;