From ab162ebb540f36c5973c9f02051fd29c80037282 Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Wed, 14 Aug 2024 07:14:58 -0600 Subject: [PATCH] feat(module-federation): Update SSR enabling static serving for remotes (#27345) ## Current Behavior 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 Remotes will start as static by default, which allows for better scaling as the remotes increase. ## Related Issue(s) TODO - [ ] Migrations --- docs/generated/manifests/menus.json | 16 +- docs/generated/manifests/nx-api.json | 18 +- docs/generated/packages-metadata.json | 18 +- .../executors/module-federation-dev-ssr.json | 38 +- .../module-federation-ssr-dev-server.json | 27 + docs/shared/reference/sitemap.md | 2 +- e2e/angular/src/module-federation.test.ts | 86 ++-- e2e/react/src/react-module-federation.test.ts | 113 ++++- packages/angular/executors.json | 10 +- packages/angular/executors.ts | 2 +- .../module-federation-dev-ssr.impl.ts | 181 ------- .../lib/build-static-remotes.ts | 91 ++++ .../lib/normalize-options.ts | 19 + .../lib/start-dev-remotes.ts | 58 +++ .../lib/start-static-remotes.ts | 50 ++ .../module-federation-ssr-dev-server.impl.ts | 196 +++++++ .../schema.d.ts | 8 +- .../schema.json | 36 +- .../module-federation-dev-server.impl.ts | 11 +- .../module-federation-ssr-dev-server.impl.ts | 479 +++++++++++++----- .../schema.json | 27 + .../server.ts__tmpl__ | 2 +- .../module-federation-ssr/server.ts__tmpl__ | 2 +- .../generators/host/lib/setup-ssr-for-host.ts | 1 + .../server.ts__tmpl__ | 2 +- .../module-federation-ssr/server.ts__tmpl__ | 2 +- .../remote/lib/setup-ssr-for-remote.ts | 1 + .../setup-ssr/files/server.ts__tmpl__ | 2 +- .../src/generators/setup-ssr/setup-ssr.ts | 1 + .../executors/dev-server/dev-server.impl.ts | 1 - .../ssr-dev-server/ssr-dev-server.impl.ts | 2 - .../module-federation/get-remotes-for-host.ts | 3 +- .../parse-static-remotes-config.ts | 23 +- .../start-ssr-remote-proxies.ts | 65 +++ 34 files changed, 1159 insertions(+), 434 deletions(-) delete mode 100644 packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts create mode 100644 packages/angular/src/executors/module-federation-ssr-dev-server/lib/build-static-remotes.ts create mode 100644 packages/angular/src/executors/module-federation-ssr-dev-server/lib/normalize-options.ts create mode 100644 packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-dev-remotes.ts create mode 100644 packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-static-remotes.ts create mode 100644 packages/angular/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts rename packages/angular/src/{builders/module-federation-dev-ssr => executors/module-federation-ssr-dev-server}/schema.d.ts (59%) rename packages/angular/src/{builders/module-federation-dev-ssr => executors/module-federation-ssr-dev-server}/schema.json (69%) create mode 100644 packages/webpack/src/utils/module-federation/start-ssr-remote-proxies.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 690945889f..9c7b8ad531 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -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, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 739c954120..783da85941 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -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": { diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index c8b2fe1fc7..5d031e1607 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -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": [ diff --git a/docs/generated/packages/angular/executors/module-federation-dev-ssr.json b/docs/generated/packages/angular/executors/module-federation-dev-ssr.json index 53aebbfba5..72f8ef6db7 100644 --- a/docs/generated/packages/angular/executors/module-federation-dev-ssr.json +++ b/docs/generated/packages/angular/executors/module-federation-dev-ssr.json @@ -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" } diff --git a/docs/generated/packages/react/executors/module-federation-ssr-dev-server.json b/docs/generated/packages/react/executors/module-federation-ssr-dev-server.json index fb5b3ec88d..c2fe914e1e 100644 --- a/docs/generated/packages/react/executors/module-federation-ssr-dev-server.json +++ b/docs/generated/packages/react/executors/module-federation-ssr-dev-server.json @@ -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"], diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index d785858fe4..e6a9853063 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -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) diff --git a/e2e/angular/src/module-federation.test.ts b/e2e/angular/src/module-federation.test.ts index fdaa958c47..427ec4cbfc 100644 --- a/e2e/angular/src/module-federation.test.ts +++ b/e2e/angular/src/module-federation.test.ts @@ -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; +} diff --git a/e2e/react/src/react-module-federation.test.ts b/e2e/react/src/react-module-federation.test.ts index 87cbd97baf..271b37d73a 100644 --- a/e2e/react/src/react-module-federation.test.ts +++ b/e2e/react/src/react-module-federation.test.ts @@ -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,36 +126,99 @@ describe('React Module Federation', () => { 500_000 ); - it('should generate host and remote apps with ssr', async () => { - const shell = uniq('shell'); - const remote1 = uniq('remote1'); - const remote2 = uniq('remote2'); - const remote3 = uniq('remote3'); + describe('ssr', () => { + it('should generate host and remote apps with ssr', 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 --no-interactive --projectNameAndRootFormat=derived --skipFormat` - ); - - expect(readPort(shell)).toEqual(4200); - expect(readPort(remote1)).toEqual(4201); - expect(readPort(remote2)).toEqual(4202); - expect(readPort(remote3)).toEqual(4203); - - [shell, remote1, remote2, remote3].forEach((app) => { - checkFilesExist( - `apps/${app}/module-federation.config.ts`, - `apps/${app}/module-federation.server.config.ts` + await runCLIAsync( + `generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --style=css --no-interactive --projectNameAndRootFormat=derived --skipFormat` ); - ['build', 'server'].forEach((target) => { - ['development', 'production'].forEach(async (configuration) => { - const cliOutput = runCLI(`run ${app}:${target}:${configuration}`); - expect(cliOutput).toContain('Successfully ran target'); - await killPorts(readPort(app)); + expect(readPort(shell)).toEqual(4200); + expect(readPort(remote1)).toEqual(4201); + expect(readPort(remote2)).toEqual(4202); + expect(readPort(remote3)).toEqual(4203); + + [shell, remote1, remote2, remote3].forEach((app) => { + checkFilesExist( + `apps/${app}/module-federation.config.ts`, + `apps/${app}/module-federation.server.config.ts` + ); + ['build', 'server'].forEach((target) => { + ['development', 'production'].forEach(async (configuration) => { + const cliOutput = runCLI(`run ${app}:${target}:${configuration}`); + expect(cliOutput).toContain('Successfully ran target'); + + await killPorts(readPort(app)); + }); }); }); + }, 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 + )}')); + }); }); - }, 500_000); + `; + }); + + 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'); diff --git a/packages/angular/executors.json b/packages/angular/executors.json index ea2b1a6bb2..a567140e80 100644 --- a/packages/angular/executors.json +++ b/packages/angular/executors.json @@ -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." } } } diff --git a/packages/angular/executors.ts b/packages/angular/executors.ts index c184d0c48f..0d790947d3 100644 --- a/packages/angular/executors.ts +++ b/packages/angular/executors.ts @@ -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'; diff --git a/packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts b/packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts deleted file mode 100644 index 00635d5873..0000000000 --- a/packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts +++ /dev/null @@ -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((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 -); diff --git a/packages/angular/src/executors/module-federation-ssr-dev-server/lib/build-static-remotes.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/build-static-remotes.ts new file mode 100644 index 0000000000..f58b3eb3e5 --- /dev/null +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/build-static-remotes.ts @@ -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 = {}; + for (const app of staticRemotesConfig.remotes) { + mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${ + options.host + }:${options.staticRemotesPort}/${ + staticRemotesConfig.config[app].urlSegment + }`; + } + + await new Promise((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; +} diff --git a/packages/angular/src/executors/module-federation-ssr-dev-server/lib/normalize-options.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/normalize-options.ts new file mode 100644 index 0000000000..70e22c1de1 --- /dev/null +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/normalize-options.ts @@ -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, + }; +} diff --git a/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-dev-remotes.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-dev-remotes.ts new file mode 100644 index 0000000000..a935b10473 --- /dev/null +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-dev-remotes.ts @@ -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, + 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; +} diff --git a/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-static-remotes.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-static-remotes.ts new file mode 100644 index 0000000000..f684287bcb --- /dev/null +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/lib/start-static-remotes.ts @@ -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; +} diff --git a/packages/angular/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts new file mode 100644 index 0000000000..87172201cb --- /dev/null +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts @@ -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) => + 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; diff --git a/packages/angular/src/builders/module-federation-dev-ssr/schema.d.ts b/packages/angular/src/executors/module-federation-ssr-dev-server/schema.d.ts similarity index 59% rename from packages/angular/src/builders/module-federation-dev-ssr/schema.d.ts rename to packages/angular/src/executors/module-federation-ssr-dev-server/schema.d.ts index e7dd4bfb28..843777f3c6 100644 --- a/packages/angular/src/builders/module-federation-dev-ssr/schema.d.ts +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/schema.d.ts @@ -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; } diff --git a/packages/angular/src/builders/module-federation-dev-ssr/schema.json b/packages/angular/src/executors/module-federation-ssr-dev-server/schema.json similarity index 69% rename from packages/angular/src/builders/module-federation-dev-ssr/schema.json rename to packages/angular/src/executors/module-federation-ssr-dev-server/schema.json index 7f35f61cbb..5c5c209918 100644 --- a/packages/angular/src/builders/module-federation-dev-ssr/schema.json +++ b/packages/angular/src/executors/module-federation-ssr-dev-server/schema.json @@ -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,7 +64,24 @@ "devRemotes": { "type": "array", "items": { - "type": "string" + "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, 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 c3920f557d..0f01bc03a1 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 @@ -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((res, rej) => { const staticProcess = fork( nxBin, diff --git a/packages/react/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts b/packages/react/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts index 77db245144..d0488357b9 100644 --- a/packages/react/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts +++ b/packages/react/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl.ts @@ -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 = {}; + + for (const remoteApp of staticRemotesConfig.remotes) { + mapLocationOfRemotes[remoteApp] = `http${options.ssl ? 's' : ''}://${ + options.host + }:${options.staticRemotesPort}/${ + staticRemotesConfig.config[remoteApp].urlSegment + }`; + } + + await new Promise((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; + const remoteNames = options.devRemotes?.map((remote) => + typeof remote === 'string' ? remote : remote.remoteName + ); - if (remotesToSkip.has(validRemote)) { - return false; - } else if (!context.projectGraph.nodes[validRemote]) { - remotesNotInWorkspace.push(validRemote); - return false; - } else { - return true; - } - }); + const remotes = getRemotes( + remoteNames, + options.skipRemotes, + moduleFederationConfig, + { + projectName: context.projectName, + projectGraph: context.projectGraph, + root: context.root, + }, + pathToManifestFile + ); - if (remotesNotInWorkspace.length > 0) { - logger.warn( - `Skipping serving ${remotesNotInWorkspace.join( - ', ' - )} as they could not be found in the workspace. Ensure they are served correctly.` - ); - } + options.staticRemotesPort ??= remotes.staticRemotePort; - const devServeApps = !options.devRemotes - ? [] - : Array.isArray(options.devRemotes) - ? options.devRemotes - : [options.devRemotes]; + process.env.NX_MF_DEV_REMOTES = JSON.stringify( + remotes.devRemotes.map((r) => (typeof r === 'string' ? r : r.remoteName)) + ); - // Set NX_MF_DEV_REMOTES for the Nx Runtime Library Control Plugin - process.env.NX_MF_DEV_REMOTES = JSON.stringify(devServeApps); + const staticRemotesConfig = parseStaticSsrRemotesConfig( + [...remotes.staticRemotes, ...remotes.dynamicRemotes], + context + ); - for (const app of knownRemotes) { - const [appName] = Array.isArray(app) ? app : [app]; - const isDev = devServeApps.includes(appName); - const remoteServeIter = isDev - ? await runExecutor( - { - project: appName, - target: 'serve', - configuration: context.configurationName, - }, - { - watch: isDev, - }, - 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, - }, - }); + const mappedLocationsOfStaticRemotes = await buildSsrStaticRemotes( + staticRemotesConfig, + nxBin, + context, + options + ); - child.on('message', (msg) => { - if (msg === 'nx.server.ready') { - next(true); - done(); - } - }); - }), - (x) => x - ); + const devRemoteIters = await startRemotes( + remotes.devRemotes, + context, + options + ); - iter = combineAsyncIterables(iter, remoteServeIter); - } + const staticRemotesIter = startSsrStaticRemotesFileServer( + staticRemotesConfig, + context, + options + ); - 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 - }` - ); - } - }); + 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(); + } + } + ) + ); } diff --git a/packages/react/src/executors/module-federation-ssr-dev-server/schema.json b/packages/react/src/executors/module-federation-ssr-dev-server/schema.json index 865ea1ee7c..9e19288760 100644 --- a/packages/react/src/executors/module-federation-ssr-dev-server/schema.json +++ b/packages/react/src/executors/module-federation-ssr-dev-server/schema.json @@ -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"] diff --git a/packages/react/src/generators/host/files/module-federation-ssr-ts/server.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation-ssr-ts/server.ts__tmpl__ index 22781ec5b8..ea686b7bc5 100644 --- a/packages/react/src/generators/host/files/module-federation-ssr-ts/server.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ssr-ts/server.ts__tmpl__ @@ -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 %>'); diff --git a/packages/react/src/generators/host/files/module-federation-ssr/server.ts__tmpl__ b/packages/react/src/generators/host/files/module-federation-ssr/server.ts__tmpl__ index 22781ec5b8..ea686b7bc5 100644 --- a/packages/react/src/generators/host/files/module-federation-ssr/server.ts__tmpl__ +++ b/packages/react/src/generators/host/files/module-federation-ssr/server.ts__tmpl__ @@ -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 %>'); diff --git a/packages/react/src/generators/host/lib/setup-ssr-for-host.ts b/packages/react/src/generators/host/lib/setup-ssr-for-host.ts index 7acfc5ec45..1a6ff12434 100644 --- a/packages/react/src/generators/host/lib/setup-ssr-for-host.ts +++ b/packages/react/src/generators/host/lib/setup-ssr-for-host.ts @@ -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), diff --git a/packages/react/src/generators/remote/files/module-federation-ssr-ts/server.ts__tmpl__ b/packages/react/src/generators/remote/files/module-federation-ssr-ts/server.ts__tmpl__ index 8b377d7360..9723e09478 100644 --- a/packages/react/src/generators/remote/files/module-federation-ssr-ts/server.ts__tmpl__ +++ b/packages/react/src/generators/remote/files/module-federation-ssr-ts/server.ts__tmpl__ @@ -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 %>'); diff --git a/packages/react/src/generators/remote/files/module-federation-ssr/server.ts__tmpl__ b/packages/react/src/generators/remote/files/module-federation-ssr/server.ts__tmpl__ index 8b377d7360..9723e09478 100644 --- a/packages/react/src/generators/remote/files/module-federation-ssr/server.ts__tmpl__ +++ b/packages/react/src/generators/remote/files/module-federation-ssr/server.ts__tmpl__ @@ -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 %>'); diff --git a/packages/react/src/generators/remote/lib/setup-ssr-for-remote.ts b/packages/react/src/generators/remote/lib/setup-ssr-for-remote.ts index 6b8e067123..a19938152a 100644 --- a/packages/react/src/generators/remote/lib/setup-ssr-for-remote.ts +++ b/packages/react/src/generators/remote/lib/setup-ssr-for-remote.ts @@ -30,6 +30,7 @@ export async function setupSsrForRemote( project.root, { ...options, + port: Number(options?.devServerPort) || 4200, appName, tmpl: '', browserBuildOutputPath: project.targets.build.options.outputPath, diff --git a/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ b/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ index 6c102510a3..3e935a1fd3 100644 --- a/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ +++ b/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ @@ -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 %>'); diff --git a/packages/react/src/generators/setup-ssr/setup-ssr.ts b/packages/react/src/generators/setup-ssr/setup-ssr.ts index e25b8684f1..2a6d9f4c3b 100644 --- a/packages/react/src/generators/setup-ssr/setup-ssr.ts +++ b/packages/react/src/generators/setup-ssr/setup-ssr.ts @@ -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('", "')}",` diff --git a/packages/webpack/src/executors/dev-server/dev-server.impl.ts b/packages/webpack/src/executors/dev-server/dev-server.impl.ts index 79e6a49f00..a283e86a7a 100644 --- a/packages/webpack/src/executors/dev-server/dev-server.impl.ts +++ b/packages/webpack/src/executors/dev-server/dev-server.impl.ts @@ -3,7 +3,6 @@ import { ExecutorContext, parseTargetString, readTargetOptions, - targetToTargetString, } from '@nx/devkit'; import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await'; diff --git a/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts b/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts index 286705fec6..fdd484c1d7 100644 --- a/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts +++ b/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts @@ -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') { diff --git a/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts b/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts index 93646ab0d7..c2506e49c8 100644 --- a/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts +++ b/packages/webpack/src/utils/module-federation/get-remotes-for-host.ts @@ -139,7 +139,8 @@ export function getRemotes( context.projectGraph.nodes[r].data.targets['serve'].options.port ), ] as number[]) - ) + 1; + ) + + (remotesToSkip.size + 1); return { staticRemotes, diff --git a/packages/webpack/src/utils/module-federation/parse-static-remotes-config.ts b/packages/webpack/src/utils/module-federation/parse-static-remotes-config.ts index 460b6f7a2e..ef918839d1 100644 --- a/packages/webpack/src/utils/module-federation/parse-static-remotes-config.ts +++ b/packages/webpack/src/utils/module-federation/parse-static-remotes-config.ts @@ -7,7 +7,6 @@ export type StaticRemoteConfig = { urlSegment: string; port: number; }; - export type StaticRemotesConfig = { remotes: string[]; config: Record | 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 = {}; + 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 }; +} diff --git a/packages/webpack/src/utils/module-federation/start-ssr-remote-proxies.ts b/packages/webpack/src/utils/module-federation/start-ssr-remote-proxies.ts new file mode 100644 index 0000000000..13e6a70a2a --- /dev/null +++ b/packages/webpack/src/utils/module-federation/start-ssr-remote-proxies.ts @@ -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, + 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`); +}