feat(react): add recursive remote serve (#19638)
This commit is contained in:
parent
b0fed89c11
commit
97fac1b0b4
@ -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,
|
||||||
|
|||||||
@ -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": []
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -20,4 +20,6 @@ export interface Schema {
|
|||||||
devRemotes?: string[];
|
devRemotes?: string[];
|
||||||
skipRemotes?: string[];
|
skipRemotes?: string[];
|
||||||
pathToManifestFile?: string;
|
pathToManifestFile?: string;
|
||||||
|
static?: boolean;
|
||||||
|
isInitialHost?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user