Skip to content
Closed
6 changes: 6 additions & 0 deletions .changeset/shared-provider-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/sdk': patch
'@module-federation/manifest': patch
---

Add optional `providers` metadata for multiple concrete version/import pairs and their assets while retaining existing shared fields and layer identities. Single-provider and consumer-only entries omit this field; older manifest readers remain supported.
103 changes: 103 additions & 0 deletions packages/manifest/__tests__/Graph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,106 @@ it.each([
},
60000,
);

it.each([undefined, 'common'])(
'emits concrete provider alternatives with layer %s',
async (layer) => {
const directory = await mkdtemp(path.join(tmpdir(), 'mf-providers-'));
try {
await writeFile(path.join(directory, 'package.json'), '{}');
await writeFile(
path.join(directory, 'shared.js'),
'module.exports = "shared";',
);
const options = {
name: 'providers',
shared: {
first: {
import: './shared.js?first',
shareKey: 'shared',
layer,
version: '1.0.0',
},
second: {
import: './shared.js?second',
shareKey: 'shared',
layer,
version: '2.0.0',
},
...(layer
? {
third: {
import: './shared.js?third',
shareKey: 'shared',
layer: 'client',
version: '3.0.0',
},
}
: {}),
},
};
const compiler = webpack({
context: directory,
mode: 'development',
experiments: { layers: true },
entry: { first: './shared.js', second: './shared.js' },
optimization: { runtimeChunk: 'multiple' },
output: { path: directory, publicPath: '/', filename: '[name].js' },
plugins: [new EnhancedPlugin({ ...options, manifest: false })],
});
new StatsPlugin(options, {
pluginVersion: 'test',
bundler: 'webpack',
}).apply(compiler);
await new Promise<void>((resolve, reject) => {
compiler.run((error, stats) =>
compiler.close((closeError) => {
if (error || closeError) reject(error || closeError);
else if (stats.hasErrors()) reject(new Error(stats.toString()));
else resolve();
}),
);
});
const artifact = JSON.parse(
await readFile(path.join(directory, 'mf-stats.json'), 'utf8'),
);
expect(artifact.shared).toHaveLength(layer ? 2 : 1);
const row = artifact.shared.find((item) => item.layer === layer);
expect(row.version).toBe('1.0.0');
expect(row.providers).toEqual([
{
version: '1.0.0',
import: './shared.js?first',
assets: {
js: {
sync: [
layer ? '_common_shared_js_first.js' : 'shared_js_first.js',
],
async: [],
},
css: { sync: [], async: [] },
},
},
{
version: '2.0.0',
import: './shared.js?second',
assets: {
js: {
sync: [
layer ? '_common_shared_js_second.js' : 'shared_js_second.js',
],
async: [],
},
css: { sync: [], async: [] },
},
},
]);
if (layer)
expect(
artifact.shared.find((item) => item.layer === 'client').providers,
).toBeUndefined();
} finally {
await rm(directory, { recursive: true, force: true });
}
},
);
23 changes: 22 additions & 1 deletion packages/manifest/__tests__/ManifestReaderCompatibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ const { ManifestManager: LegacyManifestManager } = require(
),
) as { ManifestManager: typeof ManifestManager };

it('preserves existing unlayered manifest fields', () => {
it('preserves legacy manifest fields while adding concrete provider metadata', () => {
const assets = {
js: { sync: ['react.js'], async: [] },
css: { sync: [], async: [] },
Expand Down Expand Up @@ -244,6 +244,27 @@ it('preserves existing unlayered manifest fields', () => {
const legacy = generate(LegacyManifestManager, stats);
expect(generate(ManifestManager, stats)).toEqual(legacy);
expect('providers' in legacy.shared[0]).toBe(false);

const providers = [
{ version: '18.0.0', import: './react18.js?mode=server', assets },
{
version: '19.0.0',
import: './react19.js',
assets: {
...assets,
js: { sync: ['react19.js'], async: ['react19-async.js'] },
},
},
];
stats.shared[0].providers = providers;
const current = generate(ManifestManager, stats);
expect(current.shared[0].providers).toEqual(providers);
const { providers: _providers, ...legacyFields } = current.shared[0];
expect(legacyFields).toEqual(legacy.shared[0]);
expect(generate(LegacyManifestManager, stats)).toEqual(legacy);

stats.shared[0].providers = [providers[0]];
expect(generate(ManifestManager, stats)).toEqual(legacy);
});

it('preserves native layer metadata when rebuilding a manifest', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/manifest/__tests__/ModuleHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ it('decodes native ordered scope identities with UTF-8 lengths', () => {
it('reads space-containing share keys from the structural identity suffix', () => {
const key = '10:s7:defaultl9:服务端4:b) c';
const identifier = `provide shared module (default) (服务端) b) c@19.0.0 = /first.js [identity:${key}]`;
const { sharedMap } = new ModuleHandler(
const { sharedMap, sharedProviderModules } = new ModuleHandler(
{ name: 'host' },
[{ identifier, moduleType: 'provide-module' }],
{ bundler: 'rspack' },
Expand All @@ -478,6 +478,11 @@ it('reads space-containing share keys from the structural identity suffix', () =
version: '19.0.0',
layer: '服务端',
});
expect(sharedProviderModules[0]).toMatchObject({
name: key,
version: '19.0.0',
request: '/first.js',
});
});

it('attributes same-source aliases to the resolved importer layer', () => {
Expand Down
144 changes: 144 additions & 0 deletions packages/manifest/__tests__/StatsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,150 @@ it('keeps assets separate for layered aliases of the same source', () => {
expect(assets['./Client'].js.sync).toEqual(['client.js']);
});

it('collects concrete provider versions and assets without changing the legacy row', () => {
const compiler = { context: '/project' } as Compiler;
const first = {
name: 'provide shared module (default) react@18.0.0 = /project/react18?x=1',
identifier:
'provide shared module (default) react@18.0.0 = /project/react18?x=1 [identity:example]',
moduleType: 'provide-module',
};
const second = {
name: 'provide shared module (default) react@19.0.0 = /project/react19.js',
identifier:
'provide shared module (default) react@19.0.0 = /project/react19.js',
moduleType: 'provide-module',
};
const consume = {
identifier:
'consume shared module (default) react@17.0.0 (fallback: /project/fallback.js)',
moduleType: 'consume-shared-module',
};
const modules = [
first,
second,
consume,
{
identifier: '/project/react18.js?x=1',
issuerName: first.name,
chunks: [1],
},
{
identifier: '/project/react19.js',
reasons: [{ moduleIdentifier: second.identifier }],
chunks: [2],
},
];
const chunk = (id: number) => ({
id,
files: [`react${id}.js`],
groupsIterable: [],
getAllAsyncChunks: () => [
{ files: [`react${id}-async.js`], groupsIterable: [] },
],
});
const compilation = {
chunks: new Set([chunk(1), chunk(2)]),
} as unknown as Compilation;
const manager = new StatsManager();
const collect = (input: typeof modules) =>
new ModuleHandler({ name: 'host' }, input, { bundler: 'rspack' }).collect();
const { sharedMap, sharedProviderModules } = collect(modules);
expect(sharedMap.react.version).toBe('18.0.0');
const providers = manager['_getSharedProviders'](
compiler,
compilation,
{ modules },
sharedProviderModules,
[],
);
expect(providers.react).toEqual([
{
version: '18.0.0',
import: './react18.js?x=1',
assets: {
js: { sync: ['react1.js'], async: ['react1-async.js'] },
css: { sync: [], async: [] },
},
},
{
version: '19.0.0',
import: './react19.js',
assets: {
js: { sync: ['react2.js'], async: ['react2-async.js'] },
css: { sync: [], async: [] },
},
},
]);
expect(
manager['_getSharedProviders'](
compiler,
compilation,
{ modules },
collect([first, first, consume]).sharedProviderModules,
[],
),
).toEqual({});
expect(collect([consume]).sharedProviderModules).toEqual([]);
});

it('keeps provider alternatives inside their layer identity', () => {
const serverKey = '10:s7:defaultl6:server5:react';
const clientKey = '10:s7:defaultl6:client5:react';
const modules = [
{
moduleType: 'provide-module',
identifier: `provide shared module (default) (server) react@18.0.0 = /project/react18.js [identity:${serverKey}]`,
},
{
moduleType: 'provide-module',
identifier: `provide shared module (default) (server) react@19.0.0 = /project/react19.js [identity:${serverKey}]`,
},
{
moduleType: 'provide-module',
identifier: `provide shared module (default) (client) react@20.0.0 = /project/react20.js [identity:${clientKey}]`,
},
];
const providerModules = [...modules];
const targets = modules.map((module, index) => ({
identifier: `/project/react${index + 18}.js`,
reasons: [{ moduleIdentifier: module.identifier }],
chunks: [],
}));
const statsModules = [...modules, ...targets];
const { sharedMap, sharedProviderModules } = new ModuleHandler(
{ name: 'host' },
providerModules,
{ bundler: 'rspack' },
).collect();
expect(Object.values(sharedMap)).toEqual([
expect.objectContaining({
id: `host:shared:${serverKey}`,
name: 'react',
layer: 'server',
version: '18.0.0',
}),
expect.objectContaining({
id: `host:shared:${clientKey}`,
name: 'react',
layer: 'client',
version: '20.0.0',
}),
]);
const providers = new StatsManager()['_getSharedProviders'](
{ context: '/project' } as Compiler,
{ chunks: new Set() } as unknown as Compilation,
{ modules: statsModules },
sharedProviderModules,
[],
);
expect(Object.keys(providers)).toEqual([serverKey]);
expect(providers[serverKey].map(({ version }) => version)).toEqual([
'18.0.0',
'19.0.0',
]);
});

it.each([false, true])(
'emits each public expose once with its metadata (disableAssetsAnalyze=%s)',
async (disableAssetsAnalyze) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/manifest/src/ManifestManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ class ManifestManager {
fallbackType: cur.fallbackType,
...(cur.layer !== undefined ? { layer: cur.layer } : {}),
...(cur.shareScope !== undefined ? { shareScope: cur.shareScope } : {}),
...(cur.providers && cur.providers.length > 1
? { providers: cur.providers }
: {}),
};
sum.push(shared);
return sum;
Expand Down
Loading
Loading