feat(module-federation): Update SSR enabling static serving for remotes (#27345)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

Currently, the default for remotes is to server them as development.
Which means a separate node process for each remote that is inside the
workspace.

This does not scale well and can lead to out of memory exceptions.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

Remotes will start as static by default, which allows for better scaling
as the remotes increase.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

TODO
- [ ] Migrations
This commit is contained in:
Nicholas Cunningham 2024-08-14 07:14:58 -06:00 committed by GitHub
parent 225a8e019c
commit ab162ebb54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1159 additions and 434 deletions

View File

@ -6755,6 +6755,14 @@
"isExternal": false,
"disableCollapsible": false
},
{
"id": "module-federation-dev-ssr",
"path": "/nx-api/angular/executors/module-federation-dev-ssr",
"name": "module-federation-dev-ssr",
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "application",
"path": "/nx-api/angular/executors/application",
@ -6794,14 +6802,6 @@
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "module-federation-dev-ssr",
"path": "/nx-api/angular/executors/module-federation-dev-ssr",
"name": "module-federation-dev-ssr",
"children": [],
"isExternal": false,
"disableCollapsible": false
}
],
"isExternal": false,

View File

@ -98,6 +98,15 @@
"path": "/nx-api/angular/executors/module-federation-dev-server",
"type": "executor"
},
"/nx-api/angular/executors/module-federation-dev-ssr": {
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"file": "generated/packages/angular/executors/module-federation-dev-ssr.json",
"hidden": false,
"name": "module-federation-dev-ssr",
"originalFilePath": "/packages/angular/src/executors/module-federation-ssr-dev-server/schema.json",
"path": "/nx-api/angular/executors/module-federation-dev-ssr",
"type": "executor"
},
"/nx-api/angular/executors/application": {
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/) with integrated SSR and prerendering capabilities. _Note: this is only supported in Angular versions >= 17.0.0_.",
"file": "generated/packages/angular/executors/application.json",
@ -142,15 +151,6 @@
"originalFilePath": "/packages/angular/src/builders/webpack-server/schema.json",
"path": "/nx-api/angular/executors/webpack-server",
"type": "executor"
},
"/nx-api/angular/executors/module-federation-dev-ssr": {
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host.",
"file": "generated/packages/angular/executors/module-federation-dev-ssr.json",
"hidden": false,
"name": "module-federation-dev-ssr",
"originalFilePath": "/packages/angular/src/builders/module-federation-dev-ssr/schema.json",
"path": "/nx-api/angular/executors/module-federation-dev-ssr",
"type": "executor"
}
},
"generators": {

View File

@ -93,6 +93,15 @@
"path": "angular/executors/module-federation-dev-server",
"type": "executor"
},
{
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"file": "generated/packages/angular/executors/module-federation-dev-ssr.json",
"hidden": false,
"name": "module-federation-dev-ssr",
"originalFilePath": "/packages/angular/src/executors/module-federation-ssr-dev-server/schema.json",
"path": "angular/executors/module-federation-dev-ssr",
"type": "executor"
},
{
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/) with integrated SSR and prerendering capabilities. _Note: this is only supported in Angular versions >= 17.0.0_.",
"file": "generated/packages/angular/executors/application.json",
@ -137,15 +146,6 @@
"originalFilePath": "/packages/angular/src/builders/webpack-server/schema.json",
"path": "angular/executors/webpack-server",
"type": "executor"
},
{
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host.",
"file": "generated/packages/angular/executors/module-federation-dev-ssr.json",
"hidden": false,
"name": "module-federation-dev-ssr",
"originalFilePath": "/packages/angular/src/builders/module-federation-dev-ssr/schema.json",
"path": "angular/executors/module-federation-dev-ssr",
"type": "executor"
}
],
"generators": [

View File

@ -1,10 +1,11 @@
{
"name": "module-federation-dev-ssr",
"implementation": "/packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts",
"implementation": "/packages/angular/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts",
"schema": {
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Module Federation SSR Dev Server Target",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host.",
"outputCapture": "direct-nodejs",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"type": "object",
"properties": {
"browserTarget": {
@ -65,7 +66,20 @@
},
"devRemotes": {
"type": "array",
"items": { "type": "string" },
"items": {
"oneOf": [
{ "type": "string" },
{
"type": "object",
"properties": {
"remoteName": { "type": "string" },
"configuration": { "type": "string" }
},
"required": ["remoteName"],
"additionalProperties": false
}
]
},
"description": "List of remote applications to run in development mode (i.e. using serve target).",
"x-priority": "important"
},
@ -82,15 +96,29 @@
"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."
},
"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"
},
"parallel": {
"type": "number",
"description": "Max number of parallel processes for building static remotes"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
}
},
"additionalProperties": false,
"required": ["browserTarget", "serverTarget"],
"presets": []
},
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host.",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"aliases": [],
"hidden": false,
"path": "/packages/angular/src/builders/module-federation-dev-ssr/schema.json",
"path": "/packages/angular/src/executors/module-federation-ssr-dev-server/schema.json",
"type": "executor"
}

View File

@ -41,6 +41,33 @@
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
},
"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."
},
"ssl": {
"type": "boolean",
"description": "Serve using HTTPS.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving HTTPS."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"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"
}
},
"required": ["browserTarget", "serverTarget"],

View File

@ -327,12 +327,12 @@
- [package](/nx-api/angular/executors/package)
- [browser-esbuild](/nx-api/angular/executors/browser-esbuild)
- [module-federation-dev-server](/nx-api/angular/executors/module-federation-dev-server)
- [module-federation-dev-ssr](/nx-api/angular/executors/module-federation-dev-ssr)
- [application](/nx-api/angular/executors/application)
- [extract-i18n](/nx-api/angular/executors/extract-i18n)
- [webpack-browser](/nx-api/angular/executors/webpack-browser)
- [dev-server](/nx-api/angular/executors/dev-server)
- [webpack-server](/nx-api/angular/executors/webpack-server)
- [module-federation-dev-ssr](/nx-api/angular/executors/module-federation-dev-ssr)
- [generators](/nx-api/angular/generators)
- [add-linting](/nx-api/angular/generators/add-linting)
- [application](/nx-api/angular/generators/application)

View File

@ -2,6 +2,7 @@ import { names } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
killPorts,
killProcessAndPorts,
newProject,
readJson,
@ -230,48 +231,47 @@ describe('Angular Module Federation', () => {
const remote2Port = readJson(join(remote2, 'project.json')).targets.serve
.options.port;
const processSwc = await runCommandUntil(
`serve-ssr ${host} --port=${hostPort}`,
(output) =>
output.includes(
`Node Express server listening on http://localhost:${remote1Port}`
) &&
output.includes(
`Node Express server listening on http://localhost:${remote2Port}`
) &&
output.includes(
`Angular Universal Live Development Server is listening`
)
[host, remote1, remote2].forEach((app) => {
checkFilesExist(
`${app}/module-federation.config.ts`,
`${app}/webpack.server.config.ts`
);
await killProcessAndPorts(
processSwc.pid,
hostPort,
remote1Port,
remote2Port
);
['build', 'server'].forEach((target) => {
['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:${target}:${configuration}`);
expect(cliOutput).toContain('Successfully ran target');
const processTsNode = await runCommandUntil(
`serve-ssr ${host} --port=${hostPort}`,
(output) =>
output.includes(
`Node Express server listening on http://localhost:${remote1Port}`
) &&
output.includes(
`Node Express server listening on http://localhost:${remote2Port}`
) &&
output.includes(
`Angular Universal Live Development Server is listening`
),
{ env: { NX_PREFER_TS_NODE: 'true' } }
);
await killPorts(readPort(app));
});
});
});
await killProcessAndPorts(
processTsNode.pid,
hostPort,
remote1Port,
remote2Port
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
updateFile(
`${host}-e2e/src/example.spec.ts`,
(_) => `import { test, expect } from '@playwright/test';
test('renders remotes', async ({ page }) => {
await page.goto('/');
// Expect the page to contain a specific text.
// get ul li text
const items = page.locator('ul li');
await items.nth(2).waitFor()
expect(await items.count()).toEqual(3);
expect(await items.nth(0).innerText()).toContain('Home');
expect(await items.nth(1).innerText()).toContain('${capitalize(remote1)}');
expect(await items.nth(2).innerText()).toContain('${capitalize(remote2)}');
});`
);
if (runE2ETests()) {
const e2eProcess = await runCommandUntil(`e2e ${host}-e2e`, (output) =>
output.includes(`Successfully ran target e2e for project ${host}-e2e`)
);
await killProcessAndPorts(e2eProcess.pid);
}
}, 20_000_000);
it('should should support generating host and remote apps with --project-name-and-root-format=derived', async () => {
@ -497,3 +497,13 @@ describe('Angular Module Federation', () => {
}
}, 500_000);
});
function readPort(appName: string): number {
let config;
try {
config = readJson(join('apps', appName, 'project.json'));
} catch {
config = readJson(join(appName, 'project.json'));
}
return config.targets.serve.options.port;
}

View File

@ -22,7 +22,7 @@ import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
describe('React Module Federation', () => {
describe('Default Configuration', () => {
beforeAll(() => {
newProject({ packages: ['@nx/react'] });
newProject({ packages: ['@nx/react', '@nx/webpack'] });
});
afterAll(() => cleanupProject());
@ -126,6 +126,7 @@ describe('React Module Federation', () => {
500_000
);
describe('ssr', () => {
it('should generate host and remote apps with ssr', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
@ -157,6 +158,68 @@ describe('React Module Federation', () => {
});
}, 500_000);
it('should serve remotes as static when running the host by default', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
await runCLIAsync(
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
);
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
output.includes(`Nx SSR Static remotes proxies started successfully`)
);
await killProcessAndPorts(serveResult.pid);
}, 500_000);
it('should serve remotes as static and they should be able to be accessed from the host', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
await runCLIAsync(
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
);
const capitalize = (s: string) =>
s.charAt(0).toUpperCase() + s.slice(1);
updateFile(`apps/${shell}-e2e/src/e2e/app.cy.ts`, (content) => {
return `
describe('${shell}-e2e', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
expect(cy.get('ul li').should('have.length', 4));
expect(cy.get('ul li').eq(0).should('have.text', 'Home'));
expect(cy.get('ul li').eq(1).should('have.text', '${capitalize(
remote1
)}'));
expect(cy.get('ul li').eq(2).should('have.text', '${capitalize(
remote2
)}'));
expect(cy.get('ul li').eq(3).should('have.text', '${capitalize(
remote3
)}'));
});
});
`;
});
if (runE2ETests()) {
const hostE2eResults = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(hostE2eResults.pid);
}
}, 600_000);
});
it('should should support generating host and remote apps with the new name and root format', async () => {
const shell = uniq('shell');
const remote = uniq('remote');

View File

@ -25,6 +25,11 @@
"schema": "./src/executors/module-federation-dev-server/schema.json",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) allowing to specify which remote applications should be served with the host."
},
"module-federation-dev-ssr": {
"implementation": "./src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl",
"schema": "./src/executors/module-federation-ssr-dev-server/schema.json",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host."
},
"application": {
"implementation": "./src/executors/application/application.impl",
"schema": "./src/executors/application/schema.json",
@ -51,11 +56,6 @@
"implementation": "./src/builders/webpack-server/webpack-server.impl",
"schema": "./src/builders/webpack-server/schema.json",
"description": "Builds a server Angular application using [webpack](https://webpack.js.org/). This executor is a drop-in replacement for the `@angular-devkit/build-angular:server` builder provided by the Angular CLI. It is usually used in tandem with the `@nx/angular:webpack-browser` executor when your Angular application uses a custom webpack configuration."
},
"module-federation-dev-ssr": {
"implementation": "./src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl",
"schema": "./src/builders/module-federation-dev-ssr/schema.json",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host."
}
}
}

View File

@ -1,4 +1,3 @@
export * from './src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl';
export * from './src/builders/webpack-browser/webpack-browser.impl';
export * from './src/builders/webpack-server/webpack-server.impl';
export * from './src/executors/module-federation-dev-server/module-federation-dev-server.impl';
@ -8,6 +7,7 @@ export * from './src/executors/package/package.impl';
export * from './src/executors/browser-esbuild/browser-esbuild.impl';
export * from './src/executors/application/application.impl';
export * from './src/executors/extract-i18n/extract-i18n.impl';
export * from './src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl';
import { executeDevServerBuilder } from './src/builders/dev-server/dev-server.impl';

View File

@ -1,181 +0,0 @@
import {
getPackageManagerCommand,
readCachedProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import { execSync, fork } from 'child_process';
import { existsSync } from 'fs';
import { scheduleTarget } from 'nx/src/adapter/ngcli-adapter';
import { getExecutorInformation } from 'nx/src/command-line/run/executor-utils';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
import { extname, join } from 'path';
import { from } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { getInstalledAngularVersionInfo } from '../../executors/utilities/angular-version-utils';
import {
getDynamicMfManifestFile,
getDynamicRemotes,
getStaticRemotes,
validateDevRemotes,
} from '../utilities/module-federation';
import type { Schema } from './schema';
export function executeModuleFederationDevSSRBuilder(
schema: Schema,
context: import('@angular-devkit/architect').BuilderContext
) {
const { ...options } = schema;
const projectGraph = readCachedProjectGraph();
const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(projectGraph);
const project = workspaceProjects[context.target.project];
let pathToManifestFile: string;
if (options.pathToManifestFile) {
const userPathToManifestFile = join(
context.workspaceRoot,
options.pathToManifestFile
);
if (!existsSync(userPathToManifestFile)) {
throw new Error(
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
);
} else if (extname(options.pathToManifestFile) !== '.json') {
throw new Error(
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
);
}
pathToManifestFile = userPathToManifestFile;
} else {
pathToManifestFile = getDynamicMfManifestFile(
project,
context.workspaceRoot
);
}
const devServeRemotes = !options.devRemotes
? []
: Array.isArray(options.devRemotes)
? options.devRemotes
: [options.devRemotes];
// Set NX_MF_DEV_REMOTES for the Nx Runtime Library Control Plugin
process.env.NX_MF_DEV_REMOTES = JSON.stringify(devServeRemotes);
validateDevRemotes({ devRemotes: devServeRemotes }, workspaceProjects);
const remotesToSkip = new Set(options.skipRemotes ?? []);
const staticRemotes = getStaticRemotes(
project,
context,
workspaceProjects,
remotesToSkip
);
const dynamicRemotes = getDynamicRemotes(
project,
context,
workspaceProjects,
remotesToSkip,
pathToManifestFile
);
const remotes = [...staticRemotes, ...dynamicRemotes];
const remoteProcessPromises = [];
for (const remote of remotes) {
const isDev = devServeRemotes.includes(remote);
const target = isDev ? 'serve-ssr' : 'static-server';
if (!workspaceProjects[remote].targets?.[target]) {
throw new Error(
`Could not find "${target}" target in "${remote}" project.`
);
} 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,
workspaceProjects
);
if (schema.additionalProperties || 'verbose' in schema.properties) {
runOptions.verbose = options.verbose;
}
}
const remotePromise = new Promise<void>((res, rej) => {
if (target === 'static-server') {
const remoteProject = workspaceProjects[remote];
const remoteServerOutput = join(
workspaceRoot,
remoteProject.targets.server.options.outputPath,
'main.js'
);
const pm = getPackageManagerCommand();
execSync(
`${pm.exec} nx run ${remote}:server${
context.target.configuration
? `:${context.target.configuration}`
: ''
}`,
{ stdio: 'inherit' }
);
const child = fork(remoteServerOutput, {
env: { PORT: remoteProject.targets['serve-ssr'].options.port },
});
child.on('message', (msg) => {
if (msg === 'nx.server.ready') {
res();
}
});
}
if (target === 'serve-ssr') {
scheduleTarget(
context.workspaceRoot,
{
project: remote,
target,
configuration: context.target.configuration,
runOptions,
projects: workspaceProjects,
},
options.verbose
).then((obs) =>
obs
.pipe(
tap((result) => {
result.success && res();
})
)
.toPromise()
);
}
});
remoteProcessPromises.push(remotePromise);
}
const { major: angularMajorVersion } = getInstalledAngularVersionInfo();
const { executeSSRDevServerBuilder } =
angularMajorVersion >= 17
? require('@angular-devkit/build-angular')
: require('@nguniversal/builders');
return from(Promise.all(remoteProcessPromises)).pipe(
switchMap(() => executeSSRDevServerBuilder(options, context))
);
}
export default require('@angular-devkit/architect').createBuilder(
executeModuleFederationDevSSRBuilder
);

View File

@ -0,0 +1,91 @@
import type { Schema } from '../schema';
import { type ExecutorContext, logger } from '@nx/devkit';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { fork } from 'node:child_process';
import { join } from 'node:path';
import { createWriteStream } from 'node:fs';
import type { StaticRemotesConfig } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
export async function buildStaticRemotes(
staticRemotesConfig: StaticRemotesConfig,
nxBin,
context: ExecutorContext,
options: Schema
) {
if (!staticRemotesConfig.remotes.length) {
return;
}
const mappedLocationOfRemotes: Record<string, string> = {};
for (const app of staticRemotesConfig.remotes) {
mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${
options.host
}:${options.staticRemotesPort}/${
staticRemotesConfig.config[app].urlSegment
}`;
}
await new Promise<void>((resolve, reject) => {
logger.info(
`NX Building ${staticRemotesConfig.remotes.length} static remotes...`
);
const staticProcess = fork(
nxBin,
[
'run-many',
`--target=server`,
`--projects=${staticRemotesConfig.remotes.join(',')}`,
...(context.configurationName
? [`--configuration=${context.configurationName}`]
: []),
...(options.parallel ? [`--parallel=${options.parallel}`] : []),
],
{
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}
);
// File to debug build failures e.g. 2024-01-01T00_00_0_0Z-build.log'
const remoteBuildLogFile = join(
workspaceDataDirectory,
`${new Date().toISOString().replace(/[:\.]/g, '_')}-build.log`
);
const stdoutStream = createWriteStream(remoteBuildLogFile);
staticProcess.stdout.on('data', (data) => {
const ANSII_CODE_REGEX =
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
const stdoutString = data.toString().replace(ANSII_CODE_REGEX, '');
stdoutStream.write(stdoutString);
// in addition to writing into the stdout stream, also show error directly in console
// so the error is easily discoverable. 'ERROR in' is the key word to search in webpack output.
if (stdoutString.includes('ERROR in')) {
logger.log(stdoutString);
}
if (stdoutString.includes('Successfully ran target server')) {
staticProcess.stdout.removeAllListeners('data');
logger.info(
`NX Built ${staticRemotesConfig.remotes.length} static remotes`
);
resolve();
}
});
staticProcess.stderr.on('data', (data) => logger.info(data.toString()));
staticProcess.once('exit', (code) => {
stdoutStream.end();
staticProcess.stdout.removeAllListeners('data');
staticProcess.stderr.removeAllListeners('data');
if (code !== 0) {
reject(
`Remote failed to start. A complete log can be found in: ${remoteBuildLogFile}`
);
} else {
resolve();
}
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
return mappedLocationOfRemotes;
}

View File

@ -0,0 +1,19 @@
import { workspaceRoot } from '@nx/devkit';
import type { Schema } from '../schema';
import { join } from 'path';
export function normalizeOptions(options: Schema): Schema {
const devServeRemotes = !options.devRemotes
? []
: Array.isArray(options.devRemotes)
? options.devRemotes
: [options.devRemotes];
return {
...options,
devRemotes: devServeRemotes,
ssl: options.ssl ?? false,
sslCert: options.sslCert ? join(workspaceRoot, options.sslCert) : undefined,
sslKey: options.sslKey ? join(workspaceRoot, options.sslKey) : undefined,
};
}

View File

@ -0,0 +1,58 @@
import { type Schema } from '../schema';
import {
type ExecutorContext,
type ProjectConfiguration,
runExecutor,
} from '@nx/devkit';
export async function startRemotes(
remotes: string[],
workspaceProjects: Record<string, ProjectConfiguration>,
options: Schema,
context: ExecutorContext
) {
const target = 'serve-ssr';
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes) {
if (!workspaceProjects[app].targets?.[target]) {
throw new Error(`Could not find "${target}" target in "${app}" project.`);
} else if (!workspaceProjects[app].targets?.[target].executor) {
throw new Error(
`Could not find executor for "${target}" target in "${app}" project.`
);
}
const [_, executor] =
workspaceProjects[app].targets[target].executor.split(':');
const isUsingModuleFederationSsrDevServerExecutor = executor.includes(
'module-federation-dev-ssr'
);
const configurationOverride = options.devRemotes.find(
(
r
): r is {
remoteName: string;
configuration: string;
} => typeof r !== 'string' && r.remoteName === app
)?.configuration;
remoteIters.push(
await runExecutor(
{
project: app,
target,
configuration: configurationOverride ?? context.configurationName,
},
{
...{ verbose: options.verbose ?? false },
...(isUsingModuleFederationSsrDevServerExecutor
? { isInitialHost: false }
: {}),
},
context
)
);
}
return remoteIters;
}

View File

@ -0,0 +1,50 @@
import { type ExecutorContext, workspaceRoot } from '@nx/devkit';
import { type Schema } from '../schema';
import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
import { join } from 'path';
import { cpSync, rmSync } from 'fs';
import type { StaticRemotesConfig } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
export function startStaticRemotes(
ssrStaticRemotesConfig: StaticRemotesConfig,
context: ExecutorContext,
options: Schema
) {
if (ssrStaticRemotesConfig.remotes.length === 0) {
return;
}
// The directories are usually generated with /browser and /server suffixes so we need to copy them to a common directory
const commonOutputDirectory = join(workspaceRoot, 'tmp/static-remotes');
for (const app of ssrStaticRemotesConfig.remotes) {
const remoteConfig = ssrStaticRemotesConfig.config[app];
cpSync(
remoteConfig.outputPath,
join(commonOutputDirectory, remoteConfig.urlSegment),
{
force: true,
recursive: true,
}
);
}
const staticRemotesIter = fileServerExecutor(
{
cors: true,
watch: false,
staticFilePath: commonOutputDirectory,
parallel: false,
spa: false,
withDeps: false,
host: options.host,
port: options.staticRemotesPort,
ssl: options.ssl,
sslCert: options.sslCert,
sslKey: options.sslKey,
cacheSeconds: -1,
},
context
);
return staticRemotesIter;
}

View File

@ -0,0 +1,196 @@
import { type ExecutorContext, logger } from '@nx/devkit';
import { existsSync } from 'fs';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
import { extname, join } from 'path';
import {
getDynamicMfManifestFile,
validateDevRemotes,
} from '../../builders/utilities/module-federation';
import type { Schema } from './schema';
import {
getModuleFederationConfig,
getRemotes,
} from '@nx/webpack/src/utils/module-federation';
import { parseStaticSsrRemotesConfig } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
import { buildStaticRemotes } from './lib/build-static-remotes';
import { startRemotes } from './lib/start-dev-remotes';
import { startStaticRemotes } from './lib/start-static-remotes';
import {
combineAsyncIterables,
createAsyncIterable,
mapAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable';
import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { normalizeOptions } from './lib/normalize-options';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
import { startSsrRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-ssr-remote-proxies';
import { executeDevServerBuilder } from '../../builders/dev-server/dev-server.impl';
export async function* moduleFederationSsrDevServerExecutor(
schema: Schema,
context: ExecutorContext
) {
const nxBin = require.resolve('nx/bin/nx');
const options = normalizeOptions(schema);
const currIter = eachValueFrom(
executeDevServerBuilder(
options,
await createBuilderContext(
{
builderName: '@nx/angular:webpack-server',
description: 'Build a ssr application',
optionSchema: require('../../builders/webpack-server/schema.json'),
},
context
)
)
);
if (options.isInitialHost === false) {
return yield* currIter;
}
const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(context.projectGraph);
const project = workspaceProjects[context.projectName];
let pathToManifestFile: string;
if (options.pathToManifestFile) {
const userPathToManifestFile = join(
context.root,
options.pathToManifestFile
);
if (!existsSync(userPathToManifestFile)) {
throw new Error(
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
);
} else if (extname(options.pathToManifestFile) !== '.json') {
throw new Error(
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
);
}
pathToManifestFile = userPathToManifestFile;
} else {
pathToManifestFile = getDynamicMfManifestFile(project, context.root);
}
validateDevRemotes({ devRemotes: options.devRemotes }, workspaceProjects);
const moduleFederationConfig = getModuleFederationConfig(
project.targets.build.options.tsConfig,
context.root,
project.root,
'angular'
);
const remoteNames = options.devRemotes.map((r) =>
typeof r === 'string' ? r : r.remoteName
);
const remotes = getRemotes(
remoteNames,
options.skipRemotes,
moduleFederationConfig,
{
projectName: project.name,
projectGraph: context.projectGraph,
root: context.root,
},
pathToManifestFile
);
options.staticRemotesPort ??= remotes.staticRemotePort;
const staticRemotesConfig = parseStaticSsrRemotesConfig(
[...remotes.staticRemotes, ...remotes.dynamicRemotes],
context
);
const mappedLocationsOfStaticRemotes = await buildStaticRemotes(
staticRemotesConfig,
nxBin,
context,
options
);
// Set NX_MF_DEV_REMOTES for the Nx Runtime Library Control Plugin
process.env.NX_MF_DEV_REMOTES = JSON.stringify(options.devRemotes);
const devRemotes = await startRemotes(
remotes.devRemotes,
workspaceProjects,
options,
context
);
const staticRemotes = startStaticRemotes(
staticRemotesConfig,
context,
options
);
startSsrRemoteProxies(
staticRemotesConfig,
mappedLocationsOfStaticRemotes,
options.ssl
? { pathToCert: options.sslCert, pathToKey: options.sslKey }
: undefined
);
const removeBaseUrlEmission = (iter: AsyncIterable<unknown>) =>
mapAsyncIterable(iter, (v) => ({
...v,
baseUrl: undefined,
}));
return yield* combineAsyncIterables(
removeBaseUrlEmission(currIter),
...devRemotes.map(removeBaseUrlEmission),
...(staticRemotes ? [removeBaseUrlEmission(staticRemotes)] : []),
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length) {
logger.info(
`Nx All remotes started, server ready at http://localhost:${options.port}`
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
done();
return;
}
try {
const portsToWaitFor = staticRemotes
? [options.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: 'localhost',
})
)
);
} catch (error) {
throw new Error(
`Failed to start remotes. Check above for any errors.`,
{
cause: error,
}
);
} finally {
done();
}
}
)
);
}
export default moduleFederationSsrDevServerExecutor;

View File

@ -1,3 +1,5 @@
import { type DevRemoteDefinition } from '../../builders/utilities/module-federation';
export interface Schema {
browserTarget: string;
serverTarget: string;
@ -10,8 +12,12 @@ export interface Schema {
sslKey?: string;
sslCert?: string;
proxyConfig?: string;
devRemotes?: string[];
devRemotes?: DevRemoteDefinition[];
skipRemotes?: string[];
verbose: boolean;
pathToManifestFile?: string;
parallel?: number;
staticRemotesPort?: number;
parallel?: number;
isInitialHost?: boolean;
}

View File

@ -1,7 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Module Federation SSR Dev Server Target",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) that use SSR allowing to specify which remote applications should be served with the host.",
"outputCapture": "direct-nodejs",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"type": "object",
"properties": {
"browserTarget": {
@ -63,8 +64,25 @@
"devRemotes": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"remoteName": {
"type": "string"
},
"configuration": {
"type": "string"
}
},
"required": ["remoteName"],
"additionalProperties": false
}
]
},
"description": "List of remote applications to run in development mode (i.e. using serve target).",
"x-priority": "important"
},
@ -83,6 +101,20 @@
"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."
},
"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"
},
"parallel": {
"type": "number",
"description": "Max number of parallel processes for building static remotes"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
}
},
"additionalProperties": false,

View File

@ -20,10 +20,8 @@ import {
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { fork } from 'node:child_process';
import { join } from 'node:path';
import { cpSync, createWriteStream } from 'node:fs';
import { existsSync } from 'fs';
import { extname } from 'path';
import { cpSync, existsSync, createWriteStream } from 'fs';
import { join, extname } from 'path';
import { startRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-remote-proxies';
import {
parseStaticRemotesConfig,
@ -111,6 +109,7 @@ function startStaticRemotesFileServer(
},
context
);
return staticRemotesIter;
}
@ -193,10 +192,6 @@ async function buildStaticRemotes(
}`;
}
process.env.NX_MF_DEV_SERVER_STATIC_REMOTES = JSON.stringify(
mappedLocationOfRemotes
);
await new Promise<void>((res, rej) => {
const staticProcess = fork(
nxBin,

View File

@ -1,6 +1,5 @@
import {
ExecutorContext,
getPackageManagerCommand,
logger,
parseTargetString,
readTargetOptions,
@ -9,24 +8,60 @@ import {
} from '@nx/devkit';
import ssrDevServerExecutor from '@nx/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl';
import { WebSsrDevServerOptions } from '@nx/webpack/src/executors/ssr-dev-server/schema';
import { join } from 'path';
import * as chalk from 'chalk';
import { extname, join } from 'path';
import {
getModuleFederationConfig,
getRemotes,
} from '@nx/webpack/src/utils/module-federation';
import {
combineAsyncIterables,
createAsyncIterable,
mapAsyncIterable,
tapAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable';
import { execSync, fork } from 'child_process';
import { existsSync } from 'fs';
import { registerTsProject } from '@nx/js/src/internal';
import { fork } from 'child_process';
import { cpSync, createWriteStream, existsSync } from 'fs';
import {
parseStaticSsrRemotesConfig,
type StaticRemotesConfig,
} from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { startSsrRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-ssr-remote-proxies';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
type ModuleFederationSsrDevServerOptions = WebSsrDevServerOptions & {
devRemotes?: (
| string
| {
remoteName: string;
configuration: string;
}
)[];
type ModuleFederationDevServerOptions = WebSsrDevServerOptions & {
devRemotes?: string | string[];
skipRemotes?: string[];
host: string;
pathToManifestFile?: string;
staticRemotesPort?: number;
parallel?: number;
ssl?: boolean;
sslKey?: string;
sslCert?: string;
isInitialHost?: boolean;
};
function normalizeOptions(
options: ModuleFederationSsrDevServerOptions
): ModuleFederationSsrDevServerOptions {
return {
...options,
ssl: options.ssl ?? false,
sslCert: options.sslCert ? join(workspaceRoot, options.sslCert) : undefined,
sslKey: options.sslKey ? join(workspaceRoot, options.sslKey) : undefined,
};
}
function getBuildOptions(buildTarget: string, context: ExecutorContext) {
const target = parseTargetString(buildTarget, context);
@ -37,151 +72,333 @@ function getBuildOptions(buildTarget: string, context: ExecutorContext) {
};
}
function getModuleFederationConfig(
tsconfigPath: string,
workspaceRoot: string,
projectRoot: string
function startSsrStaticRemotesFileServer(
ssrStaticRemotesConfig: StaticRemotesConfig,
context: ExecutorContext,
options: ModuleFederationSsrDevServerOptions
) {
const moduleFederationConfigPathJS = join(
workspaceRoot,
projectRoot,
'module-federation.config.js'
);
const moduleFederationConfigPathTS = join(
workspaceRoot,
projectRoot,
'module-federation.config.ts'
);
let moduleFederationConfigPath = moduleFederationConfigPathJS;
const fullTSconfigPath = tsconfigPath.startsWith(workspaceRoot)
? tsconfigPath
: join(workspaceRoot, tsconfigPath);
// create a no-op so this can be called with issue
let cleanupTranspiler = () => {};
if (existsSync(moduleFederationConfigPathTS)) {
cleanupTranspiler = registerTsProject(fullTSconfigPath);
moduleFederationConfigPath = moduleFederationConfigPathTS;
if (ssrStaticRemotesConfig.remotes.length === 0) {
return;
}
try {
const config = require(moduleFederationConfigPath);
cleanupTranspiler();
// The directories are usually generated with /browser and /server suffixes so we need to copy them to a common directory
const commonOutputDirectory = join(workspaceRoot, 'tmp/static-remotes');
for (const app of ssrStaticRemotesConfig.remotes) {
const remoteConfig = ssrStaticRemotesConfig.config[app];
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`
cpSync(
remoteConfig.outputPath,
join(commonOutputDirectory, remoteConfig.urlSegment),
{
force: true,
recursive: true,
}
);
}
const staticRemotesIter = fileServerExecutor(
{
cors: true,
watch: false,
staticFilePath: commonOutputDirectory,
parallel: false,
spa: false,
withDeps: false,
host: options.host,
port: options.staticRemotesPort,
ssl: options.ssl,
sslCert: options.sslCert,
sslKey: options.sslKey,
cacheSeconds: -1,
},
context
);
return staticRemotesIter;
}
async function startRemotes(
remotes: string[],
context: ExecutorContext,
options: ModuleFederationSsrDevServerOptions
) {
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
const target = 'serve';
for (const app of remotes) {
const remoteProjectServeTarget =
context.projectGraph.nodes[app].data.targets[target];
const isUsingModuleFederationSsrDevServerExecutor =
remoteProjectServeTarget.executor.includes(
'module-federation-ssr-dev-server'
);
const configurationOverride = options.devRemotes?.find(
(remote): remote is { remoteName: string; configuration: string } =>
typeof remote !== 'string' && remote.remoteName === app
)?.configuration;
{
const defaultOverrides = {
...(options.host ? { host: options.host } : {}),
...(options.ssl ? { ssl: options.ssl } : {}),
...(options.sslCert ? { sslCert: options.sslCert } : {}),
...(options.sslKey ? { sslKey: options.sslKey } : {}),
};
const overrides = {
watch: true,
...defaultOverrides,
...(isUsingModuleFederationSsrDevServerExecutor
? { isInitialHost: false }
: {}),
};
remoteIters.push(
await runExecutor(
{
project: app,
target,
configuration: configurationOverride ?? context.configurationName,
},
overrides,
context
)
);
}
}
return remoteIters;
}
async function buildSsrStaticRemotes(
staticRemotesConfig: StaticRemotesConfig,
nxBin,
context: ExecutorContext,
options: ModuleFederationSsrDevServerOptions
) {
if (!staticRemotesConfig.remotes.length) {
return;
}
logger.info(
`Nx is building ${staticRemotesConfig.remotes.length} static remotes...`
);
const mapLocationOfRemotes: Record<string, string> = {};
for (const remoteApp of staticRemotesConfig.remotes) {
mapLocationOfRemotes[remoteApp] = `http${options.ssl ? 's' : ''}://${
options.host
}:${options.staticRemotesPort}/${
staticRemotesConfig.config[remoteApp].urlSegment
}`;
}
await new Promise<void>((resolve) => {
const childProcess = fork(
nxBin,
[
'run-many',
'--target=server',
'--projects',
staticRemotesConfig.remotes.join(','),
...(context.configurationName
? [`--configuration=${context.configurationName}`]
: []),
...(options.parallel ? [`--parallel=${options.parallel}`] : []),
],
{
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}
);
// Add a listener to the child process to capture the build log
const remoteBuildLogFile = join(
workspaceDataDirectory,
`${new Date().toISOString().replace(/[:\.]/g, '_')}-build.log`
);
const remoteBuildLogStream = createWriteStream(remoteBuildLogFile);
childProcess.stdout.on('data', (data) => {
const ANSII_CODE_REGEX =
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
const stdoutString = data.toString().replace(ANSII_CODE_REGEX, '');
remoteBuildLogStream.write(stdoutString);
// in addition to writing into the stdout stream, also show error directly in console
// so the error is easily discoverable. 'ERROR in' is the key word to search in webpack output.
if (stdoutString.includes('ERROR in')) {
logger.log(stdoutString);
}
if (stdoutString.includes('Successfully ran target server')) {
childProcess.stdout.removeAllListeners('data');
logger.info(
`Nx Built ${staticRemotesConfig.remotes.length} static remotes.`
);
resolve();
}
});
process.on('SIGTERM', () => childProcess.kill('SIGTERM'));
process.on('exit', () => childProcess.kill('SIGTERM'));
});
return mapLocationOfRemotes;
}
export default async function* moduleFederationSsrDevServer(
options: ModuleFederationDevServerOptions,
ssrDevServerOptions: ModuleFederationSsrDevServerOptions,
context: ExecutorContext
) {
const options = normalizeOptions(ssrDevServerOptions);
// Force Node to resolve to look for the nx binary that is inside node_modules
const nxBin = require.resolve('nx/bin/nx');
let iter: any = ssrDevServerExecutor(options, context);
const p = context.projectsConfigurations.projects[context.projectName];
const projectConfig =
context.projectsConfigurations.projects[context.projectName];
const buildOptions = getBuildOptions(options.browserTarget, context);
let pathToManifestFile = join(
context.root,
projectConfig.sourceRoot,
'assets/module-federation.manifest.json'
);
if (options.pathToManifestFile) {
const userPathToManifestFile = join(
context.root,
options.pathToManifestFile
);
if (!existsSync(userPathToManifestFile)) {
throw new Error(
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
);
} else if (extname(userPathToManifestFile) !== '.json') {
throw new Error(
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
);
}
pathToManifestFile = userPathToManifestFile;
}
if (!options.isInitialHost) {
return yield* iter;
}
const moduleFederationConfig = getModuleFederationConfig(
buildOptions.tsConfig,
context.root,
p.root
projectConfig.root,
'react'
);
const remotesToSkip = new Set(options.skipRemotes ?? []);
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 remoteNames = options.devRemotes?.map((remote) =>
typeof remote === 'string' ? remote : remote.remoteName
);
}
const devServeApps = !options.devRemotes
? []
: Array.isArray(options.devRemotes)
? options.devRemotes
: [options.devRemotes];
// Set NX_MF_DEV_REMOTES for the Nx Runtime Library Control Plugin
process.env.NX_MF_DEV_REMOTES = JSON.stringify(devServeApps);
for (const app of knownRemotes) {
const [appName] = Array.isArray(app) ? app : [app];
const isDev = devServeApps.includes(appName);
const remoteServeIter = isDev
? await runExecutor(
const remotes = getRemotes(
remoteNames,
options.skipRemotes,
moduleFederationConfig,
{
project: appName,
target: 'serve',
configuration: context.configurationName,
},
{
watch: isDev,
projectName: context.projectName,
projectGraph: context.projectGraph,
root: context.root,
},
pathToManifestFile
);
options.staticRemotesPort ??= remotes.staticRemotePort;
process.env.NX_MF_DEV_REMOTES = JSON.stringify(
remotes.devRemotes.map((r) => (typeof r === 'string' ? r : r.remoteName))
);
const staticRemotesConfig = parseStaticSsrRemotesConfig(
[...remotes.staticRemotes, ...remotes.dynamicRemotes],
context
)
: mapAsyncIterable(
createAsyncIterable(async ({ next, done }) => {
const remoteProject =
context.projectsConfigurations.projects[appName];
const remoteServerOutput = join(
workspaceRoot,
remoteProject.targets.server.options.outputPath,
remoteProject.targets.server.options.outputFileName
);
const pm = getPackageManagerCommand();
execSync(
`${pm.exec} nx run ${appName}:server${
context.configurationName ? `:${context.configurationName}` : ''
}`,
{ stdio: 'inherit' }
);
const child = fork(remoteServerOutput, {
env: {
PORT: remoteProject.targets['serve-browser'].options.port,
},
});
child.on('message', (msg) => {
if (msg === 'nx.server.ready') {
next(true);
const mappedLocationsOfStaticRemotes = await buildSsrStaticRemotes(
staticRemotesConfig,
nxBin,
context,
options
);
const devRemoteIters = await startRemotes(
remotes.devRemotes,
context,
options
);
const staticRemotesIter = startSsrStaticRemotesFileServer(
staticRemotesConfig,
context,
options
);
startSsrRemoteProxies(
staticRemotesConfig,
mappedLocationsOfStaticRemotes,
options.ssl
? {
pathToCert: options.sslCert,
pathToKey: options.sslKey,
}
: undefined
);
return yield* combineAsyncIterables(
iter,
...devRemoteIters,
...(staticRemotesIter ? [staticRemotesIter] : []),
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length === 0) {
done();
return;
}
try {
const host = options.host ?? 'localhost';
const baseUrl = `http${options.ssl ? 's' : ''}://${host}:${
options.port
}`;
const portsToWaitFor = staticRemotesIter
? [options.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host,
})
)
);
logger.info(
`Nx all ssr remotes have started, server ready at ${baseUrl}`
);
next({ success: true, baseUrl });
} catch (error) {
throw new Error(
`Nx failed to start ssr remotes. Check above for errors.`,
{
cause: error,
}
);
} finally {
done();
}
});
}),
(x) => x
);
iter = combineAsyncIterables(iter, remoteServeIter);
}
let numAwaiting = knownRemotes.length + 1; // remotes + host
return yield* tapAsyncIterable(iter, (x) => {
numAwaiting--;
if (numAwaiting === 0) {
logger.info(
`[ ${chalk.green('ready')} ] http://${options.host ?? 'localhost'}:${
options.port ?? 4200
}`
)
);
}
});
}

View File

@ -42,6 +42,33 @@
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
},
"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."
},
"ssl": {
"type": "boolean",
"description": "Serve using HTTPS.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving HTTPS."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"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"
}
},
"required": ["browserTarget", "serverTarget"]

View File

@ -4,7 +4,7 @@ import cors from 'cors';
import { handleRequest } from './src/main.server';
const port = process.env['PORT'] || 4200;
const port = process.env['PORT'] || <%= port %>;
const app = express();
const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>');

View File

@ -4,7 +4,7 @@ import cors from 'cors';
import { handleRequest } from './src/main.server';
const port = process.env['PORT'] || 4200;
const port = process.env['PORT'] || <%= port %>;
const app = express();
const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>');

View File

@ -34,6 +34,7 @@ export async function setupSsrForHost(
{
...options,
static: !options?.dynamic,
port: Number(options?.devServerPort) || 4200,
remotes: defaultRemoteManifest.map(({ name, port }) => {
return {
...names(name),

View File

@ -4,7 +4,7 @@ import cors from 'cors';
import { handleRequest } from './src/main.server';
const port = process.env['PORT'] || 4200;
const port = process.env['PORT'] || <%= port %>;
const app = express();
const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>');

View File

@ -4,7 +4,7 @@ import cors from 'cors';
import { handleRequest } from './src/main.server';
const port = process.env['PORT'] || 4200;
const port = process.env['PORT'] || <%= port %>;
const app = express();
const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>');

View File

@ -30,6 +30,7 @@ export async function setupSsrForRemote(
project.root,
{
...options,
port: Number(options?.devServerPort) || 4200,
appName,
tmpl: '',
browserBuildOutputPath: project.targets.build.options.outputPath,

View File

@ -4,7 +4,7 @@ import cors from 'cors';
import { handleRequest } from './src/main.server';
const port = process.env['PORT'] || 4200;
const port = process.env['PORT'] || <%= port %>;
const app = express();
const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>');

View File

@ -216,6 +216,7 @@ export async function setupSsrGenerator(tree: Tree, options: Schema) {
generateFiles(tree, join(__dirname, 'files'), projectRoot, {
tmpl: '',
port: Number(options?.serverPort) || 4200,
extraInclude:
options.extraInclude?.length > 0
? `"${options.extraInclude.join('", "')}",`

View File

@ -3,7 +3,6 @@ import {
ExecutorContext,
parseTargetString,
readTargetOptions,
targetToTargetString,
} from '@nx/devkit';
import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await';

View File

@ -51,8 +51,6 @@ export async function* ssrDevServerExecutor(
let nodeStarted = false;
const combined = combineAsyncIterables(runBrowser, runServer);
process.env['PORT'] = `${options.port}`;
for await (const output of combined) {
if (!output.success) throw new Error('Could not build application');
if (output.options?.target === 'node') {

View File

@ -139,7 +139,8 @@ export function getRemotes(
context.projectGraph.nodes[r].data.targets['serve'].options.port
),
] as number[])
) + 1;
) +
(remotesToSkip.size + 1);
return {
staticRemotes,

View File

@ -7,7 +7,6 @@ export type StaticRemoteConfig = {
urlSegment: string;
port: number;
};
export type StaticRemotesConfig = {
remotes: string[];
config: Record<string, StaticRemoteConfig> | undefined;
@ -34,3 +33,25 @@ export function parseStaticRemotesConfig(
return { remotes: staticRemotes, config };
}
export function parseStaticSsrRemotesConfig(
staticRemotes: string[] | undefined,
context: ExecutorContext
): StaticRemotesConfig {
if (!staticRemotes?.length) {
return { remotes: [], config: undefined };
}
const config: Record<string, StaticRemoteConfig> = {};
for (const app of staticRemotes) {
const outputPath = dirname(
context.projectGraph.nodes[app].data.targets['build'].options.outputPath // dist/checkout/browser -> checkout
) as string;
const basePath = dirname(outputPath); // dist/checkout -> dist
const urlSegment = basename(outputPath); // dist/checkout -> checkout
const port =
context.projectGraph.nodes[app].data.targets['serve'].options.port;
config[app] = { basePath, outputPath, urlSegment, port };
}
return { remotes: staticRemotes, config };
}

View File

@ -0,0 +1,65 @@
import type { Express } from 'express';
import { logger } from '@nx/devkit';
import type { StaticRemotesConfig } from './parse-static-remotes-config';
import { existsSync, readFileSync } from 'fs';
export function startSsrRemoteProxies(
staticRemotesConfig: StaticRemotesConfig,
mappedLocationsOfRemotes: Record<string, string>,
sslOptions?: { pathToCert: string; pathToKey: string }
) {
const { createProxyMiddleware } = require('http-proxy-middleware');
const express = require('express');
let sslCert: Buffer;
let sslKey: Buffer;
if (sslOptions && sslOptions.pathToCert && sslOptions.pathToKey) {
if (existsSync(sslOptions.pathToCert) && existsSync(sslOptions.pathToKey)) {
sslCert = readFileSync(sslOptions.pathToCert);
sslKey = readFileSync(sslOptions.pathToKey);
} else {
logger.warn(
`Encountered SSL options in project.json, however, the certificate files do not exist in the filesystem. Using http.`
);
logger.warn(
`Attempted to find '${sslOptions.pathToCert}' and '${sslOptions.pathToKey}'.`
);
}
}
const http = require('http');
const https = require('https');
logger.info(`NX Starting static remotes proxies...`);
for (const app of staticRemotesConfig.remotes) {
const expressProxy: Express = express();
/**
* SSR remotes have two output paths: one for the browser and one for the server.
* We need to handle paths for both of them.
* The browser output path is used to serve the client-side code.
* The server output path is used to serve the server-side code.
*/
expressProxy.use(
createProxyMiddleware({
target: `${mappedLocationsOfRemotes[app]}`,
secure: sslCert ? false : undefined,
changeOrigin: true,
pathRewrite: (path) => {
if (path.includes('/server')) {
return path;
} else {
return `browser/${path}`;
}
},
})
);
const proxyServer = (sslCert ? https : http)
.createServer({ cert: sslCert, key: sslKey }, expressProxy)
.listen(staticRemotesConfig.config[app].port);
process.on('SIGTERM', () => proxyServer.close());
process.on('exit', () => proxyServer.close());
}
logger.info(`Nx SSR Static remotes proxies started successfully`);
}