feat(react): add module federation static server (#27802)

<!-- 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 -->
We can serve each application in a module federation setup statically,
but only individually.


## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
Add a serve static executor for module federation hosts which will also
spin up the remotes statically

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

Fixes #
This commit is contained in:
Colum Ferry 2024-09-09 17:50:52 +01:00 committed by GitHub
parent 24025c86fb
commit 2bc7d4e6e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 723 additions and 119 deletions

View File

@ -9052,6 +9052,14 @@
"children": [], "children": [],
"isExternal": false, "isExternal": false,
"disableCollapsible": false "disableCollapsible": false
},
{
"id": "module-federation-static-server",
"path": "/nx-api/react/executors/module-federation-static-server",
"name": "module-federation-static-server",
"children": [],
"isExternal": false,
"disableCollapsible": false
} }
], ],
"isExternal": false, "isExternal": false,

View File

@ -2232,6 +2232,15 @@
"originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json", "originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json",
"path": "/nx-api/react/executors/module-federation-ssr-dev-server", "path": "/nx-api/react/executors/module-federation-ssr-dev-server",
"type": "executor" "type": "executor"
},
"/nx-api/react/executors/module-federation-static-server": {
"description": "Serve a host and its remotes statically.",
"file": "generated/packages/react/executors/module-federation-static-server.json",
"hidden": false,
"name": "module-federation-static-server",
"originalFilePath": "/packages/react/src/executors/module-federation-static-server/schema.json",
"path": "/nx-api/react/executors/module-federation-static-server",
"type": "executor"
} }
}, },
"generators": { "generators": {

View File

@ -2206,6 +2206,15 @@
"originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json", "originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json",
"path": "react/executors/module-federation-ssr-dev-server", "path": "react/executors/module-federation-ssr-dev-server",
"type": "executor" "type": "executor"
},
{
"description": "Serve a host and its remotes statically.",
"file": "generated/packages/react/executors/module-federation-static-server.json",
"hidden": false,
"name": "module-federation-static-server",
"originalFilePath": "/packages/react/src/executors/module-federation-static-server/schema.json",
"path": "react/executors/module-federation-static-server",
"type": "executor"
} }
], ],
"generators": [ "generators": [

View File

@ -0,0 +1,20 @@
{
"name": "module-federation-static-server",
"implementation": "/packages/react/src/executors/module-federation-static-server/module-federation-static-server.impl.ts",
"schema": {
"version": 2,
"outputCapture": "direct-nodejs",
"title": "Module Federation Static Dev Server",
"description": "Serve a host application statically along with it's remotes.",
"cli": "nx",
"type": "object",
"properties": { "serveTarget": { "type": "string" } },
"required": ["serveTarget"],
"presets": []
},
"description": "Serve a host and its remotes statically.",
"aliases": [],
"hidden": false,
"path": "/packages/react/src/executors/module-federation-static-server/schema.json",
"type": "executor"
}

View File

@ -596,6 +596,7 @@
- [executors](/nx-api/react/executors) - [executors](/nx-api/react/executors)
- [module-federation-dev-server](/nx-api/react/executors/module-federation-dev-server) - [module-federation-dev-server](/nx-api/react/executors/module-federation-dev-server)
- [module-federation-ssr-dev-server](/nx-api/react/executors/module-federation-ssr-dev-server) - [module-federation-ssr-dev-server](/nx-api/react/executors/module-federation-ssr-dev-server)
- [module-federation-static-server](/nx-api/react/executors/module-federation-static-server)
- [generators](/nx-api/react/generators) - [generators](/nx-api/react/generators)
- [init](/nx-api/react/generators/init) - [init](/nx-api/react/generators/init)
- [application](/nx-api/react/generators/application) - [application](/nx-api/react/generators/application)

View File

@ -125,6 +125,72 @@ describe('React Rspack Module Federation', () => {
}, },
500_000 500_000
); );
it('should generate host and remote apps and use playwright for e2es', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=rspack --e2eTestRunner=playwright --style=css --no-interactive --skipFormat`
);
checkFilesExist(`apps/${shell}/module-federation.config.ts`);
checkFilesExist(`apps/${remote1}/module-federation.config.ts`);
checkFilesExist(`apps/${remote2}/module-federation.config.ts`);
checkFilesExist(`apps/${remote3}/module-federation.config.ts`);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining(
'Test Suites: 1 passed, 1 total'
),
});
updateFile(
`apps/${shell}-e2e/src/example.spec.ts`,
stripIndents`
import { test, expect } from '@playwright/test';
test('should display welcome message', async ({page}) => {
await page.goto("/");
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
test('should load remote 1', async ({page}) => {
await page.goto("/${remote1}");
expect(await page.locator('h1').innerText()).toContain('${remote1}');
});
test('should load remote 2', async ({page}) => {
await page.goto("/${remote2}");
expect(await page.locator('h1').innerText()).toContain('${remote2}');
});
test('should load remote 3', async ({page}) => {
await page.goto("/${remote3}");
expect(await page.locator('h1').innerText()).toContain('${remote3}');
});
`
);
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) => output.includes('Successfully ran target e2e for project')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
}, 500_000);
it('should have interop between webpack host and rspack remote', async () => { it('should have interop between webpack host and rspack remote', async () => {
const shell = uniq('shell'); const shell = uniq('shell');
@ -1049,7 +1115,7 @@ describe('React Rspack Module Federation', () => {
}); });
afterAll(() => cleanupProject()); afterAll(() => cleanupProject());
it('ttt should load remote dynamic module', async () => { it('should load remote dynamic module', async () => {
const shell = uniq('shell'); const shell = uniq('shell');
const remote = uniq('remote'); const remote = uniq('remote');
const remotePort = 4205; const remotePort = 4205;

View File

@ -126,6 +126,73 @@ describe('React Module Federation', () => {
500_000 500_000
); );
it('should generate host and remote apps and use playwright for e2es', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=webpack --e2eTestRunner=playwright --style=css --no-interactive --skipFormat`
);
checkFilesExist(`apps/${shell}/module-federation.config.ts`);
checkFilesExist(`apps/${remote1}/module-federation.config.ts`);
checkFilesExist(`apps/${remote2}/module-federation.config.ts`);
checkFilesExist(`apps/${remote3}/module-federation.config.ts`);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining(
'Test Suites: 1 passed, 1 total'
),
});
updateFile(
`apps/${shell}-e2e/src/example.spec.ts`,
stripIndents`
import { test, expect } from '@playwright/test';
test('should display welcome message', async ({page}) => {
await page.goto("/");
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
test('should load remote 1', async ({page}) => {
await page.goto("/${remote1}");
expect(await page.locator('h1').innerText()).toContain('${remote1}');
});
test('should load remote 2', async ({page}) => {
await page.goto("/${remote2}");
expect(await page.locator('h1').innerText()).toContain('${remote2}');
});
test('should load remote 3', async ({page}) => {
await page.goto("/${remote3}");
expect(await page.locator('h1').innerText()).toContain('${remote3}');
});
`
);
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) => output.includes('Successfully ran target e2e for project')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
}, 500_000);
describe('ssr', () => { describe('ssr', () => {
it('should generate host and remote apps with ssr', async () => { it('should generate host and remote apps with ssr', async () => {
const shell = uniq('shell'); const shell = uniq('shell');

View File

@ -9,6 +9,11 @@
"implementation": "./src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl", "implementation": "./src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl",
"schema": "./src/executors/module-federation-ssr-dev-server/schema.json", "schema": "./src/executors/module-federation-ssr-dev-server/schema.json",
"description": "Serve a host application along with it's known remotes." "description": "Serve a host application along with it's known remotes."
},
"module-federation-static-server": {
"implementation": "./src/executors/module-federation-static-server/module-federation-static-server.impl",
"schema": "./src/executors/module-federation-static-server/schema.json",
"description": "Serve a host and its remotes statically."
} }
} }
} }

View File

@ -42,7 +42,9 @@
"@nx/devkit": "file:../devkit", "@nx/devkit": "file:../devkit",
"@nx/js": "file:../js", "@nx/js": "file:../js",
"@nx/eslint": "file:../eslint", "@nx/eslint": "file:../eslint",
"@nx/web": "file:../web" "@nx/web": "file:../web",
"express": "^4.19.2",
"http-proxy-middleware": "^3.0.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@ -8,7 +8,7 @@ import {
} 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 fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
import { WebDevServerOptions } from '@nx/webpack/src/executors/dev-server/schema'; import { ModuleFederationDevServerOptions } from './schema';
import { import {
getModuleFederationConfig, getModuleFederationConfig,
getRemotes, getRemotes,
@ -18,31 +18,14 @@ import {
createAsyncIterable, createAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable'; } from '@nx/devkit/src/utils/async-iterable';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open'; import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; import { cpSync, existsSync } from 'fs';
import { fork } from 'node:child_process'; import { extname, join } 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 { startRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-remote-proxies';
import { import {
parseStaticRemotesConfig, parseStaticRemotesConfig,
type StaticRemotesConfig, type StaticRemotesConfig,
} from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config'; } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
import { buildStaticRemotes } from '../../utils/build-static.remotes';
type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: (
| string
| {
remoteName: string;
configuration: string;
}
)[];
skipRemotes?: string[];
static?: boolean;
isInitialHost?: boolean;
parallel?: number;
staticRemotesPort?: number;
pathToManifestFile?: string;
};
function getBuildOptions(buildTarget: string, context: ExecutorContext) { function getBuildOptions(buildTarget: string, context: ExecutorContext) {
const target = parseTargetString(buildTarget, context); const target = parseTargetString(buildTarget, context);
@ -170,93 +153,6 @@ async function startRemotes(
return remoteIters; return remoteIters;
} }
async function buildStaticRemotes(
staticRemotesConfig: StaticRemotesConfig,
nxBin,
context: ExecutorContext,
options: ModuleFederationDevServerOptions
) {
if (!staticRemotesConfig.remotes.length) {
return;
}
logger.info(
`NX Building ${staticRemotesConfig.remotes.length} static remotes...`
);
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>((res, rej) => {
const staticProcess = fork(
nxBin,
[
'run-many',
`--target=build`,
`--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 build')) {
staticProcess.stdout.removeAllListeners('data');
logger.info(
`NX Built ${staticRemotesConfig.remotes.length} static remotes`
);
res();
}
});
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) {
rej(
`Remote failed to start. A complete log can be found in: ${remoteBuildLogFile}`
);
} else {
res();
}
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
return mappedLocationOfRemotes;
}
export default async function* moduleFederationDevServer( export default async function* moduleFederationDevServer(
options: ModuleFederationDevServerOptions, options: ModuleFederationDevServerOptions,
context: ExecutorContext context: ExecutorContext

View File

@ -0,0 +1,17 @@
import { WebDevServerOptions } from '@nx/webpack';
export type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: (
| string
| {
remoteName: string;
configuration: string;
}
)[];
skipRemotes?: string[];
static?: boolean;
isInitialHost?: boolean;
parallel?: number;
staticRemotesPort?: number;
pathToManifestFile?: string;
};

View File

@ -0,0 +1,392 @@
import { ModuleFederationStaticServerSchema } from './schema';
import { ModuleFederationDevServerOptions } from '../module-federation-dev-server/schema';
import { ExecutorContext } from 'nx/src/config/misc-interfaces';
import { basename, extname, join } from 'path';
import {
logger,
parseTargetString,
readTargetOptions,
Target,
workspaceRoot,
} from '@nx/devkit';
import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
import {
getModuleFederationConfig,
getRemotes,
} from '@nx/webpack/src/utils/module-federation';
import {
parseStaticRemotesConfig,
StaticRemotesConfig,
} from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
import { buildStaticRemotes } from '../../utils/build-static.remotes';
import { fork } from 'child_process';
import type { WebpackExecutorOptions } from '@nx/webpack';
import * as process from 'node:process';
import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
import type { Express } from 'express';
import {
combineAsyncIterables,
createAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
function getBuildAndServeOptionsFromServeTarget(
serveTarget: string,
context: ExecutorContext
) {
const target = parseTargetString(serveTarget, context);
const serveOptions: ModuleFederationDevServerOptions = readTargetOptions(
target,
context
);
const buildTarget = parseTargetString(serveOptions.buildTarget, context);
const buildOptions: WebpackExecutorOptions = readTargetOptions(
buildTarget,
context
);
let pathToManifestFile = join(
context.root,
context.projectGraph.nodes[context.projectName].data.sourceRoot,
'assets/module-federation.manifest.json'
);
if (serveOptions.pathToManifestFile) {
const userPathToManifestFile = join(
context.root,
serveOptions.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(serveOptions.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;
}
return {
buildTarget,
buildOptions,
serveOptions,
pathToManifestFile,
};
}
async function buildHost(
nxBin: string,
buildTarget: Target,
context: ExecutorContext
) {
await new Promise<void>((res, rej) => {
const staticProcess = fork(
nxBin,
[
`run`,
`${buildTarget.project}:${buildTarget.target}${
buildTarget.configuration
? `:${buildTarget.configuration}`
: context.configurationName
? `:${context.configurationName}`
: ''
}`,
],
{
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}
);
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, '');
// 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 build')) {
staticProcess.stdout.removeAllListeners('data');
logger.info(`NX Built host`);
res();
}
});
staticProcess.stderr.on('data', (data) => logger.info(data.toString()));
staticProcess.once('exit', (code) => {
staticProcess.stdout.removeAllListeners('data');
staticProcess.stderr.removeAllListeners('data');
if (code !== 0) {
rej(`Host failed to build. See above for details.`);
} else {
res();
}
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
}
function moveToTmpDirectory(
staticRemotesConfig: StaticRemotesConfig,
hostOutputPath: string,
hostUrlSegment: string
) {
const commonOutputDirectory = join(
workspaceRoot,
'tmp/static-module-federation'
);
for (const app of staticRemotesConfig.remotes) {
const remoteConfig = staticRemotesConfig.config[app];
cpSync(
remoteConfig.outputPath,
join(commonOutputDirectory, remoteConfig.urlSegment),
{
force: true,
recursive: true,
}
);
}
cpSync(hostOutputPath, join(commonOutputDirectory, hostUrlSegment), {
force: true,
recursive: true,
});
const cleanup = () => {
rmSync(commonOutputDirectory, { force: true, recursive: true });
};
process.on('SIGTERM', () => {
cleanup();
});
process.on('exit', () => {
cleanup();
});
return commonOutputDirectory;
}
export function startProxies(
staticRemotesConfig: StaticRemotesConfig,
hostServeOptions: ModuleFederationDevServerOptions,
mappedLocationOfHost: string,
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();
expressProxy.use(
createProxyMiddleware({
target: mappedLocationsOfRemotes[app],
changeOrigin: true,
secure: sslCert ? false : undefined,
})
);
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 Static remotes proxies started successfully`);
logger.info(`NX Starting static host proxy...`);
const expressProxy: Express = express();
expressProxy.use(
createProxyMiddleware({
target: mappedLocationOfHost,
changeOrigin: true,
secure: sslCert ? false : undefined,
pathRewrite: (path) => {
let pathRewrite = path;
for (const app of staticRemotesConfig.remotes) {
if (path.endsWith(app)) {
pathRewrite = '/';
break;
}
}
return pathRewrite;
},
})
);
const proxyServer = (sslCert ? https : http)
.createServer({ cert: sslCert, key: sslKey }, expressProxy)
.listen(hostServeOptions.port);
process.on('SIGTERM', () => proxyServer.close());
process.on('exit', () => proxyServer.close());
logger.info('NX Static host proxy started successfully');
}
export default async function* moduleFederationStaticServer(
schema: ModuleFederationStaticServerSchema,
context: ExecutorContext
) {
// Force Node to resolve to look for the nx binary that is inside node_modules
const nxBin = require.resolve('nx/bin/nx');
// Get the remotes from the module federation config
const p = context.projectsConfigurations.projects[context.projectName];
const options = getBuildAndServeOptionsFromServeTarget(
schema.serveTarget,
context
);
const moduleFederationConfig = getModuleFederationConfig(
options.buildOptions.tsConfig,
context.root,
p.root,
'react'
);
const remotes = getRemotes(
[],
options.serveOptions.skipRemotes,
moduleFederationConfig,
{
projectName: context.projectName,
projectGraph: context.projectGraph,
root: context.root,
},
options.pathToManifestFile
);
const staticRemotesConfig = parseStaticRemotesConfig(
[...remotes.staticRemotes, ...remotes.dynamicRemotes],
context
);
options.serveOptions.staticRemotesPort ??= remotes.staticRemotePort;
const mappedLocationsOfStaticRemotes = await buildStaticRemotes(
staticRemotesConfig,
nxBin,
context,
options.serveOptions
);
// Build the host
const hostUrlSegment = basename(options.buildOptions.outputPath);
const mappedLocationOfHost = `http${options.serveOptions.ssl ? 's' : ''}://${
options.serveOptions.host
}:${options.serveOptions.staticRemotesPort}/${hostUrlSegment}`;
await buildHost(nxBin, options.buildTarget, context);
// Move to a temporary directory
const commonOutputDirectory = moveToTmpDirectory(
staticRemotesConfig,
options.buildOptions.outputPath,
hostUrlSegment
);
// File Serve the temporary directory
const staticFileServerIter = fileServerExecutor(
{
cors: true,
watch: false,
staticFilePath: commonOutputDirectory,
parallel: false,
spa: false,
withDeps: false,
host: options.serveOptions.host,
port: options.serveOptions.staticRemotesPort,
ssl: options.serveOptions.ssl,
sslCert: options.serveOptions.sslCert,
sslKey: options.serveOptions.sslKey,
cacheSeconds: -1,
},
context
);
// express proxy all of it
startProxies(
staticRemotesConfig,
options.serveOptions,
mappedLocationOfHost,
mappedLocationsOfStaticRemotes,
options.serveOptions.ssl
? {
pathToCert: join(workspaceRoot, options.serveOptions.sslCert),
pathToKey: join(workspaceRoot, options.serveOptions.sslKey),
}
: undefined
);
return yield* combineAsyncIterables(
staticFileServerIter,
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
const host = options.serveOptions.host ?? 'localhost';
const baseUrl = `http${options.serveOptions.ssl ? 's' : ''}://${host}:${
options.serveOptions.port
}`;
if (remotes.remotePorts.length === 0) {
const portsToWaitFor = [options.serveOptions.staticRemotesPort];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: host,
})
)
);
logger.info(`NX Server ready at ${baseUrl}`);
next({ success: true, baseUrl: baseUrl });
done();
return;
}
try {
const portsToWaitFor = staticFileServerIter
? [options.serveOptions.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: host,
})
)
);
logger.info(`NX Server ready at ${baseUrl}`);
next({ success: true, baseUrl: baseUrl });
} catch (err) {
throw new Error(`Failed to start. Check above for any errors.`, {
cause: err,
});
} finally {
done();
}
}
)
);
}

View File

@ -0,0 +1,3 @@
export interface ModuleFederationStaticServerSchema {
serveTarget: string;
}

View File

@ -0,0 +1,14 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"title": "Module Federation Static Dev Server",
"description": "Serve a host application statically along with it's remotes.",
"cli": "nx",
"type": "object",
"properties": {
"serveTarget": {
"type": "string"
}
},
"required": ["serveTarget"]
}

View File

@ -20,7 +20,7 @@ export function updateModuleFederationProject(
dynamic?: boolean; dynamic?: boolean;
bundler?: 'rspack' | 'webpack'; bundler?: 'rspack' | 'webpack';
} }
): GeneratorCallback { ) {
const projectConfig = readProjectConfiguration(host, options.projectName); const projectConfig = readProjectConfiguration(host, options.projectName);
if (options.bundler === 'rspack') { if (options.bundler === 'rspack') {
@ -101,25 +101,25 @@ export function updateModuleFederationProject(
projectConfig.targets.serve.options.port = options.devServerPort; projectConfig.targets.serve.options.port = options.devServerPort;
// `serve-static` for remotes that don't need to be in development mode // `serve-static` for remotes that don't need to be in development mode
const serveStaticExecutor =
options.bundler === 'rspack'
? '@nx/rspack:module-federation-static-server'
: '@nx/react:module-federation-static-server';
projectConfig.targets['serve-static'] = { projectConfig.targets['serve-static'] = {
executor: '@nx/web:file-server', executor: serveStaticExecutor,
defaultConfiguration: 'production', defaultConfiguration: 'production',
options: { options: {
buildTarget: `${options.projectName}:build`, serveTarget: `${options.projectName}:serve`,
watch: false,
port: options.devServerPort,
}, },
configurations: { configurations: {
development: { development: {
buildTarget: `${options.projectName}:build:development`, serveTarget: `${options.projectName}:serve:development`,
}, },
production: { production: {
buildTarget: `${options.projectName}:build:production`, serveTarget: `${options.projectName}:serve:production`,
}, },
}, },
}; };
updateProjectConfiguration(host, options.projectName, projectConfig); updateProjectConfiguration(host, options.projectName, projectConfig);
return addDependenciesToPackageJson(host, {}, { '@nx/web': nxVersion });
} }

View File

@ -0,0 +1,95 @@
import type { StaticRemotesConfig } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
import { ExecutorContext } from '@nx/devkit';
import { ModuleFederationDevServerOptions } from '../executors/module-federation-dev-server/schema';
import { logger } from 'nx/src/utils/logger';
import { fork } from 'node:child_process';
import { join } from 'path';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { createWriteStream } from 'fs';
export async function buildStaticRemotes(
staticRemotesConfig: StaticRemotesConfig,
nxBin,
context: ExecutorContext,
options: ModuleFederationDevServerOptions
) {
if (!staticRemotesConfig.remotes.length) {
return;
}
logger.info(
`NX Building ${staticRemotesConfig.remotes.length} static remotes...`
);
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>((res, rej) => {
const staticProcess = fork(
nxBin,
[
'run-many',
`--target=build`,
`--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 build')) {
staticProcess.stdout.removeAllListeners('data');
logger.info(
`NX Built ${staticRemotesConfig.remotes.length} static remotes`
);
res();
}
});
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) {
rej(
`Remote failed to start. A complete log can be found in: ${remoteBuildLogFile}`
);
} else {
res();
}
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
return mappedLocationOfRemotes;
}