From 43a20e2ecc104d072eb1071e24783890b6ecbcfe Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 21 May 2025 09:45:58 +0100 Subject: [PATCH] feat(angular): add support for rspack module federation (#31231) ## Current Behavior We currently have no method for generating Angular Rspack Module Federation applications ## Expected Behavior Update the `host` and `remote` generators to support a `--bundler` flag to allow users to select Rspack as their bundler method --- .../packages/angular/generators/host.json | 6 + .../packages/angular/generators/remote.json | 6 + .../src/module-federation.rspack.test.ts | 178 ++++++++++++++++ .../convert-to-rspack/convert-to-rspack.ts | 9 +- .../lib/get-custom-webpack-config.spec.ts | 2 +- .../lib/get-custom-webpack-config.ts | 2 +- packages/angular/src/generators/host/host.ts | 30 ++- .../angular/src/generators/host/schema.d.ts | 1 + .../angular/src/generators/host/schema.json | 6 + .../angular/src/generators/remote/remote.ts | 30 +++ .../angular/src/generators/remote/schema.d.ts | 1 + .../angular/src/generators/remote/schema.json | 6 + .../src/generators/utils/assert-mf-utils.ts | 16 ++ .../src/utils/backward-compatible-versions.ts | 2 + packages/angular/src/utils/versions.ts | 1 + packages/module-federation/angular.ts | 3 + .../nx-module-federation-dev-server-plugin.ts | 142 +++++++++++++ .../angular/nx-module-federation-plugin.ts | 87 ++++++++ ...module-federation-ssr-dev-server-plugin.ts | 191 ++++++++++++++++++ .../nx-module-federation-dev-server-plugin.ts | 4 +- .../src/plugins/utils/build-static-remotes.ts | 2 +- .../src/plugins/utils/get-static-remotes.ts | 4 +- .../with-module-federation/angular/utils.ts | 108 +++++++++- 23 files changed, 821 insertions(+), 16 deletions(-) create mode 100644 e2e/angular/src/module-federation.rspack.test.ts create mode 100644 packages/angular/src/generators/utils/assert-mf-utils.ts create mode 100644 packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-dev-server-plugin.ts create mode 100644 packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-plugin.ts create mode 100644 packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-ssr-dev-server-plugin.ts diff --git a/docs/generated/packages/angular/generators/host.json b/docs/generated/packages/angular/generators/host.json index 8625ea9511..e19716c567 100644 --- a/docs/generated/packages/angular/generators/host.json +++ b/docs/generated/packages/angular/generators/host.json @@ -37,6 +37,12 @@ "x-priority": "important", "alias": "producers" }, + "bundler": { + "type": "string", + "description": "The bundler to use for the host application.", + "default": "webpack", + "enum": ["webpack", "rspack"] + }, "dynamic": { "type": "boolean", "description": "Should the host application use dynamic federation?", diff --git a/docs/generated/packages/angular/generators/remote.json b/docs/generated/packages/angular/generators/remote.json index fa4c6f716e..18a26c6023 100644 --- a/docs/generated/packages/angular/generators/remote.json +++ b/docs/generated/packages/angular/generators/remote.json @@ -42,6 +42,12 @@ "type": "number", "description": "The port on which this app should be served." }, + "bundler": { + "type": "string", + "description": "The bundler to use for the remote application.", + "default": "webpack", + "enum": ["webpack", "rspack"] + }, "style": { "description": "The file extension to be used for style files.", "type": "string", diff --git a/e2e/angular/src/module-federation.rspack.test.ts b/e2e/angular/src/module-federation.rspack.test.ts new file mode 100644 index 0000000000..6b0010ee97 --- /dev/null +++ b/e2e/angular/src/module-federation.rspack.test.ts @@ -0,0 +1,178 @@ +import { names } from '@nx/devkit'; +import { + checkFilesExist, + cleanupProject, + killPorts, + killProcessAndPorts, + newProject, + readFile, + readJson, + runCLI, + runCommandUntil, + runE2ETests, + uniq, + updateFile, + updateJson, +} from '@nx/e2e/utils'; +import { join } from 'path'; + +describe('Angular Module Federation', () => { + let proj: string; + let oldVerboseLoggingValue: string; + + beforeAll(() => { + proj = newProject({ packages: ['@nx/angular'] }); + oldVerboseLoggingValue = process.env.NX_E2E_VERBOSE_LOGGING; + process.env.NX_E2E_VERBOSE_LOGGING = 'true'; + }); + afterAll(() => { + cleanupProject(); + process.env.NX_E2E_VERBOSE_LOGGING = oldVerboseLoggingValue; + }); + + it('should generate valid host and remote apps', async () => { + const hostApp = uniq('app'); + const remoteApp1 = uniq('remote'); + const sharedLib = uniq('shared-lib'); + const wildcardLib = uniq('wildcard-lib'); + const secondaryEntry = uniq('secondary'); + const hostPort = 4300; + const remotePort = 4301; + + // generate host app + runCLI( + `generate @nx/angular:host ${hostApp} --style=css --bundler=rspack --no-standalone --no-interactive` + ); + let rspackConfigFileContents = readFile(join(hostApp, 'rspack.config.ts')); + let updatedConfigFileContents = rspackConfigFileContents.replace( + `maximumError: '1mb'`, + `maximumError: '11mb'` + ); + updateFile(join(hostApp, 'rspack.config.ts'), updatedConfigFileContents); + + // generate remote app + runCLI( + `generate @nx/angular:remote ${remoteApp1} --host=${hostApp} --bundler=rspack --port=${remotePort} --style=css --no-standalone --no-interactive` + ); + rspackConfigFileContents = readFile(join(remoteApp1, 'rspack.config.ts')); + updatedConfigFileContents = rspackConfigFileContents.replace( + `maximumError: '1mb'`, + `maximumError: '11mb'` + ); + updateFile(join(remoteApp1, 'rspack.config.ts'), updatedConfigFileContents); + + // check files are generated without the layout directory ("apps/") + checkFilesExist( + `${hostApp}/src/app/app.module.ts`, + `${remoteApp1}/src/app/app.module.ts` + ); + + // check default generated host is built successfully + const buildOutput = runCLI(`build ${hostApp}`); + expect(buildOutput).toContain('Successfully ran target build'); + + // generate a shared lib with a seconary entry point + runCLI( + `generate @nx/angular:library ${sharedLib} --buildable --no-standalone --no-interactive` + ); + runCLI( + `generate @nx/angular:library-secondary-entry-point --library=${sharedLib} --name=${secondaryEntry} --no-interactive` + ); + + // Add a library that will be accessed via a wildcard in tspath mappings + runCLI( + `generate @nx/angular:library ${wildcardLib} --buildable --no-standalone --no-interactive` + ); + + updateJson('tsconfig.base.json', (json) => { + delete json.compilerOptions.paths[`@${proj}/${wildcardLib}`]; + json.compilerOptions.paths[`@${proj}/${wildcardLib}/*`] = [ + `${wildcardLib}/src/lib/*`, + ]; + return json; + }); + + // update host & remote files to use shared library + updateFile( + `${hostApp}/src/app/app.module.ts`, + `import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { ${ + names(wildcardLib).className + }Module } from '@${proj}/${wildcardLib}/${ + names(secondaryEntry).fileName + }.module'; + import { ${ + names(sharedLib).className + }Module } from '@${proj}/${sharedLib}'; + import { ${ + names(secondaryEntry).className + }Module } from '@${proj}/${sharedLib}/${secondaryEntry}'; + import { AppComponent } from './app.component'; + import { NxWelcomeComponent } from './nx-welcome.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [ + BrowserModule, + ${names(sharedLib).className}Module, + ${names(wildcardLib).className}Module, + RouterModule.forRoot( + [ + { + path: '${remoteApp1}', + loadChildren: () => + import('${remoteApp1}/Module').then( + (m) => m.RemoteEntryModule + ), + }, + ], + { initialNavigation: 'enabledBlocking' } + ), + ], + providers: [], + bootstrap: [AppComponent], + }) + export class AppModule {} + ` + ); + updateFile( + `${remoteApp1}/src/app/remote-entry/entry.module.ts`, + `import { NgModule } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { RouterModule } from '@angular/router'; + import { ${names(sharedLib).className}Module } from '@${proj}/${sharedLib}'; + import { ${ + names(secondaryEntry).className + }Module } from '@${proj}/${sharedLib}/${secondaryEntry}'; + import { RemoteEntryComponent } from './entry.component'; + import { NxWelcomeComponent } from './nx-welcome.component'; + + @NgModule({ + declarations: [RemoteEntryComponent, NxWelcomeComponent], + imports: [ + CommonModule, + ${names(sharedLib).className}Module, + RouterModule.forChild([ + { + path: '', + component: RemoteEntryComponent, + }, + ]), + ], + providers: [], + }) + export class RemoteEntryModule {} + ` + ); + + const processSwc = await runCommandUntil( + `serve ${remoteApp1}`, + (output) => + !output.includes(`Remote '${remoteApp1}' failed to serve correctly`) && + output.includes(`Build at:`) + ); + await killProcessAndPorts(processSwc.pid, remotePort); + }, 20_000_000); +}); diff --git a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts index fae900b7e1..e32354fdeb 100644 --- a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts +++ b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts @@ -17,6 +17,7 @@ import { angularRspackVersion, nxVersion, tsNodeVersion, + webpackMergeVersion, } from '../../utils/versions'; import { createConfig } from './lib/create-config'; import { getCustomWebpackConfig } from './lib/get-custom-webpack-config'; @@ -47,12 +48,7 @@ const RENAMED_OPTIONS = { const DEFAULT_PORT = 4200; -const REMOVED_OPTIONS = [ - 'buildOptimizer', - 'buildTarget', - 'browserTarget', - 'publicHost', -]; +const REMOVED_OPTIONS = ['buildOptimizer', 'buildTarget', 'browserTarget']; function normalizeFromProjectRoot( tree: Tree, @@ -506,6 +502,7 @@ export async function convertToRspack( {}, { '@nx/angular-rspack': angularRspackVersion, + 'webpack-merge': webpackMergeVersion, 'ts-node': tsNodeVersion, } ); diff --git a/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.spec.ts b/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.spec.ts index 49825c4d9f..64bcfa9cbe 100644 --- a/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.spec.ts +++ b/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.spec.ts @@ -16,7 +16,7 @@ describe('convertconvertWebpackConfigToUseNxModuleFederationPlugin', () => { // ASSERT expect(newWebpackConfigContents).toMatchInlineSnapshot(` " - import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/rspack'; + import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/angular'; import config from './module-federation.config'; diff --git a/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.ts b/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.ts index 73620a7356..7afbb1c3aa 100644 --- a/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.ts +++ b/packages/angular/src/generators/convert-to-rspack/lib/get-custom-webpack-config.ts @@ -56,7 +56,7 @@ export function convertWebpackConfigToUseNxModuleFederationPlugin( newWebpackConfigContents = `${webpackConfigContents.slice( 0, withModuleFederationImportNode.getStart() - )}import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/rspack';${webpackConfigContents.slice( + )}import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/angular';${webpackConfigContents.slice( withModuleFederationImportNode.getEnd() )}`; diff --git a/packages/angular/src/generators/host/host.ts b/packages/angular/src/generators/host/host.ts index 966939523f..e7542d23cd 100644 --- a/packages/angular/src/generators/host/host.ts +++ b/packages/angular/src/generators/host/host.ts @@ -2,8 +2,10 @@ import { formatFiles, getProjects, joinPathFragments, + readProjectConfiguration, runTasksInSerial, Tree, + updateProjectConfiguration, } from '@nx/devkit'; import { determineProjectNameAndRootOptions, @@ -18,9 +20,19 @@ import { setupMf } from '../setup-mf/setup-mf'; import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs'; import { updateSsrSetup } from './lib'; import type { Schema } from './schema'; +import { assertRspackIsCSR } from '../utils/assert-mf-utils'; +import convertToRspack from '../convert-to-rspack/convert-to-rspack'; export async function host(tree: Tree, schema: Schema) { assertNotUsingTsSolutionSetup(tree, 'angular', 'host'); + // TODO: Replace with Rspack when confidence is high enough + schema.bundler ??= 'webpack'; + const isRspack = schema.bundler === 'rspack'; + assertRspackIsCSR( + schema.bundler, + schema.ssr ?? false, + schema.serverRouting ?? false + ); const { typescriptConfiguration = true, ...options }: Schema = schema; options.standalone = options.standalone ?? true; @@ -100,7 +112,8 @@ export async function host(tree: Tree, schema: Schema) { installTasks.push(ssrInstallTask); } - for (const remote of remotesToGenerate) { + for (let i = 0; i < remotesToGenerate.length; i++) { + const remote = remotesToGenerate[i]; const remoteDirectory = options.directory ? joinPathFragments(options.directory, '..', remote) : appRoot === '.' @@ -111,6 +124,7 @@ export async function host(tree: Tree, schema: Schema) { name: remote, directory: remoteDirectory, host: hostProjectName, + port: isRspack ? 4200 + i + 1 : undefined, skipFormat: true, standalone: options.standalone, typescriptConfiguration, @@ -119,6 +133,20 @@ export async function host(tree: Tree, schema: Schema) { addMfEnvToTargetDefaultInputs(tree); + if (isRspack) { + await convertToRspack(tree, { + project: hostProjectName, + skipInstall: options.skipPackageJson, + skipFormat: true, + }); + } + + const project = readProjectConfiguration(tree, hostProjectName); + project.targets.serve ??= {}; + project.targets.serve.options ??= {}; + project.targets.serve.options.port = 4200; + updateProjectConfiguration(tree, hostProjectName, project); + if (!options.skipFormat) { await formatFiles(tree); } diff --git a/packages/angular/src/generators/host/schema.d.ts b/packages/angular/src/generators/host/schema.d.ts index cbe9bf2ad3..269abd3b56 100644 --- a/packages/angular/src/generators/host/schema.d.ts +++ b/packages/angular/src/generators/host/schema.d.ts @@ -5,6 +5,7 @@ import type { Styles } from '../utils/types'; export interface Schema { directory: string; name?: string; + bundler?: 'webpack' | 'rspack'; remotes?: string[]; dynamic?: boolean; setParserOptionsProject?: boolean; diff --git a/packages/angular/src/generators/host/schema.json b/packages/angular/src/generators/host/schema.json index 8c79cf21ef..9148f30df5 100644 --- a/packages/angular/src/generators/host/schema.json +++ b/packages/angular/src/generators/host/schema.json @@ -37,6 +37,12 @@ "x-priority": "important", "alias": "producers" }, + "bundler": { + "type": "string", + "description": "The bundler to use for the host application.", + "default": "webpack", + "enum": ["webpack", "rspack"] + }, "dynamic": { "type": "boolean", "description": "Should the host application use dynamic federation?", diff --git a/packages/angular/src/generators/remote/remote.ts b/packages/angular/src/generators/remote/remote.ts index 6809807046..0864c40289 100644 --- a/packages/angular/src/generators/remote/remote.ts +++ b/packages/angular/src/generators/remote/remote.ts @@ -2,9 +2,11 @@ import { addDependenciesToPackageJson, formatFiles, getProjects, + readProjectConfiguration, runTasksInSerial, stripIndents, Tree, + updateProjectConfiguration, } from '@nx/devkit'; import { determineProjectNameAndRootOptions, @@ -18,9 +20,19 @@ import { setupMf } from '../setup-mf/setup-mf'; import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs'; import { findNextAvailablePort, updateSsrSetup } from './lib'; import type { Schema } from './schema'; +import { assertRspackIsCSR } from '../utils/assert-mf-utils'; +import convertToRspack from '../convert-to-rspack/convert-to-rspack'; export async function remote(tree: Tree, schema: Schema) { assertNotUsingTsSolutionSetup(tree, 'angular', 'remote'); + // TODO: Replace with Rspack when confidence is high enough + schema.bundler ??= 'webpack'; + const isRspack = schema.bundler === 'rspack'; + assertRspackIsCSR( + schema.bundler, + schema.ssr ?? false, + schema.serverRouting ?? false + ); const { typescriptConfiguration = true, ...options }: Schema = schema; options.standalone = options.standalone ?? true; @@ -105,6 +117,24 @@ export async function remote(tree: Tree, schema: Schema) { addMfEnvToTargetDefaultInputs(tree); + if (isRspack) { + await convertToRspack(tree, { + project: remoteProjectName, + skipInstall: options.skipPackageJson, + skipFormat: true, + }); + } + + const project = readProjectConfiguration(tree, remoteProjectName); + project.targets.serve ??= {}; + project.targets.serve.options ??= {}; + if (options.host) { + project.targets.serve.dependsOn ??= []; + project.targets.serve.dependsOn.push(`${options.host}:serve`); + } + project.targets.serve.options.port = port; + updateProjectConfiguration(tree, remoteProjectName, project); + if (!options.skipFormat) { await formatFiles(tree); } diff --git a/packages/angular/src/generators/remote/schema.d.ts b/packages/angular/src/generators/remote/schema.d.ts index 574894d75c..537536e4a7 100644 --- a/packages/angular/src/generators/remote/schema.d.ts +++ b/packages/angular/src/generators/remote/schema.d.ts @@ -5,6 +5,7 @@ import type { Styles } from '../utils/types'; export interface Schema { directory: string; name?: string; + bundler?: 'webpack' | 'rspack'; host?: string; port?: number; setParserOptionsProject?: boolean; diff --git a/packages/angular/src/generators/remote/schema.json b/packages/angular/src/generators/remote/schema.json index 7ad2b48bfd..252147d9de 100644 --- a/packages/angular/src/generators/remote/schema.json +++ b/packages/angular/src/generators/remote/schema.json @@ -42,6 +42,12 @@ "type": "number", "description": "The port on which this app should be served." }, + "bundler": { + "type": "string", + "description": "The bundler to use for the remote application.", + "default": "webpack", + "enum": ["webpack", "rspack"] + }, "style": { "description": "The file extension to be used for style files.", "type": "string", diff --git a/packages/angular/src/generators/utils/assert-mf-utils.ts b/packages/angular/src/generators/utils/assert-mf-utils.ts new file mode 100644 index 0000000000..121a155a9d --- /dev/null +++ b/packages/angular/src/generators/utils/assert-mf-utils.ts @@ -0,0 +1,16 @@ +export function assertRspackIsCSR( + bundler: 'webpack' | 'rspack', + ssr: boolean, + serverRouting: boolean +) { + if (bundler === 'rspack' && serverRouting) { + throw new Error( + 'Server Routing is not currently supported for Angular Rspack Module Federation. Please use webpack instead.' + ); + } + if (bundler === 'rspack' && ssr) { + throw new Error( + 'SSR is not currently supported for Angular Rspack Module Federation. Please use webpack instead.' + ); + } +} diff --git a/packages/angular/src/utils/backward-compatible-versions.ts b/packages/angular/src/utils/backward-compatible-versions.ts index 16bb4510a0..eb288146a2 100644 --- a/packages/angular/src/utils/backward-compatible-versions.ts +++ b/packages/angular/src/utils/backward-compatible-versions.ts @@ -51,6 +51,7 @@ export const backwardCompatibleVersions: VersionMap = { typesNodeVersion: '18.16.9', jasmineMarblesVersion: '^0.9.2', jsoncEslintParserVersion: '^2.1.0', + webpackMergeVersion: '^5.8.0', }, angularV18: { angularVersion: '~18.2.0', @@ -80,5 +81,6 @@ export const backwardCompatibleVersions: VersionMap = { typesNodeVersion: '18.16.9', jasmineMarblesVersion: '^0.9.2', jsoncEslintParserVersion: '^2.1.0', + webpackMergeVersion: '^5.8.0', }, }; diff --git a/packages/angular/src/utils/versions.ts b/packages/angular/src/utils/versions.ts index 7be0f8a15b..5c89240d8e 100644 --- a/packages/angular/src/utils/versions.ts +++ b/packages/angular/src/utils/versions.ts @@ -17,6 +17,7 @@ export const typesExpressVersion = '^4.17.21'; export const browserSyncVersion = '^3.0.0'; export const moduleFederationNodeVersion = '^2.6.26'; export const moduleFederationEnhancedVersion = '^0.9.0'; +export const webpackMergeVersion = '^5.8.0'; export const angularEslintVersion = '^19.2.0'; export const typescriptEslintVersion = '^7.16.0'; diff --git a/packages/module-federation/angular.ts b/packages/module-federation/angular.ts index 2f61fe40b0..447ade1570 100644 --- a/packages/module-federation/angular.ts +++ b/packages/module-federation/angular.ts @@ -1,2 +1,5 @@ export * from './src/with-module-federation/angular/with-module-federation'; export * from './src/with-module-federation/angular/with-module-federation-ssr'; +export * from './src/plugins/nx-module-federation-plugin/angular/nx-module-federation-plugin'; +export * from './src/plugins/nx-module-federation-plugin/angular/nx-module-federation-dev-server-plugin'; +export * from './src/plugins/nx-module-federation-plugin/angular/nx-module-federation-ssr-dev-server-plugin'; diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-dev-server-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-dev-server-plugin.ts new file mode 100644 index 0000000000..d9f43ede93 --- /dev/null +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-dev-server-plugin.ts @@ -0,0 +1,142 @@ +import { + Compilation, + Compiler, + DefinePlugin, + RspackPluginInstance, +} from '@rspack/core'; +import * as pc from 'picocolors'; +import { + logger, + readCachedProjectGraph, + readProjectsConfigurationFromProjectGraph, + workspaceRoot, +} from '@nx/devkit'; +import { ModuleFederationConfig } from '../../../utils/models'; +import { extname, join } from 'path'; +import { existsSync } from 'fs'; +import { + buildStaticRemotes, + getDynamicMfManifestFile, + getRemotes, + getStaticRemotes, + parseRemotesConfig, + startRemoteProxies, + startStaticRemotesFileServer, +} from '../../utils'; +import { NxModuleFederationDevServerConfig } from '../../models'; + +const PLUGIN_NAME = 'NxModuleFederationDevServerPlugin'; + +export class NxModuleFederationDevServerPlugin implements RspackPluginInstance { + private nxBin = require.resolve('nx/bin/nx'); + + constructor( + private _options: { + config: ModuleFederationConfig; + devServerConfig?: NxModuleFederationDevServerConfig; + } + ) { + this._options.devServerConfig ??= { + host: 'localhost', + }; + } + + apply(compiler: Compiler) { + const isDevServer = process.env['WEBPACK_SERVE']; + if (!isDevServer) { + return; + } + compiler.hooks.watchRun.tapAsync( + PLUGIN_NAME, + async (compiler, callback) => { + compiler.hooks.beforeCompile.tapAsync( + PLUGIN_NAME, + async (params, callback) => { + const staticRemotesConfig = await this.setup(); + + logger.info( + `NX Starting module federation dev-server for ${pc.bold( + this._options.config.name + )} with ${Object.keys(staticRemotesConfig).length} remotes` + ); + + const mappedLocationOfRemotes = await buildStaticRemotes( + staticRemotesConfig, + this._options.devServerConfig, + this.nxBin + ); + startStaticRemotesFileServer( + staticRemotesConfig, + workspaceRoot, + this._options.devServerConfig.staticRemotesPort + ); + startRemoteProxies(staticRemotesConfig, mappedLocationOfRemotes, { + pathToCert: this._options.devServerConfig.sslCert, + pathToKey: this._options.devServerConfig.sslCert, + }); + + new DefinePlugin({ + 'process.env.NX_MF_DEV_REMOTES': process.env.NX_MF_DEV_REMOTES, + }).apply(compiler); + + callback(); + } + ); + callback(); + } + ); + } + + private async setup() { + const projectGraph = readCachedProjectGraph(); + const { projects: workspaceProjects } = + readProjectsConfigurationFromProjectGraph(projectGraph); + const project = workspaceProjects[this._options.config.name]; + if (!this._options.devServerConfig.pathToManifestFile) { + this._options.devServerConfig.pathToManifestFile = + getDynamicMfManifestFile(project, workspaceRoot); + } else { + const userPathToManifestFile = join( + workspaceRoot, + this._options.devServerConfig.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(this._options.devServerConfig.pathToManifestFile) !== '.json' + ) { + throw new Error( + `The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.` + ); + } + + this._options.devServerConfig.pathToManifestFile = userPathToManifestFile; + } + + const { remotes, staticRemotePort } = getRemotes( + this._options.config, + projectGraph, + this._options.devServerConfig.pathToManifestFile + ); + this._options.devServerConfig.staticRemotesPort ??= staticRemotePort; + + const remotesConfig = parseRemotesConfig( + remotes, + workspaceRoot, + projectGraph + ); + const staticRemotesConfig = await getStaticRemotes( + remotesConfig.config ?? {}, + this._options.devServerConfig?.devRemoteFindOptions, + this._options.devServerConfig?.host + ); + const devRemotes = remotes.filter((r) => !staticRemotesConfig[r]); + process.env.NX_MF_DEV_REMOTES = JSON.stringify([ + ...(devRemotes.length > 0 ? devRemotes : []), + project.name, + ]); + return staticRemotesConfig ?? {}; + } +} diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-plugin.ts new file mode 100644 index 0000000000..705d853dac --- /dev/null +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-plugin.ts @@ -0,0 +1,87 @@ +import { Compiler, RspackPluginInstance } from '@rspack/core'; +import { + ModuleFederationConfig, + NxModuleFederationConfigOverride, +} from '../../../utils/models'; +import { getModuleFederationConfigSync } from '../../../with-module-federation/angular/utils'; + +export class NxModuleFederationPlugin implements RspackPluginInstance { + constructor( + private _options: { + config: ModuleFederationConfig; + isServer?: boolean; + }, + private configOverride?: NxModuleFederationConfigOverride + ) {} + + apply(compiler: Compiler) { + if (global.NX_GRAPH_CREATION) { + return; + } + + // This is required to ensure Module Federation will build the project correctly + compiler.options.optimization ??= {}; + compiler.options.optimization.runtimeChunk = false; + compiler.options.output.publicPath = !compiler.options.output.publicPath + ? 'auto' + : compiler.options.output.publicPath; + compiler.options.output.uniqueName = this._options.config.name; + if (compiler.options.output.scriptType === 'module') { + compiler.options.output.scriptType = undefined; + compiler.options.output.module = undefined; + } + if (this._options.isServer) { + compiler.options.target = 'async-node'; + compiler.options.output.library ??= { + type: 'commonjs-module', + }; + compiler.options.output.library.type = 'commonjs-module'; + } + + const config = getModuleFederationConfigSync( + this._options.config, + { + isServer: this._options.isServer, + }, + true + ); + const sharedLibraries = config.sharedLibraries; + const sharedDependencies = config.sharedDependencies; + const mappedRemotes = config.mappedRemotes; + + const runtimePlugins = []; + if (this.configOverride?.runtimePlugins) { + runtimePlugins.push(...(this.configOverride.runtimePlugins ?? [])); + } + if (this._options.isServer) { + runtimePlugins.push( + require.resolve('@module-federation/node/runtimePlugin') + ); + } + + new (require('@module-federation/enhanced/rspack').ModuleFederationPlugin)({ + name: this._options.config.name.replace(/-/g, '_'), + filename: 'remoteEntry.js', + exposes: this._options.config.exposes, + remotes: mappedRemotes, + shared: { + ...(sharedDependencies ?? {}), + }, + ...(this._options.isServer + ? { + library: { + type: 'commonjs-module', + }, + remoteType: 'script', + } + : {}), + ...(this.configOverride ? this.configOverride : {}), + runtimePlugins, + virtualRuntimeEntry: true, + }).apply(compiler); + + if (sharedLibraries) { + sharedLibraries.getReplacementPlugin().apply(compiler as any); + } + } +} diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-ssr-dev-server-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-ssr-dev-server-plugin.ts new file mode 100644 index 0000000000..b1cd9b262c --- /dev/null +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/angular/nx-module-federation-ssr-dev-server-plugin.ts @@ -0,0 +1,191 @@ +import { + Compilation, + Compiler, + DefinePlugin, + RspackPluginInstance, +} from '@rspack/core'; +import * as pc from 'picocolors'; +import { + logger, + readCachedProjectGraph, + readProjectsConfigurationFromProjectGraph, + workspaceRoot, +} from '@nx/devkit'; +import { ModuleFederationConfig } from '../../../utils/models'; +import { dirname, extname, join } from 'path'; +import { existsSync } from 'fs'; +import { + buildStaticRemotes, + getDynamicMfManifestFile, + getRemotes, + getStaticRemotes, + parseRemotesConfig, + startRemoteProxies, + startStaticRemotesFileServer, +} from '../../utils'; +import { NxModuleFederationDevServerConfig } from '../../models'; +import { ChildProcess, fork } from 'node:child_process'; + +const PLUGIN_NAME = 'NxModuleFederationSSRDevServerPlugin'; + +export class NxModuleFederationSSRDevServerPlugin + implements RspackPluginInstance +{ + private devServerProcess: ChildProcess | undefined; + private nxBin = require.resolve('nx/bin/nx'); + + constructor( + private _options: { + config: ModuleFederationConfig; + devServerConfig?: NxModuleFederationDevServerConfig; + } + ) { + this._options.devServerConfig ??= { + host: 'localhost', + }; + } + + apply(compiler: Compiler) { + const isDevServer = process.env['WEBPACK_SERVE']; + if (!isDevServer) { + return; + } + compiler.hooks.watchRun.tapAsync( + PLUGIN_NAME, + async (compiler, callback) => { + compiler.hooks.beforeCompile.tapAsync( + PLUGIN_NAME, + async (params, callback) => { + const staticRemotesConfig = await this.setup(compiler); + + logger.info( + `NX Starting module federation dev-server for ${pc.bold( + this._options.config.name + )} with ${Object.keys(staticRemotesConfig).length} remotes` + ); + + const mappedLocationOfRemotes = await buildStaticRemotes( + staticRemotesConfig, + this._options.devServerConfig, + this.nxBin + ); + startStaticRemotesFileServer( + staticRemotesConfig, + workspaceRoot, + this._options.devServerConfig.staticRemotesPort + ); + startRemoteProxies( + staticRemotesConfig, + mappedLocationOfRemotes, + { + pathToCert: this._options.devServerConfig.sslCert, + pathToKey: this._options.devServerConfig.sslCert, + }, + true + ); + + new DefinePlugin({ + 'process.env.NX_MF_DEV_REMOTES': process.env.NX_MF_DEV_REMOTES, + }).apply(compiler); + + await this.startServer(compiler); + + callback(); + } + ); + callback(); + } + ); + } + + private async startServer(compiler: Compiler) { + compiler.hooks.done.tapAsync(PLUGIN_NAME, async (_, callback) => { + const serverPath = join( + compiler.options.output.path, + (compiler.options.output.filename as string) ?? 'server.js' + ); + if (this.devServerProcess) { + await new Promise((res) => { + this.devServerProcess.on('exit', () => { + res(); + }); + this.devServerProcess.kill('SIGKILL'); + this.devServerProcess = undefined; + }); + } + + if (!existsSync(serverPath)) { + for (let retries = 0; retries < 10; retries++) { + await new Promise((res) => setTimeout(res, 200)); + if (existsSync(serverPath)) { + break; + } + } + if (!existsSync(serverPath)) { + throw new Error(`Could not find server bundle at ${serverPath}.`); + } + } + + this.devServerProcess = fork(serverPath); + process.on('exit', () => { + this.devServerProcess?.kill('SIGKILL'); + }); + process.on('SIGINT', () => { + this.devServerProcess?.kill('SIGKILL'); + }); + callback(); + }); + } + + private async setup(compiler: Compiler) { + const projectGraph = readCachedProjectGraph(); + const { projects: workspaceProjects } = + readProjectsConfigurationFromProjectGraph(projectGraph); + const project = workspaceProjects[this._options.config.name]; + if (!this._options.devServerConfig.pathToManifestFile) { + this._options.devServerConfig.pathToManifestFile = + getDynamicMfManifestFile(project, workspaceRoot); + } else { + const userPathToManifestFile = join( + workspaceRoot, + this._options.devServerConfig.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(this._options.devServerConfig.pathToManifestFile) !== '.json' + ) { + throw new Error( + `The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.` + ); + } + + this._options.devServerConfig.pathToManifestFile = userPathToManifestFile; + } + + const { remotes, staticRemotePort } = getRemotes( + this._options.config, + projectGraph, + this._options.devServerConfig.pathToManifestFile + ); + this._options.devServerConfig.staticRemotesPort ??= staticRemotePort; + + const remotesConfig = parseRemotesConfig( + remotes, + workspaceRoot, + projectGraph, + true + ); + const staticRemotesConfig = await getStaticRemotes( + remotesConfig.config ?? {} + ); + const devRemotes = remotes.filter((r) => !staticRemotesConfig[r]); + process.env.NX_MF_DEV_REMOTES = JSON.stringify([ + ...(devRemotes.length > 0 ? devRemotes : []), + project.name, + ]); + return staticRemotesConfig ?? {}; + } +} diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-dev-server-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-dev-server-plugin.ts index e793083afe..d9f43ede93 100644 --- a/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-dev-server-plugin.ts +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-dev-server-plugin.ts @@ -128,7 +128,9 @@ export class NxModuleFederationDevServerPlugin implements RspackPluginInstance { projectGraph ); const staticRemotesConfig = await getStaticRemotes( - remotesConfig.config ?? {} + remotesConfig.config ?? {}, + this._options.devServerConfig?.devRemoteFindOptions, + this._options.devServerConfig?.host ); const devRemotes = remotes.filter((r) => !staticRemotesConfig[r]); process.env.NX_MF_DEV_REMOTES = JSON.stringify([ diff --git a/packages/module-federation/src/plugins/utils/build-static-remotes.ts b/packages/module-federation/src/plugins/utils/build-static-remotes.ts index 2e6a7a35e6..a88fee5882 100644 --- a/packages/module-federation/src/plugins/utils/build-static-remotes.ts +++ b/packages/module-federation/src/plugins/utils/build-static-remotes.ts @@ -18,7 +18,7 @@ export async function buildStaticRemotes( const mappedLocationOfRemotes: Record = {}; for (const app of remotes) { mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${ - options.host + options.host ?? 'localhost' }:${options.staticRemotesPort}/${staticRemotesConfig[app].urlSegment}`; } diff --git a/packages/module-federation/src/plugins/utils/get-static-remotes.ts b/packages/module-federation/src/plugins/utils/get-static-remotes.ts index f50d8d6dd6..09ad47e4bd 100644 --- a/packages/module-federation/src/plugins/utils/get-static-remotes.ts +++ b/packages/module-federation/src/plugins/utils/get-static-remotes.ts @@ -4,7 +4,8 @@ import { DevRemoteFindOptions } from '../models'; export async function getStaticRemotes( remotesConfig: Record, - devRemoteFindOptions?: DevRemoteFindOptions + devRemoteFindOptions?: DevRemoteFindOptions, + host: string = '127.0.0.1' ) { const remotes = Object.keys(remotesConfig); const findStaticRemotesPromises: Promise[] = []; @@ -14,6 +15,7 @@ export async function getStaticRemotes( waitForPortOpen(remotesConfig[remote].port, { retries: devRemoteFindOptions?.retries ?? 3, retryDelay: devRemoteFindOptions?.retryDelay ?? 1000, + host, }).then( (res) => { resolve(undefined); diff --git a/packages/module-federation/src/with-module-federation/angular/utils.ts b/packages/module-federation/src/with-module-federation/angular/utils.ts index 21d4a1246d..f0e0f31d7c 100644 --- a/packages/module-federation/src/with-module-federation/angular/utils.ts +++ b/packages/module-federation/src/with-module-federation/angular/utils.ts @@ -18,11 +18,23 @@ import { import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph'; export function applyDefaultEagerPackages( - sharedConfig: Record + sharedConfig: Record, + useRspack = false ) { const DEFAULT_PACKAGES_TO_LOAD_EAGERLY = [ '@angular/localize', '@angular/localize/init', + ...(useRspack + ? [ + '@angular/core', + '@angular/core/primitives/signals', + '@angular/core/event-dispatch', + '@angular/core/rxjs-interop', + '@angular/common', + '@angular/common/http', + '@angular/platform-browser', + ] + : []), ]; for (const pkg of DEFAULT_PACKAGES_TO_LOAD_EAGERLY) { if (!sharedConfig[pkg]) { @@ -37,6 +49,7 @@ export const DEFAULT_NPM_PACKAGES_TO_AVOID = [ 'zone.js', '@nx/angular/mf', '@nrwl/angular/mf', + '@nx/angular-rspack', ]; export const DEFAULT_ANGULAR_PACKAGES_TO_SHARE = [ '@angular/core', @@ -44,9 +57,16 @@ export const DEFAULT_ANGULAR_PACKAGES_TO_SHARE = [ '@angular/common', ]; -export function getFunctionDeterminateRemoteUrl(isServer: boolean = false) { +export function getFunctionDeterminateRemoteUrl( + isServer: boolean = false, + useRspack = false +) { const target = 'serve'; - const remoteEntry = isServer ? 'server/remoteEntry.js' : 'remoteEntry.mjs'; + const remoteEntry = isServer + ? 'server/remoteEntry.js' + : useRspack + ? 'remoteEntry.js' + : 'remoteEntry.mjs'; return function (remote: string) { const mappedStaticRemotesFromEnv = process.env @@ -78,7 +98,7 @@ export function getFunctionDeterminateRemoteUrl(isServer: boolean = false) { serveTarget.options?.host ?? `http${serveTarget.options.ssl ? 's' : ''}://localhost`; const port = serveTarget.options?.port ?? 4201; - return `${ + return `${useRspack ? `${remote}@` : ''}${ host.endsWith('/') ? host.slice(0, -1) : host }:${port}/${remoteEntry}`; }; @@ -164,3 +184,83 @@ export async function getModuleFederationConfig( : mapRemotesFunction(mfConfig.remotes, 'mjs', determineRemoteUrlFn); return { sharedLibraries, sharedDependencies, mappedRemotes }; } + +export function getModuleFederationConfigSync( + mfConfig: ModuleFederationConfig, + options: { + isServer: boolean; + determineRemoteUrl?: (remote: string) => string; + } = { isServer: false }, + useRspack = false +) { + const projectGraph: ProjectGraph = readCachedProjectGraph(); + + if (!projectGraph.nodes[mfConfig.name]?.data) { + throw Error( + `Cannot find project "${mfConfig.name}". Check that the name is correct in module-federation.config.js` + ); + } + + const dependencies = getDependentPackagesForProject( + projectGraph, + mfConfig.name + ); + + if (mfConfig.shared) { + dependencies.workspaceLibraries = dependencies.workspaceLibraries.filter( + (lib) => mfConfig.shared(lib.importKey, {}) !== false + ); + dependencies.npmPackages = dependencies.npmPackages.filter( + (pkg) => mfConfig.shared(pkg, {}) !== false + ); + } + + const sharedLibraries = shareWorkspaceLibraries( + dependencies.workspaceLibraries + ); + + const npmPackages = sharePackages( + Array.from( + new Set([ + ...dependencies.npmPackages.filter( + (pkg) => !DEFAULT_NPM_PACKAGES_TO_AVOID.includes(pkg) + ), + ]) + ) + ); + + DEFAULT_NPM_PACKAGES_TO_AVOID.forEach((pkgName) => { + if (pkgName in npmPackages) { + delete npmPackages[pkgName]; + } + }); + + const sharedDependencies = { + ...sharedLibraries.getLibraries( + projectGraph.nodes[mfConfig.name].data.root + ), + ...npmPackages, + }; + + applyDefaultEagerPackages(sharedDependencies, useRspack); + applySharedFunction(sharedDependencies, mfConfig.shared); + applyAdditionalShared( + sharedDependencies, + mfConfig.additionalShared, + projectGraph + ); + const determineRemoteUrlFn = + options.determineRemoteUrl || + getFunctionDeterminateRemoteUrl(options.isServer, useRspack); + + const mapRemotesFunction = options.isServer ? mapRemotesForSSR : mapRemotes; + const mappedRemotes = + !mfConfig.remotes || mfConfig.remotes.length === 0 + ? {} + : mapRemotesFunction( + mfConfig.remotes, + useRspack ? 'js' : 'mjs', + determineRemoteUrlFn + ); + return { sharedLibraries, sharedDependencies, mappedRemotes }; +}