Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-rspack-manifests.md
Original file line number Diff line number Diff line change
@@ -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.
182 changes: 182 additions & 0 deletions packages/manifest/__tests__/StatsPlugin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
};
};

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);
});
});
55 changes: 53 additions & 2 deletions packages/manifest/src/StatsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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.`,
Comment on lines +83 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update bundled Rspack before enforcing the new floor

This guard makes every Rspack manifest build fail below 1.7.12, but the workspace still resolves existing manifest/demo Rspack apps to older @rspack/core versions (for example apps/manifest-demo/3010-rspack-provider is locked to 1.3.9 in pnpm-lock.yaml), and I checked .github/workflows/devtools.yml: it runs pnpm run app:manifest:dev and app:manifest:prod. As committed, those CI jobs throw during plugin application before tests start; update the affected package specs/lockfile or opt those configs out of manifest generation along with this guard.

Useful? React with 👍 / 👎.

);
}
}

const res = this._statsManager.validate(compiler);

if (!res) {
Expand All @@ -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()),
Expand Down Expand Up @@ -99,7 +144,13 @@ export class StatsPlugin implements WebpackPluginInstance {
return;
}

// webpack + legacy rspack
if (this._bundler === 'rspack') {
Comment on lines 144 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enable native manifest when manifest is omitted

With Rspack configs that rely on this wrapper's longstanding default (manifest omitted), StatsPlugin still reaches this branch because undefined !== false, but packages/rspack/src/ModuleFederationPlugin.ts passes the same undefined option to Rspack's native ModuleFederationPlugin. The native plugin has not been explicitly enabled to emit mf-stats.json, so this new error fires during processAssets instead of producing the default manifest as before (for example, apps/manifest-demo/3010-rspack-provider/rspack.config.js omits manifest). Please normalize omitted manifests to true before invoking Rspack or keep the legacy fallback for that case.

Useful? React with 👍 / 👎.

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,
Expand Down
Loading