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
This commit is contained in:
Colum Ferry 2025-05-21 09:45:58 +01:00 committed by GitHub
parent a52a4356df
commit 43a20e2ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 821 additions and 16 deletions

View File

@ -37,6 +37,12 @@
"x-priority": "important", "x-priority": "important",
"alias": "producers" "alias": "producers"
}, },
"bundler": {
"type": "string",
"description": "The bundler to use for the host application.",
"default": "webpack",
"enum": ["webpack", "rspack"]
},
"dynamic": { "dynamic": {
"type": "boolean", "type": "boolean",
"description": "Should the host application use dynamic federation?", "description": "Should the host application use dynamic federation?",

View File

@ -42,6 +42,12 @@
"type": "number", "type": "number",
"description": "The port on which this app should be served." "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": { "style": {
"description": "The file extension to be used for style files.", "description": "The file extension to be used for style files.",
"type": "string", "type": "string",

View File

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

View File

@ -17,6 +17,7 @@ import {
angularRspackVersion, angularRspackVersion,
nxVersion, nxVersion,
tsNodeVersion, tsNodeVersion,
webpackMergeVersion,
} from '../../utils/versions'; } from '../../utils/versions';
import { createConfig } from './lib/create-config'; import { createConfig } from './lib/create-config';
import { getCustomWebpackConfig } from './lib/get-custom-webpack-config'; import { getCustomWebpackConfig } from './lib/get-custom-webpack-config';
@ -47,12 +48,7 @@ const RENAMED_OPTIONS = {
const DEFAULT_PORT = 4200; const DEFAULT_PORT = 4200;
const REMOVED_OPTIONS = [ const REMOVED_OPTIONS = ['buildOptimizer', 'buildTarget', 'browserTarget'];
'buildOptimizer',
'buildTarget',
'browserTarget',
'publicHost',
];
function normalizeFromProjectRoot( function normalizeFromProjectRoot(
tree: Tree, tree: Tree,
@ -506,6 +502,7 @@ export async function convertToRspack(
{}, {},
{ {
'@nx/angular-rspack': angularRspackVersion, '@nx/angular-rspack': angularRspackVersion,
'webpack-merge': webpackMergeVersion,
'ts-node': tsNodeVersion, 'ts-node': tsNodeVersion,
} }
); );

View File

@ -16,7 +16,7 @@ describe('convertconvertWebpackConfigToUseNxModuleFederationPlugin', () => {
// ASSERT // ASSERT
expect(newWebpackConfigContents).toMatchInlineSnapshot(` 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'; import config from './module-federation.config';

View File

@ -56,7 +56,7 @@ export function convertWebpackConfigToUseNxModuleFederationPlugin(
newWebpackConfigContents = `${webpackConfigContents.slice( newWebpackConfigContents = `${webpackConfigContents.slice(
0, 0,
withModuleFederationImportNode.getStart() withModuleFederationImportNode.getStart()
)}import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/rspack';${webpackConfigContents.slice( )}import { NxModuleFederationPlugin, NxModuleFederationDevServerPlugin } from '@nx/module-federation/angular';${webpackConfigContents.slice(
withModuleFederationImportNode.getEnd() withModuleFederationImportNode.getEnd()
)}`; )}`;

View File

@ -2,8 +2,10 @@ import {
formatFiles, formatFiles,
getProjects, getProjects,
joinPathFragments, joinPathFragments,
readProjectConfiguration,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { import {
determineProjectNameAndRootOptions, determineProjectNameAndRootOptions,
@ -18,9 +20,19 @@ import { setupMf } from '../setup-mf/setup-mf';
import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs'; import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs';
import { updateSsrSetup } from './lib'; import { updateSsrSetup } from './lib';
import type { Schema } from './schema'; 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) { export async function host(tree: Tree, schema: Schema) {
assertNotUsingTsSolutionSetup(tree, 'angular', 'host'); 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; const { typescriptConfiguration = true, ...options }: Schema = schema;
options.standalone = options.standalone ?? true; options.standalone = options.standalone ?? true;
@ -100,7 +112,8 @@ export async function host(tree: Tree, schema: Schema) {
installTasks.push(ssrInstallTask); installTasks.push(ssrInstallTask);
} }
for (const remote of remotesToGenerate) { for (let i = 0; i < remotesToGenerate.length; i++) {
const remote = remotesToGenerate[i];
const remoteDirectory = options.directory const remoteDirectory = options.directory
? joinPathFragments(options.directory, '..', remote) ? joinPathFragments(options.directory, '..', remote)
: appRoot === '.' : appRoot === '.'
@ -111,6 +124,7 @@ export async function host(tree: Tree, schema: Schema) {
name: remote, name: remote,
directory: remoteDirectory, directory: remoteDirectory,
host: hostProjectName, host: hostProjectName,
port: isRspack ? 4200 + i + 1 : undefined,
skipFormat: true, skipFormat: true,
standalone: options.standalone, standalone: options.standalone,
typescriptConfiguration, typescriptConfiguration,
@ -119,6 +133,20 @@ export async function host(tree: Tree, schema: Schema) {
addMfEnvToTargetDefaultInputs(tree); 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) { if (!options.skipFormat) {
await formatFiles(tree); await formatFiles(tree);
} }

View File

@ -5,6 +5,7 @@ import type { Styles } from '../utils/types';
export interface Schema { export interface Schema {
directory: string; directory: string;
name?: string; name?: string;
bundler?: 'webpack' | 'rspack';
remotes?: string[]; remotes?: string[];
dynamic?: boolean; dynamic?: boolean;
setParserOptionsProject?: boolean; setParserOptionsProject?: boolean;

View File

@ -37,6 +37,12 @@
"x-priority": "important", "x-priority": "important",
"alias": "producers" "alias": "producers"
}, },
"bundler": {
"type": "string",
"description": "The bundler to use for the host application.",
"default": "webpack",
"enum": ["webpack", "rspack"]
},
"dynamic": { "dynamic": {
"type": "boolean", "type": "boolean",
"description": "Should the host application use dynamic federation?", "description": "Should the host application use dynamic federation?",

View File

@ -2,9 +2,11 @@ import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
formatFiles, formatFiles,
getProjects, getProjects,
readProjectConfiguration,
runTasksInSerial, runTasksInSerial,
stripIndents, stripIndents,
Tree, Tree,
updateProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { import {
determineProjectNameAndRootOptions, determineProjectNameAndRootOptions,
@ -18,9 +20,19 @@ import { setupMf } from '../setup-mf/setup-mf';
import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs'; import { addMfEnvToTargetDefaultInputs } from '../utils/add-mf-env-to-inputs';
import { findNextAvailablePort, updateSsrSetup } from './lib'; import { findNextAvailablePort, updateSsrSetup } from './lib';
import type { Schema } from './schema'; 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) { export async function remote(tree: Tree, schema: Schema) {
assertNotUsingTsSolutionSetup(tree, 'angular', 'remote'); 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; const { typescriptConfiguration = true, ...options }: Schema = schema;
options.standalone = options.standalone ?? true; options.standalone = options.standalone ?? true;
@ -105,6 +117,24 @@ export async function remote(tree: Tree, schema: Schema) {
addMfEnvToTargetDefaultInputs(tree); 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) { if (!options.skipFormat) {
await formatFiles(tree); await formatFiles(tree);
} }

View File

@ -5,6 +5,7 @@ import type { Styles } from '../utils/types';
export interface Schema { export interface Schema {
directory: string; directory: string;
name?: string; name?: string;
bundler?: 'webpack' | 'rspack';
host?: string; host?: string;
port?: number; port?: number;
setParserOptionsProject?: boolean; setParserOptionsProject?: boolean;

View File

@ -42,6 +42,12 @@
"type": "number", "type": "number",
"description": "The port on which this app should be served." "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": { "style": {
"description": "The file extension to be used for style files.", "description": "The file extension to be used for style files.",
"type": "string", "type": "string",

View File

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

View File

@ -51,6 +51,7 @@ export const backwardCompatibleVersions: VersionMap = {
typesNodeVersion: '18.16.9', typesNodeVersion: '18.16.9',
jasmineMarblesVersion: '^0.9.2', jasmineMarblesVersion: '^0.9.2',
jsoncEslintParserVersion: '^2.1.0', jsoncEslintParserVersion: '^2.1.0',
webpackMergeVersion: '^5.8.0',
}, },
angularV18: { angularV18: {
angularVersion: '~18.2.0', angularVersion: '~18.2.0',
@ -80,5 +81,6 @@ export const backwardCompatibleVersions: VersionMap = {
typesNodeVersion: '18.16.9', typesNodeVersion: '18.16.9',
jasmineMarblesVersion: '^0.9.2', jasmineMarblesVersion: '^0.9.2',
jsoncEslintParserVersion: '^2.1.0', jsoncEslintParserVersion: '^2.1.0',
webpackMergeVersion: '^5.8.0',
}, },
}; };

View File

@ -17,6 +17,7 @@ export const typesExpressVersion = '^4.17.21';
export const browserSyncVersion = '^3.0.0'; export const browserSyncVersion = '^3.0.0';
export const moduleFederationNodeVersion = '^2.6.26'; export const moduleFederationNodeVersion = '^2.6.26';
export const moduleFederationEnhancedVersion = '^0.9.0'; export const moduleFederationEnhancedVersion = '^0.9.0';
export const webpackMergeVersion = '^5.8.0';
export const angularEslintVersion = '^19.2.0'; export const angularEslintVersion = '^19.2.0';
export const typescriptEslintVersion = '^7.16.0'; export const typescriptEslintVersion = '^7.16.0';

View File

@ -1,2 +1,5 @@
export * from './src/with-module-federation/angular/with-module-federation'; export * from './src/with-module-federation/angular/with-module-federation';
export * from './src/with-module-federation/angular/with-module-federation-ssr'; 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';

View File

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

View File

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

View File

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

View File

@ -128,7 +128,9 @@ export class NxModuleFederationDevServerPlugin implements RspackPluginInstance {
projectGraph projectGraph
); );
const staticRemotesConfig = await getStaticRemotes( const staticRemotesConfig = await getStaticRemotes(
remotesConfig.config ?? {} remotesConfig.config ?? {},
this._options.devServerConfig?.devRemoteFindOptions,
this._options.devServerConfig?.host
); );
const devRemotes = remotes.filter((r) => !staticRemotesConfig[r]); const devRemotes = remotes.filter((r) => !staticRemotesConfig[r]);
process.env.NX_MF_DEV_REMOTES = JSON.stringify([ process.env.NX_MF_DEV_REMOTES = JSON.stringify([

View File

@ -18,7 +18,7 @@ export async function buildStaticRemotes(
const mappedLocationOfRemotes: Record<string, string> = {}; const mappedLocationOfRemotes: Record<string, string> = {};
for (const app of remotes) { for (const app of remotes) {
mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${ mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${
options.host options.host ?? 'localhost'
}:${options.staticRemotesPort}/${staticRemotesConfig[app].urlSegment}`; }:${options.staticRemotesPort}/${staticRemotesConfig[app].urlSegment}`;
} }

View File

@ -4,7 +4,8 @@ import { DevRemoteFindOptions } from '../models';
export async function getStaticRemotes( export async function getStaticRemotes(
remotesConfig: Record<string, StaticRemoteConfig>, remotesConfig: Record<string, StaticRemoteConfig>,
devRemoteFindOptions?: DevRemoteFindOptions devRemoteFindOptions?: DevRemoteFindOptions,
host: string = '127.0.0.1'
) { ) {
const remotes = Object.keys(remotesConfig); const remotes = Object.keys(remotesConfig);
const findStaticRemotesPromises: Promise<string | undefined>[] = []; const findStaticRemotesPromises: Promise<string | undefined>[] = [];
@ -14,6 +15,7 @@ export async function getStaticRemotes(
waitForPortOpen(remotesConfig[remote].port, { waitForPortOpen(remotesConfig[remote].port, {
retries: devRemoteFindOptions?.retries ?? 3, retries: devRemoteFindOptions?.retries ?? 3,
retryDelay: devRemoteFindOptions?.retryDelay ?? 1000, retryDelay: devRemoteFindOptions?.retryDelay ?? 1000,
host,
}).then( }).then(
(res) => { (res) => {
resolve(undefined); resolve(undefined);

View File

@ -18,11 +18,23 @@ import {
import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph'; import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph';
export function applyDefaultEagerPackages( export function applyDefaultEagerPackages(
sharedConfig: Record<string, SharedLibraryConfig> sharedConfig: Record<string, SharedLibraryConfig>,
useRspack = false
) { ) {
const DEFAULT_PACKAGES_TO_LOAD_EAGERLY = [ const DEFAULT_PACKAGES_TO_LOAD_EAGERLY = [
'@angular/localize', '@angular/localize',
'@angular/localize/init', '@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) { for (const pkg of DEFAULT_PACKAGES_TO_LOAD_EAGERLY) {
if (!sharedConfig[pkg]) { if (!sharedConfig[pkg]) {
@ -37,6 +49,7 @@ export const DEFAULT_NPM_PACKAGES_TO_AVOID = [
'zone.js', 'zone.js',
'@nx/angular/mf', '@nx/angular/mf',
'@nrwl/angular/mf', '@nrwl/angular/mf',
'@nx/angular-rspack',
]; ];
export const DEFAULT_ANGULAR_PACKAGES_TO_SHARE = [ export const DEFAULT_ANGULAR_PACKAGES_TO_SHARE = [
'@angular/core', '@angular/core',
@ -44,9 +57,16 @@ export const DEFAULT_ANGULAR_PACKAGES_TO_SHARE = [
'@angular/common', '@angular/common',
]; ];
export function getFunctionDeterminateRemoteUrl(isServer: boolean = false) { export function getFunctionDeterminateRemoteUrl(
isServer: boolean = false,
useRspack = false
) {
const target = 'serve'; 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) { return function (remote: string) {
const mappedStaticRemotesFromEnv = process.env const mappedStaticRemotesFromEnv = process.env
@ -78,7 +98,7 @@ export function getFunctionDeterminateRemoteUrl(isServer: boolean = false) {
serveTarget.options?.host ?? serveTarget.options?.host ??
`http${serveTarget.options.ssl ? 's' : ''}://localhost`; `http${serveTarget.options.ssl ? 's' : ''}://localhost`;
const port = serveTarget.options?.port ?? 4201; const port = serveTarget.options?.port ?? 4201;
return `${ return `${useRspack ? `${remote}@` : ''}${
host.endsWith('/') ? host.slice(0, -1) : host host.endsWith('/') ? host.slice(0, -1) : host
}:${port}/${remoteEntry}`; }:${port}/${remoteEntry}`;
}; };
@ -164,3 +184,83 @@ export async function getModuleFederationConfig(
: mapRemotesFunction(mfConfig.remotes, 'mjs', determineRemoteUrlFn); : mapRemotesFunction(mfConfig.remotes, 'mjs', determineRemoteUrlFn);
return { sharedLibraries, sharedDependencies, mappedRemotes }; 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 };
}