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"], "enum": ["eslint"],
"default": "eslint" "default": "eslint"
}, },
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false
},
"skipFormat": { "skipFormat": {
"description": "Skip formatting files.", "description": "Skip formatting files.",
"type": "boolean", "type": "boolean",

View File

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

View File

@ -2,6 +2,7 @@ import { Tree, stripIndents } from '@nx/devkit';
import { import {
checkFilesExist, checkFilesExist,
cleanupProject, cleanupProject,
fileExists,
killPorts, killPorts,
killProcessAndPorts, killProcessAndPorts,
newProject, newProject,
@ -10,6 +11,7 @@ import {
runCLIAsync, runCLIAsync,
runCommandUntil, runCommandUntil,
runE2ETests, runE2ETests,
tmpProjPath,
uniq, uniq,
updateFile, updateFile,
updateJson, updateJson,
@ -165,9 +167,11 @@ describe('React Module Federation', () => {
`apps/${app}/module-federation.server.config.ts` `apps/${app}/module-federation.server.config.ts`
); );
['build', 'server'].forEach((target) => { ['build', 'server'].forEach((target) => {
['development', 'production'].forEach((configuration) => { ['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:${target}:${configuration}`); const cliOutput = runCLI(`run ${app}:${target}:${configuration}`);
expect(cliOutput).toContain('Successfully ran target'); expect(cliOutput).toContain('Successfully ran target');
await killPorts(readPort(app));
}); });
}); });
}); });
@ -866,6 +870,91 @@ describe('React Module Federation', () => {
} }
}, 500_000); }, 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 { 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 NxWelcome from "./nx-welcome";
<% } %> <% } %>
import { Link, Route, Routes } from 'react-router-dom'; import { Link, Route, Routes } from 'react-router-dom';
<% if (dynamic) { %>
import { loadRemoteModule } from '@nx/react/mf';
<% } %>
<% if (remotes.length > 0) { %> <% if (remotes.length > 0) { %>
<% remotes.forEach(function(r) { %> <% 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')); const <%= r.className %> = React.lazy(() => import('<%= r.fileName %>/Module'));
<% } %>
<% }); %> <% }); %>
<% } %> <% } %>
export function App() { export function App() {
return ( return (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>

View File

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

View File

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

View File

@ -15,8 +15,11 @@ const config: ModuleFederationConfig = {
* *
*/ */
remotes: [ remotes: [
<% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %> <% if (static) {
], remotes.forEach(function(r) { %> "<%= r.fileName %>", <% });
}
%>
],
}; };
export default config; 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: [
<% 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 = { const options: NormalizedSchema = {
...(await normalizeOptions<Schema>(host, schema, '@nx/react:host')), ...(await normalizeOptions<Schema>(host, schema, '@nx/react:host')),
typescriptConfiguration: schema.typescriptConfiguration ?? true, typescriptConfiguration: schema.typescriptConfiguration ?? true,
dynamic: schema.dynamic ?? false,
}; };
const initTask = await applicationGenerator(host, { const initTask = await applicationGenerator(host, {
@ -71,6 +72,8 @@ export async function hostGeneratorInternal(
skipFormat: true, skipFormat: true,
projectNameAndRootFormat: options.projectNameAndRootFormat, projectNameAndRootFormat: options.projectNameAndRootFormat,
typescriptConfiguration: options.typescriptConfiguration, typescriptConfiguration: options.typescriptConfiguration,
dynamic: options.dynamic,
host: options.name,
}); });
tasks.push(remoteTask); tasks.push(remoteTask);
remotePort++; remotePort++;

View File

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

View File

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

View File

@ -74,6 +74,11 @@
"enum": ["eslint"], "enum": ["eslint"],
"default": "eslint" "default": "eslint"
}, },
"dynamic": {
"type": "boolean",
"description": "Should the host application use dynamic federation?",
"default": false
},
"skipFormat": { "skipFormat": {
"description": "Skip formatting files.", "description": "Skip formatting files.",
"type": "boolean", "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`] = ` exports[`remote generator should create the remote with the correct config files 3`] = `
"module.exports = { "module.exports = {
name: 'test', name: 'test',
exposes: { exposes: {
'./Module': './src/remote-entry.ts', './Module': './src/remote-entry.ts',
}, },
@ -65,6 +66,7 @@ exports[`remote generator should create the remote with the correct config files
const config: ModuleFederationConfig = { const config: ModuleFederationConfig = {
name: 'test', name: 'test',
exposes: { exposes: {
'./Module': './src/remote-entry.ts', './Module': './src/remote-entry.ts',
}, },

View File

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

View File

@ -1,5 +1,8 @@
module.exports = { module.exports = {
name: '<%= projectName %>', name: '<%= projectName %>',
<% if (dynamic) { %>
library: { type: 'var', name: '<%= projectName %>'},
<% } %>
exposes: { exposes: {
'./Module': './src/remote-entry.ts', './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 setupSsrGenerator from '../setup-ssr/setup-ssr';
import { setupSsrForRemote } from './lib/setup-ssr-for-remote'; import { setupSsrForRemote } from './lib/setup-ssr-for-remote';
import { setupTspathForRemote } from './lib/setup-tspath-for-remote'; import { setupTspathForRemote } from './lib/setup-tspath-for-remote';
import { addRemoteToDynamicHost } from './lib/add-remote-to-dynamic-host';
export function addModuleFederationFiles( export function addModuleFederationFiles(
host: Tree, host: Tree,
@ -72,6 +73,7 @@ export async function remoteGeneratorInternal(host: Tree, schema: Schema) {
const options: NormalizedSchema<Schema> = { const options: NormalizedSchema<Schema> = {
...(await normalizeOptions<Schema>(host, schema, '@nx/react:remote')), ...(await normalizeOptions<Schema>(host, schema, '@nx/react:remote')),
typescriptConfiguration: schema.typescriptConfiguration ?? false, typescriptConfiguration: schema.typescriptConfiguration ?? false,
dynamic: schema.dynamic ?? false,
}; };
const initAppTask = await applicationGenerator(host, { const initAppTask = await applicationGenerator(host, {
...options, ...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) { if (!options.skipFormat) {
await formatFiles(host); await formatFiles(host);
} }

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
GeneratorCallback, GeneratorCallback,
joinPathFragments,
readProjectConfiguration, readProjectConfiguration,
Tree, Tree,
updateProjectConfiguration, updateProjectConfiguration,
@ -14,6 +15,7 @@ export function updateModuleFederationProject(
appProjectRoot: string; appProjectRoot: string;
devServerPort?: number; devServerPort?: number;
typescriptConfiguration?: boolean; typescriptConfiguration?: boolean;
dynamic?: boolean;
} }
): GeneratorCallback { ): GeneratorCallback {
const projectConfig = readProjectConfiguration(host, options.projectName); 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 = projectConfig.targets.serve.executor =
'@nx/react:module-federation-dev-server'; '@nx/react:module-federation-dev-server';
projectConfig.targets.serve.options.port = options.devServerPort; projectConfig.targets.serve.options.port = options.devServerPort;