From 2bc7d4e6e9e8bbf2956a373c3fe054021ff4832a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 9 Sep 2024 17:50:52 +0100 Subject: [PATCH] feat(react): add module federation static server (#27802) ## Current Behavior We can serve each application in a module federation setup statically, but only individually. ## Expected Behavior Add a serve static executor for module federation hosts which will also spin up the remotes statically ## Related Issue(s) Fixes # --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../module-federation-static-server.json | 20 + docs/shared/reference/sitemap.md | 1 + .../react-module-federation.rspack.test.ts | 68 ++- e2e/react/src/react-module-federation.test.ts | 67 +++ packages/react/executors.json | 5 + packages/react/package.json | 4 +- .../module-federation-dev-server.impl.ts | 112 +---- .../module-federation-dev-server/schema.d.ts | 17 + .../module-federation-static-server.impl.ts | 392 ++++++++++++++++++ .../schema.d.ts | 3 + .../schema.json | 14 + .../rules/update-module-federation-project.ts | 18 +- .../react/src/utils/build-static.remotes.ts | 95 +++++ 16 files changed, 723 insertions(+), 119 deletions(-) create mode 100644 docs/generated/packages/react/executors/module-federation-static-server.json create mode 100644 packages/react/src/executors/module-federation-dev-server/schema.d.ts create mode 100644 packages/react/src/executors/module-federation-static-server/module-federation-static-server.impl.ts create mode 100644 packages/react/src/executors/module-federation-static-server/schema.d.ts create mode 100644 packages/react/src/executors/module-federation-static-server/schema.json create mode 100644 packages/react/src/utils/build-static.remotes.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 76b9ce6ba3..9969db428e 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -9052,6 +9052,14 @@ "children": [], "isExternal": 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, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 7634ba456a..50d615aad7 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -2232,6 +2232,15 @@ "originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json", "path": "/nx-api/react/executors/module-federation-ssr-dev-server", "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": { diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 61e869d0fa..2690174615 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2206,6 +2206,15 @@ "originalFilePath": "/packages/react/src/executors/module-federation-ssr-dev-server/schema.json", "path": "react/executors/module-federation-ssr-dev-server", "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": [ diff --git a/docs/generated/packages/react/executors/module-federation-static-server.json b/docs/generated/packages/react/executors/module-federation-static-server.json new file mode 100644 index 0000000000..4cd3fd244b --- /dev/null +++ b/docs/generated/packages/react/executors/module-federation-static-server.json @@ -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" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index c470249c18..641f3cb6bf 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -596,6 +596,7 @@ - [executors](/nx-api/react/executors) - [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-static-server](/nx-api/react/executors/module-federation-static-server) - [generators](/nx-api/react/generators) - [init](/nx-api/react/generators/init) - [application](/nx-api/react/generators/application) diff --git a/e2e/react/src/react-module-federation.rspack.test.ts b/e2e/react/src/react-module-federation.rspack.test.ts index 32a0ceb564..d080226a88 100644 --- a/e2e/react/src/react-module-federation.rspack.test.ts +++ b/e2e/react/src/react-module-federation.rspack.test.ts @@ -125,6 +125,72 @@ describe('React Rspack Module Federation', () => { }, 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 () => { const shell = uniq('shell'); @@ -1049,7 +1115,7 @@ describe('React Rspack Module Federation', () => { }); afterAll(() => cleanupProject()); - it('ttt should load remote dynamic module', async () => { + it('should load remote dynamic module', async () => { const shell = uniq('shell'); const remote = uniq('remote'); const remotePort = 4205; diff --git a/e2e/react/src/react-module-federation.test.ts b/e2e/react/src/react-module-federation.test.ts index b5c85a1c47..b6e6c18517 100644 --- a/e2e/react/src/react-module-federation.test.ts +++ b/e2e/react/src/react-module-federation.test.ts @@ -126,6 +126,73 @@ describe('React Module Federation', () => { 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', () => { it('should generate host and remote apps with ssr', async () => { const shell = uniq('shell'); diff --git a/packages/react/executors.json b/packages/react/executors.json index a82dd5af31..cae3ac14cc 100644 --- a/packages/react/executors.json +++ b/packages/react/executors.json @@ -9,6 +9,11 @@ "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": "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." } } } diff --git a/packages/react/package.json b/packages/react/package.json index 451a44d8eb..41ef3e5138 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,7 +42,9 @@ "@nx/devkit": "file:../devkit", "@nx/js": "file:../js", "@nx/eslint": "file:../eslint", - "@nx/web": "file:../web" + "@nx/web": "file:../web", + "express": "^4.19.2", + "http-proxy-middleware": "^3.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts index bed4992d74..03d784cbfe 100644 --- a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts +++ b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts @@ -8,7 +8,7 @@ import { } from '@nx/devkit'; import devServerExecutor from '@nx/webpack/src/executors/dev-server/dev-server.impl'; import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl'; -import { WebDevServerOptions } from '@nx/webpack/src/executors/dev-server/schema'; +import { ModuleFederationDevServerOptions } from './schema'; import { getModuleFederationConfig, getRemotes, @@ -18,31 +18,14 @@ import { createAsyncIterable, } from '@nx/devkit/src/utils/async-iterable'; 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 { cpSync, existsSync, createWriteStream } from 'fs'; -import { join, extname } from 'path'; +import { cpSync, existsSync } from 'fs'; +import { extname, join } from 'path'; import { startRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-remote-proxies'; import { parseStaticRemotesConfig, type StaticRemotesConfig, } from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config'; - -type ModuleFederationDevServerOptions = WebDevServerOptions & { - devRemotes?: ( - | string - | { - remoteName: string; - configuration: string; - } - )[]; - skipRemotes?: string[]; - static?: boolean; - isInitialHost?: boolean; - parallel?: number; - staticRemotesPort?: number; - pathToManifestFile?: string; -}; +import { buildStaticRemotes } from '../../utils/build-static.remotes'; function getBuildOptions(buildTarget: string, context: ExecutorContext) { const target = parseTargetString(buildTarget, context); @@ -170,93 +153,6 @@ async function startRemotes( 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 = {}; - - for (const app of staticRemotesConfig.remotes) { - mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${ - options.host - }:${options.staticRemotesPort}/${ - staticRemotesConfig.config[app].urlSegment - }`; - } - - await new Promise((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( options: ModuleFederationDevServerOptions, context: ExecutorContext diff --git a/packages/react/src/executors/module-federation-dev-server/schema.d.ts b/packages/react/src/executors/module-federation-dev-server/schema.d.ts new file mode 100644 index 0000000000..38a7266e98 --- /dev/null +++ b/packages/react/src/executors/module-federation-dev-server/schema.d.ts @@ -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; +}; diff --git a/packages/react/src/executors/module-federation-static-server/module-federation-static-server.impl.ts b/packages/react/src/executors/module-federation-static-server/module-federation-static-server.impl.ts new file mode 100644 index 0000000000..bdbade2191 --- /dev/null +++ b/packages/react/src/executors/module-federation-static-server/module-federation-static-server.impl.ts @@ -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((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, + 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(); + } + } + ) + ); +} diff --git a/packages/react/src/executors/module-federation-static-server/schema.d.ts b/packages/react/src/executors/module-federation-static-server/schema.d.ts new file mode 100644 index 0000000000..8386a0f200 --- /dev/null +++ b/packages/react/src/executors/module-federation-static-server/schema.d.ts @@ -0,0 +1,3 @@ +export interface ModuleFederationStaticServerSchema { + serveTarget: string; +} diff --git a/packages/react/src/executors/module-federation-static-server/schema.json b/packages/react/src/executors/module-federation-static-server/schema.json new file mode 100644 index 0000000000..329ac0a622 --- /dev/null +++ b/packages/react/src/executors/module-federation-static-server/schema.json @@ -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"] +} diff --git a/packages/react/src/rules/update-module-federation-project.ts b/packages/react/src/rules/update-module-federation-project.ts index 95b0df9ea8..f42e7f3e9b 100644 --- a/packages/react/src/rules/update-module-federation-project.ts +++ b/packages/react/src/rules/update-module-federation-project.ts @@ -20,7 +20,7 @@ export function updateModuleFederationProject( dynamic?: boolean; bundler?: 'rspack' | 'webpack'; } -): GeneratorCallback { +) { const projectConfig = readProjectConfiguration(host, options.projectName); if (options.bundler === 'rspack') { @@ -101,25 +101,25 @@ export function updateModuleFederationProject( projectConfig.targets.serve.options.port = options.devServerPort; // `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'] = { - executor: '@nx/web:file-server', + executor: serveStaticExecutor, defaultConfiguration: 'production', options: { - buildTarget: `${options.projectName}:build`, - watch: false, - port: options.devServerPort, + serveTarget: `${options.projectName}:serve`, }, configurations: { development: { - buildTarget: `${options.projectName}:build:development`, + serveTarget: `${options.projectName}:serve:development`, }, production: { - buildTarget: `${options.projectName}:build:production`, + serveTarget: `${options.projectName}:serve:production`, }, }, }; updateProjectConfiguration(host, options.projectName, projectConfig); - - return addDependenciesToPackageJson(host, {}, { '@nx/web': nxVersion }); } diff --git a/packages/react/src/utils/build-static.remotes.ts b/packages/react/src/utils/build-static.remotes.ts new file mode 100644 index 0000000000..3d7da5fd0e --- /dev/null +++ b/packages/react/src/utils/build-static.remotes.ts @@ -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 = {}; + + for (const app of staticRemotesConfig.remotes) { + mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${ + options.host + }:${options.staticRemotesPort}/${ + staticRemotesConfig.config[app].urlSegment + }`; + } + + await new Promise((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; +}