feat(react): add recursive remote serve (#19638)

This commit is contained in:
Colum Ferry 2023-10-17 14:33:28 +01:00 committed by GitHub
parent b0fed89c11
commit 97fac1b0b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 644 additions and 202 deletions

View File

@ -112,6 +112,16 @@
"pathToManifestFile": { "pathToManifestFile": {
"type": "string", "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." "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, "additionalProperties": false,

View File

@ -90,6 +90,16 @@
"baseHref": { "baseHref": {
"type": "string", "type": "string",
"description": "Base url for the application being built." "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": [] "presets": []

View File

@ -385,4 +385,97 @@ describe('Angular Module Federation', () => {
await killProcessAndPorts(hostE2eResults.pid); await killProcessAndPorts(hostE2eResults.pid);
} }
}, 500_000); }, 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: \`<div class="childremote">{{title}}</div>\`
})
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);
}); });

View File

@ -549,6 +549,89 @@ describe('React Module Federation', () => {
} }
}, 500_000); }, 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 (
<React.Suspense fallback={null}>
<div className='remote'>
My Remote Library: { myLib() }
</div>
<NxWelcome title="Host" />
</React.Suspense>
);
}
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', () => { describe('Promised based remotes', () => {
it('should support promised based remotes', async () => { it('should support promised based remotes', async () => {
const remote = uniq('remote'); const remote = uniq('remote');

View File

@ -64,6 +64,7 @@
"@nx/js": "file:../js", "@nx/js": "file:../js",
"@nx/eslint": "file:../eslint", "@nx/eslint": "file:../eslint",
"@nx/webpack": "file:../webpack", "@nx/webpack": "file:../webpack",
"@nx/web": "file:../web",
"@nx/workspace": "file:../workspace" "@nx/workspace": "file:../workspace"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -1,5 +1,10 @@
import type { Schema } from './schema'; 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 { scheduleTarget } from 'nx/src/adapter/ngcli-adapter';
import { executeWebpackDevServerBuilder } from '../webpack-dev-server/webpack-dev-server.impl'; import { executeWebpackDevServerBuilder } from '../webpack-dev-server/webpack-dev-server.impl';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph'; import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
@ -11,18 +16,66 @@ import {
} from '../utilities/module-federation'; } from '../utilities/module-federation';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { extname, join } from 'path'; 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( export function executeModuleFederationDevServerBuilder(
schema: Schema, schema: Schema,
context: import('@angular-devkit/architect').BuilderContext context: import('@angular-devkit/architect').BuilderContext
): ReturnType<typeof executeWebpackDevServerBuilder> { ): ReturnType<typeof executeWebpackDevServerBuilder | any> {
const nxBin = require.resolve('nx');
const { ...options } = schema; const { ...options } = schema;
const projectGraph = readCachedProjectGraph(); const projectGraph = readCachedProjectGraph();
const { projects: workspaceProjects } = const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(projectGraph); readProjectsConfigurationFromProjectGraph(projectGraph);
const project = workspaceProjects[context.target.project]; 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( let pathToManifestFile = join(
context.workspaceRoot, context.workspaceRoot,
project.sourceRoot, project.sourceRoot,
@ -48,70 +101,107 @@ export function executeModuleFederationDevServerBuilder(
validateDevRemotes(options, workspaceProjects); validateDevRemotes(options, workspaceProjects);
const remotesToSkip = new Set( const moduleFederationConfig = getModuleFederationConfig(
findMatchingProjects(options.skipRemotes, projectGraph.nodes) ?? [] project.targets.build.options.tsConfig,
context.workspaceRoot,
project.root,
'angular'
); );
if (remotesToSkip.size > 0) { const remotes = getRemotes(
logger.info( options.devRemotes,
`Remotes not served automatically: ${[...remotesToSkip].join(', ')}` options.skipRemotes,
); moduleFederationConfig,
} {
const staticRemotes = getStaticRemotes( projectName: project.name,
project, projectGraph,
context, root: context.workspaceRoot,
workspaceProjects, },
remotesToSkip
);
const dynamicRemotes = getDynamicRemotes(
project,
context,
workspaceProjects,
remotesToSkip,
pathToManifestFile pathToManifestFile
); );
const remotes = [...staticRemotes, ...dynamicRemotes];
const devServeRemotes = !options.devRemotes let isCollectingStaticRemoteOutput = true;
? []
: Array.isArray(options.devRemotes)
? findMatchingProjects(options.devRemotes, projectGraph.nodes)
: findMatchingProjects([options.devRemotes], projectGraph.nodes);
for (const remote of remotes) { for (const app of remotes.staticRemotes) {
const isDev = devServeRemotes.includes(remote); const remoteProjectServeTarget =
const target = isDev ? 'serve' : 'serve-static'; projectGraph.nodes[app].data.targets['serve-static'];
const isUsingModuleFederationDevServerExecutor =
if (!workspaceProjects[remote].targets?.[target]) { remoteProjectServeTarget.executor.includes(
throw new Error( 'module-federation-dev-server'
`Could not find "${target}" target in "${remote}" project.`
); );
} else if (!workspaceProjects[remote].targets?.[target].executor) { 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( throw new Error(
`Could not find executor for "${target}" target in "${remote}" project.` `Could not find executor for "serve" target in "${app}" project.`
); );
} }
const runOptions: { verbose?: boolean } = {}; const runOptions: { verbose?: boolean; isInitialHost?: boolean } = {};
if (options.verbose) {
const [collection, executor] = const [collection, executor] =
workspaceProjects[remote].targets[target].executor.split(':'); workspaceProjects[app].targets['serve'].executor.split(':');
const isUsingModuleFederationDevServerExecutor = executor.includes(
'module-federation-dev-server'
);
const { schema } = getExecutorInformation( const { schema } = getExecutorInformation(
collection, collection,
executor, executor,
workspaceRoot workspaceRoot
); );
if (
if (schema.additionalProperties || 'verbose' in schema.properties) { (options.verbose && schema.additionalProperties) ||
'verbose' in schema.properties
) {
runOptions.verbose = options.verbose; runOptions.verbose = options.verbose;
} }
if (isUsingModuleFederationDevServerExecutor) {
runOptions.isInitialHost = false;
} }
scheduleTarget( const serve$ = scheduleTarget(
context.workspaceRoot, context.workspaceRoot,
{ {
project: remote, project: app,
target, target: 'serve',
configuration: context.target.configuration, configuration: context.target.configuration,
runOptions, runOptions,
}, },
@ -119,13 +209,17 @@ export function executeModuleFederationDevServerBuilder(
).then((obs) => { ).then((obs) => {
obs.toPromise().catch((err) => { obs.toPromise().catch((err) => {
throw new Error( 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( export default require('@angular-devkit/architect').createBuilder(

View File

@ -20,4 +20,6 @@ export interface Schema {
devRemotes?: string[]; devRemotes?: string[];
skipRemotes?: string[]; skipRemotes?: string[];
pathToManifestFile?: string; pathToManifestFile?: string;
static?: boolean;
isInitialHost?: boolean;
} }

View File

@ -122,6 +122,16 @@
"pathToManifestFile": { "pathToManifestFile": {
"type": "string", "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." "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, "additionalProperties": false,

View File

@ -6,22 +6,24 @@ import {
runExecutor, runExecutor,
} from '@nx/devkit'; } from '@nx/devkit';
import devServerExecutor from '@nx/webpack/src/executors/dev-server/dev-server.impl'; 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 { WebDevServerOptions } from '@nx/webpack/src/executors/dev-server/schema';
import { join } from 'path'; import {
getModuleFederationConfig,
getRemotes,
} from '@nx/webpack/src/utils/module-federation';
import { import {
combineAsyncIterables, combineAsyncIterables,
createAsyncIterable, createAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable'; } from '@nx/devkit/src/utils/async-iterable';
import * as chalk from 'chalk';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open'; 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 { fork } from 'child_process';
import { existsSync } from 'fs';
import { registerTsProject } from '@nx/js/src/internal';
type ModuleFederationDevServerOptions = WebDevServerOptions & { type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: string | string[]; devRemotes?: string[];
skipRemotes?: string[]; skipRemotes?: string[];
static?: boolean;
isInitialHost?: boolean;
}; };
function getBuildOptions(buildTarget: string, context: ExecutorContext) { function getBuildOptions(buildTarget: string, context: ExecutorContext) {
@ -34,140 +36,95 @@ 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( export default async function* moduleFederationDevServer(
options: ModuleFederationDevServerOptions, options: ModuleFederationDevServerOptions,
context: ExecutorContext context: ExecutorContext
): AsyncIterableIterator<{ success: boolean; baseUrl?: string }> { ): AsyncIterableIterator<{ success: boolean; baseUrl?: string }> {
const nxBin = require.resolve('nx'); 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 p = context.projectsConfigurations.projects[context.projectName];
const buildOptions = getBuildOptions(options.buildTarget, context); const buildOptions = getBuildOptions(options.buildTarget, context);
if (!options.isInitialHost) {
return yield* currIter;
}
const moduleFederationConfig = getModuleFederationConfig( const moduleFederationConfig = getModuleFederationConfig(
buildOptions.tsConfig, buildOptions.tsConfig,
context.root, context.root,
p.root p.root,
'react'
); );
const remotesToSkip = new Set( const remotes = getRemotes(
findMatchingProjects(options.skipRemotes, context.projectGraph.nodes) ?? [] options.devRemotes,
); options.skipRemotes,
moduleFederationConfig,
if (remotesToSkip.size > 0) { {
logger.info( projectName: context.projectName,
`Remotes not served automatically: ${[...remotesToSkip.values()].join( projectGraph: context.projectGraph,
', ' root: context.root,
)}`
);
} }
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;
}
});
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; let isCollectingStaticRemoteOutput = true;
const devRemoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes.devRemotes) {
const remoteProjectServeTarget =
context.projectGraph.nodes[app].data.targets['serve'];
const isUsingModuleFederationDevServerExecutor =
remoteProjectServeTarget.executor.includes(
'module-federation-dev-server'
);
for (const app of knownRemotes) {
const appName = Array.isArray(app) ? app[0] : app;
if (devServeApps.includes(appName)) {
devRemoteIters.push( devRemoteIters.push(
await runExecutor( await runExecutor(
{ {
project: appName, project: app,
target: 'serve', target: 'serve',
configuration: context.configurationName, configuration: context.configurationName,
}, },
{ {
watch: true, watch: true,
...(isUsingModuleFederationDevServerExecutor
? { isInitialHost: false }
: {}),
}, },
context context
) )
); );
} else { }
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'
);
let outWithErr: null | string[] = []; let outWithErr: null | string[] = [];
const staticProcess = fork( const staticProcess = fork(
nxBin, nxBin,
[ [
'run', 'run',
`${appName}:serve-static${ `${app}:serve-static${
context.configurationName ? `:${context.configurationName}` : '' context.configurationName ? `:${context.configurationName}` : ''
}`, }`,
...(isUsingModuleFederationDevServerExecutor
? [`--isInitialHost=false`]
: []),
], ],
{ {
cwd: context.root, cwd: context.root,
@ -192,20 +149,23 @@ export default async function* moduleFederationDevServer(
process.on('SIGTERM', () => staticProcess.kill('SIGTERM')); process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM')); process.on('exit', () => staticProcess.kill('SIGTERM'));
} }
}
return yield* combineAsyncIterables( return yield* combineAsyncIterables(
currIter, currIter,
...devRemoteIters, ...devRemoteIters,
createAsyncIterable<{ success: true; baseUrl: string }>( createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => { async ({ next, done }) => {
if (remotePorts.length === 0) { if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length === 0) {
done(); done();
return; return;
} }
try { try {
await Promise.all( 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. // 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. // Most remotes should start in under 1 minute.
waitForPortOpen(port, { waitForPortOpen(port, {

View File

@ -91,6 +91,16 @@
"baseHref": { "baseHref": {
"type": "string", "type": "string",
"description": "Base url for the application being built." "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"
} }
} }
} }

View File

@ -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<string>,
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<string>();
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`
);
}
}

View File

@ -3,3 +3,4 @@ export * from './dependencies';
export * from './package-json'; export * from './package-json';
export * from './remotes'; export * from './remotes';
export * from './models'; export * from './models';
export * from './get-remotes-for-host';