From 6475c41ec81cc044c304297843ca8f66a94ef4e1 Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Mon, 13 Nov 2023 10:53:21 -0700 Subject: [PATCH] feat(module-federation): Add react support for dynamic federation (#20024) --- .../packages/react/generators/host.json | 5 + .../packages/react/generators/remote.json | 6 + .../src/react-module-federation.test.ts | 91 ++++++++++++++- packages/react/mf/dynamic-federation.ts | 105 ++++++++++++++++++ packages/react/mf/index.ts | 5 + .../common/src/app/__fileName__.tsx__tmpl__ | 8 ++ ...module-federation.server.config.ts__tmpl__ | 7 +- ...module-federation.server.config.js__tmpl__ | 7 +- .../module-federation.config.ts__tmpl__ | 7 +- .../module-federation-ts/src/main.ts__tmpl__ | 11 +- .../module-federation.config.js__tmpl__ | 7 +- .../module-federation/src/main.ts__tmpl__ | 11 +- packages/react/src/generators/host/host.ts | 3 + .../host/lib/add-module-federation-files.ts | 58 +++++++--- .../generators/host/lib/setup-ssr-for-host.ts | 1 + .../react/src/generators/host/schema.d.ts | 1 + .../react/src/generators/host/schema.json | 5 + .../remote/__snapshots__/remote.spec.ts.snap | 2 + .../module-federation.config.ts__tmpl__ | 3 + .../module-federation.config.js__tmpl__ | 3 + .../remote/lib/add-remote-to-dynamic-host.ts | 17 +++ .../react/src/generators/remote/remote.ts | 16 +++ .../react/src/generators/remote/schema.d.ts | 1 + .../react/src/generators/remote/schema.json | 6 + .../rules/update-module-federation-project.ts | 15 +++ 25 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 packages/react/mf/dynamic-federation.ts create mode 100644 packages/react/mf/index.ts create mode 100644 packages/react/src/generators/remote/lib/add-remote-to-dynamic-host.ts diff --git a/docs/generated/packages/react/generators/host.json b/docs/generated/packages/react/generators/host.json index f8b5541d82..a649972887 100644 --- a/docs/generated/packages/react/generators/host.json +++ b/docs/generated/packages/react/generators/host.json @@ -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", diff --git a/docs/generated/packages/react/generators/remote.json b/docs/generated/packages/react/generators/remote.json index dd82c74102..4852b0af51 100644 --- a/docs/generated/packages/react/generators/remote.json +++ b/docs/generated/packages/react/generators/remote.json @@ -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", diff --git a/e2e/react-core/src/react-module-federation.test.ts b/e2e/react-core/src/react-module-federation.test.ts index 1f32f2e1b1..73a9edc903 100644 --- a/e2e/react-core/src/react-module-federation.test.ts +++ b/e2e/react-core/src/react-module-federation.test.ts @@ -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 { diff --git a/packages/react/mf/dynamic-federation.ts b/packages/react/mf/dynamic-federation.ts new file mode 100644 index 0000000000..4584452c2d --- /dev/null +++ b/packages/react/mf/dynamic-federation.ts @@ -0,0 +1,105 @@ +export type ResolveRemoteUrlFunction = ( + remoteName: string +) => string | Promise; + +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; +declare const __webpack_share_scopes__: { default: unknown }; +let remoteUrlDefinitions: Record; +let resolveRemoteUrl: ResolveRemoteUrlFunction; +const remoteModuleMap = new Map(); +const remoteContainerMap = new Map(); +let initialSharingScopeCreated = false; + +export function setRemoteUrlResolver( + _resolveRemoteUrl: ResolveRemoteUrlFunction +) { + resolveRemoteUrl = _resolveRemoteUrl; +} + +export function setRemoteDefinitions(definitions: Record) { + 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 => { + 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; +} diff --git a/packages/react/mf/index.ts b/packages/react/mf/index.ts new file mode 100644 index 0000000000..189ef9f03f --- /dev/null +++ b/packages/react/mf/index.ts @@ -0,0 +1,5 @@ +export { + loadRemoteModule, + setRemoteDefinitions, + setRemoteUrlResolver, +} from './dynamic-federation'; diff --git a/packages/react/src/generators/host/files/common/src/app/__fileName__.tsx__tmpl__ b/packages/react/src/generators/host/files/common/src/app/__fileName__.tsx__tmpl__ index 00edc49cc2..7960d003ff 100644 --- a/packages/react/src/generators/host/files/common/src/app/__fileName__.tsx__tmpl__ +++ b/packages/react/src/generators/host/files/common/src/app/__fileName__.tsx__tmpl__ @@ -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 ( diff --git a/packages/react/src/generators/host/files/module-federation-ssr-ts/module-federation.server.config.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation-ssr-ts/module-federation.server.config.ts__tmpl__ index 24f5f21f76..c64f2cff8f 100644 --- a/packages/react/src/generators/host/files/module-federation-ssr-ts/module-federation.server.config.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ssr-ts/module-federation.server.config.ts__tmpl__ @@ -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; diff --git a/packages/react/src/generators/host/files/module-federation-ssr/module-federation.server.config.js__tmpl__ b/packages/react/src/generators/host/files/module-federation-ssr/module-federation.server.config.js__tmpl__ index 7cdaf87171..279f291753 100644 --- a/packages/react/src/generators/host/files/module-federation-ssr/module-federation.server.config.js__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ssr/module-federation.server.config.js__tmpl__ @@ -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; diff --git a/packages/react/src/generators/host/files/module-federation-ts/module-federation.config.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation-ts/module-federation.config.ts__tmpl__ index 0bbc4dd4bf..58f9690785 100644 --- a/packages/react/src/generators/host/files/module-federation-ts/module-federation.config.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ts/module-federation.config.ts__tmpl__ @@ -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; diff --git a/packages/react/src/generators/host/files/module-federation-ts/src/main.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation-ts/src/main.ts__tmpl__ index 137c64f9f4..a4061a83ea 100644 --- a/packages/react/src/generators/host/files/module-federation-ts/src/main.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ts/src/main.ts__tmpl__ @@ -1 +1,10 @@ -import('./bootstrap'); \ No newline at end of file +<% 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)); +<% } %> \ No newline at end of file diff --git a/packages/react/src/generators/host/files/module-federation/module-federation.config.js__tmpl__ b/packages/react/src/generators/host/files/module-federation/module-federation.config.js__tmpl__ index 7ac48c12c7..04b6e6394d 100644 --- a/packages/react/src/generators/host/files/module-federation/module-federation.config.js__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation/module-federation.config.js__tmpl__ @@ -13,6 +13,9 @@ module.exports = { * */ remotes: [ - <% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %> + <% if (static) { + remotes.forEach(function(r) { %> "<%= r.fileName %>", <% }); + } + %> ], -}; +}; \ No newline at end of file diff --git a/packages/react/src/generators/host/files/module-federation/src/main.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation/src/main.ts__tmpl__ index 137c64f9f4..a4061a83ea 100644 --- a/packages/react/src/generators/host/files/module-federation/src/main.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation/src/main.ts__tmpl__ @@ -1 +1,10 @@ -import('./bootstrap'); \ No newline at end of file +<% 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)); +<% } %> \ No newline at end of file diff --git a/packages/react/src/generators/host/host.ts b/packages/react/src/generators/host/host.ts index 9e7e290036..61142d20bb 100644 --- a/packages/react/src/generators/host/host.ts +++ b/packages/react/src/generators/host/host.ts @@ -39,6 +39,7 @@ export async function hostGeneratorInternal( const options: NormalizedSchema = { ...(await normalizeOptions(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++; diff --git a/packages/react/src/generators/host/lib/add-module-federation-files.ts b/packages/react/src/generators/host/lib/add-module-federation-files.ts index 2c11386b4b..d020ade2c0 100644 --- a/packages/react/src/generators/host/lib/add-module-federation-files.ts +++ b/packages/react/src/generators/host/lib/add-module-federation-files.ts @@ -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, '{}'); } } } diff --git a/packages/react/src/generators/host/lib/setup-ssr-for-host.ts b/packages/react/src/generators/host/lib/setup-ssr-for-host.ts index 0d6d2fb737..7acfc5ec45 100644 --- a/packages/react/src/generators/host/lib/setup-ssr-for-host.ts +++ b/packages/react/src/generators/host/lib/setup-ssr-for-host.ts @@ -33,6 +33,7 @@ export async function setupSsrForHost( project.root, { ...options, + static: !options?.dynamic, remotes: defaultRemoteManifest.map(({ name, port }) => { return { ...names(name), diff --git a/packages/react/src/generators/host/schema.d.ts b/packages/react/src/generators/host/schema.d.ts index 6b399c2b07..7cd4420edf 100644 --- a/packages/react/src/generators/host/schema.d.ts +++ b/packages/react/src/generators/host/schema.d.ts @@ -25,6 +25,7 @@ export interface Schema { unitTestRunner: 'jest' | 'vitest' | 'none'; minimal?: boolean; typescriptConfiguration?: boolean; + dynamic?: boolean; } export interface NormalizedSchema extends Schema { diff --git a/packages/react/src/generators/host/schema.json b/packages/react/src/generators/host/schema.json index 51b8dbf681..3353826107 100644 --- a/packages/react/src/generators/host/schema.json +++ b/packages/react/src/generators/host/schema.json @@ -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", diff --git a/packages/react/src/generators/remote/__snapshots__/remote.spec.ts.snap b/packages/react/src/generators/remote/__snapshots__/remote.spec.ts.snap index 52f7473004..9bc0841399 100644 --- a/packages/react/src/generators/remote/__snapshots__/remote.spec.ts.snap +++ b/packages/react/src/generators/remote/__snapshots__/remote.spec.ts.snap @@ -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', }, diff --git a/packages/react/src/generators/remote/files/module-federation-ts/module-federation.config.ts__tmpl__ b/packages/react/src/generators/remote/files/module-federation-ts/module-federation.config.ts__tmpl__ index 00e5754cce..d3f4d6bea1 100644 --- a/packages/react/src/generators/remote/files/module-federation-ts/module-federation.config.ts__tmpl__ +++ b/packages/react/src/generators/remote/files/module-federation-ts/module-federation.config.ts__tmpl__ @@ -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', }, diff --git a/packages/react/src/generators/remote/files/module-federation/module-federation.config.js__tmpl__ b/packages/react/src/generators/remote/files/module-federation/module-federation.config.js__tmpl__ index 67bfd8b4ea..c03dfd1688 100644 --- a/packages/react/src/generators/remote/files/module-federation/module-federation.config.js__tmpl__ +++ b/packages/react/src/generators/remote/files/module-federation/module-federation.config.js__tmpl__ @@ -1,5 +1,8 @@ module.exports = { name: '<%= projectName %>', + <% if (dynamic) { %> + library: { type: 'var', name: '<%= projectName %>'}, + <% } %> exposes: { './Module': './src/remote-entry.ts', }, diff --git a/packages/react/src/generators/remote/lib/add-remote-to-dynamic-host.ts b/packages/react/src/generators/remote/lib/add-remote-to-dynamic-host.ts new file mode 100644 index 0000000000..42bd6d7b97 --- /dev/null +++ b/packages/react/src/generators/remote/lib/add-remote-to-dynamic-host.ts @@ -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}`, + }) + ); +} diff --git a/packages/react/src/generators/remote/remote.ts b/packages/react/src/generators/remote/remote.ts index ded21931f1..338e125d2a 100644 --- a/packages/react/src/generators/remote/remote.ts +++ b/packages/react/src/generators/remote/remote.ts @@ -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 = { ...(await normalizeOptions(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); } diff --git a/packages/react/src/generators/remote/schema.d.ts b/packages/react/src/generators/remote/schema.d.ts index cb57362406..51a87edc6f 100644 --- a/packages/react/src/generators/remote/schema.d.ts +++ b/packages/react/src/generators/remote/schema.d.ts @@ -26,6 +26,7 @@ export interface Schema { tags?: string; unitTestRunner: 'jest' | 'vitest' | 'none'; typescriptConfiguration?: boolean; + dynamic?: boolean; } export interface NormalizedSchema extends ApplicationNormalizedSchema { diff --git a/packages/react/src/generators/remote/schema.json b/packages/react/src/generators/remote/schema.json index b0f4f28ddc..569e7811a7 100644 --- a/packages/react/src/generators/remote/schema.json +++ b/packages/react/src/generators/remote/schema.json @@ -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", diff --git a/packages/react/src/rules/update-module-federation-project.ts b/packages/react/src/rules/update-module-federation-project.ts index 7534b19a07..02c4c787de 100644 --- a/packages/react/src/rules/update-module-federation-project.ts +++ b/packages/react/src/rules/update-module-federation-project.ts @@ -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;