diff --git a/docs/generated/packages/angular/executors/module-federation-dev-server.json b/docs/generated/packages/angular/executors/module-federation-dev-server.json index 73ee722d00..93ccfb897b 100644 --- a/docs/generated/packages/angular/executors/module-federation-dev-server.json +++ b/docs/generated/packages/angular/executors/module-federation-dev-server.json @@ -112,6 +112,16 @@ "pathToManifestFile": { "type": "string", "description": "Path to a Module Federation manifest file (e.g. `my/path/to/module-federation.manifest.json`) containing the dynamic remote applications relative to the workspace root." + }, + "static": { + "type": "boolean", + "description": "Whether to use a static file server instead of the webpack-dev-server. This should be used for remote applications that are also host applications." + }, + "isInitialHost": { + "type": "boolean", + "description": "Whether the host that is running this executor is the first in the project tree to do so.", + "default": true, + "x-priority": "internal" } }, "additionalProperties": false, diff --git a/docs/generated/packages/react/executors/module-federation-dev-server.json b/docs/generated/packages/react/executors/module-federation-dev-server.json index 7a346ba1dd..226b164965 100644 --- a/docs/generated/packages/react/executors/module-federation-dev-server.json +++ b/docs/generated/packages/react/executors/module-federation-dev-server.json @@ -90,6 +90,16 @@ "baseHref": { "type": "string", "description": "Base url for the application being built." + }, + "static": { + "type": "boolean", + "description": "Whether to use a static file server instead of the webpack-dev-server. This should be used for remote applications that are also host applications." + }, + "isInitialHost": { + "type": "boolean", + "description": "Whether the host that is running this executor is the first in the project tree to do so.", + "default": true, + "x-priority": "internal" } }, "presets": [] diff --git a/e2e/angular-core/src/module-federation.test.ts b/e2e/angular-core/src/module-federation.test.ts index 4cea70b1eb..97f0bde368 100644 --- a/e2e/angular-core/src/module-federation.test.ts +++ b/e2e/angular-core/src/module-federation.test.ts @@ -385,4 +385,97 @@ describe('Angular Module Federation', () => { await killProcessAndPorts(hostE2eResults.pid); } }, 500_000); + + it('should federate a module from a library and create a remote that is served recursively', async () => { + const lib = uniq('lib'); + const remote = uniq('remote'); + const childRemote = uniq('childremote'); + const module = uniq('module'); + const host = uniq('host'); + + runCLI( + `generate @nx/angular:host ${host} --remotes=${remote} --no-interactive --projectNameAndRootFormat=as-provided` + ); + + runCLI( + `generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided` + ); + + // Federate Module + runCLI( + `generate @nx/angular:federate-module ${module} --remote=${childRemote} --path=${lib}/src/index.ts --no-interactive` + ); + + updateFile(`${lib}/src/index.ts`, `export { isEven } from './lib/${lib}';`); + updateFile( + `${lib}/src/lib/${lib}.ts`, + `export function isEven(num: number) { return num % 2 === 0; }` + ); + + // Update Host to use the module + updateFile( + `${remote}/src/app/remote-entry/entry.component.ts`, + ` + import { Component } from '@angular/core'; + import { isEven } from '${childRemote}/${module}'; + + @Component({ + selector: 'proj-${remote}-entry', + template: \`
{{title}}
\` + }) + export class RemoteEntryComponent { + title = \`shell is \${isEven(2) ? 'even' : 'odd'}\`; + }` + ); + + updateFile( + `${remote}/module-federation.config.ts`, + ` + import { ModuleFederationConfig } from '@nx/webpack'; + + const config: ModuleFederationConfig = { + name: '${remote}', + remotes: ['${childRemote}'], + exposes: { + './Module': '${remote}/src/app/remote-entry/entry.module.ts', + }, + }; + + export default config;` + ); + + // Update e2e test to check the module + updateFile( + `${host}-e2e/src/e2e/app.cy.ts`, + ` + describe('${host}', () => { + beforeEach(() => cy.visit('/${remote}')); + + it('should display contain the remote library', () => { + expect(cy.get('div.childremote')).to.exist; + expect(cy.get('div.childremote').contains('shell is even')); + }); + }); + + ` + ); + + // Build host and remote + const buildOutput = await runCommandUntil(`build ${host}`, (output) => + output.includes('Successfully ran target build') + ); + await killProcessAndPorts(buildOutput.pid); + const remoteOutput = await runCommandUntil(`build ${remote}`, (output) => + output.includes('Successfully ran target build') + ); + await killProcessAndPorts(remoteOutput.pid); + + if (runE2ETests()) { + const hostE2eResults = await runCommandUntil( + `e2e ${host}-e2e --no-watch --verbose`, + (output) => output.includes('All specs passed!') + ); + await killProcessAndPorts(hostE2eResults.pid); + } + }, 500_000); }); diff --git a/e2e/react-core/src/react-module-federation.test.ts b/e2e/react-core/src/react-module-federation.test.ts index 63c97f8a63..5f8831212c 100644 --- a/e2e/react-core/src/react-module-federation.test.ts +++ b/e2e/react-core/src/react-module-federation.test.ts @@ -549,6 +549,89 @@ describe('React Module Federation', () => { } }, 500_000); + it('should federate a module from a library and create a remote and serve it recursively', async () => { + const lib = uniq('lib'); + const remote = uniq('remote'); + const childRemote = uniq('childremote'); + const module = uniq('module'); + const host = uniq('host'); + + runCLI( + `generate @nx/react:host ${host} --remotes=${remote} --no-interactive --projectNameAndRootFormat=as-provided` + ); + + runCLI( + `generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided` + ); + + // Federate Module + runCLI( + `generate @nx/react:federate-module ${module} --remote=${childRemote} --path=${lib}/src/index.ts --no-interactive` + ); + + updateFile( + `${lib}/src/index.ts`, + `export { default } from './lib/${lib}';` + ); + updateFile( + `${lib}/src/lib/${lib}.ts`, + `export default function lib() { return 'Hello from ${lib}'; };` + ); + + // Update Host to use the module + updateFile( + `${remote}/src/app/app.tsx`, + ` + import * as React from 'react'; + import NxWelcome from './nx-welcome'; + + import myLib from '${childRemote}/${module}'; + + export function App() { + return ( + +
+ My Remote Library: { myLib() } +
+ +
+ ); + } + + export default App; + ` + ); + + // Update e2e test to check the module + updateFile( + `${host}-e2e/src/e2e/app.cy.ts`, + ` + describe('${host}', () => { + beforeEach(() => cy.visit('/${remote}')); + + it('should display contain the remote library', () => { + expect(cy.get('div.remote')).to.exist; + expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}')); + }); + }); + + ` + ); + + // Build host and remote + const buildOutput = runCLI(`build ${host}`); + const remoteOutput = runCLI(`build ${remote}`); + + expect(buildOutput).toContain('Successfully ran target build'); + expect(remoteOutput).toContain('Successfully ran target build'); + + if (runE2ETests()) { + const hostE2eResults = runCLI(`e2e ${host}-e2e --no-watch --verbose`); + + expect(hostE2eResults).toContain('All specs passed!'); + } + }, 500_000); + describe('Promised based remotes', () => { it('should support promised based remotes', async () => { const remote = uniq('remote'); diff --git a/packages/angular/package.json b/packages/angular/package.json index 0d11748b7f..0d5cff872a 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -64,6 +64,7 @@ "@nx/js": "file:../js", "@nx/eslint": "file:../eslint", "@nx/webpack": "file:../webpack", + "@nx/web": "file:../web", "@nx/workspace": "file:../workspace" }, "peerDependencies": { diff --git a/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts b/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts index 4cde100752..32b89533dc 100644 --- a/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts +++ b/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts @@ -1,5 +1,10 @@ import type { Schema } from './schema'; -import { logger, readCachedProjectGraph, workspaceRoot } from '@nx/devkit'; +import { + logger, + readCachedProjectGraph, + readNxJson, + workspaceRoot, +} from '@nx/devkit'; import { scheduleTarget } from 'nx/src/adapter/ngcli-adapter'; import { executeWebpackDevServerBuilder } from '../webpack-dev-server/webpack-dev-server.impl'; import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph'; @@ -11,18 +16,66 @@ import { } from '../utilities/module-federation'; import { existsSync } from 'fs'; import { extname, join } from 'path'; -import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; +import { + getModuleFederationConfig, + getRemotes, +} from '@nx/webpack/src/utils/module-federation'; +import { fork } from 'child_process'; +import { combineLatest, concatMap, from, switchMap } from 'rxjs'; export function executeModuleFederationDevServerBuilder( schema: Schema, context: import('@angular-devkit/architect').BuilderContext -): ReturnType { +): ReturnType { + const nxBin = require.resolve('nx'); const { ...options } = schema; const projectGraph = readCachedProjectGraph(); const { projects: workspaceProjects } = readProjectsConfigurationFromProjectGraph(projectGraph); const project = workspaceProjects[context.target.project]; + const staticFileServer = from( + import('@nx/web/src/executors/file-server/file-server.impl') + ).pipe( + switchMap((fileServerExecutor) => + fileServerExecutor.default( + { + port: options.port, + host: options.host, + ssl: options.ssl, + buildTarget: options.browserTarget, + parallel: false, + spa: false, + withDeps: false, + cors: true, + }, + { + projectGraph, + root: context.workspaceRoot, + target: + projectGraph.nodes[context.target.project].data.targets[ + context.target.target + ], + targetName: context.target.target, + projectName: context.target.project, + configurationName: context.target.configuration, + cwd: context.currentDirectory, + isVerbose: options.verbose, + projectsConfigurations: + readProjectsConfigurationFromProjectGraph(projectGraph), + nxJsonConfiguration: readNxJson(), + } + ) + ) + ); + const webpackDevServer = executeWebpackDevServerBuilder(options, context); + + const currExecutor = options.static ? staticFileServer : webpackDevServer; + + if (options.isInitialHost === false) { + return currExecutor; + } + let pathToManifestFile = join( context.workspaceRoot, project.sourceRoot, @@ -48,70 +101,107 @@ export function executeModuleFederationDevServerBuilder( validateDevRemotes(options, workspaceProjects); - const remotesToSkip = new Set( - findMatchingProjects(options.skipRemotes, projectGraph.nodes) ?? [] + const moduleFederationConfig = getModuleFederationConfig( + project.targets.build.options.tsConfig, + context.workspaceRoot, + project.root, + 'angular' ); - if (remotesToSkip.size > 0) { - logger.info( - `Remotes not served automatically: ${[...remotesToSkip].join(', ')}` - ); - } - const staticRemotes = getStaticRemotes( - project, - context, - workspaceProjects, - remotesToSkip - ); - const dynamicRemotes = getDynamicRemotes( - project, - context, - workspaceProjects, - remotesToSkip, + const remotes = getRemotes( + options.devRemotes, + options.skipRemotes, + moduleFederationConfig, + { + projectName: project.name, + projectGraph, + root: context.workspaceRoot, + }, pathToManifestFile ); - const remotes = [...staticRemotes, ...dynamicRemotes]; - const devServeRemotes = !options.devRemotes - ? [] - : Array.isArray(options.devRemotes) - ? findMatchingProjects(options.devRemotes, projectGraph.nodes) - : findMatchingProjects([options.devRemotes], projectGraph.nodes); + let isCollectingStaticRemoteOutput = true; - for (const remote of remotes) { - const isDev = devServeRemotes.includes(remote); - const target = isDev ? 'serve' : 'serve-static'; - - if (!workspaceProjects[remote].targets?.[target]) { - throw new Error( - `Could not find "${target}" target in "${remote}" project.` + for (const app of remotes.staticRemotes) { + const remoteProjectServeTarget = + projectGraph.nodes[app].data.targets['serve-static']; + const isUsingModuleFederationDevServerExecutor = + remoteProjectServeTarget.executor.includes( + 'module-federation-dev-server' ); - } else if (!workspaceProjects[remote].targets?.[target].executor) { - throw new Error( - `Could not find executor for "${target}" target in "${remote}" project.` - ); - } - - const runOptions: { verbose?: boolean } = {}; - if (options.verbose) { - const [collection, executor] = - workspaceProjects[remote].targets[target].executor.split(':'); - const { schema } = getExecutorInformation( - collection, - executor, - workspaceRoot - ); - - if (schema.additionalProperties || 'verbose' in schema.properties) { - runOptions.verbose = options.verbose; + let outWithErr: null | string[] = []; + const staticProcess = fork( + nxBin, + [ + 'run', + `${app}:serve-static${ + context.target.configuration ? `:${context.target.configuration}` : '' + }`, + ...(isUsingModuleFederationDevServerExecutor + ? [`--isInitialHost=false`] + : []), + ], + { + cwd: context.workspaceRoot, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], } + ); + staticProcess.stdout.on('data', (data) => { + if (isCollectingStaticRemoteOutput) { + outWithErr.push(data.toString()); + } else { + outWithErr = null; + staticProcess.stdout.removeAllListeners('data'); + } + }); + staticProcess.stderr.on('data', (data) => logger.info(data.toString())); + staticProcess.on('exit', (code) => { + if (code !== 0) { + logger.info(outWithErr.join('')); + throw new Error(`Remote failed to start. See above for errors.`); + } + }); + process.on('SIGTERM', () => staticProcess.kill('SIGTERM')); + process.on('exit', () => staticProcess.kill('SIGTERM')); + } + + const devRemotes$ = []; + for (const app of remotes.devRemotes) { + if (!workspaceProjects[app].targets?.['serve']) { + throw new Error(`Could not find "serve" target in "${app}" project.`); + } else if (!workspaceProjects[app].targets?.['serve'].executor) { + throw new Error( + `Could not find executor for "serve" target in "${app}" project.` + ); } - scheduleTarget( + const runOptions: { verbose?: boolean; isInitialHost?: boolean } = {}; + const [collection, executor] = + workspaceProjects[app].targets['serve'].executor.split(':'); + const isUsingModuleFederationDevServerExecutor = executor.includes( + 'module-federation-dev-server' + ); + const { schema } = getExecutorInformation( + collection, + executor, + workspaceRoot + ); + if ( + (options.verbose && schema.additionalProperties) || + 'verbose' in schema.properties + ) { + runOptions.verbose = options.verbose; + } + + if (isUsingModuleFederationDevServerExecutor) { + runOptions.isInitialHost = false; + } + + const serve$ = scheduleTarget( context.workspaceRoot, { - project: remote, - target, + project: app, + target: 'serve', configuration: context.target.configuration, runOptions, }, @@ -119,13 +209,17 @@ export function executeModuleFederationDevServerBuilder( ).then((obs) => { obs.toPromise().catch((err) => { throw new Error( - `Remote '${remote}' failed to serve correctly due to the following: \r\n${err.toString()}` + `Remote '${app}' failed to serve correctly due to the following: \r\n${err.toString()}` ); }); }); + + devRemotes$.push(serve$); } - return executeWebpackDevServerBuilder(options, context); + return devRemotes$.length > 0 + ? combineLatest([...devRemotes$]).pipe(concatMap(() => currExecutor)) + : currExecutor; } export default require('@angular-devkit/architect').createBuilder( diff --git a/packages/angular/src/builders/module-federation-dev-server/schema.d.ts b/packages/angular/src/builders/module-federation-dev-server/schema.d.ts index cf3f1ebc52..e99e9ffb80 100644 --- a/packages/angular/src/builders/module-federation-dev-server/schema.d.ts +++ b/packages/angular/src/builders/module-federation-dev-server/schema.d.ts @@ -20,4 +20,6 @@ export interface Schema { devRemotes?: string[]; skipRemotes?: string[]; pathToManifestFile?: string; + static?: boolean; + isInitialHost?: boolean; } diff --git a/packages/angular/src/builders/module-federation-dev-server/schema.json b/packages/angular/src/builders/module-federation-dev-server/schema.json index 897762871c..fec21cb4d3 100644 --- a/packages/angular/src/builders/module-federation-dev-server/schema.json +++ b/packages/angular/src/builders/module-federation-dev-server/schema.json @@ -122,6 +122,16 @@ "pathToManifestFile": { "type": "string", "description": "Path to a Module Federation manifest file (e.g. `my/path/to/module-federation.manifest.json`) containing the dynamic remote applications relative to the workspace root." + }, + "static": { + "type": "boolean", + "description": "Whether to use a static file server instead of the webpack-dev-server. This should be used for remote applications that are also host applications." + }, + "isInitialHost": { + "type": "boolean", + "description": "Whether the host that is running this executor is the first in the project tree to do so.", + "default": true, + "x-priority": "internal" } }, "additionalProperties": false, diff --git a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts index ee765650a4..94d24da781 100644 --- a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts +++ b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts @@ -6,22 +6,24 @@ import { runExecutor, } from '@nx/devkit'; import devServerExecutor from '@nx/webpack/src/executors/dev-server/dev-server.impl'; +import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl'; import { WebDevServerOptions } from '@nx/webpack/src/executors/dev-server/schema'; -import { join } from 'path'; +import { + getModuleFederationConfig, + getRemotes, +} from '@nx/webpack/src/utils/module-federation'; import { combineAsyncIterables, createAsyncIterable, } from '@nx/devkit/src/utils/async-iterable'; -import * as chalk from 'chalk'; import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open'; -import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; import { fork } from 'child_process'; -import { existsSync } from 'fs'; -import { registerTsProject } from '@nx/js/src/internal'; type ModuleFederationDevServerOptions = WebDevServerOptions & { - devRemotes?: string | string[]; + devRemotes?: string[]; skipRemotes?: string[]; + static?: boolean; + isInitialHost?: boolean; }; function getBuildOptions(buildTarget: string, context: ExecutorContext) { @@ -34,164 +36,118 @@ function getBuildOptions(buildTarget: string, context: ExecutorContext) { }; } -function getModuleFederationConfig( - tsconfigPath: string, - workspaceRoot: string, - projectRoot: string -) { - const moduleFederationConfigPathJS = join( - workspaceRoot, - projectRoot, - 'module-federation.config.js' - ); - - const moduleFederationConfigPathTS = join( - workspaceRoot, - projectRoot, - 'module-federation.config.ts' - ); - - let moduleFederationConfigPath = moduleFederationConfigPathJS; - - // create a no-op so this can be called with issue - const fullTSconfigPath = tsconfigPath.startsWith(workspaceRoot) - ? tsconfigPath - : join(workspaceRoot, tsconfigPath); - let cleanupTranspiler = () => {}; - if (existsSync(moduleFederationConfigPathTS)) { - cleanupTranspiler = registerTsProject(fullTSconfigPath); - moduleFederationConfigPath = moduleFederationConfigPathTS; - } - - try { - const config = require(moduleFederationConfigPath); - cleanupTranspiler(); - - return config.default || config; - } catch { - throw new Error( - `Could not load ${moduleFederationConfigPath}. Was this project generated with "@nx/react:host"?\nSee: https://nx.dev/concepts/more-concepts/faster-builds-with-module-federation` - ); - } -} - export default async function* moduleFederationDevServer( options: ModuleFederationDevServerOptions, context: ExecutorContext ): AsyncIterableIterator<{ success: boolean; baseUrl?: string }> { const nxBin = require.resolve('nx'); - const currIter = devServerExecutor(options, context); + const currIter = options.static + ? fileServerExecutor( + { + ...options, + parallel: false, + withDeps: false, + spa: false, + cors: true, + }, + context + ) + : devServerExecutor(options, context); + const p = context.projectsConfigurations.projects[context.projectName]; const buildOptions = getBuildOptions(options.buildTarget, context); + if (!options.isInitialHost) { + return yield* currIter; + } + const moduleFederationConfig = getModuleFederationConfig( buildOptions.tsConfig, context.root, - p.root + p.root, + 'react' ); - const remotesToSkip = new Set( - findMatchingProjects(options.skipRemotes, context.projectGraph.nodes) ?? [] - ); - - if (remotesToSkip.size > 0) { - logger.info( - `Remotes not served automatically: ${[...remotesToSkip.values()].join( - ', ' - )}` - ); - } - const remotesNotInWorkspace: string[] = []; - - const knownRemotes = (moduleFederationConfig.remotes ?? []).filter((r) => { - const validRemote = Array.isArray(r) ? r[0] : r; - - if (remotesToSkip.has(validRemote)) { - return false; - } else if (!context.projectGraph.nodes[validRemote]) { - remotesNotInWorkspace.push(validRemote); - return false; - } else { - return true; + const remotes = getRemotes( + options.devRemotes, + options.skipRemotes, + moduleFederationConfig, + { + projectName: context.projectName, + projectGraph: context.projectGraph, + root: context.root, } - }); - - if (remotesNotInWorkspace.length > 0) { - logger.warn( - `Skipping serving ${remotesNotInWorkspace.join( - ', ' - )} as they could not be found in the workspace. Ensure they are served correctly.` - ); - } - - const remotePorts = knownRemotes.map( - (r) => context.projectGraph.nodes[r].data.targets['serve'].options.port ); - const devServeApps = !options.devRemotes - ? [] - : Array.isArray(options.devRemotes) - ? findMatchingProjects(options.devRemotes, context.projectGraph.nodes) - : findMatchingProjects([options.devRemotes], context.projectGraph.nodes); - - logger.info( - `NX Starting module federation dev-server for ${chalk.bold( - context.projectName - )} with ${knownRemotes.length} remotes` - ); - - const devRemoteIters: AsyncIterable<{ success: boolean }>[] = []; let isCollectingStaticRemoteOutput = true; + const devRemoteIters: AsyncIterable<{ success: boolean }>[] = []; - for (const app of knownRemotes) { - const appName = Array.isArray(app) ? app[0] : app; - if (devServeApps.includes(appName)) { - devRemoteIters.push( - await runExecutor( - { - project: appName, - target: 'serve', - configuration: context.configurationName, - }, - { - watch: true, - }, - context - ) + for (const app of remotes.devRemotes) { + const remoteProjectServeTarget = + context.projectGraph.nodes[app].data.targets['serve']; + const isUsingModuleFederationDevServerExecutor = + remoteProjectServeTarget.executor.includes( + 'module-federation-dev-server' ); - } else { - let outWithErr: null | string[] = []; - const staticProcess = fork( - nxBin, - [ - 'run', - `${appName}:serve-static${ - context.configurationName ? `:${context.configurationName}` : '' - }`, - ], + + devRemoteIters.push( + await runExecutor( { - cwd: context.root, - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - } + project: app, + target: 'serve', + configuration: context.configurationName, + }, + { + watch: true, + ...(isUsingModuleFederationDevServerExecutor + ? { isInitialHost: false } + : {}), + }, + context + ) + ); + } + for (const app of remotes.staticRemotes) { + const remoteProjectServeTarget = + context.projectGraph.nodes[app].data.targets['serve-static']; + const isUsingModuleFederationDevServerExecutor = + remoteProjectServeTarget.executor.includes( + 'module-federation-dev-server' ); - staticProcess.stdout.on('data', (data) => { - if (isCollectingStaticRemoteOutput) { - outWithErr.push(data.toString()); - } else { - outWithErr = null; - staticProcess.stdout.removeAllListeners('data'); - } - }); - staticProcess.stderr.on('data', (data) => logger.info(data.toString())); - staticProcess.on('exit', (code) => { - if (code !== 0) { - logger.info(outWithErr.join('')); - throw new Error(`Remote failed to start. See above for errors.`); - } - }); - process.on('SIGTERM', () => staticProcess.kill('SIGTERM')); - process.on('exit', () => staticProcess.kill('SIGTERM')); - } + let outWithErr: null | string[] = []; + const staticProcess = fork( + nxBin, + [ + 'run', + `${app}:serve-static${ + context.configurationName ? `:${context.configurationName}` : '' + }`, + ...(isUsingModuleFederationDevServerExecutor + ? [`--isInitialHost=false`] + : []), + ], + { + cwd: context.root, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + } + ); + staticProcess.stdout.on('data', (data) => { + if (isCollectingStaticRemoteOutput) { + outWithErr.push(data.toString()); + } else { + outWithErr = null; + staticProcess.stdout.removeAllListeners('data'); + } + }); + staticProcess.stderr.on('data', (data) => logger.info(data.toString())); + staticProcess.on('exit', (code) => { + if (code !== 0) { + logger.info(outWithErr.join('')); + throw new Error(`Remote failed to start. See above for errors.`); + } + }); + process.on('SIGTERM', () => staticProcess.kill('SIGTERM')); + process.on('exit', () => staticProcess.kill('SIGTERM')); } return yield* combineAsyncIterables( @@ -199,13 +155,17 @@ export default async function* moduleFederationDevServer( ...devRemoteIters, createAsyncIterable<{ success: true; baseUrl: string }>( async ({ next, done }) => { - if (remotePorts.length === 0) { + if (!options.isInitialHost) { + done(); + return; + } + if (remotes.remotePorts.length === 0) { done(); return; } try { await Promise.all( - remotePorts.map((port) => + remotes.remotePorts.map((port) => // Allow 20 minutes for each remote to start, which is plenty of time but we can tweak it later if needed. // Most remotes should start in under 1 minute. waitForPortOpen(port, { diff --git a/packages/react/src/executors/module-federation-dev-server/schema.json b/packages/react/src/executors/module-federation-dev-server/schema.json index 59cc7679d6..9ae43269b1 100644 --- a/packages/react/src/executors/module-federation-dev-server/schema.json +++ b/packages/react/src/executors/module-federation-dev-server/schema.json @@ -91,6 +91,16 @@ "baseHref": { "type": "string", "description": "Base url for the application being built." + }, + "static": { + "type": "boolean", + "description": "Whether to use a static file server instead of the webpack-dev-server. This should be used for remote applications that are also host applications." + }, + "isInitialHost": { + "type": "boolean", + "description": "Whether the host that is running this executor is the first in the project tree to do so.", + "default": true, + "x-priority": "internal" } } } diff --git a/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts b/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts new file mode 100644 index 0000000000..99d35cfffb --- /dev/null +++ b/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts @@ -0,0 +1,168 @@ +import { logger, type ProjectGraph } from '@nx/devkit'; +import { registerTsProject } from '@nx/js/src/internal'; +import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; +import * as chalk from 'chalk'; +import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { ModuleFederationConfig } from './models/index'; + +interface ModuleFederationExecutorContext { + projectName: string; + projectGraph: ProjectGraph; + root: string; +} + +function extractRemoteProjectsFromConfig( + config: ModuleFederationConfig, + pathToManifestFile?: string +) { + const remotes = []; + if (pathToManifestFile && existsSync(pathToManifestFile)) { + const moduleFederationManifestJson = readFileSync( + pathToManifestFile, + 'utf-8' + ); + + if (moduleFederationManifestJson) { + // This should have shape of + // { + // "remoteName": "remoteLocation", + // } + const parsedManifest = JSON.parse(moduleFederationManifestJson); + if ( + Object.keys(parsedManifest).every( + (key) => + typeof key === 'string' && typeof parsedManifest[key] === 'string' + ) + ) { + remotes.push(Object.keys(parsedManifest)); + } + } + } + const staticRemotes = + config.remotes?.map((r) => (Array.isArray(r) ? r[0] : r)) ?? []; + remotes.push(...staticRemotes); + return remotes; +} + +function collectRemoteProjects( + remote: string, + collected: Set, + context: ModuleFederationExecutorContext +) { + const remoteProject = context.projectGraph.nodes[remote]?.data; + if (!context.projectGraph.nodes[remote] || collected.has(remote)) { + return; + } + + collected.add(remote); + + const remoteProjectRoot = remoteProject.root; + const remoteProjectTsConfig = remoteProject.targets['build'].options.tsConfig; + const remoteProjectConfig = getModuleFederationConfig( + remoteProjectTsConfig, + context.root, + remoteProjectRoot + ); + const remoteProjectRemotes = + extractRemoteProjectsFromConfig(remoteProjectConfig); + + remoteProjectRemotes.forEach((r) => + collectRemoteProjects(r, collected, context) + ); +} + +export function getRemotes( + devRemotes: string[], + skipRemotes: string[], + config: ModuleFederationConfig, + context: ModuleFederationExecutorContext, + pathToManifestFile?: string +) { + const collectedRemotes = new Set(); + const remotes = extractRemoteProjectsFromConfig(config, pathToManifestFile); + remotes.forEach((r) => collectRemoteProjects(r, collectedRemotes, context)); + const remotesToSkip = new Set( + findMatchingProjects(skipRemotes, context.projectGraph.nodes) ?? [] + ); + + if (remotesToSkip.size > 0) { + logger.info( + `Remotes not served automatically: ${[...remotesToSkip.values()].join( + ', ' + )}` + ); + } + + const knownRemotes = Array.from(collectedRemotes).filter( + (r) => !remotesToSkip.has(r) + ); + + logger.info( + `NX Starting module federation dev-server for ${chalk.bold( + context.projectName + )} with ${knownRemotes.length} remotes` + ); + + const devServeApps = new Set( + !devRemotes + ? [] + : Array.isArray(devRemotes) + ? findMatchingProjects(devRemotes, context.projectGraph.nodes) + : findMatchingProjects([devRemotes], context.projectGraph.nodes) + ); + + const staticRemotes = knownRemotes.filter((r) => !devServeApps.has(r)); + const devServeRemotes = knownRemotes.filter((r) => devServeApps.has(r)); + const remotePorts = knownRemotes.map( + (r) => context.projectGraph.nodes[r].data.targets['serve'].options.port + ); + + return { + staticRemotes, + devRemotes: devServeRemotes, + remotePorts, + }; +} + +export function getModuleFederationConfig( + tsconfigPath: string, + workspaceRoot: string, + projectRoot: string, + pluginName: 'react' | 'angular' = 'react' +) { + const moduleFederationConfigPathJS = join( + workspaceRoot, + projectRoot, + 'module-federation.config.js' + ); + + const moduleFederationConfigPathTS = join( + workspaceRoot, + projectRoot, + 'module-federation.config.ts' + ); + + let moduleFederationConfigPath = moduleFederationConfigPathJS; + + // create a no-op so this can be called with issue + const fullTSconfigPath = tsconfigPath.startsWith(workspaceRoot) + ? tsconfigPath + : join(workspaceRoot, tsconfigPath); + let cleanupTranspiler = () => {}; + if (existsSync(moduleFederationConfigPathTS)) { + cleanupTranspiler = registerTsProject(fullTSconfigPath); + moduleFederationConfigPath = moduleFederationConfigPathTS; + } + + try { + const config = require(moduleFederationConfigPath); + cleanupTranspiler(); + + return config.default || config; + } catch { + throw new Error( + `Could not load ${moduleFederationConfigPath}. Was this project generated with "@nx/${pluginName}:host"?\nSee: https://nx.dev/concepts/more-concepts/faster-builds-with-module-federation` + ); + } +} diff --git a/packages/webpack/src/utils/module-federation/index.ts b/packages/webpack/src/utils/module-federation/index.ts index afe418c156..d8ccb8fd03 100644 --- a/packages/webpack/src/utils/module-federation/index.ts +++ b/packages/webpack/src/utils/module-federation/index.ts @@ -3,3 +3,4 @@ export * from './dependencies'; export * from './package-json'; export * from './remotes'; export * from './models'; +export * from './get-remotes-for-host';