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
This commit is contained in:
Colum Ferry 2025-03-21 15:17:45 +00:00 committed by GitHub
parent 32f0acab42
commit 487aa6fa78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 273 additions and 30 deletions

View File

@ -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';

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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<void>((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<void>((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 ?? {};
}
}

View File

@ -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 };

View File

@ -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)

View File

@ -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,
});

View File

@ -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)) {