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';