From 1db0aea9f76d27c3f9bc547ba5de59eb87109039 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 05:29:07 +0000 Subject: [PATCH 1/2] feat(manifest): preserve provider metadata as independent draft --- .changeset/shared-provider-metadata.md | 6 + packages/manifest/README.md | 17 ++ .../__tests__/ProviderMetadata.spec.ts | 221 ++++++++++++++++++ packages/manifest/src/ManifestManager.ts | 3 + packages/manifest/src/ModuleHandler.ts | 26 ++- packages/manifest/src/StatsManager.ts | 139 ++++++++++- packages/sdk/src/types/manifest.ts | 2 + packages/sdk/src/types/stats.ts | 8 + 8 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 .changeset/shared-provider-metadata.md create mode 100644 packages/manifest/__tests__/ProviderMetadata.spec.ts diff --git a/.changeset/shared-provider-metadata.md b/.changeset/shared-provider-metadata.md new file mode 100644 index 00000000000..b7e78c11d94 --- /dev/null +++ b/.changeset/shared-provider-metadata.md @@ -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. diff --git a/packages/manifest/README.md b/packages/manifest/README.md index ae668bba64a..483de4bffe9 100644 --- a/packages/manifest/README.md +++ b/packages/manifest/README.md @@ -4,6 +4,23 @@ This package contains the manifest plugin for webpack/rspack internal. +### Deferred 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 proposal is deferred pending [RFC #5082](https://github.com/module-federation/core/issues/5082) and is not part of the active +layers stack. + +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 diff --git a/packages/manifest/__tests__/ProviderMetadata.spec.ts b/packages/manifest/__tests__/ProviderMetadata.spec.ts new file mode 100644 index 00000000000..d225ba8e288 --- /dev/null +++ b/packages/manifest/__tests__/ProviderMetadata.spec.ts @@ -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((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' }, + ]); +}); diff --git a/packages/manifest/src/ManifestManager.ts b/packages/manifest/src/ManifestManager.ts index 7fd533b673f..d336d21979a 100644 --- a/packages/manifest/src/ManifestManager.ts +++ b/packages/manifest/src/ManifestManager.ts @@ -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; diff --git a/packages/manifest/src/ModuleHandler.ts b/packages/manifest/src/ModuleHandler.ts index 7d3f8cf0881..3027d61e9d1 100644 --- a/packages/manifest/src/ModuleHandler.ts +++ b/packages/manifest/src/ModuleHandler.ts @@ -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 }; @@ -239,6 +246,7 @@ class ModuleHandler { mod: StatsModule, sharedMap: ShareMap, exposesMap: ExposeMap, + sharedProviderModules: SharedProviderModule[], ) { const { identifier, moduleType } = mod; if (!identifier) { @@ -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, + }); + } } } @@ -535,6 +552,7 @@ class ModuleHandler { const exposesMap: { [exposeImportValue: string]: StatsExpose } = {}; const sharedMap: { [sharedKey: string]: StatsShared } = {}; + const sharedProviderModules: SharedProviderModule[] = []; this._initializeExposesFromOptions(exposesMap); @@ -559,7 +577,12 @@ class ModuleHandler { } if (isSharedModule(moduleType)) { - this._handleSharedModule(mod, sharedMap, exposesMap); + this._handleSharedModule( + mod, + sharedMap, + exposesMap, + sharedProviderModules, + ); } if (isRemoteModule(identifier)) { @@ -573,6 +596,7 @@ class ModuleHandler { remotes, exposesMap, sharedMap, + sharedProviderModules, }; } } diff --git a/packages/manifest/src/StatsManager.ts b/packages/manifest/src/StatsManager.ts index 4859df57fbf..014790fbac2 100644 --- a/packages/manifest/src/StatsManager.ts +++ b/packages/manifest/src/StatsManager.ts @@ -15,8 +15,10 @@ import { StatsMetaDataWithGetPublicPath, StatsMetaDataWithPublicPath, StatsShared, + StatsSharedProvider, } from '@module-federation/sdk'; import { Compilation, Compiler } from 'webpack'; +import path from 'path'; import type { StatsCompilation, StatsModule, @@ -43,6 +45,7 @@ import { import { HOT_UPDATE_SUFFIX } from './constants'; import { ModuleHandler, + SharedProviderModule, getExposeItem, getExposeName, getShareItem, @@ -343,6 +346,130 @@ class StatsManager { return assets; } + private _getSharedProviders( + compiler: Compiler, + compilation: Compilation, + stats: StatsCompilation, + providerModules: SharedProviderModule[], + entryPointNames: string[], + ): Record { + const providers: Record = {}; + for (const { + name, + version, + request, + module: providerModule, + } of providerModules) { + const targets = (stats.modules || []).filter( + (module) => + (providerModule.name !== undefined && + module.issuerName === providerModule.name) || + module.reasons?.some( + (reason) => + (providerModule.identifier !== undefined && + (reason.moduleIdentifier === providerModule.identifier || + reason.resolvedModuleIdentifier === + providerModule.identifier)) || + (providerModule.name !== undefined && + (reason.moduleName === providerModule.name || + reason.resolvedModule === providerModule.name)), + ), + ); + for (const target of targets) { + const resourceModule = target.modules?.[0] || target; + let resolvedRequest = resourceModule.identifier || request; + if ( + resourceModule.moduleType && + resolvedRequest.startsWith(`${resourceModule.moduleType}|`) + ) + resolvedRequest = resolvedRequest.slice( + resourceModule.moduleType.length + 1, + ); + if ( + resourceModule.layer != null && + resolvedRequest.endsWith(`|${resourceModule.layer}`) + ) + resolvedRequest = resolvedRequest.slice( + 0, + -resourceModule.layer.length - 1, + ); + if (resourceModule.nameForCondition) { + const condition = resourceModule.nameForCondition; + const index = resolvedRequest.lastIndexOf(condition); + const suffix = + index >= 0 ? resolvedRequest.slice(index + condition.length) : ''; + resolvedRequest = condition + (suffix.startsWith('?') ? suffix : ''); + } + const imported = resolvedRequest + .split('!') + .map((resource) => { + const query = resource.indexOf('?'); + const resourcePath = + query < 0 ? resource : resource.slice(0, query); + const suffix = query < 0 ? '' : resource.slice(query); + const paths = + path.win32.isAbsolute(resourcePath) && + !path.posix.isAbsolute(resourcePath) + ? path.win32 + : path.posix; + if (!paths.isAbsolute(resourcePath)) return resource; + const relative = paths + .relative(compiler.context, resourcePath) + .replace(/\\/g, '/'); + if (paths.isAbsolute(relative)) return resource; + return `${relative.startsWith('../') ? '' : './'}${relative}${suffix}`; + }) + .join('!'); + const entries = (providers[name] ||= []); + let provider = entries.find( + (item) => item.version === version && item.import === imported, + ); + if (!provider) { + provider = { + version, + import: imported, + assets: { + js: { sync: [], async: [] }, + css: { sync: [], async: [] }, + }, + }; + entries.push(provider); + } + for (const chunkID of target.chunks || []) { + const chunk = findChunk(chunkID, compilation.chunks); + if (!chunk) continue; + const assets = getAssetsByChunk(chunk, entryPointNames); + for (const file of chunk.files) { + if (file.includes(HOT_UPDATE_SUFFIX)) continue; + assets[file.endsWith('.css') ? 'css' : 'js'].sync.push(file); + } + for (const type of ['js', 'css'] as const) { + for (const loading of ['sync', 'async'] as const) { + provider.assets[type][loading] = [ + ...new Set([ + ...provider.assets[type][loading], + ...assets[type][loading], + ]), + ].sort(); + } + } + } + } + } + for (const [name, entries] of Object.entries(providers)) { + if (entries.length < 2) { + delete providers[name]; + } else { + entries.sort((a, b) => { + if (a.version !== b.version) return a.version < b.version ? -1 : 1; + if (a.import !== b.import) return a.import < b.import ? -1 : 1; + return 0; + }); + } + } + return providers; + } + private async _generateStats( compiler: Compiler, compilation: Compilation, @@ -423,7 +550,8 @@ class StatsManager { const moduleHandler = new ModuleHandler(this._options, filteredModules, { bundler: this._bundler, }); - const { remotes, exposesMap, sharedMap } = moduleHandler.collect(); + const { remotes, exposesMap, sharedMap, sharedProviderModules } = + moduleHandler.collect(); const entryPointNames = [...compilation.entrypoints.values()] .map((e) => e.name) .filter((v) => !!v) as Array; @@ -436,7 +564,16 @@ class StatsManager { entryPointNames, ); + const providers = this._getSharedProviders( + compiler, + compilation, + webpackStats, + sharedProviderModules, + entryPointNames, + ); Object.keys(sharedMap).forEach((sharedKey) => { + if (providers[sharedKey]) + sharedMap[sharedKey].providers = providers[sharedKey]; const assets = sharedAssets[sharedKey]; if (assets) { sharedMap[sharedKey].assets = assets; diff --git a/packages/sdk/src/types/manifest.ts b/packages/sdk/src/types/manifest.ts index 7f5e0777b0b..9ac9bdb6d91 100644 --- a/packages/sdk/src/types/manifest.ts +++ b/packages/sdk/src/types/manifest.ts @@ -1,6 +1,7 @@ import { StatsMetaData, StatsAssets, + StatsSharedProvider, StatsExpose, BasicStatsMetaData, RemoteEntryType, @@ -9,6 +10,7 @@ import { RemoteWithEntry, RemoteWithVersion } from './common'; export interface ManifestShared { id: string; + providers?: StatsSharedProvider[]; name: string; version: string; singleton: boolean; diff --git a/packages/sdk/src/types/stats.ts b/packages/sdk/src/types/stats.ts index 000fda634bd..a014ad3cb7f 100644 --- a/packages/sdk/src/types/stats.ts +++ b/packages/sdk/src/types/stats.ts @@ -79,8 +79,16 @@ interface StatsAssetsInfo { async: string[]; } +export interface StatsSharedProvider { + version: string; + import: string; + assets: StatsAssets; +} + export interface StatsShared { id: string; + /** Concrete providers when more than one version/import pair is available. */ + providers?: StatsSharedProvider[]; name: string; version: string; singleton: boolean; From fc62e00ac7a5ca1042639455182bc97bbd641fad Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 05:30:20 +0000 Subject: [PATCH 2/2] docs(manifest): explain provider metadata proposal --- packages/manifest/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/manifest/README.md b/packages/manifest/README.md index 483de4bffe9..347e82129e9 100644 --- a/packages/manifest/README.md +++ b/packages/manifest/README.md @@ -4,13 +4,15 @@ This package contains the manifest plugin for webpack/rspack internal. -### Deferred provider metadata proposal +### 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 proposal is deferred pending [RFC #5082](https://github.com/module-federation/core/issues/5082) and is not part of the active -layers stack. +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