From 6a1e8102d9679da76a7f4a241578ec15257cc64e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 14 Sep 2026 19:25:00 +0000 Subject: [PATCH 1/2] feat(manifest): report concrete shared provider alternatives --- .changeset/shared-provider-metadata.md | 6 + .../__tests__/LegacyReaderCompat.spec.ts | 23 ++- .../manifest/__tests__/ModuleHandler.spec.ts | 7 +- .../manifest/__tests__/StatsManager.spec.ts | 144 ++++++++++++++++++ packages/manifest/src/ManifestManager.ts | 3 + packages/manifest/src/ModuleHandler.ts | 29 +++- packages/manifest/src/StatsManager.ts | 143 ++++++++++++++++- packages/sdk/src/types/manifest.ts | 2 + packages/sdk/src/types/stats.ts | 10 ++ 9 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 .changeset/shared-provider-metadata.md diff --git a/.changeset/shared-provider-metadata.md b/.changeset/shared-provider-metadata.md new file mode 100644 index 00000000000..db2c812642e --- /dev/null +++ b/.changeset/shared-provider-metadata.md @@ -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. diff --git a/packages/manifest/__tests__/LegacyReaderCompat.spec.ts b/packages/manifest/__tests__/LegacyReaderCompat.spec.ts index 01e5ef1eb6c..4003e31230f 100644 --- a/packages/manifest/__tests__/LegacyReaderCompat.spec.ts +++ b/packages/manifest/__tests__/LegacyReaderCompat.spec.ts @@ -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: [] }, @@ -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', () => { diff --git a/packages/manifest/__tests__/ModuleHandler.spec.ts b/packages/manifest/__tests__/ModuleHandler.spec.ts index 26a946eaf19..b96742d8be9 100644 --- a/packages/manifest/__tests__/ModuleHandler.spec.ts +++ b/packages/manifest/__tests__/ModuleHandler.spec.ts @@ -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' }, @@ -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', () => { diff --git a/packages/manifest/__tests__/StatsManager.spec.ts b/packages/manifest/__tests__/StatsManager.spec.ts index 211bd64920b..f9b8ebb89ad 100644 --- a/packages/manifest/__tests__/StatsManager.spec.ts +++ b/packages/manifest/__tests__/StatsManager.spec.ts @@ -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) => { diff --git a/packages/manifest/src/ManifestManager.ts b/packages/manifest/src/ManifestManager.ts index 41f47f42258..28236909114 100644 --- a/packages/manifest/src/ManifestManager.ts +++ b/packages/manifest/src/ManifestManager.ts @@ -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; diff --git a/packages/manifest/src/ModuleHandler.ts b/packages/manifest/src/ModuleHandler.ts index 5b0b1fc334c..94b9f4d0768 100644 --- a/packages/manifest/src/ModuleHandler.ts +++ b/packages/manifest/src/ModuleHandler.ts @@ -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 }; @@ -315,6 +322,7 @@ class ModuleHandler { mod: StatsModule, sharedMap: ShareMap, exposesMap: ExposeMap, + sharedProviderModules: SharedProviderModule[], ) { const { identifier, moduleType } = mod; if (!identifier) { @@ -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, + }); + } } } @@ -667,6 +687,7 @@ class ModuleHandler { const exposesMap: ExposeMap = {}; const sharedMap: { [sharedKey: string]: StatsShared } = {}; + const sharedProviderModules: SharedProviderModule[] = []; this._initializeExposesFromOptions(exposesMap); @@ -698,7 +719,12 @@ class ModuleHandler { } if (isSharedModule(moduleType)) { - this._handleSharedModule(mod, sharedMap, exposesMap); + this._handleSharedModule( + mod, + sharedMap, + exposesMap, + sharedProviderModules, + ); } if (isRemoteModule(identifier)) { @@ -710,6 +736,7 @@ class ModuleHandler { remotes, exposesMap, sharedMap, + sharedProviderModules, }; } } diff --git a/packages/manifest/src/StatsManager.ts b/packages/manifest/src/StatsManager.ts index 5e6d9174d34..3e7e6cde7dc 100644 --- a/packages/manifest/src/StatsManager.ts +++ b/packages/manifest/src/StatsManager.ts @@ -14,8 +14,10 @@ import { StatsMetaDataWithGetPublicPath, StatsMetaDataWithPublicPath, StatsShared, + StatsSharedProvider, } from '@module-federation/sdk'; import { Compilation, Compiler } from 'webpack'; +import path from 'node:path'; import type { StatsCompilation, StatsModule, @@ -39,7 +41,12 @@ import { UNKNOWN_MODULE_NAME, } from '@module-federation/managers'; import { HOT_UPDATE_SUFFIX } from './constants'; -import { ModuleHandler, getExposeItem, getShareItem } from './ModuleHandler'; +import { + ModuleHandler, + SharedProviderModule, + getExposeItem, + getShareItem, +} from './ModuleHandler'; import { StatsInfo } from './types'; class StatsManager { @@ -332,6 +339,128 @@ 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) || + (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, @@ -416,7 +545,8 @@ class StatsManager { 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; @@ -429,7 +559,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 ff49f7c4324..333d5256672 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, @@ -12,6 +13,7 @@ export interface ManifestShared { identityId?: string; layer?: string; shareScope?: string | 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 055cfef7076..1c10a83c367 100644 --- a/packages/sdk/src/types/stats.ts +++ b/packages/sdk/src/types/stats.ts @@ -79,6 +79,14 @@ interface StatsAssetsInfo { async: string[]; } +export interface StatsSharedProvider { + version: string; + import: string; + assets: StatsAssets; + fallback?: string; + fallbackName?: string; +} + export interface StatsSharedRequirement { name: string; layer?: string; @@ -90,6 +98,8 @@ export interface StatsShared { identityId?: string; layer?: string; shareScope?: string | string[]; + /** Concrete providers when more than one version/import pair is available. */ + providers?: StatsSharedProvider[]; name: string; version: string; singleton: boolean; From efba6a9887d984971c0e85049a2fbfd0f35d7047 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 01:16:25 +0000 Subject: [PATCH 2/2] refactor(sdk): remove unused provider fallback fields --- packages/sdk/src/types/stats.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sdk/src/types/stats.ts b/packages/sdk/src/types/stats.ts index 142ec21bb82..9ca7afd6b00 100644 --- a/packages/sdk/src/types/stats.ts +++ b/packages/sdk/src/types/stats.ts @@ -83,8 +83,6 @@ export interface StatsSharedProvider { version: string; import: string; assets: StatsAssets; - fallback?: string; - fallbackName?: string; } export interface StatsSharedRequirement {