Skip to content
Draft
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
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 provider metadata for multiple concrete version/import pairs and their assets while retaining existing shared fields. Single-provider and consumer-only entries omit this field.
19 changes: 19 additions & 0 deletions packages/manifest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@

This package contains the manifest plugin for webpack/rspack internal.

### Provider metadata proposal

The independent provider-metadata draft records multiple concrete version/import
pairs and their assets in an optional `shared[].providers` array. Existing shared
fields remain unchanged; singleton and consumer-only rows omit the array.
This draft implements [RFC #5082](https://github.com/module-federation/core/issues/5082)
for review independently of the active layers stack. It preserves the mapping
between each concrete provider and its import/assets that a single shared
summary cannot express.

Webpack/Rspack parity is a goal for that RFC. This main-based draft uses the
existing stats collector; graph collection, layer/scope identity integration and
native Rspack emission must be reconciled before adoption. The earlier stacked
implementation and its layer tests remain preserved in [PR #5078](https://github.com/module-federation/core/pull/5078)
at [commit 964500cbb](https://github.com/module-federation/core/commit/964500cbb7debbe644c7c64ac0780d2255526233).
The subsequent resolved-provider-identifier fix is included in this draft; its
original commit and full history are retained at local ref
`backup/provider-before-independent-draft-3afa5e71e` (`3afa5e71e`).

## Installation

```sh
Expand Down
221 changes: 221 additions & 0 deletions packages/manifest/__tests__/ProviderMetadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/** @jest-environment node */
import type { Compiler, Compilation } from 'webpack';
import { ModuleHandler } from '../src/ModuleHandler';
import { StatsManager } from '../src/StatsManager';
import { ManifestManager } from '../src/ManifestManager';
import type { Stats } from '@module-federation/sdk';
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { StatsPlugin } from '../src/StatsPlugin';

const webpack = process.getBuiltinModule('module').createRequire(__filename)(
'webpack',
);

it('emits concrete providers with the main Webpack collector', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'mf-providers-'));
try {
await writeFile(path.join(directory, 'package.json'), '{}');
await writeFile(path.join(directory, 'entry.js'), '');
await writeFile(
path.join(directory, 'shared.js'),
'module.exports = "shared";',
);
const options = {
name: 'host',
shared: {
first: {
import: './shared.js?first',
shareKey: 'shared',
version: '1.0.0',
},
second: {
import: './shared.js?second',
shareKey: 'shared',
version: '2.0.0',
},
},
};
const compiler = webpack({
context: directory,
mode: 'development',
entry: './entry.js',
output: { path: directory, publicPath: '/' },
plugins: [new webpack.container.ModuleFederationPlugin(options)],
});
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 stats = JSON.parse(
await readFile(path.join(directory, 'mf-stats.json'), 'utf8'),
) as Stats;
expect(stats.shared).toHaveLength(1);
const providers = stats.shared[0].providers!;
expect(
providers.map(({ version, import: imported }) => ({ version, imported })),
).toEqual([
{ version: '1.0.0', imported: './shared.js?first' },
{ version: '2.0.0', imported: './shared.js?second' },
]);
for (const provider of providers) {
expect(provider.assets.js.sync).toHaveLength(1);
await readFile(path.join(directory, provider.assets.js.sync[0]));
}
const manifest = JSON.parse(
await readFile(path.join(directory, 'mf-manifest.json'), 'utf8'),
);
expect(manifest.shared[0].providers).toEqual(providers);
stats.shared[0].providers = [providers[0]];
const projected = new ManifestManager().generateManifest({
stats,
compiler,
compilation: {} as Compilation,
publicPath: '/',
bundler: 'webpack',
});
expect(projected.shared[0].providers).toBeUndefined();
expect(projected.shared[0].version).toBe(stats.shared[0].version);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

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',
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('matches nameless Webpack providers through resolved reason identifiers', () => {
const providers = ['18.0.0', '19.0.0'].map((version) => ({
moduleType: 'provide-module',
identifier: `provide module (default) react@${version} = /project/react${version}.js`,
}));
const modules = [
...providers,
...providers.map((provider, index) => ({
identifier: `/project/react${index + 18}.js`,
reasons: [
{
moduleIdentifier: '/project/barrel.js',
resolvedModuleIdentifier: provider.identifier,
},
],
chunks: [],
})),
];
const { sharedProviderModules } = new ModuleHandler(
{ name: 'host' },
modules,
{ bundler: 'webpack' },
).collect();
expect(sharedProviderModules).toHaveLength(2);
const alternatives = new StatsManager()['_getSharedProviders'](
{ context: '/project' } as Compiler,
{ chunks: new Set() } as unknown as Compilation,
{ modules },
sharedProviderModules,
[],
);
expect(
alternatives.react?.map(({ version, import: imported }) => ({
version,
imported,
})),
).toEqual([
{ version: '18.0.0', imported: './react18.js' },
{ version: '19.0.0', imported: './react19.js' },
]);
});
3 changes: 3 additions & 0 deletions packages/manifest/src/ManifestManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class ManifestManager {
fallback: cur.fallback,
fallbackName: cur.fallbackName,
fallbackType: cur.fallbackType,
...(cur.providers && cur.providers.length > 1
? { providers: cur.providers }
: {}),
};
sum.push(shared);
return sum;
Expand Down
26 changes: 25 additions & 1 deletion packages/manifest/src/ModuleHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import {
import type managerTypes from '@module-federation/managers';
import { getFileNameWithOutExt } from './utils';

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

type ShareMap = { [sharedKey: string]: StatsShared };
type ExposeMap = { [exposeImportValue: string]: StatsExpose };
type RemotesConsumerMap = { [remoteKey: string]: StatsRemote };
Expand Down Expand Up @@ -239,6 +246,7 @@ class ModuleHandler {
mod: StatsModule,
sharedMap: ShareMap,
exposesMap: ExposeMap,
sharedProviderModules: SharedProviderModule[],
) {
const { identifier, moduleType } = mod;
if (!identifier) {
Expand Down Expand Up @@ -320,6 +328,15 @@ class ModuleHandler {
if (name && version) {
initShared(name, version);
collectRelationshipMap(mod, name);
const separator = identifier.indexOf(' = ');
if (separator !== -1) {
sharedProviderModules.push({
name,
version,
request: identifier.slice(separator + 3),
module: mod,
});
}
}
}

Expand Down Expand Up @@ -535,6 +552,7 @@ class ModuleHandler {

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

this._initializeExposesFromOptions(exposesMap);

Expand All @@ -559,7 +577,12 @@ class ModuleHandler {
}

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

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