From 487aa6fa786b3b43f2b75d2bd710c9dfb28b593a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 21 Mar 2025 15:17:45 +0000 Subject: [PATCH] feat(module-federation): add ssr support to rspack crystal plugin (#30437) ## Current Behavior The current `NxModuleFederationPlugin` does not support SSR ## Expected Behavior The current `NxModuleFederationPlugin` supports SSR --- packages/module-federation/rspack.ts | 1 + .../nx-module-federation-dev-server-plugin.ts | 15 +- .../rspack/nx-module-federation-plugin.ts | 48 +++-- ...module-federation-ssr-dev-server-plugin.ts | 187 ++++++++++++++++++ .../src/plugins/utils/parse-remotes-config.ts | 10 +- .../src/plugins/utils/start-remote-proxies.ts | 12 +- .../nx-app-rspack-plugin.ts | 8 + .../src/plugins/utils/apply-base-config.ts | 22 ++- 8 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-ssr-dev-server-plugin.ts diff --git a/packages/module-federation/rspack.ts b/packages/module-federation/rspack.ts index 92a8c1b91e..e8493748eb 100644 --- a/packages/module-federation/rspack.ts +++ b/packages/module-federation/rspack.ts @@ -2,3 +2,4 @@ export * from './src/with-module-federation/rspack/with-module-federation'; export * from './src/with-module-federation/rspack/with-module-federation-ssr'; export * from './src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-plugin'; export * from './src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-dev-server-plugin'; +export * from './src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-ssr-dev-server-plugin'; 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 d9121e45b6..9bb3a4016f 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 @@ -24,24 +24,30 @@ import { startStaticRemotesFileServer, } from '../../utils'; import { NxModuleFederationDevServerConfig } from '../../models'; +import { ChildProcess, fork } from 'node:child_process'; const PLUGIN_NAME = 'NxModuleFederationDevServerPlugin'; export class NxModuleFederationDevServerPlugin implements RspackPluginInstance { + private devServerProcess: ChildProcess | undefined; private nxBin = require.resolve('nx/bin/nx'); constructor( private _options: { config: ModuleFederationConfig; - devServerConfig: NxModuleFederationDevServerConfig; + devServerConfig?: NxModuleFederationDevServerConfig; } - ) {} + ) { + this._options.devServerConfig ??= { + host: 'localhost', + }; + } apply(compiler: Compiler) { compiler.hooks.beforeCompile.tapAsync( PLUGIN_NAME, async (params, callback) => { - const staticRemotesConfig = await this.setup(); + const staticRemotesConfig = await this.setup(compiler); logger.info( `NX Starting module federation dev-server for ${pc.bold( @@ -67,12 +73,13 @@ export class NxModuleFederationDevServerPlugin implements RspackPluginInstance { new DefinePlugin({ 'process.env.NX_MF_DEV_REMOTES': process.env.NX_MF_DEV_REMOTES, }).apply(compiler); + callback(); } ); } - private async setup() { + private async setup(compiler: Compiler) { const projectGraph = readCachedProjectGraph(); const { projects: workspaceProjects } = readProjectsConfigurationFromProjectGraph(projectGraph); diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-plugin.ts index 7c1541b748..a759f69820 100644 --- a/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-plugin.ts +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-plugin.ts @@ -12,6 +12,7 @@ export class NxModuleFederationPlugin implements RspackPluginInstance { private _options: { config: ModuleFederationConfig; devServerConfig?: NxModuleFederationDevServerConfig; + isServer?: boolean; }, private configOverride?: NxModuleFederationConfigOverride ) {} @@ -23,15 +24,32 @@ export class NxModuleFederationPlugin implements RspackPluginInstance { // This is required to ensure Module Federation will build the project correctly compiler.options.optimization.runtimeChunk = false; + compiler.options.output.uniqueName = this._options.config.name; + if (this._options.isServer) { + compiler.options.target = 'async-node'; + compiler.options.output.library ??= { + type: 'commonjs-module', + }; + compiler.options.output.library.type = 'commonjs-module'; + } - const isDevServer = !!process.env['WEBPACK_SERVE']; - - // TODO(colum): Add support for SSR - const config = getModuleFederationConfig(this._options.config); + const config = getModuleFederationConfig(this._options.config, { + isServer: this._options.isServer, + }); 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', @@ -40,25 +58,21 @@ export class NxModuleFederationPlugin implements RspackPluginInstance { shared: { ...(sharedDependencies ?? {}), }, + ...(this._options.isServer + ? { + library: { + type: 'commonjs-module', + }, + remoteType: 'script', + } + : {}), ...(this.configOverride ? this.configOverride : {}), - runtimePlugins: this.configOverride - ? this.configOverride.runtimePlugins ?? [] - : [], + runtimePlugins, virtualRuntimeEntry: true, }).apply(compiler); if (sharedLibraries) { sharedLibraries.getReplacementPlugin().apply(compiler as any); } - - if (isDevServer) { - new NxModuleFederationDevServerPlugin({ - config: this._options.config, - devServerConfig: { - ...(this._options.devServerConfig ?? {}), - host: this._options.devServerConfig?.host ?? 'localhost', - }, - }).apply(compiler); - } } } diff --git a/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-ssr-dev-server-plugin.ts b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-ssr-dev-server-plugin.ts new file mode 100644 index 0000000000..724c6899f8 --- /dev/null +++ b/packages/module-federation/src/plugins/nx-module-federation-plugin/rspack/nx-module-federation-ssr-dev-server-plugin.ts @@ -0,0 +1,187 @@ +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) { + 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.afterEmit.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(); + this.devServerProcess = undefined; + }); + } + + if (!existsSync(serverPath)) { + for (let retries = 0; retries < 10; retries++) { + await new Promise((res) => setTimeout(res, 100)); + 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(); + }); + process.on('SIGINT', () => { + this.devServerProcess?.kill(); + }); + 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/utils/parse-remotes-config.ts b/packages/module-federation/src/plugins/utils/parse-remotes-config.ts index bad4bd6b61..fd4070adbb 100644 --- a/packages/module-federation/src/plugins/utils/parse-remotes-config.ts +++ b/packages/module-federation/src/plugins/utils/parse-remotes-config.ts @@ -6,7 +6,8 @@ import { StaticRemoteConfig } from '../../utils'; export function parseRemotesConfig( remotes: string[] | undefined, workspaceRoot: string, - projectGraph: ProjectGraph + projectGraph: ProjectGraph, + isServer?: boolean ) { if (!remotes?.length) { return { remotes: [], config: undefined }; @@ -32,7 +33,12 @@ export function parseRemotesConfig( const basePath = dirname(outputPath); const urlSegment = app; const port = projectGraph.nodes[app].data.targets?.['serve']?.options.port; - config[app] = { basePath, outputPath, urlSegment, port }; + config[app] = { + basePath, + outputPath: isServer ? dirname(outputPath) : outputPath, + urlSegment, + port, + }; } return { remotes, config }; diff --git a/packages/module-federation/src/plugins/utils/start-remote-proxies.ts b/packages/module-federation/src/plugins/utils/start-remote-proxies.ts index f286f330ae..04c8e3fe58 100644 --- a/packages/module-federation/src/plugins/utils/start-remote-proxies.ts +++ b/packages/module-federation/src/plugins/utils/start-remote-proxies.ts @@ -8,7 +8,8 @@ export function startRemoteProxies( sslOptions?: { pathToCert: string; pathToKey: string; - } + }, + isServer?: boolean ) { const { createProxyMiddleware } = require('http-proxy-middleware'); const express = require('express'); @@ -40,6 +41,15 @@ export function startRemoteProxies( target: mappedLocationsOfRemotes[app], changeOrigin: true, secure: sslCert ? false : undefined, + pathRewrite: isServer + ? (path) => { + if (path.includes('/server')) { + return path; + } else { + return `browser/${path}`; + } + } + : undefined, }) ); const proxyServer = (sslCert ? https : http) diff --git a/packages/rspack/src/plugins/nx-app-rspack-plugin/nx-app-rspack-plugin.ts b/packages/rspack/src/plugins/nx-app-rspack-plugin/nx-app-rspack-plugin.ts index 63e68f6cb1..a20f114cb9 100644 --- a/packages/rspack/src/plugins/nx-app-rspack-plugin/nx-app-rspack-plugin.ts +++ b/packages/rspack/src/plugins/nx-app-rspack-plugin/nx-app-rspack-plugin.ts @@ -34,6 +34,14 @@ export class NxAppRspackPlugin { this.options.target = target; } + if ( + compiler.options.entry && + compiler.options.entry['main'] && + typeof compiler.options.entry['main'] === 'object' && + Object.keys(compiler.options.entry['main']).length === 0 + ) { + compiler.options.entry = {}; + } applyBaseConfig(this.options, compiler.options, { useNormalizedEntry: true, }); diff --git a/packages/rspack/src/plugins/utils/apply-base-config.ts b/packages/rspack/src/plugins/utils/apply-base-config.ts index 3798c60598..c5f86fa2db 100644 --- a/packages/rspack/src/plugins/utils/apply-base-config.ts +++ b/packages/rspack/src/plugins/utils/apply-base-config.ts @@ -65,11 +65,11 @@ function applyNxIndependentConfig( process.env.NODE_ENV === 'production' || options.mode === 'production'; const hashFormat = getOutputHashFormat(options.outputHashing as string); config.context = path.join(options.root, options.projectRoot); - config.target ??= options.target as 'node' | 'web'; + config.target ??= options.target as 'async-node' | 'node' | 'web'; config.node = false; config.mode = // When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value. - config.target === 'node' + config.target === 'node' || config.target === 'async-node' ? 'none' : // Otherwise, make sure it matches `process.env.NODE_ENV`. // When mode is development or production, rspack will automatically @@ -86,7 +86,11 @@ function applyNxIndependentConfig( : 'none'); // When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change. // So to mitigate this we enable in memory caching when target is Node and in watch mode. - config.cache = options.target === 'node' && options.watch ? true : undefined; + config.cache = + (options.target === 'node' || options.target === 'async-node') && + options.watch + ? true + : undefined; config.devtool = options.sourceMap === true ? 'source-map' : options.sourceMap; @@ -94,8 +98,11 @@ function applyNxIndependentConfig( config.output = { ...(config.output ?? {}), libraryTarget: - (config as Configuration).output?.libraryTarget ?? - (options.target === 'node' ? 'commonjs' : undefined), + options.target === 'node' + ? 'commonjs' + : options.target === 'async-node' + ? 'commonjs-module' + : undefined, path: config.output?.path ?? (options.outputPath @@ -333,7 +340,10 @@ function applyNxDependentConfig( } const externals = []; - if (options.target === 'node' && options.externalDependencies === 'all') { + if ( + (options.target === 'node' || options.target === 'async-node') && + options.externalDependencies === 'all' + ) { const modulesDir = `${options.root}/node_modules`; externals.push(nodeExternals({ modulesDir })); } else if (Array.isArray(options.externalDependencies)) {