From 1d30d96a1b993602ac2c448e5b72065ba1f13335 Mon Sep 17 00:00:00 2001 From: 2heal1 Date: Sun, 12 Jul 2026 14:18:50 +0800 Subject: [PATCH] feat: implement Rspack manifest support and add tests for StatsPlugin --- .changeset/tidy-rspack-manifests.md | 5 + .../manifest/__tests__/StatsPlugin.spec.ts | 182 ++++++++++++++++++ packages/manifest/src/StatsPlugin.ts | 55 +++++- 3 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-rspack-manifests.md create mode 100644 packages/manifest/__tests__/StatsPlugin.spec.ts diff --git a/.changeset/tidy-rspack-manifests.md b/.changeset/tidy-rspack-manifests.md new file mode 100644 index 00000000000..e09e4f46381 --- /dev/null +++ b/.changeset/tidy-rspack-manifests.md @@ -0,0 +1,5 @@ +--- +"@module-federation/manifest": patch +--- + +Use Rspack's built-in Module Federation manifest and require Rspack 1.7.12 or 2.0.0 and above when manifests are enabled. diff --git a/packages/manifest/__tests__/StatsPlugin.spec.ts b/packages/manifest/__tests__/StatsPlugin.spec.ts new file mode 100644 index 00000000000..9618e5f345d --- /dev/null +++ b/packages/manifest/__tests__/StatsPlugin.spec.ts @@ -0,0 +1,182 @@ +import type { Compiler } from 'webpack'; +import type { moduleFederationPlugin } from '@module-federation/sdk'; + +jest.mock( + '@module-federation/sdk', + () => ({ + bindLoggerToCompiler: jest.fn(), + moduleFederationPlugin: {}, + }), + { virtual: true }, +); + +jest.mock('../src/logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + +jest.mock('../src/StatsManager', () => ({ + StatsManager: class { + fileName = 'mf-stats.json'; + + init = jest.fn(); + validate = jest.fn(() => true); + updateStats = jest.fn((stats) => stats); + generateStats = jest.fn(async () => ({ generatedBy: 'webpack' })); + getPublicPath = jest.fn(() => '/'); + }, +})); + +jest.mock('../src/ManifestManager', () => ({ + ManifestManager: class { + fileName = 'mf-manifest.json'; + + init = jest.fn(); + updateManifest = jest.fn(() => ({ generatedBy: 'rspack' })); + generateManifest = jest.fn(() => ({ generatedBy: 'webpack' })); + }, +})); + +import { StatsPlugin } from '../src/StatsPlugin'; + +const createCompiler = (rspackVersion?: string) => { + class RawSource { + constructor(readonly value: string) {} + } + + return { + webpack: { + rspackVersion, + sources: { RawSource }, + }, + options: { + output: { publicPath: 'auto' }, + }, + hooks: { + thisCompilation: { + tap: jest.fn(), + }, + }, + } as unknown as Compiler; +}; + +const createPlugin = ( + bundler: 'webpack' | 'rspack', + manifest: moduleFederationPlugin.ModuleFederationPluginOptions['manifest'] = true, +) => + new StatsPlugin( + { + name: 'host', + manifest, + }, + { + pluginVersion: '1.0.0', + bundler, + }, + ); + +const getProcessAssetsHandler = (compiler: Compiler, getAsset: jest.Mock) => { + const processAssets = { + tapPromise: jest.fn(), + }; + const compilation = { + constructor: { + PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER: 100, + }, + hooks: { processAssets }, + getAsset, + updateAsset: jest.fn(), + emitAsset: jest.fn(), + }; + + const thisCompilationHandler = ( + compiler.hooks.thisCompilation.tap as jest.Mock + ).mock.calls[0][1]; + thisCompilationHandler(compilation); + + return { + compilation, + handler: processAssets.tapPromise.mock.calls[0][1] as () => Promise, + }; +}; + +describe('StatsPlugin', () => { + it.each(['1.3.9', '1.7.11', '1.7.12-beta.0', '2.0.0-beta.1'])( + 'requires a Rspack upgrade when manifest is enabled on %s', + (version) => { + const compiler = createCompiler(version); + + expect(() => createPlugin('rspack').apply(compiler)).toThrow( + /upgrade to Rspack 1\.7\.12 or 2\.0\.0 and above\.$/, + ); + }, + ); + + it.each(['1.7.12', '1.8.0', '2.0.0', '2.1.2'])( + 'accepts Rspack %s built-in manifest support', + (version) => { + const compiler = createCompiler(version); + + expect(() => createPlugin('rspack').apply(compiler)).not.toThrow(); + expect(compiler.hooks.thisCompilation.tap).toHaveBeenCalledTimes(1); + }, + ); + + it('does not require a supported Rspack version when manifest is disabled', () => { + const compiler = createCompiler('1.3.9'); + + expect(() => createPlugin('rspack', false).apply(compiler)).not.toThrow(); + }); + + it('does not apply the Rspack version requirement to webpack', () => { + const compiler = createCompiler(); + + expect(() => createPlugin('webpack').apply(compiler)).not.toThrow(); + }); + + it('uses Rspack built-in stats without running the webpack generator', async () => { + const compiler = createCompiler('1.7.12'); + createPlugin('rspack').apply(compiler); + const source = { + source: () => JSON.stringify({ generatedBy: 'rspack' }), + }; + const { compilation, handler } = getProcessAssetsHandler( + compiler, + jest.fn(() => ({ source })), + ); + + await handler(); + + expect(compilation.updateAsset).toHaveBeenCalledTimes(2); + expect(compilation.emitAsset).not.toHaveBeenCalled(); + }); + + it('does not fall back when Rspack built-in stats are missing', async () => { + const compiler = createCompiler('2.0.0'); + createPlugin('rspack').apply(compiler); + const { compilation, handler } = getProcessAssetsHandler( + compiler, + jest.fn(() => undefined), + ); + + await expect(handler()).rejects.toThrow( + /Rspack's built-in manifest did not emit mf-stats\.json/, + ); + expect(compilation.emitAsset).not.toHaveBeenCalled(); + }); + + it('keeps webpack manifest generation unchanged', async () => { + const compiler = createCompiler(); + createPlugin('webpack').apply(compiler); + const { compilation, handler } = getProcessAssetsHandler( + compiler, + jest.fn(() => undefined), + ); + + await handler(); + + expect(compilation.emitAsset).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/manifest/src/StatsPlugin.ts b/packages/manifest/src/StatsPlugin.ts index fe104630332..60f8d9d537d 100644 --- a/packages/manifest/src/StatsPlugin.ts +++ b/packages/manifest/src/StatsPlugin.ts @@ -8,6 +8,36 @@ import { StatsManager } from './StatsManager'; import { PLUGIN_IDENTIFIER } from './constants'; import logger from './logger'; +const isRspackManifestSupported = (version?: string): boolean => { + if (!version) { + return false; + } + + const match = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ); + + if (!match) { + return false; + } + + const [, majorValue, minorValue, patchValue, prerelease] = match; + const current = [Number(majorValue), Number(minorValue), Number(patchValue)]; + const minimum = current[0] === 1 ? [1, 7, 12] : [2, 0, 0]; + + for (let index = 0; index < current.length; index += 1) { + if (current[index] > minimum[index]) { + return true; + } + if (current[index] < minimum[index]) { + return false; + } + } + + return !prerelease; +}; + export class StatsPlugin implements WebpackPluginInstance { readonly name = 'StatsPlugin'; private _options: moduleFederationPlugin.ModuleFederationPluginOptions = {}; @@ -42,6 +72,21 @@ export class StatsPlugin implements WebpackPluginInstance { if (!this._enable) { return; } + + if (this._bundler === 'rspack' && this._options.manifest !== false) { + const rspackVersion = ( + compiler.webpack as typeof compiler.webpack & { + rspackVersion?: string; + } + ).rspackVersion; + + if (!isRspackManifestSupported(rspackVersion)) { + throw new Error( + `[ ${PLUGIN_IDENTIFIER} ]: Rspack ${rspackVersion || 'unknown'} does not support the required built-in manifest capability. Please upgrade to Rspack 1.7.12 or 2.0.0 and above.`, + ); + } + } + const res = this._statsManager.validate(compiler); if (!res) { @@ -59,7 +104,7 @@ export class StatsPlugin implements WebpackPluginInstance { const existedStats = compilation.getAsset( this._statsManager.fileName, ); - // new rspack should hit + // Rspack's built-in manifest is the only source of Rspack stats. if (existedStats) { let updatedStats = this._statsManager.updateStats( JSON.parse(existedStats.source.source().toString()), @@ -99,7 +144,13 @@ export class StatsPlugin implements WebpackPluginInstance { return; } - // webpack + legacy rspack + if (this._bundler === 'rspack') { + throw new Error( + `[ ${PLUGIN_IDENTIFIER} ]: Rspack's built-in manifest did not emit ${this._statsManager.fileName}.`, + ); + } + + // webpack let stats = await this._statsManager.generateStats( compiler, compilation,