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__/Graph.spec.ts b/packages/manifest/__tests__/Graph.spec.ts index d59443a491f..c14828f4070 100644 --- a/packages/manifest/__tests__/Graph.spec.ts +++ b/packages/manifest/__tests__/Graph.spec.ts @@ -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((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 }); + } + }, +); diff --git a/packages/manifest/__tests__/ManifestReaderCompatibility.spec.ts b/packages/manifest/__tests__/ManifestReaderCompatibility.spec.ts index 8f5b666da42..d2aec9bd355 100644 --- a/packages/manifest/__tests__/ManifestReaderCompatibility.spec.ts +++ b/packages/manifest/__tests__/ManifestReaderCompatibility.spec.ts @@ -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: [] }, @@ -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', () => { diff --git a/packages/manifest/__tests__/ModuleHandler.spec.ts b/packages/manifest/__tests__/ModuleHandler.spec.ts index cf4f0f21305..8f47192ee92 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 d4fdf36ee7a..210b2f9a762 100644 --- a/packages/manifest/__tests__/StatsManager.spec.ts +++ b/packages/manifest/__tests__/StatsManager.spec.ts @@ -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) => { diff --git a/packages/manifest/src/ManifestManager.ts b/packages/manifest/src/ManifestManager.ts index 99c853cc771..b2f769aba66 100644 --- a/packages/manifest/src/ManifestManager.ts +++ b/packages/manifest/src/ManifestManager.ts @@ -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; diff --git a/packages/manifest/src/ModuleHandler.ts b/packages/manifest/src/ModuleHandler.ts index 5cfc639693d..041cdc27296 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 63ebc81d625..6af7ea9ca65 100644 --- a/packages/manifest/src/StatsManager.ts +++ b/packages/manifest/src/StatsManager.ts @@ -14,11 +14,13 @@ import { StatsMetaDataWithGetPublicPath, StatsMetaDataWithPublicPath, StatsShared, + StatsSharedProvider, } from '@module-federation/sdk'; import { Compilation, Compiler } from 'webpack'; import type { StatsCompilation } from 'webpack/lib/stats/DefaultStatsFactoryPlugin'; import { isDev, + getProviderImport, getAssetsByChunk, findChunk, getAssetsByChunkIDs, @@ -36,7 +38,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'; import { collectGraph } from './collectGraph'; @@ -337,6 +344,109 @@ 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 = getProviderImport(compiler.context, resolvedRequest); + 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, @@ -427,7 +537,8 @@ class StatsManager { webpackStats.modules || [], { bundler: this._bundler }, ); - const { remotes, exposesMap, sharedMap } = moduleHandler.collect(); + const { remotes, exposesMap, sharedMap, sharedProviderModules } = + moduleHandler.collect(); const entryPointNames = [...compilation.entrypoints.keys()]; const sharedAssets = this._getProvideSharedAssets( compilation, @@ -438,7 +549,15 @@ class StatsManager { compilation, entryPointNames, ); + const providers = this._getSharedProviders( + compiler, + compilation, + webpackStats, + sharedProviderModules, + entryPointNames, + ); for (const [key, shared] of Object.entries(sharedMap)) { + if (providers[key]) shared.providers = providers[key]; if (sharedAssets[key]) shared.assets = sharedAssets[key]; } for (const [key, expose] of Object.entries(exposesMap)) { diff --git a/packages/manifest/src/collectGraph.ts b/packages/manifest/src/collectGraph.ts index f9008b70320..44dfd657882 100644 --- a/packages/manifest/src/collectGraph.ts +++ b/packages/manifest/src/collectGraph.ts @@ -1,10 +1,11 @@ -import type { Compilation, Module, Chunk } from 'webpack'; +import type { Compilation, Module, Chunk, NormalModule } from 'webpack'; import type DependenciesBlock from 'webpack/lib/DependenciesBlock'; import type { StatsAssets, StatsExpose, StatsRemote, StatsShared, + StatsSharedProvider, Stats, } from '@module-federation/sdk'; import type { @@ -13,8 +14,10 @@ import type { SharedManager, } from '@module-federation/managers'; import path from 'path'; +import { HOT_UPDATE_SUFFIX } from './constants'; import { getExposeItem, getShareItem } from './ModuleHandler'; import { + getProviderImport, getAssetsByChunk, getAssetsByChunkIDs, getSharedIdentityKey, @@ -23,6 +26,7 @@ import { type ProvideData = { name: string; version: string; + request: string; shareScope: string | string[]; shareConfig: { layer?: string }; }; @@ -71,6 +75,7 @@ export function collectGraph( const exposesMap: Record = {}; const sharedMap: Record = {}; const remotes: StatsRemote[] = []; + const providers: Record = {}; const sharedKeys = new Map(); const entryNames = [...compilation.entrypoints.keys()]; @@ -145,6 +150,45 @@ export function collectGraph( } sharedMap[key] = row; } + if (!consume && 'version' in data) { + for (const target of dependencies(module)) { + const resourceModule = + (target as Module & { rootModule?: NormalModule }).rootModule ?? + target; + const resource = + (resourceModule as NormalModule).resource || + resourceModule.nameForCondition() || + data.request; + const imported = getProviderImport( + compilation.compiler.context, + resource, + ); + const entries = (providers[key] ||= []); + 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 chunk of chunkGraph.getModuleChunksIterable(target)) { + addAssets(provider.assets, [chunk]); + for (const file of chunk.files) { + if (!file.includes(HOT_UPDATE_SUFFIX)) + provider.assets[file.endsWith('.css') ? 'css' : 'js'].sync.push( + file, + ); + } + } + } + } if (consume) { const ids = new Set(); for (const fallback of dependencies(module)) { @@ -222,6 +266,26 @@ export function collectGraph( } } } + for (const [key, entries] of Object.entries(providers)) { + if (entries.length < 2) continue; + for (const provider of entries) + 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]), + ].sort(); + sharedMap[key].providers = entries.sort((a, b) => + a.version < b.version + ? -1 + : a.version > b.version + ? 1 + : a.import < b.import + ? -1 + : a.import > b.import + ? 1 + : 0, + ); + } return { exposes: Object.values(exposesMap), shared: Object.values(sharedMap), diff --git a/packages/manifest/src/utils.ts b/packages/manifest/src/utils.ts index 5b7c9129f13..f41ec6b3fdd 100644 --- a/packages/manifest/src/utils.ts +++ b/packages/manifest/src/utils.ts @@ -414,3 +414,25 @@ export function getTypesMetaInfo( return defaultTypesMetaInfo; } } + +export function getProviderImport(context: string, request: string): string { + return request + .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(context, resourcePath) + .replace(/\\/g, '/'); + if (paths.isAbsolute(relative)) return resource; + return `${relative.startsWith('../') ? '' : './'}${relative}${suffix}`; + }) + .join('!'); +} diff --git a/packages/sdk/src/types/manifest.ts b/packages/sdk/src/types/manifest.ts index fd04b460d1e..aeb4f76c3e7 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, @@ -11,6 +12,7 @@ export interface ManifestShared { id: 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 6a87e39e095..9ca7afd6b00 100644 --- a/packages/sdk/src/types/stats.ts +++ b/packages/sdk/src/types/stats.ts @@ -79,6 +79,12 @@ interface StatsAssetsInfo { async: string[]; } +export interface StatsSharedProvider { + version: string; + import: string; + assets: StatsAssets; +} + export interface StatsSharedRequirement { name: string; layer?: string; @@ -89,6 +95,8 @@ export interface StatsShared { id: 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;