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.
23 changes: 22 additions & 1 deletion packages/manifest/__tests__/LegacyReaderCompat.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 legacy 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 legacy 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 @@ -141,6 +141,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 @@ -84,6 +84,9 @@ class ManifestManager {
...(cur.identityId !== undefined ? { identityId: cur.identityId } : {}),
...(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
29 changes: 28 additions & 1 deletion packages/manifest/src/ModuleHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import {
getSharedIdentity,
} from './utils';

export interface SharedProviderModule {
name: string;
version: string;
request: string;
module: StatsModule;
}

type ShareMap = { [sharedKey: string]: StatsShared };
type ExposeMap = { [exposeKey: string]: StatsExpose };
type RemotesConsumerMap = { [remoteKey: string]: StatsRemote };
Expand Down Expand Up @@ -315,6 +322,7 @@ class ModuleHandler {
mod: StatsModule,
sharedMap: ShareMap,
exposesMap: ExposeMap,
sharedProviderModules: SharedProviderModule[],
) {
const { identifier, moduleType } = mod;
if (!identifier) {
Expand Down Expand Up @@ -451,6 +459,18 @@ class ModuleHandler {
if (name && version) {
initShared(name, version);
collectRelationshipMap(mod, name);
const separator = identifier.indexOf(' = ');
if (separator !== -1) {
const request = identifier
.slice(separator + 3)
.replace(/ \[identity:[\s\S]*\]$/, '');
sharedProviderModules.push({
name: sharedKey(name),
version,
request,
module: mod,
});
}
}
}

Expand Down Expand Up @@ -667,6 +687,7 @@ class ModuleHandler {

const exposesMap: ExposeMap = {};
const sharedMap: { [sharedKey: string]: StatsShared } = {};
const sharedProviderModules: SharedProviderModule[] = [];

this._initializeExposesFromOptions(exposesMap);

Expand Down Expand Up @@ -698,7 +719,12 @@ class ModuleHandler {
}

if (isSharedModule(moduleType)) {
this._handleSharedModule(mod, sharedMap, exposesMap);
this._handleSharedModule(
mod,
sharedMap,
exposesMap,
sharedProviderModules,
);
}

if (isRemoteModule(identifier)) {
Expand All @@ -710,6 +736,7 @@ class ModuleHandler {
remotes,
exposesMap,
sharedMap,
sharedProviderModules,
};
}
}
Expand Down
Loading
Loading