From 36c7e8f1f3629713cd14478648946fde4e700c6d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:24:39 +0000 Subject: [PATCH 01/38] feat(enhanced)!: compose the federation runtime bootstrap by default Every ModuleFederationPlugin build now plans and renders the composed bootstrap. The full bootstrap remains only for an externalized or aliased runtime, where the composed imports cannot reach the external. --- .changeset/composed-runtime-default-major.md | 19 +++++++++++++++++++ .../lib/container/ModuleFederationPlugin.ts | 16 +++++++--------- .../runtime/FederationCompositionPlugin.ts | 11 +++++++++-- .../runtime/FederationRuntimePlugin.ts | 2 +- 4 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 .changeset/composed-runtime-default-major.md diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md new file mode 100644 index 00000000000..6e6536999bb --- /dev/null +++ b/.changeset/composed-runtime-default-major.md @@ -0,0 +1,19 @@ +--- +'@module-federation/runtime-core': major +'@module-federation/runtime': major +'@module-federation/webpack-bundler-runtime': major +'@module-federation/enhanced': major +'@module-federation/rspack': major +'@module-federation/sdk': major +'@module-federation/node': major +--- + +The composed federation runtime is the default and only bootstrap. `ModuleFederationPlugin` now always generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. + +Removed: + +- The `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, `FEDERATION_HAS_EXPOSES`, and `FEDERATION_BUILD_IDENTIFIER` defines. The plugins no longer emit them and the runtime no longer reads them. Use `experiments.optimization.disable*` with the plugin, or import the capabilities you need from `@module-federation/runtime/compose` when you bundle the runtime yourself. +- `__webpack_require__.federation.runtime`. Use `__webpack_require__.federation.instance`, and `instance.platform.loadScriptNode` for the Node loader. +- The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. + +A build whose installed runtime packages do not export the composition subpaths now fails with an error that names the minimum runtime version. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index a2197bacce9..8bf998ac351 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -161,15 +161,13 @@ class ModuleFederationPlugin implements WebpackPluginInstance { new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); - if (experiments?.composedRuntime) { - // DefinePlugin reads its definitions per compilation, after the plan picked a mode. - compiler.hooks.compile.tap('ModuleFederationPlugin', () => { - if (!composedEntryOf(compiler)) return; - for (const key of Object.keys(definePluginOptions)) { - if (key !== 'ENV_TARGET') delete definePluginOptions[key]; - } - }); - } + // DefinePlugin reads its definitions per compilation, after the plan picked a mode. + compiler.hooks.compile.tap('ModuleFederationPlugin', () => { + if (!composedEntryOf(compiler)) return; + for (const key of Object.keys(definePluginOptions)) { + if (key !== 'ENV_TARGET') delete definePluginOptions[key]; + } + }); } /** diff --git a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts index ab4e7284765..61b97f26278 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts @@ -196,10 +196,17 @@ class FederationCompositionPlugin { compilation: Compilation, { family, entry, legacyReason }: Outcome, ) { - if (legacyReason !== undefined) { + const { externalRuntime, provideExternalRuntime } = + this._options.experiments ?? {}; + // An externalized runtime is the full runtime by request; the other reasons are worth a warning. + if ( + legacyReason !== undefined && + !externalRuntime && + !provideExternalRuntime + ) { compilation.warnings.push( new WebpackError( - `experiments.composedRuntime is set, but this build uses the full federation runtime because ${legacyReason}.`, + `This build uses the full federation runtime because ${legacyReason}.`, ), ); } diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index 914fe026d93..220c1d9e08a 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -531,7 +531,7 @@ class FederationRuntimePlugin { // dont run multiple times on every apply() if (!onceForCompiler.has(compiler)) { const options = this.options; - if (options?.experiments?.composedRuntime) { + if (options) { new FederationCompositionPlugin(options, (composition) => { const source = FederationRuntimePlugin.getTemplate( compiler, From bd074d9cc50f52fec4f7deabef0d560ee3f02d5c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:27:11 +0000 Subject: [PATCH 02/38] test(enhanced): assert the composed plan for disableRemote and disableShared The composed plan keeps the remotes and share-scope adapters when remote modules and a container are in the graph, and drops the kernel's remote and shared handlers. --- .../container/experiments-optimization/index.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/enhanced/test/configCases/container/experiments-optimization/index.js b/packages/enhanced/test/configCases/container/experiments-optimization/index.js index e9679a5fa3a..cbd4ffd1771 100644 --- a/packages/enhanced/test/configCases/container/experiments-optimization/index.js +++ b/packages/enhanced/test/configCases/container/experiments-optimization/index.js @@ -77,29 +77,30 @@ if (!globalThis.__EXPERIMENTS_OPTIMIZATION_CASE__) { expect(nodeRemoteEntryEsm).toContain('attrs:{name'); }); - it('should eliminate the complete remote consumption path', () => { + it('should eliminate the remote handler but keep the remotes adapter its remote modules call', () => { expect(noRemote.length).toBeLessThan(fullCapabilities.length); expect(fullCapabilities).toContain('availableExposes'); expect(fullCapabilities).toContain('mf_module_id'); - expect(fullCapabilities).toContain('Container missing'); expect(noRemote).not.toContain('availableExposes'); expect(noRemote).not.toContain('mf_module_id'); - expect(noRemote).not.toContain('Container missing'); + expect(noRemote).toContain('Container missing'); }); - it('should eliminate the complete shared consumption path', () => { - const sharedRuntimeMarkers = [ + it('should eliminate the shared handler but keep the share scope its container and remotes initialize', () => { + const sharedHandlerMarkers = ['afterRegisterShare', 'errorLoadShare']; + const shareScopeMarkers = [ 'Initialization of sharing external failed', 'Shared module is not available for eager consumption', - 'No fallback item found for shareKey', - 'tree-shake-plugin', ]; expect(noShared.length).toBeLessThan(fullCapabilities.length); - sharedRuntimeMarkers.forEach((marker) => { + sharedHandlerMarkers.forEach((marker) => { expect(fullCapabilities).toContain(marker); expect(noShared).not.toContain(marker); }); + shareScopeMarkers.forEach((marker) => { + expect(noShared).toContain(marker); + }); }); it('should eliminate container initialization from the consumer entry without exposes', () => { From d01ec17e3174a8c142dff4c3c87c9d209fb0d34d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:29:14 +0000 Subject: [PATCH 03/38] feat(node)!: load remote entries only through the instance platform Remove the fallback to federation.runtime.loadScriptNode. A platform without a Node loader now fails the load with a named error. --- .../node/src/__tests__/runtimePlugin.test.ts | 89 +++++++++---------- .../__tests__/webpackChunkUtilities.test.ts | 21 ++--- .../node/src/plugins/webpackChunkUtilities.ts | 12 +-- packages/node/src/runtimePlugin.ts | 23 ++--- 4 files changed, 72 insertions(+), 73 deletions(-) diff --git a/packages/node/src/__tests__/runtimePlugin.test.ts b/packages/node/src/__tests__/runtimePlugin.test.ts index e013e58b5c0..34549db4269 100644 --- a/packages/node/src/__tests__/runtimePlugin.test.ts +++ b/packages/node/src/__tests__/runtimePlugin.test.ts @@ -52,10 +52,10 @@ const mockWebpackRequire = { o: jest.fn(), l: jest.fn(), federation: { - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, chunkMatcher: jest.fn().mockReturnValue(true), @@ -788,20 +788,18 @@ describe('runtimePlugin', () => { ); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toHaveBeenCalledWith('http://localhost:3001/remoteEntry.js', { attrs: { globalName: 'test-remote' }, }); }); - it('loads through the instance platform when it has loadScriptNode', async () => { + it('loads through the instance platform', async () => { const federation = (global as any).__webpack_require__.federation; const calls: unknown[][] = []; const container = { get: jest.fn() }; - const legacyLoader = jest.fn(); const originalInstance = federation.instance; - const originalRuntime = federation.runtime; - federation.runtime = { loadScriptNode: legacyLoader }; federation.instance = { platform: { loadScriptNode: async (...args: unknown[]) => { @@ -830,28 +828,20 @@ describe('runtimePlugin', () => { { attrs: { globalName: 'platform-remote' } }, ], ]); - expect(legacyLoader).not.toHaveBeenCalled(); expect(done).toHaveBeenCalledWith({ wrapped: container }); } finally { federation.instance = originalInstance; - federation.runtime = originalRuntime; delete (globalThis as any)['platform-remote']; } }); - it('falls back to federation.runtime.loadScriptNode without a platform loader', async () => { + it('fails the load when the instance platform has no Node loader', async () => { const federation = (global as any).__webpack_require__.federation; - const container = { get: jest.fn() }; const originalInstance = federation.instance; - const originalRuntime = federation.runtime; - federation.runtime = { - loadScriptNode: jest.fn().mockResolvedValue(container), - }; + federation.runtime = { loadScriptNode: jest.fn() }; federation.instance = { - platform: { isBrowser: () => false }, - initRawContainer: (_name: string, _url: string, res: unknown) => ({ - wrapped: res, - }), + platform: { isBrowser: () => true }, + initRawContainer: jest.fn(), }; try { setupScriptLoader(); @@ -859,20 +849,22 @@ describe('runtimePlugin', () => { (global as any).__webpack_require__.l( 'http://localhost:3001/remoteEntry.js', done, - 'legacy-remote', + 'web-remote', '', ); await new Promise(process.nextTick); - expect(federation.runtime.loadScriptNode).toHaveBeenCalledWith( - 'http://localhost:3001/remoteEntry.js', - { attrs: { globalName: 'legacy-remote' } }, + expect(federation.runtime.loadScriptNode).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /needs a node or universal platform/, + ), + }), ); - expect(done).toHaveBeenCalledWith({ wrapped: container }); } finally { federation.instance = originalInstance; - federation.runtime = originalRuntime; - delete (globalThis as any)['legacy-remote']; + delete federation.runtime; } }); @@ -1003,10 +995,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1079,10 +1071,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1119,7 +1111,8 @@ describe('runtimePlugin', () => { ); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toHaveBeenCalledWith('http://localhost:3001/remoteEntry.js', { attrs: { globalName: 'test-remote' }, }); @@ -1149,8 +1142,11 @@ describe('runtimePlugin', () => { it('should handle errors in loadScriptNode', async () => { // Mock loadScriptNode to reject with an error - (global as any).__webpack_require__.federation.runtime.loadScriptNode = - jest.fn().mockRejectedValue(new Error('Loading error')); + ( + global as any + ).__webpack_require__.federation.instance.platform.loadScriptNode = jest + .fn() + .mockRejectedValue(new Error('Loading error')); const doneMock = jest.fn(); @@ -1217,10 +1213,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1251,10 +1247,11 @@ describe('runtimePlugin', () => { expect((global as any).__webpack_require__).toBeDefined(); expect((global as any).__webpack_require__.federation).toBeDefined(); expect( - (global as any).__webpack_require__.federation.runtime, + (global as any).__webpack_require__.federation.instance.platform, ).toBeDefined(); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toBeDefined(); expect((global as any).__webpack_require__.l).toBeDefined(); expect((global as any).__webpack_require__.f).toBeDefined(); @@ -1321,10 +1318,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1353,7 +1350,9 @@ describe('runtimePlugin', () => { it('should load remote entries correctly', () => { const loadScriptNodeMock = jest.fn().mockResolvedValue({}); - (global as any).__webpack_require__.federation.runtime.loadScriptNode = + ( + global as any + ).__webpack_require__.federation.instance.platform.loadScriptNode = loadScriptNodeMock; const doneMock = jest.fn(); diff --git a/packages/node/src/__tests__/webpackChunkUtilities.test.ts b/packages/node/src/__tests__/webpackChunkUtilities.test.ts index 13522b0550a..e7d7a248bfe 100644 --- a/packages/node/src/__tests__/webpackChunkUtilities.test.ts +++ b/packages/node/src/__tests__/webpackChunkUtilities.test.ts @@ -24,13 +24,11 @@ const initRawContainer = (_name: string, _url: string, res: unknown) => ({ }); describe('generateLoadScript', () => { - it('loads through the instance platform when it has loadScriptNode', async () => { + it('loads through the instance platform', async () => { const container = { get: jest.fn() }; const calls: unknown[][] = []; - const legacyLoader = jest.fn(); const result = await runEmittedLoader({ - runtime: { loadScriptNode: legacyLoader }, instance: { platform: { loadScriptNode: async (...args: unknown[]) => { @@ -45,23 +43,22 @@ describe('generateLoadScript', () => { expect(calls).toEqual([ ['http://localhost:3001/remoteEntry.js', { attrs: {} }], ]); - expect(legacyLoader).not.toHaveBeenCalled(); expect(result).toEqual({ wrapped: container }); }); - it('falls back to federation.runtime.loadScriptNode without a platform loader', async () => { - const container = { get: jest.fn() }; - const legacyLoader = jest.fn().mockResolvedValue(container); + it('fails the load when the instance platform has no Node loader', async () => { + const legacyLoader = jest.fn(); const result = await runEmittedLoader({ runtime: { loadScriptNode: legacyLoader }, - instance: { initRawContainer }, + instance: { platform: { isBrowser: () => true }, initRawContainer }, }); - expect(legacyLoader).toHaveBeenCalledWith( - 'http://localhost:3001/remoteEntry.js', - { attrs: {} }, + expect(legacyLoader).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/needs a node or universal platform/), + }), ); - expect(result).toEqual({ wrapped: container }); }); }); diff --git a/packages/node/src/plugins/webpackChunkUtilities.ts b/packages/node/src/plugins/webpackChunkUtilities.ts index 12bee09531b..dd62d5204d7 100644 --- a/packages/node/src/plugins/webpackChunkUtilities.ts +++ b/packages/node/src/plugins/webpackChunkUtilities.ts @@ -289,12 +289,12 @@ export function generateLoadScript(runtimeTemplate: any): string { } } try { - const federation = ${RuntimeGlobals.require}.federation; - const platform = federation.instance.platform; - const res = await (platform && platform.loadScriptNode - ? platform.loadScriptNode(url, { attrs: {} }) - : federation.runtime.loadScriptNode(url, { attrs: {} })); - const enhancedRemote = federation.instance.initRawContainer(name, url, res); + const instance = ${RuntimeGlobals.require}.federation.instance; + if (!instance.platform.loadScriptNode) { + throw new Error('Loading a remote entry in Node needs a node or universal platform capability.'); + } + const res = await instance.platform.loadScriptNode(url, { attrs: {} }); + const enhancedRemote = instance.initRawContainer(name, url, res); // use normal global assignment if(!usesInternalRef && !globalThis[name]) { globalThis[name] = enhancedRemote diff --git a/packages/node/src/runtimePlugin.ts b/packages/node/src/runtimePlugin.ts index 444e5198a6b..b718b01621d 100644 --- a/packages/node/src/runtimePlugin.ts +++ b/packages/node/src/runtimePlugin.ts @@ -20,7 +20,6 @@ type WebpackRequire = { chunkId: string, ) => void; federation: { - runtime: { loadScriptNode: LoadScriptNode }; instance: ModuleFederation; chunkMatcher?: (chunkId: string) => boolean; rootOutputDir?: string; @@ -372,6 +371,9 @@ export const deleteChunk = ( return true; }; +const NO_NODE_LOADER = + 'Loading a remote entry in Node needs a node or universal platform capability.'; + // Hoisted function to set up webpack script loader export const setupScriptLoader = (): void => { __webpack_require__.l = ( @@ -382,15 +384,16 @@ export const setupScriptLoader = (): void => { ): void => { if (!key || chunkId) throw new Error(`__webpack_require__.l name is required for ${url}`); - const { instance, runtime } = __webpack_require__.federation; - const platform = instance.platform as - | (ModuleFederation['platform'] & { loadScriptNode?: LoadScriptNode }) - | undefined; - const info = { attrs: { globalName: key } }; - (platform?.loadScriptNode - ? platform.loadScriptNode(url, info) - : runtime.loadScriptNode(url, info) - ) + const platform = __webpack_require__.federation.instance + .platform as ModuleFederation['platform'] & { + loadScriptNode?: LoadScriptNode; + }; + if (!platform.loadScriptNode) { + done(new Error(NO_NODE_LOADER)); + return; + } + platform + .loadScriptNode(url, { attrs: { globalName: key } }) .then((res) => { const enhancedRemote = __webpack_require__.federation.instance.initRawContainer( From fe83d46091145cb455429bc68a3c15b63f1566d7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:30:40 +0000 Subject: [PATCH 04/38] feat(webpack-bundler-runtime,enhanced)!: remove federation.runtime The webpack-bundler-runtime root no longer carries the @module-federation/runtime namespace and calls init directly. createFederation drops the loadScriptNode shim, and the bootstrap guard only checks bundlerRuntime. --- .../runtime/FederationRuntimePlugin.ts | 6 +----- .../container/FederationRuntimePlugin.test.ts | 6 +++--- .../__tests__/compose.spec.ts | 10 +--------- .../__tests__/legacy-root.spec.ts | 8 +++----- packages/webpack-bundler-runtime/src/compose.ts | 17 +---------------- packages/webpack-bundler-runtime/src/index.ts | 7 +++---- packages/webpack-bundler-runtime/src/types.ts | 1 - 7 files changed, 12 insertions(+), 43 deletions(-) diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index 220c1d9e08a..6cbf8e52ff1 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -187,11 +187,8 @@ class FederationRuntimePlugin { ); }); } - // A composed federation object has no runtime namespace, only its bundler runtime. const embedRuntimeLines = Template.asString([ - composition === undefined - ? `if(!${federationGlobal}.runtime || !${federationGlobal}.bundlerRuntime){` - : `if(!${federationGlobal}.bundlerRuntime){`, + `if(!${federationGlobal}.bundlerRuntime){`, Template.indent([ `var prevFederation = ${federationGlobal};`, `${federationGlobal} = {}`, @@ -223,7 +220,6 @@ class FederationRuntimePlugin { `${federationGlobal}.initOptions.plugins.concat(pluginsToAdd) : pluginsToAdd;`, ]) : '', - // `${federationGlobal}.instance = ${federationGlobal}.runtime.init(${federationGlobal}.initOptions);`, `${federationGlobal}.instance = ${federationGlobal}.bundlerRuntime.init({webpackRequire:${RuntimeGlobals.require}});`, `if(${federationGlobal}.attachShareScopeMap){`, Template.indent([ diff --git a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts index 616c5f1aa08..2199f0aed82 100644 --- a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts +++ b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts @@ -192,17 +192,17 @@ describe('FederationRuntimePlugin runtimePluginCalls', () => { }); describe('runtime bootstrap guards', () => { - it('rehydrates bundler runtime when runtime exists but bundlerRuntime is missing', () => { + it('rehydrates federation only when bundlerRuntime is missing', () => { const template = FederationRuntimePlugin.getTemplate( compiler as Compiler, mockOptions, 'bundler-runtime.js', - {}, ); expect(template).toContain( - 'if(!__webpack_require__.federation.runtime || !__webpack_require__.federation.bundlerRuntime)', + 'if(!__webpack_require__.federation.bundlerRuntime){', ); + expect(template).not.toContain('__webpack_require__.federation.runtime'); }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/compose.spec.ts b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts index 98195af04c6..4d0c58c6baf 100644 --- a/packages/webpack-bundler-runtime/__tests__/compose.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts @@ -144,8 +144,7 @@ describe('createFederation', () => { 'S', 'init', ]); - expect(Object.keys(federation.runtime)).toEqual(['loadScriptNode']); - expect(federation).not.toHaveProperty('runtime.init'); + expect(federation).not.toHaveProperty('runtime'); }); test('init passes buildId as the default id', () => { @@ -184,13 +183,6 @@ describe('createFederation', () => { federation.instance!.options.plugins.map((plugin) => plugin.name), ).not.toContain('tree-shake-plugin'); }); - - test('runtime.loadScriptNode names the missing node platform', () => { - const { federation } = boot({}, []); - expect(() => federation.runtime.loadScriptNode('x.js', {})).toThrow( - /loadScriptNode/, - ); - }); }); describe('adapters against enabled and disabled handlers', () => { diff --git a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts index 14c5778fbee..1ba944c5ae0 100644 --- a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts @@ -3,7 +3,6 @@ import * as legacyRoot from '../src/index'; describe('legacy root keeps the keys rspack copies onto federation', () => { test('keeps the default export keys', () => { expect(Object.keys(legacyRoot.default)).toEqual([ - 'runtime', 'instance', 'initOptions', 'bundlerRuntime', @@ -36,13 +35,12 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { 'default', 'initOptions', 'instance', - 'runtime', ].sort(), ); }); - test('keeps federation.runtime as the runtime namespace', () => { - expect(typeof legacyRoot.default.runtime!.init).toBe('function'); - expect(legacyRoot.runtime).toBe(legacyRoot.default.runtime); + test('has no federation.runtime namespace', () => { + expect(legacyRoot.default).not.toHaveProperty('runtime'); + expect(legacyRoot).not.toHaveProperty('runtime'); }); }); diff --git a/packages/webpack-bundler-runtime/src/compose.ts b/packages/webpack-bundler-runtime/src/compose.ts index d8291c070e5..fe9ef8d7424 100644 --- a/packages/webpack-bundler-runtime/src/compose.ts +++ b/packages/webpack-bundler-runtime/src/compose.ts @@ -2,7 +2,6 @@ import { init as composeInit } from '@module-federation/runtime/compose'; import type { Capabilities, FederationKernel, - NodePlatform, } from '@module-federation/runtime-core/kernel'; import { attachShareScopeMap } from './attachShareScopeMap'; import type { @@ -14,14 +13,13 @@ import type { export type ComposedFederation = Omit< Federation, - 'runtime' | 'instance' | 'bundlerRuntime' + 'instance' | 'bundlerRuntime' > & { instance?: FederationKernel; bundlerRuntime: Partial & { S: BundlerRuntime['S']; init(options: { webpackRequire: WebpackRequire }): FederationKernel; }; - runtime: { loadScriptNode: NodePlatform['loadScriptNode'] }; }; export function createFederation({ @@ -54,19 +52,6 @@ export function createFederation({ }, attachShareScopeMap, bundlerRuntimeOptions: {}, - runtime: { - loadScriptNode(url, info) { - const platform = federation.instance?.platform as - | Partial - | undefined; - if (!platform?.loadScriptNode) { - throw new Error( - 'federation.runtime.loadScriptNode needs a node or universal platform capability.', - ); - } - return platform.loadScriptNode(url, info); - }, - }, }; for (const adapter of adapters) { Object.assign(federation.bundlerRuntime, adapter.bundlerRuntime); diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index c2fef6c5092..289106353ef 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -1,4 +1,4 @@ -import * as runtime from '@module-federation/runtime'; +import { init } from '@module-federation/runtime'; import type { Adapter, Federation, WebpackRequire } from './types'; import { attachShareScopeMap } from './attachShareScopeMap'; import { remotes } from './adapters/remotes'; @@ -39,14 +39,13 @@ const bundlerRuntime = Object.assign( for (const adapter of adapters) { adapter.beforeInit?.(webpackRequire, initOptions); } - return webpackRequire.federation.runtime!.init(initOptions); + return init(initOptions); }, }, ...adapters.map((adapter) => adapter.bundlerRuntime), ) as NonNullable; const federation: Federation = { - runtime, instance: undefined, initOptions: undefined, bundlerRuntime, @@ -55,7 +54,7 @@ const federation: Federation = { }; // Keep CJS interop stable for consumers that iterate required keys directly. -export { runtime, attachShareScopeMap }; +export { attachShareScopeMap }; export const instance = federation.instance; export const initOptions = federation.initOptions; export { bundlerRuntime }; diff --git a/packages/webpack-bundler-runtime/src/types.ts b/packages/webpack-bundler-runtime/src/types.ts index c8f475c36cb..8c7cbe4d2c5 100644 --- a/packages/webpack-bundler-runtime/src/types.ts +++ b/packages/webpack-bundler-runtime/src/types.ts @@ -216,7 +216,6 @@ export interface GetSharedFallbackGetterOptions { } export interface Federation { - runtime?: typeof runtime; instance?: runtime.ModuleFederation; initOptions?: InitOptions; installInitialConsumes?: (options: InstallInitialConsumesOptions) => any; From 187dfdd2c6443cc7715ab4067938d7fd1810a88f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:32:52 +0000 Subject: [PATCH 05/38] feat(runtime-core,runtime,webpack-bundler-runtime)!: stop reading the capability and build-id defines The runtime-core root constructs ModuleFederation with the full capability set, Module is always the remote module, and the webpack-bundler-runtime root always includes its four adapters. FEDERATION_BUILD_IDENTIFIER no longer sets the default id or matches instances; the bundle's current instance handles reuse. --- packages/esbuild/global.d.ts | 1 - .../runtime-core/__tests__/kernel.spec.ts | 35 +++++++++++---- packages/runtime-core/global.d.ts | 1 - packages/runtime-core/src/core.ts | 3 +- packages/runtime-core/src/index.ts | 45 ++++--------------- packages/runtime-core/src/remote/disabled.ts | 6 --- packages/runtime-core/src/utils/env.ts | 8 ---- packages/runtime/__tests__/compose.spec.ts | 17 +++++++ packages/runtime/global.d.ts | 1 - packages/runtime/src/utils.ts | 14 ------ .../__tests__/legacy-root.spec.ts | 24 ++++++++++ packages/webpack-bundler-runtime/src/index.ts | 20 +-------- 12 files changed, 78 insertions(+), 97 deletions(-) diff --git a/packages/esbuild/global.d.ts b/packages/esbuild/global.d.ts index 7eff77bc2bc..8012a54f427 100644 --- a/packages/esbuild/global.d.ts +++ b/packages/esbuild/global.d.ts @@ -1,4 +1,3 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; diff --git a/packages/runtime-core/__tests__/kernel.spec.ts b/packages/runtime-core/__tests__/kernel.spec.ts index edcf845ebfc..2ce0bcfab49 100644 --- a/packages/runtime-core/__tests__/kernel.spec.ts +++ b/packages/runtime-core/__tests__/kernel.spec.ts @@ -2,12 +2,17 @@ import { afterEach, describe, expect, it, rs } from '@rstest/core'; import { FederationKernel } from '../src/kernel'; import { remote } from '../src/remote/capability'; import { PLATFORM_UNAVAILABLE_MESSAGE } from '../src/platform/unavailable'; -import { ModuleFederation } from '../src'; +import { Module, ModuleFederation } from '../src'; +import { Module as RemoteModule } from '../src/module'; import type { ModuleFederationRuntimePlugin, Platform } from '../src/type'; declare global { // eslint-disable-next-line no-var var FEDERATION_OPTIMIZE_NO_REMOTE: boolean | undefined; + // eslint-disable-next-line no-var + var FEDERATION_OPTIMIZE_NO_SHARED: boolean | undefined; + // eslint-disable-next-line no-var + var FEDERATION_BUILD_IDENTIFIER: string | undefined; } const remoteInfo = { @@ -24,6 +29,8 @@ const pluginNames = (instance: FederationKernel) => describe('FederationKernel', () => { afterEach(() => { delete globalThis.FEDERATION_OPTIMIZE_NO_REMOTE; + delete globalThis.FEDERATION_OPTIMIZE_NO_SHARED; + delete globalThis.FEDERATION_BUILD_IDENTIFIER; }); it('has disabled handlers and a platform that rejects loads without capabilities', async () => { @@ -92,6 +99,8 @@ describe('FederationKernel', () => { describe('root ModuleFederation', () => { afterEach(() => { delete globalThis.FEDERATION_OPTIMIZE_NO_REMOTE; + delete globalThis.FEDERATION_OPTIMIZE_NO_SHARED; + delete globalThis.FEDERATION_BUILD_IDENTIFIER; }); it('composes shared, remote, snapshot and the universal platform', () => { @@ -115,18 +124,28 @@ describe('root ModuleFederation', () => { expect(instance.options.inBrowser).toBe(true); }); - it('drops remote and snapshot when FEDERATION_OPTIMIZE_NO_REMOTE is set', async () => { + it('ignores the removed capability and build-id defines', () => { globalThis.FEDERATION_OPTIMIZE_NO_REMOTE = true; + globalThis.FEDERATION_OPTIMIZE_NO_SHARED = true; + globalThis.FEDERATION_BUILD_IDENTIFIER = 'root:1.0.0'; const instance = new ModuleFederation({ - name: 'root-no-remote', + name: 'root-defines', remotes: [{ name: 'app', entry: remoteInfo.entry }], + shared: { + react: { version: '18.0.0', lib: () => ({ root: true }) }, + }, }); - expect(pluginNames(instance)).toEqual([]); - expect(instance.options.remotes).toEqual([]); - await expect(instance.loadRemote('app/Button')).rejects.toThrow( - 'Remote loading is disabled', - ); + expect(pluginNames(instance)).toEqual([ + 'snapshot-plugin', + 'generate-preload-assets-plugin', + ]); + expect(instance.options.remotes.map((r) => r.name)).toEqual(['app']); + expect(instance.loadShareSync<{ root: boolean }>('react')()).toEqual({ + root: true, + }); + expect(instance.options.id).toBeFalsy(); + expect(Module).toBe(RemoteModule); }); }); diff --git a/packages/runtime-core/global.d.ts b/packages/runtime-core/global.d.ts index a74d2d87a02..012c2a032d0 100644 --- a/packages/runtime-core/global.d.ts +++ b/packages/runtime-core/global.d.ts @@ -1,5 +1,4 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; declare const FEDERATION_ALLOW_NEW_FUNCTION: string | undefined; diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index c676069f809..434d08ab5a9 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -26,7 +26,7 @@ import { SharedHandlerContract, SnapshotHandlerContract, } from './type'; -import { getBuilderId, registerPlugins, getRemoteEntry, error } from './utils'; +import { registerPlugins, getRemoteEntry, error } from './utils'; import { getShortErrorMsg, RUNTIME_010, @@ -296,7 +296,6 @@ export class FederationKernel { // TODO: Validate the details of the options // Initialize options with default values const defaultOptions: Options = { - id: getBuilderId(), name: userOptions.name, plugins, remotes: [], diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e8add54312e..664f8add56a 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -1,6 +1,5 @@ import helpersDefault, { type IGlobalUtils, type IShareUtils } from './helpers'; -import { Module as RemoteModule } from './module'; -import { UnavailableRemoteModule } from './remote/disabled'; +import { Module } from './module'; import { FederationKernel } from './core'; import { shared } from './shared/capability'; import { remote } from './remote/capability'; @@ -8,46 +7,18 @@ import { snapshot } from './plugins/snapshot/capability'; import { universal } from './platform/universal'; import type { Capabilities, UserOptions } from './type'; -declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; -declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; - const helpers = helpersDefault; -const Module = ( - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE - ? UnavailableRemoteModule - : RemoteModule -) as typeof RemoteModule; - -// The only reader of the capability defines. Each check stays inline so the -// bundler folds it at parse time and drops the unused capability import. -const legacyCapabilities = (): Capabilities => - ({ - shared: - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' && - FEDERATION_OPTIMIZE_NO_SHARED - ? undefined - : shared, - remote: - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE - ? undefined - : remote, - snapshot: - (typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE) || - (typeof FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'boolean' && - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN) - ? undefined - : snapshot, - platform: universal, - }) as Capabilities; +const fullCapabilities: Capabilities = { + shared, + remote, + snapshot, + platform: universal, +}; export class ModuleFederation extends FederationKernel { constructor( userOptions: UserOptions, - capabilities: Capabilities = legacyCapabilities(), + capabilities: Capabilities = fullCapabilities, ) { super(userOptions, capabilities); } diff --git a/packages/runtime-core/src/remote/disabled.ts b/packages/runtime-core/src/remote/disabled.ts index d398e666ecf..94473961fd6 100644 --- a/packages/runtime-core/src/remote/disabled.ts +++ b/packages/runtime-core/src/remote/disabled.ts @@ -4,12 +4,6 @@ import { PluginSystem } from '../utils/hooks'; const REMOTE_DISABLED_MESSAGE = 'Remote loading is disabled by experiments.optimization.disableRemote.'; -export class UnavailableRemoteModule { - constructor() { - throw new Error(REMOTE_DISABLED_MESSAGE); - } -} - export class DisabledRemoteHandler implements RemoteHandlerContract { // No lifecycle: plugins that tap remote hooks register nothing. hooks: RemoteHandlerContract['hooks'] = new PluginSystem( diff --git a/packages/runtime-core/src/utils/env.ts b/packages/runtime-core/src/utils/env.ts index 9c87af66172..b667a746599 100644 --- a/packages/runtime-core/src/utils/env.ts +++ b/packages/runtime-core/src/utils/env.ts @@ -7,11 +7,3 @@ export { export function isDevelopmentMode(): boolean { return true; } - -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} diff --git a/packages/runtime/__tests__/compose.spec.ts b/packages/runtime/__tests__/compose.spec.ts index 67fc84f9c5c..19fb85a987f 100644 --- a/packages/runtime/__tests__/compose.spec.ts +++ b/packages/runtime/__tests__/compose.spec.ts @@ -31,6 +31,23 @@ describe('runtime/compose', () => { expect(host.options.name).toBe('compose-debug'); }); + it('does not match a page-global instance by a build-id define', () => { + ( + globalThis as { FEDERATION_BUILD_IDENTIFIER?: string } + ).FEDERATION_BUILD_IDENTIFIER = 'foreign:1.0.0'; + const foreign = new FederationKernel({ + name: 'foreign-build', + id: 'foreign:1.0.0', + }); + CurrentGlobal.__FEDERATION__.__INSTANCES__.unshift(foreign); + try { + expect(init({ name: 'own-build' })).not.toBe(foreign); + } finally { + delete (globalThis as { FEDERATION_BUILD_IDENTIFIER?: string }) + .FEDERATION_BUILD_IDENTIFIER; + } + }); + it('reuses the bundle-local instance before a page-global match', () => { const first = composeInit({ name: 'compose-reuse' }, {}); const foreign = registerForeignInstance('compose-reuse'); diff --git a/packages/runtime/global.d.ts b/packages/runtime/global.d.ts index a74d2d87a02..012c2a032d0 100644 --- a/packages/runtime/global.d.ts +++ b/packages/runtime/global.d.ts @@ -1,5 +1,4 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; declare const FEDERATION_ALLOW_NEW_FUNCTION: string | undefined; diff --git a/packages/runtime/src/utils.ts b/packages/runtime/src/utils.ts index 18d5afae2a3..d1b3f76d70e 100644 --- a/packages/runtime/src/utils.ts +++ b/packages/runtime/src/utils.ts @@ -3,25 +3,11 @@ import { type ModuleFederation, } from '@module-federation/runtime-core/kernel'; -// injected by bundler, so it can not use runtime-core stuff -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} - export function isMatchingInstance( instance: ModuleFederation, name: string, version: string | undefined, ): boolean { - const buildId = getBuilderId(); - if (buildId && instance.options.id === buildId) { - return true; - } - if (instance.options.name === name && !instance.options.version && !version) { return true; } diff --git a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts index 1ba944c5ae0..ce30840819a 100644 --- a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts @@ -26,6 +26,30 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { ); }); + test('ignores the removed capability defines', () => { + const globals = globalThis as Record; + globals['FEDERATION_OPTIMIZE_NO_REMOTE'] = true; + globals['FEDERATION_OPTIMIZE_NO_SHARED'] = true; + globals['FEDERATION_HAS_EXPOSES'] = false; + try { + jest.isolateModules(() => { + const root = require('../src/index') as typeof legacyRoot; + expect(Object.keys(root.default.bundlerRuntime!)).toEqual( + expect.arrayContaining([ + 'remotes', + 'consumes', + 'I', + 'initContainerEntry', + ]), + ); + }); + } finally { + delete globals['FEDERATION_OPTIMIZE_NO_REMOTE']; + delete globals['FEDERATION_OPTIMIZE_NO_SHARED']; + delete globals['FEDERATION_HAS_EXPOSES']; + } + }); + test('keeps the named exports', () => { expect(Object.keys(legacyRoot).sort()).toEqual( [ diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index 289106353ef..ff390f7104b 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -6,27 +6,9 @@ import { consumes } from './adapters/consumes'; import { shareScope } from './adapters/share-scope'; import { container } from './adapters/container'; -declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; -declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; -declare const FEDERATION_HAS_EXPOSES: boolean; - export * from './types'; -const USE_REMOTE = - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_REMOTE - : true; -const USE_SHARED = - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_SHARED - : true; -const USE_EXPOSES = - typeof FEDERATION_HAS_EXPOSES === 'boolean' ? FEDERATION_HAS_EXPOSES : true; - -const adapters: Adapter[] = []; -if (USE_REMOTE) adapters.push(remotes); -if (USE_SHARED) adapters.push(consumes, shareScope); -if (USE_EXPOSES) adapters.push(container); +const adapters: Adapter[] = [remotes, consumes, shareScope, container]; const bundlerRuntime = Object.assign( { From 22c1db4783dc3443fe4287e8da905c1ad70408e3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:34:47 +0000 Subject: [PATCH 06/38] feat(enhanced,rspack)!: stop emitting the capability and build-id defines ModuleFederationPlugin in enhanced and in the rspack wrapper now defines only ENV_TARGET, and only when experiments.optimization.target is set. --- .../lib/container/ModuleFederationPlugin.ts | 50 +------------------ .../ModuleFederationPlugin.defines.test.ts | 49 ++++++++++++++++++ .../__tests__/ModuleFederationPlugin.spec.ts | 38 ++++---------- packages/rspack/src/ModuleFederationPlugin.ts | 30 +---------- 4 files changed, 63 insertions(+), 104 deletions(-) create mode 100644 packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index 8bf998ac351..a1f14a538bb 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -5,11 +5,10 @@ 'use strict'; import { DtsPlugin } from '@module-federation/dts-plugin'; -import { ContainerManager, utils } from '@module-federation/managers'; +import { ContainerManager } from '@module-federation/managers'; import { StatsPlugin } from '@module-federation/manifest'; import { bindLoggerToCompiler, - composeKeyWithSeparator, type moduleFederationPlugin, infrastructureLogger, } from '@module-federation/sdk'; @@ -23,7 +22,6 @@ import ContainerReferencePlugin from './ContainerReferencePlugin'; import FederationRuntimePlugin from './runtime/FederationRuntimePlugin'; import FederationCompositionPlugin, { COVERED_BY_OPTIONS, - composedEntryOf, optionsParticipant, } from './runtime/FederationCompositionPlugin'; import { RemoteEntryPlugin } from '@module-federation/rspack/remote-entry-plugin'; @@ -102,45 +100,9 @@ class ModuleFederationPlugin implements WebpackPluginInstance { } private _patchBundlerConfig(compiler: Compiler): void { - const { name, experiments, exposes } = this._options; + const { experiments } = this._options; const definePluginOptions: Record = {}; - const MFPluginNum = compiler.options.plugins.filter( - (p): p is WebpackPluginInstance => - !!p && (p as any).name === 'ModuleFederationPlugin', - ).length; - - if (name && MFPluginNum < 2) { - definePluginOptions['FEDERATION_BUILD_IDENTIFIER'] = JSON.stringify( - composeKeyWithSeparator(name, utils.getBuildVersion()), - ); - } - - const disableSnapshot = experiments?.optimization?.disableSnapshot ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = - disableSnapshot; - definePluginOptions['FEDERATION_OPTIMIZE_NO_REMOTE'] = - experiments?.optimization?.disableRemote ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SHARED'] = - experiments?.optimization?.disableShared ?? false; - definePluginOptions['FEDERATION_HAS_EXPOSES'] = - hasExposes(exposes) || - compiler.options.plugins.some((plugin) => { - if (!plugin || typeof plugin !== 'object') { - return false; - } - - const namedPlugin = plugin as WebpackPluginInstance & { - name?: string; - _options?: moduleFederationPlugin.ModuleFederationPluginOptions; - }; - if (namedPlugin.name !== 'ModuleFederationPlugin') { - return false; - } - - return hasExposes(namedPlugin._options?.exposes); - }); - // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( experiments?.optimization && @@ -160,14 +122,6 @@ class ModuleFederationPlugin implements WebpackPluginInstance { // No inference for ENV_TARGET. If not manually set and valid, it's not defined. new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); - - // DefinePlugin reads its definitions per compilation, after the plan picked a mode. - compiler.hooks.compile.tap('ModuleFederationPlugin', () => { - if (!composedEntryOf(compiler)) return; - for (const key of Object.keys(definePluginOptions)) { - if (key !== 'ENV_TARGET') delete definePluginOptions[key]; - } - }); } /** diff --git a/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts b/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts new file mode 100644 index 00000000000..2ab549b5400 --- /dev/null +++ b/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts @@ -0,0 +1,49 @@ +/* + * @rstest-environment node + */ +import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; + +type Options = ConstructorParameters[0]; + +function definesFor(options: Options) { + const definitions: Record[] = []; + class DefinePlugin { + constructor(value: Record) { + definitions.push(value); + } + apply() {} + } + const compiler = { + options: { plugins: [] }, + webpack: { DefinePlugin }, + }; + ( + new ModuleFederationPlugin(options) as unknown as { + _patchBundlerConfig(compiler: unknown): void; + } + )._patchBundlerConfig(compiler); + return Object.assign({}, ...definitions); +} + +describe('ModuleFederationPlugin defines', () => { + it('defines only ENV_TARGET, from experiments.optimization.target', () => { + expect( + definesFor({ + name: 'host', + exposes: { './Button': './Button' }, + experiments: { + optimization: { + target: 'web', + disableRemote: true, + disableShared: true, + disableSnapshot: true, + }, + }, + }), + ).toEqual({ ENV_TARGET: '"web"' }); + }); + + it('defines nothing without a target', () => { + expect(definesFor({ name: 'host' })).toEqual({}); + }); +}); diff --git a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts index ff101840e3e..2e19fc65d29 100644 --- a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts +++ b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts @@ -99,37 +99,19 @@ describe('runtime resolution compatibility', () => { }); }); -describe('runtime capability optimization defines', () => { - it('keeps all runtime capabilities enabled by default', () => { - expect(getOptimizationDefines()).toMatchObject({ - FEDERATION_OPTIMIZE_NO_REMOTE: false, - FEDERATION_OPTIMIZE_NO_SHARED: false, - FEDERATION_HAS_EXPOSES: false, - }); - }); - - it('derives expose capability from the container configuration', () => { - expect(getOptimizationDefines(undefined, {})).toMatchObject({ - FEDERATION_HAS_EXPOSES: false, - }); +describe('runtime optimization defines', () => { + it('defines only ENV_TARGET, from experiments.optimization.target', () => { expect( - getOptimizationDefines(undefined, { - './Button': './src/Button', - }), - ).toMatchObject({ - FEDERATION_HAS_EXPOSES: true, - }); + getOptimizationDefines( + { target: 'web', disableRemote: true, disableShared: true }, + { './Button': './src/Button' }, + ), + ).toEqual({ ENV_TARGET: '"web"' }); }); - it('defines each disabled runtime capability independently', () => { + it('defines nothing without a target', () => { expect( - getOptimizationDefines({ - disableRemote: true, - disableShared: true, - }), - ).toMatchObject({ - FEDERATION_OPTIMIZE_NO_REMOTE: true, - FEDERATION_OPTIMIZE_NO_SHARED: true, - }); + getOptimizationDefines({ disableSnapshot: true }, { './Button': './B' }), + ).toEqual({}); }); }); diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index 4fe78266666..8bd80d22cd7 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -7,12 +7,11 @@ import type { } from '@rspack/core'; import { bindLoggerToCompiler, - composeKeyWithSeparator, moduleFederationPlugin, } from '@module-federation/sdk'; import { StatsPlugin } from '@module-federation/manifest'; -import { ContainerManager, utils } from '@module-federation/managers'; +import { ContainerManager } from '@module-federation/managers'; import { DtsPlugin } from '@module-federation/dts-plugin'; import ReactBridgePlugin from '@module-federation/bridge-react-webpack-plugin'; import path from 'node:path'; @@ -37,17 +36,6 @@ type RuntimeEntrySpec = { cjs: string; }; -function hasExposes( - exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'], -): boolean { - return Boolean( - exposes && - (Array.isArray(exposes) - ? exposes.length > 0 - : Object.keys(exposes).length > 0), - ); -} - function resolveRuntimeEntry( spec: RuntimeEntrySpec, implementation: string | undefined, @@ -110,22 +98,8 @@ export class ModuleFederationPlugin implements RspackPluginInstance { } private _patchBundlerConfig(compiler: Compiler): void { - const { name, experiments, exposes } = this._options; + const { experiments } = this._options; const definePluginOptions: Record = {}; - if (name) { - definePluginOptions['FEDERATION_BUILD_IDENTIFIER'] = JSON.stringify( - composeKeyWithSeparator(name, utils.getBuildVersion()), - ); - } - // Add FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN - const disableSnapshot = experiments?.optimization?.disableSnapshot ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = - disableSnapshot; - definePluginOptions['FEDERATION_OPTIMIZE_NO_REMOTE'] = - experiments?.optimization?.disableRemote ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SHARED'] = - experiments?.optimization?.disableShared ?? false; - definePluginOptions['FEDERATION_HAS_EXPOSES'] = hasExposes(exposes); // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( From 60e40ab9557bfd91ea308132ba86cd54496d631d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:36:28 +0000 Subject: [PATCH 07/38] feat(managers,enhanced)!: fail the build when the runtime family is too old to compose selectMode reports an unsupported mode for a runtime family that does not export the composition subpaths. ModuleFederationPlugin turns it into a build error that names the minimum runtime version instead of falling back to the full runtime. --- .../runtime/FederationCompositionPlugin.ts | 14 +++- .../container/olderRuntimeFamily.test.ts | 73 +++++++++++++++++++ .../__tests__/composition/selectMode.spec.ts | 29 +++++--- packages/managers/src/composition/family.ts | 3 + .../managers/src/composition/selectMode.ts | 18 ++++- packages/managers/src/index.ts | 1 + 6 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts diff --git a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts index 61b97f26278..8217d3feec6 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts @@ -40,6 +40,7 @@ interface Outcome { family: RuntimeFamily; entry?: ComposedEntry; legacyReason?: string; + unsupportedReason?: string; } export interface CompositionSlot { @@ -159,6 +160,9 @@ class FederationCompositionPlugin { alias: compiler.options.resolve.alias as never, aliasExemptions: this._ownAliasTargets(compiler), }); + if (mode.mode === 'unsupported') { + return { family, unsupportedReason: mode.reason }; + } if (mode.mode === 'legacy') return { family, legacyReason: mode.reason }; const composition = renderComposition( plan, @@ -194,8 +198,16 @@ class FederationCompositionPlugin { private _check( compilation: Compilation, - { family, entry, legacyReason }: Outcome, + { family, entry, legacyReason, unsupportedReason }: Outcome, ) { + if (unsupportedReason !== undefined) { + compilation.errors.push( + new WebpackError( + `The federation runtime cannot be composed: ${unsupportedReason}.`, + ), + ); + return; + } const { externalRuntime, provideExternalRuntime } = this._options.experiments ?? {}; // An externalized runtime is the full runtime by request; the other reasons are worth a warning. diff --git a/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts b/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts new file mode 100644 index 00000000000..2e80f6886f1 --- /dev/null +++ b/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts @@ -0,0 +1,73 @@ +// @ts-nocheck +/* + * @rstest-environment node + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; +import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; +import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; + +const webpack = require( + normalizeWebpackPath('webpack'), +) as typeof import('webpack'); + +const pnpmStore = path.resolve(__dirname, '../../../../../node_modules/.pnpm'); + +function olderRuntimeTools() { + const [older] = fs + .readdirSync(pnpmStore) + .filter((dir) => + /^@module-federation\+runtime-tools@2\.\d+\.\d+$/.test(dir), + ); + return path.join( + pnpmStore, + older, + 'node_modules/@module-federation/runtime-tools', + ); +} + +describe('an older runtime family', () => { + let context: string; + afterAll(() => fs.rmSync(context, { recursive: true, force: true })); + + it('fails the build and names the minimum runtime version', async () => { + context = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-older-family-')); + fs.writeFileSync(path.join(context, 'index.js'), 'export default 1;'); + + const stats = await new Promise((resolve, reject) => + webpack( + { + context, + mode: 'production', + devtool: false, + target: 'async-node', + entry: './index.js', + output: { path: path.join(context, 'dist') }, + plugins: [ + new ModuleFederationPlugin({ + name: 'older_family_host', + remotes: { + remote: 'remote@http://localhost:3001/remoteEntry.js', + }, + implementation: olderRuntimeTools(), + dts: false, + manifest: false, + }), + ], + }, + (err, result) => (err ? reject(err) : resolve(result)), + ), + ); + + const errors = stats.toJson({ all: false, errors: true }).errors; + expect(errors.map(({ message }) => message)).toEqual([ + expect.stringMatching( + new RegExp( + `webpack-bundler-runtime at .* does not export "\\./compose"; the federation runtime packages must be ${MIN_RUNTIME_VERSION.replace(/\./g, '\\.')} or newer`, + ), + ), + ]); + }); +}); diff --git a/packages/managers/__tests__/composition/selectMode.spec.ts b/packages/managers/__tests__/composition/selectMode.spec.ts index c1230e47e13..5b8ff86e0ed 100644 --- a/packages/managers/__tests__/composition/selectMode.spec.ts +++ b/packages/managers/__tests__/composition/selectMode.spec.ts @@ -3,7 +3,10 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { resolveRuntimeFamily } from '../../src/composition/family'; +import { + MIN_RUNTIME_VERSION, + resolveRuntimeFamily, +} from '../../src/composition/family'; import { selectMode, type ModeInputs } from '../../src/composition/selectMode'; import { composableFamily, packageDir, tempDir } from './fixtures'; @@ -13,13 +16,17 @@ const legacyReason = async (inputs: ModeInputs, family = composable()) => { const mode = await selectMode(family, inputs); return mode.mode === 'legacy' ? mode.reason : undefined; }; +const unsupportedReason = async (family: ReturnType) => { + const mode = await selectMode(family, {}); + return mode.mode === 'unsupported' ? mode.reason : undefined; +}; describe('selectMode', () => { it('composes a family that exports every required subpath', async () => { expect(await selectMode(composable(), {})).toEqual({ mode: 'composed' }); }); - it('selects legacy for the older runtime-tools in the pnpm store', async () => { + it('rejects the older runtime-tools in the pnpm store and names the minimum version', async () => { const store = path.resolve(__dirname, '../../../../node_modules/.pnpm'); const [older] = fs .readdirSync(store) @@ -28,8 +35,7 @@ describe('selectMode', () => { ); expect(older).toBeDefined(); - const reason = await legacyReason( - {}, + const reason = await unsupportedReason( resolveRuntimeFamily( path.join( store, @@ -42,6 +48,7 @@ describe('selectMode', () => { expect(reason).toMatch( /@module-federation\/webpack-bundler-runtime at .* does not export "\.\/compose"/, ); + expect(reason).toContain(`${MIN_RUNTIME_VERSION} or newer`); }); it('does not count a "./*" pattern as the required key', async () => { @@ -50,33 +57,33 @@ describe('selectMode', () => { exports: { '.': './dist/index.js', './*': './dist/*.js' }, }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /runtime-core at .* does not export "\.\/kernel"/, ); }); - it('selects legacy when a member has another name', async () => { + it('rejects a member with another name', async () => { const root = composableFamily(tempDir(), { '@module-federation/runtime-core': { name: 'runtime-core-fork' }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /is named "runtime-core-fork"/, ); }); - it('selects legacy when a member does not resolve', async () => { + it('rejects a member that does not resolve', async () => { const root = composableFamily(tempDir()); fs.rmSync(packageDir(root, '@module-federation/sdk'), { recursive: true }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /@module-federation\/sdk could not be resolved from/, ); }); - it('selects legacy for an exports string with no subpath keys', async () => { + it('rejects an exports string with no subpath keys', async () => { const root = composableFamily(tempDir(), { '@module-federation/runtime': { exports: './dist/index.js' as never }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /runtime at .* does not export "\.\/compose"/, ); }); diff --git a/packages/managers/src/composition/family.ts b/packages/managers/src/composition/family.ts index 8fb35dab632..b352c08ca12 100644 --- a/packages/managers/src/composition/family.ts +++ b/packages/managers/src/composition/family.ts @@ -3,6 +3,9 @@ import path from 'node:path'; import enhancedResolve from 'enhanced-resolve'; import { findPackageJson } from '../findPackageJson'; +/** The first runtime release that exports every subpath in RUNTIME_FAMILY. */ +export const MIN_RUNTIME_VERSION = '2.10.0'; + export const RUNTIME_FAMILY = { '@module-federation/runtime-tools': [], '@module-federation/webpack-bundler-runtime': [ diff --git a/packages/managers/src/composition/selectMode.ts b/packages/managers/src/composition/selectMode.ts index 9625b7152cf..4e88aa82f96 100644 --- a/packages/managers/src/composition/selectMode.ts +++ b/packages/managers/src/composition/selectMode.ts @@ -1,4 +1,9 @@ -import { FAMILY_PACKAGES, RUNTIME_FAMILY, type RuntimeFamily } from './family'; +import { + FAMILY_PACKAGES, + MIN_RUNTIME_VERSION, + RUNTIME_FAMILY, + type RuntimeFamily, +} from './family'; type ExternalCallback = (err?: Error | null, value?: unknown) => void; type ExternalFunction = (...args: any[]) => unknown; @@ -25,7 +30,8 @@ export interface ModeInputs { export type RuntimeMode = | { mode: 'composed' } - | { mode: 'legacy'; reason: string }; + | { mode: 'legacy'; reason: string } + | { mode: 'unsupported'; reason: string }; const hasOwn = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key); @@ -34,8 +40,14 @@ export async function selectMode( family: RuntimeFamily, inputs: ModeInputs, ): Promise { + const problem = familyProblem(family); + if (problem !== undefined) { + return { + mode: 'unsupported', + reason: `${problem}; the federation runtime packages must be ${MIN_RUNTIME_VERSION} or newer`, + }; + } const reason = - familyProblem(family) ?? experimentProblem(inputs) ?? (await externalsProblem(inputs)) ?? aliasProblem(inputs) ?? diff --git a/packages/managers/src/index.ts b/packages/managers/src/index.ts index 571c0889085..37edfee10d6 100644 --- a/packages/managers/src/index.ts +++ b/packages/managers/src/index.ts @@ -18,6 +18,7 @@ export type { } from './composition/plan'; export { FAMILY_PACKAGES, + MIN_RUNTIME_VERSION, RUNTIME_FAMILY, resolveRuntimeFamily, } from './composition/family'; From b7a13a4da9366de0c61a2af3d88f2b51b36c3720 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:39:15 +0000 Subject: [PATCH 08/38] feat(rspack)!: compose the federation runtime by default The wrapper always plans and redirects the native bundler runtime to the composed entry, defines only ENV_TARGET, fails the build for a runtime family that is too old, and keeps the full runtime without a warning for an externalized runtime. --- .../rspack/__tests__/composedRuntime.spec.ts | 21 +++++-- packages/rspack/src/ComposedRuntimePlugin.ts | 61 +++++++++++-------- packages/rspack/src/ModuleFederationPlugin.ts | 44 ++++++------- 3 files changed, 70 insertions(+), 56 deletions(-) diff --git a/packages/rspack/__tests__/composedRuntime.spec.ts b/packages/rspack/__tests__/composedRuntime.spec.ts index 59bd887ba76..e7170e06ff7 100644 --- a/packages/rspack/__tests__/composedRuntime.spec.ts +++ b/packages/rspack/__tests__/composedRuntime.spec.ts @@ -107,7 +107,9 @@ function expectComposed(b: Build, name: string) { expect.stringMatching(new RegExp(`/${name}\\.[0-9a-f]{12}\\.mjs$`)), ]); expect(b.modules.filter((m) => LEGACY_ENTRY.test(m))).toEqual([]); - expect(b.warnings.filter((w) => w.includes('composedRuntime'))).toEqual([]); + expect( + b.warnings.filter((w) => w.includes('full federation runtime')), + ).toEqual([]); } function serve(root: string): Promise { @@ -249,15 +251,26 @@ describe('experiments.composedRuntime', () => { ); expect(b.warnings).toEqual([ expect.stringContaining( - 'this @rspack/core has no experiments.VirtualModulesPlugin', + 'This build uses the full federation runtime because this @rspack/core has no experiments.VirtualModulesPlugin', ), ]); }); - it('keeps the full runtime without the experiment', async () => { + it('composes by default', async () => { const [b] = await harness([{ out: 'off/host', target: 'web', mf: host() }]); + expectComposed(b, 'host'); + expect(b.warnings).toEqual([]); + }); + + it('keeps the full runtime, without a warning, for experiments.externalRuntime', async () => { + const [b] = await harness([ + { + out: 'external/host', + target: 'web', + mf: host({ experiments: { externalRuntime: true } }), + }, + ]); expect(composedEntries(b)).toEqual([]); - expect(mainCode('off/host')).not.toContain('FEDERATION_BUILD_IDENTIFIER'); expect(b.modules.some((m) => LEGACY_ENTRY.test(m))).toBe(true); expect(b.warnings).toEqual([]); }); diff --git a/packages/rspack/src/ComposedRuntimePlugin.ts b/packages/rspack/src/ComposedRuntimePlugin.ts index 620f169b829..84ab012b777 100644 --- a/packages/rspack/src/ComposedRuntimePlugin.ts +++ b/packages/rspack/src/ComposedRuntimePlugin.ts @@ -10,6 +10,7 @@ import { type AdapterName, type Participant, type RuntimeFamily, + type RuntimeMode, } from '@module-federation/managers'; import type { moduleFederationPlugin } from '@module-federation/sdk'; @@ -52,13 +53,12 @@ export class ComposedRuntimePlugin { runtime: string; }, private readonly _buildId: string, - private readonly _defines: Record, ) {} apply(compiler: Compiler): void { let composition: Composition | undefined; - let deciding: Promise | undefined; - let legacyReason: string | undefined; + let deciding: Promise | undefined; + let mode: RuntimeMode | undefined; // VirtualModulesPlugin reads its modules in afterEnvironment. compiler.hooks.environment.tap(PLUGIN_NAME, () => { @@ -67,21 +67,35 @@ export class ComposedRuntimePlugin { // rspack reads resolve.alias and its builtin plugins when it creates its native // compiler, after beforeRun and watchRun. const decide = async () => { - deciding ??= this._decide(compiler, composition!).then((reason) => { - this._applyDefines(compiler, reason === undefined); - return reason; - }); - legacyReason = await deciding; + deciding ??= this._decide(compiler, composition!); + mode = await deciding; }; compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, decide); compiler.hooks.watchRun.tapPromise(PLUGIN_NAME, decide); compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - if (legacyReason === undefined) return; - compilation.warnings.push( - new compiler.rspack.WebpackError( - `experiments.composedRuntime is set, but this build uses the full federation runtime because ${legacyReason}.`, - ), - ); + const { WebpackError } = compiler.rspack; + if (mode?.mode === 'unsupported') { + compilation.errors.push( + new WebpackError( + `The federation runtime cannot be composed: ${mode.reason}.`, + ), + ); + return; + } + const { externalRuntime, provideExternalRuntime } = + this._options.experiments ?? {}; + // An externalized runtime is the full runtime by request; the other reasons are worth a warning. + if ( + mode?.mode === 'legacy' && + !externalRuntime && + !provideExternalRuntime + ) { + compilation.warnings.push( + new WebpackError( + `This build uses the full federation runtime because ${mode.reason}.`, + ), + ); + } }); } @@ -120,7 +134,7 @@ export class ComposedRuntimePlugin { private async _decide( compiler: Compiler, { family, bootstrapPath, renderError }: Composition, - ): Promise { + ): Promise { const { resolve } = compiler.options; const alias = (resolve.alias ?? {}) as Record; const mode = await selectMode(family, { @@ -133,10 +147,13 @@ export class ComposedRuntimePlugin { compiler.rspack.experiments.VirtualModulesPlugin, ), }); - if (mode.mode === 'legacy') return mode.reason; + if (mode.mode !== 'composed') return mode; if (!bootstrapPath) throw renderError; if (alias[this._native.bundlerRuntime] !== undefined) { - return `resolve.alias already maps ${this._native.bundlerRuntime}`; + return { + mode: 'legacy', + reason: `resolve.alias already maps ${this._native.bundlerRuntime}`, + }; } const runtime = alias[RUNTIME]; resolve.alias = { @@ -146,15 +163,7 @@ export class ComposedRuntimePlugin { [RUNTIME]: resolveRuntimeEsm(family), }), } as typeof resolve.alias; - return undefined; - } - - private _applyDefines(compiler: Compiler, composed: boolean) { - const { DefinePlugin } = compiler.rspack; - if (!composed) return new DefinePlugin(this._defines).apply(compiler); - const { ENV_TARGET } = this._defines; - if (ENV_TARGET !== undefined) - new DefinePlugin({ ENV_TARGET }).apply(compiler); + return mode; } } diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index 693057d5cf3..1ace221ed35 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -100,9 +100,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { this._options = options; } - private _patchBundlerConfig( - compiler: Compiler, - ): Record { + private _patchBundlerConfig(compiler: Compiler): void { const { experiments } = this._options; const definePluginOptions: Record = {}; @@ -124,10 +122,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { } // No inference for ENV_TARGET. If not manually set and valid, it's not defined. - if (!experiments?.composedRuntime) { - new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); - } - return definePluginOptions; + new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); } private _checkSingleton(compiler: Compiler): void { @@ -158,7 +153,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { throw new Error('[ ModuleFederationPlugin ]: name is required'); } this._checkSingleton(compiler); - const defines = this._patchBundlerConfig(compiler); + this._patchBundlerConfig(compiler); const containerManager = new ContainerManager(); containerManager.init(options); @@ -218,24 +213,21 @@ export class ModuleFederationPlugin implements RspackPluginInstance { options as unknown as ModuleFederationPluginOptions, ).apply(compiler); - if (options.experiments?.composedRuntime) { - new ComposedRuntimePlugin( - options, - { - runtimeTools: implementationPath, - // The same resolutions as rspack's native plugin, which imports bundlerRuntime. - bundlerRuntime: require.resolve( - '@module-federation/webpack-bundler-runtime', - { paths: [implementationPath] }, - ), - runtime: require.resolve('@module-federation/runtime', { - paths: [implementationPath], - }), - }, - composeKeyWithSeparator(options.name, utils.getBuildVersion()), - defines, - ).apply(compiler); - } + new ComposedRuntimePlugin( + options, + { + runtimeTools: implementationPath, + // The same resolutions as rspack's native plugin, which imports bundlerRuntime. + bundlerRuntime: require.resolve( + '@module-federation/webpack-bundler-runtime', + { paths: [implementationPath] }, + ), + runtime: require.resolve('@module-federation/runtime', { + paths: [implementationPath], + }), + }, + composeKeyWithSeparator(options.name, utils.getBuildVersion()), + ).apply(compiler); if (!disableManifest) { this._statsPlugin = new StatsPlugin(options, { From ca1e8392c8f330c8938ef52a20bf225e20d9170d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:40:45 +0000 Subject: [PATCH 09/38] feat(enhanced,rspack,sdk)!: remove experiments.composedRuntime The composed runtime is the only path, so the option is gone from the sdk type and the enhanced schema. The schema allows unknown experiments, so a config that still sets it builds unchanged. --- .../container/ModuleFederationPlugin.check.ts | 275 ++++++++---------- .../container/ModuleFederationPlugin.json | 4 - .../container/ModuleFederationPlugin.ts | 5 - .../default/webpack.config.js | 1 - .../src/composition/checkFederationGraph.ts | 2 +- .../rspack/__tests__/composedRuntime.spec.ts | 33 +-- .../types/plugins/ModuleFederationPlugin.ts | 5 - 7 files changed, 139 insertions(+), 186 deletions(-) diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts index e564c8b5591..bf811930a68 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts @@ -463,7 +463,6 @@ const t = { type: 'object', properties: { asyncStartup: { type: 'boolean' }, - composedRuntime: { type: 'boolean' }, externalRuntime: { type: 'boolean' }, provideExternalRuntime: { type: 'boolean' }, optimization: { @@ -3751,8 +3750,8 @@ function D( : u.push(e), c++); } - var R = i === c; - if (((o = o || R), !o)) { + var L = i === c; + if (((o = o || L), !o)) { const e = c; if (c === e) if ( @@ -3779,9 +3778,9 @@ function D( : u.push(e), c++); } - var L = e === c; - } else L = !0; - if (L) { + var E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.abortOnError @@ -3801,9 +3800,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.remoteTypesFolder @@ -3823,9 +3822,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.deleteTypesFolder @@ -3845,9 +3844,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.maxRetries @@ -3871,9 +3870,9 @@ function D( ), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.consumeAPITypes @@ -3900,9 +3899,9 @@ function D( ), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.runtimePkgs @@ -3978,11 +3977,11 @@ function D( ), c++); } - L = + E = t === c; } else - L = !0; - if (L) { + E = !0; + if (E) { if ( void 0 !== r.remoteTypeUrls @@ -4018,13 +4017,13 @@ function D( ), c++); } - var E = + var R = o === c; if ( ((s = s || - E), + R), !s) ) { const t = @@ -4280,12 +4279,12 @@ function D( ), c++); } - ((E = + ((R = t === c), (s = s || - E)); + R)); } if (s) ((c = @@ -4314,12 +4313,12 @@ function D( ), c++); } - L = + E = t === c; } else - L = !0; - if (L) { + E = !0; + if (E) { if ( void 0 !== r.timeout @@ -4348,13 +4347,13 @@ function D( ), c++); } - L = + E = e === c; } else - L = + E = !0; - if (L) { + if (E) { if ( void 0 !== r.family @@ -4397,13 +4396,13 @@ function D( ), c++); } - L = + E = n === c; } else - L = + E = !0; - if (L) + if (E) if ( void 0 !== r.typesOnBuild @@ -4432,11 +4431,11 @@ function D( ), c++); } - L = + E = e === c; } else - L = + E = !0; } } @@ -4458,8 +4457,8 @@ function D( : u.push(e), c++); } - ((R = e === c), - (o = o || R)); + ((L = e === c), + (o = o || L)); } if (o) ((c = s), @@ -4651,12 +4650,12 @@ function D( } else $ = !0; if ($) { if ( - void 0 !== e.composedRuntime + void 0 !== e.externalRuntime ) { const t = c; if ( 'boolean' != - typeof e.composedRuntime + typeof e.externalRuntime ) return ( (D.errors = [ @@ -4672,12 +4671,13 @@ function D( } else $ = !0; if ($) { if ( - void 0 !== e.externalRuntime + void 0 !== + e.provideExternalRuntime ) { const t = c; if ( 'boolean' != - typeof e.externalRuntime + typeof e.provideExternalRuntime ) return ( (D.errors = [ @@ -4691,83 +4691,82 @@ function D( ); $ = t === c; } else $ = !0; - if ($) { + if ($) if ( - void 0 !== - e.provideExternalRuntime + void 0 !== e.optimization ) { - const t = c; - if ( - 'boolean' != - typeof e.provideExternalRuntime - ) - return ( - (D.errors = [ - { - params: { - type: 'boolean', + let r = e.optimization; + const n = c; + if (c === n) { + if ( + !r || + 'object' != typeof r || + Array.isArray(r) + ) + return ( + (D.errors = [ + { + params: { + type: 'object', + }, }, - }, - ]), - !1 - ); - $ = t === c; - } else $ = !0; - if ($) - if ( - void 0 !== e.optimization - ) { - let r = e.optimization; - const n = c; - if (c === n) { - if ( - !r || - 'object' != - typeof r || - Array.isArray(r) - ) - return ( - (D.errors = [ - { - params: { - type: 'object', + ]), + !1 + ); + { + const e = c; + for (const e in r) + if ( + 'disableSnapshot' !== + e && + 'disableRemote' !== + e && + 'disableShared' !== + e && + 'target' !== e + ) + return ( + (D.errors = [ + { + params: { + additionalProperty: + e, + }, }, - }, - ]), - !1 - ); - { - const e = c; - for (const e in r) + ]), + !1 + ); + if (e === c) { + if ( + void 0 !== + r.disableSnapshot + ) { + const e = c; if ( - 'disableSnapshot' !== - e && - 'disableRemote' !== - e && - 'disableShared' !== - e && - 'target' !== e + 'boolean' != + typeof r.disableSnapshot ) return ( (D.errors = [ { params: { - additionalProperty: - e, + type: 'boolean', }, }, ]), !1 ); - if (e === c) { + var I = e === c; + } else I = !0; + if (I) { if ( void 0 !== - r.disableSnapshot + r.disableRemote ) { const e = c; if ( 'boolean' != - typeof r.disableSnapshot + typeof r.disableRemote ) return ( (D.errors = [ @@ -4779,17 +4778,17 @@ function D( ]), !1 ); - var I = e === c; + I = e === c; } else I = !0; if (I) { if ( void 0 !== - r.disableRemote + r.disableShared ) { const e = c; if ( 'boolean' != - typeof r.disableRemote + typeof r.disableShared ) return ( (D.errors = @@ -4805,15 +4804,18 @@ function D( ); I = e === c; } else I = !0; - if (I) { + if (I) if ( void 0 !== - r.disableShared + r.target ) { - const e = c; + let e = + r.target; + const n = c; if ( - 'boolean' != - typeof r.disableShared + 'web' !== + e && + 'node' !== e ) return ( (D.errors = @@ -4821,58 +4823,29 @@ function D( { params: { - type: 'boolean', + allowedValues: + t + .properties + .experiments + .properties + .optimization + .properties + .target + .enum, }, }, ]), !1 ); - I = e === c; + I = n === c; } else I = !0; - if (I) - if ( - void 0 !== - r.target - ) { - let e = - r.target; - const n = c; - if ( - 'web' !== - e && - 'node' !== - e - ) - return ( - (D.errors = - [ - { - params: - { - allowedValues: - t - .properties - .experiments - .properties - .optimization - .properties - .target - .enum, - }, - }, - ]), - !1 - ); - I = n === c; - } else I = !0; - } } } } } - $ = n === c; - } else $ = !0; - } + } + $ = n === c; + } else $ = !0; } } } diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json index 7482a90ce3e..42acc6b9ff2 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json @@ -873,10 +873,6 @@ "description": "Enable async startup for the container", "type": "boolean" }, - "composedRuntime": { - "description": "Generate a federation bootstrap that imports only the runtime capabilities this build uses. Falls back to the full runtime when the installed runtime cannot be composed.", - "type": "boolean" - }, "externalRuntime": { "description": "Externalize '@module-federation/runtime-core' and expect global '_FEDERATION_RUNTIME_CORE' at runtime.", "type": "boolean" diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts index a931f8d9d26..0a101950abf 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts @@ -971,11 +971,6 @@ export default { description: 'Enable async startup for the container', type: 'boolean', }, - composedRuntime: { - description: - 'Generate a federation bootstrap that imports only the runtime capabilities this build uses. Falls back to the full runtime when the installed runtime cannot be composed.', - type: 'boolean', - }, externalRuntime: { description: "Externalize '@module-federation/runtime-core' and expect global '_FEDERATION_RUNTIME_CORE' at runtime.", diff --git a/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js index cdbb49e7819..d307efbd991 100644 --- a/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js @@ -19,7 +19,6 @@ module.exports = { requiredVersion: '^1.0.0', }, }, - experiments: { composedRuntime: true }, }), ], }; diff --git a/packages/managers/src/composition/checkFederationGraph.ts b/packages/managers/src/composition/checkFederationGraph.ts index 08ec5774910..80cd57ffa89 100644 --- a/packages/managers/src/composition/checkFederationGraph.ts +++ b/packages/managers/src/composition/checkFederationGraph.ts @@ -63,7 +63,7 @@ export function checkFederationGraph( for (const request of [...new Set(summary.externalRequests)].sort()) { if (familyPackageOf(request)) { errors.push( - `"${request}" is external, but the composed federation bootstrap imports the runtime. Remove it from externals or disable experiments.composedRuntime.`, + `"${request}" is external, but the composed federation bootstrap imports the runtime. Remove it from externals, or set experiments.externalRuntime to externalize the whole runtime.`, ); } } diff --git a/packages/rspack/__tests__/composedRuntime.spec.ts b/packages/rspack/__tests__/composedRuntime.spec.ts index e7170e06ff7..e3bff6360c4 100644 --- a/packages/rspack/__tests__/composedRuntime.spec.ts +++ b/packages/rspack/__tests__/composedRuntime.spec.ts @@ -66,11 +66,6 @@ async function harness( return results; } -const composed = (experiments: Record = {}) => ({ - composedRuntime: true, - ...experiments, -}); - const remote = ( target: 'node' | 'web', extra: Record = {}, @@ -127,7 +122,7 @@ function serve(root: string): Promise { ); } -describe('experiments.composedRuntime', () => { +describe('composed runtime', () => { it('runs a composed host against a composed remote with one shared singleton', async () => { const server = await serve(outRoot); try { @@ -137,13 +132,13 @@ describe('experiments.composedRuntime', () => { out: 'run/remote', target: 'node', singleChunk: true, - mf: remote('node', { shared: SHARED, experiments: composed() }), + mf: remote('node', { shared: SHARED }), }, { out: 'run/host', target: 'node', mf: host( - { shared: SHARED, experiments: composed() }, + { shared: SHARED }, `http://127.0.0.1:${port}/run/remote/remoteEntry.js`, ), }, @@ -172,9 +167,9 @@ describe('experiments.composedRuntime', () => { out: 'graph/host', target: 'web', mf: host({ - experiments: composed({ + experiments: { optimization: { disableShared: true, disableSnapshot: true }, - }), + }, }), }, ]); @@ -207,9 +202,9 @@ describe('experiments.composedRuntime', () => { out: 'graph/remote', target: 'web', mf: remote('web', { - experiments: composed({ + experiments: { optimization: { disableRemote: true, disableShared: true }, - }), + }, }), }, ]); @@ -241,7 +236,7 @@ describe('experiments.composedRuntime', () => { out: 'legacy/host', target: 'web', noVirtualModules: true, - mf: host({ experiments: composed() }), + mf: host(), }, ]); expect(composedEntries(b)).toEqual([]); @@ -281,7 +276,7 @@ describe('experiments.composedRuntime', () => { { out: 'watch/host', target: 'web', - mf: host({ experiments: composed() }), + mf: host(), }, ], { watch: true }, @@ -298,18 +293,18 @@ describe('experiments.composedRuntime', () => { out: 'multi/host', target: 'web', mf: host({ - experiments: composed({ + experiments: { optimization: { disableShared: true, disableSnapshot: true }, - }), + }, }), }, { out: 'multi/remote', target: 'web', mf: remote('web', { - experiments: composed({ + experiments: { optimization: { disableRemote: true, disableShared: true }, - }), + }, }), }, ], @@ -337,7 +332,7 @@ describe('experiments.composedRuntime', () => { out: `cache/${i}`, target: 'web', cacheDir, - mf: host({ experiments: composed({ optimization: plans[key] }) }), + mf: host({ experiments: { optimization: plans[key] } }), }, ]); expectComposed(b, 'host'); diff --git a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts index 5aebb29acf8..c129a8db928 100644 --- a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts +++ b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts @@ -484,11 +484,6 @@ export interface ModuleFederationPluginOptions { dts?: boolean | PluginDtsOptions; virtualRuntimeEntry?: boolean; experiments?: { - /** - * Generate a federation bootstrap that imports only the runtime capabilities this build uses. - * Falls back to the full runtime when the installed runtime cannot be composed. - */ - composedRuntime?: boolean; externalRuntime?: boolean; provideExternalRuntime?: boolean; asyncStartup?: boolean; From 940685d5e65e5836246992c9d98dc0bacb5cfb8b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:42:03 +0000 Subject: [PATCH 10/38] docs: replace the removed runtime defines with runtime/compose The runtime size guide now shows how to compose the runtime from subpath imports in a project without ModuleFederationPlugin, and disableSnapshot is described in terms of the generated bootstrap. --- .../docs/en/configure/experiments.mdx | 2 +- .../advanced/runtime-size-optimization.mdx | 48 +++++-------------- .../docs/pt-BR/configure/experiments.mdx | 2 +- .../advanced/runtime-size-optimization.mdx | 48 +++++-------------- .../docs/zh/configure/experiments.mdx | 2 +- .../advanced/runtime-size-optimization.mdx | 48 +++++-------------- 6 files changed, 42 insertions(+), 108 deletions(-) diff --git a/apps/website-new/docs/en/configure/experiments.mdx b/apps/website-new/docs/en/configure/experiments.mdx index 5c85dff1d08..333e6cf0134 100644 --- a/apps/website-new/docs/en/configure/experiments.mdx +++ b/apps/website-new/docs/en/configure/experiments.mdx @@ -53,7 +53,7 @@ This object contains flags related to build-time optimizations that can affect t - Required: No - Default: `false` -When set to `true`, this option defines the `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` global constant as `true` during the build. In the `@module-federation/runtime-core`, this prevents the `snapshotPlugin()` and `generatePreloadAssetsPlugin()` from being included and initialized within the Module Federation instance. +When set to `true`, the federation bootstrap that the plugin generates does not import the snapshot capability of `@module-federation/runtime-core`. The `snapshotPlugin()` and `generatePreloadAssetsPlugin()` are then not in the build and are not initialized within the Module Federation instance. **Impact:** diff --git a/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx index 75213ffe767..8c4dcc0674e 100644 --- a/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ For Webpack, change the import to `@module-federation/enhanced/webpack`. This combination is suitable for a minimal producer that only exposes modules, does not consume other Remotes, does not use shared dependencies, and does not depend on Manifest / Snapshot data. Start from the defaults and enable only options that are safe for your build. -### Configure a runtime-only project with environment variables +### Compose the runtime in a project without ModuleFederationPlugin -If your project bundles `@module-federation/runtime` or `@module-federation/runtime-core` directly without `ModuleFederationPlugin`, use environment variables and replace them with build-time booleans in your bundler configuration. +If your project bundles the runtime directly without `ModuleFederationPlugin`, import only the capabilities it uses from `@module-federation/runtime/compose`. A capability that you do not import is not in the module graph, in any optimization mode. -For example, a project that does not consume Remotes, use shared dependencies or Snapshot data, and has no `exposes` can build with: +For example, a web host that only consumes Remotes: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -Webpack must replace the values explicitly: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -With Rspack, pass the same `federationDefines` object to `rspack.DefinePlugin`. With Vite, pass it to the top-level `define` option. +Add `shared` from `@module-federation/runtime-core/shared` and `snapshot` from `@module-federation/runtime-core/snapshot` when the build needs them. `import { init } from '@module-federation/runtime'` still gives the full runtime. -Leaving environment variables as runtime strings is not enough. The bundler must replace them with `true` or `false` at build time so the minifier can remove the corresponding code. All capabilities remain enabled when no value is provided, preserving existing behavior. +:::info +The `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, and `FEDERATION_HAS_EXPOSES` defines were removed. The runtime no longer reads them. +::: ## What each option removes diff --git a/apps/website-new/docs/pt-BR/configure/experiments.mdx b/apps/website-new/docs/pt-BR/configure/experiments.mdx index 1556fa1a6d8..6ca30ee5edc 100644 --- a/apps/website-new/docs/pt-BR/configure/experiments.mdx +++ b/apps/website-new/docs/pt-BR/configure/experiments.mdx @@ -53,7 +53,7 @@ Este objeto contains flags related para build-time optimizations esse pode affec - Obrigatório: No - Padrão: `false` -Quando definir para `true`, este opção defines o `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` global constant as `true` during o build. No `@module-federation/runtime-core`, este prevents o `snapshotPlugin()` e `generatePreloadAssetsPlugin()` de being included e initialized within o Module Federation instance. +Quando definida como `true`, o bootstrap de federation gerado pelo plugin não importa a capacidade de snapshot do `@module-federation/runtime-core`. Assim, `snapshotPlugin()` e `generatePreloadAssetsPlugin()` não entram no build e não são inicializados na instância do Module Federation. **Impact:** diff --git a/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx index 240ec0c983e..b307d249dd8 100644 --- a/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ Para Webpack, altere o import para `@module-federation/enhanced/webpack`. Essa combinação é adequada para um produtor mínimo que apenas expõe módulos, não consome outros Remotes, não usa dependências compartilhadas e não depende de Manifest / Snapshot. Comece com os valores padrão e habilite somente opções que sejam seguras para o seu build. -### Configurar um projeto que usa apenas o runtime com variáveis de ambiente +### Compor o runtime em um projeto sem ModuleFederationPlugin -Se o projeto empacota `@module-federation/runtime` ou `@module-federation/runtime-core` diretamente, sem `ModuleFederationPlugin`, use variáveis de ambiente e substitua-as por valores booleanos durante o build na configuração do bundler. +Se o seu projeto empacota o runtime diretamente, sem `ModuleFederationPlugin`, importe de `@module-federation/runtime/compose` apenas as capacidades que ele usa. Uma capacidade que você não importa não entra no grafo de módulos, em nenhum modo de otimização. -Por exemplo, um projeto que não consome Remotes, não usa dependências compartilhadas nem Snapshot e não tem `exposes` pode usar: +Por exemplo, um host web que apenas consome Remotes: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -No Webpack, os valores devem ser substituídos explicitamente: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -No Rspack, passe o mesmo objeto `federationDefines` para `rspack.DefinePlugin`. No Vite, passe-o para a opção `define` no nível principal. +Adicione `shared` de `@module-federation/runtime-core/shared` e `snapshot` de `@module-federation/runtime-core/snapshot` quando o build precisar deles. `import { init } from '@module-federation/runtime'` continua fornecendo o runtime completo. -Não basta deixar as variáveis de ambiente como strings no código executado. O bundler deve substituí-las por `true` ou `false` durante o build para que o minificador remova o código correspondente. Quando nenhum valor é fornecido, todos os recursos continuam habilitados, preservando o comportamento existente. +:::info +Os defines `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` e `FEDERATION_HAS_EXPOSES` foram removidos. O runtime não os lê mais. +::: ## O que cada opção remove diff --git a/apps/website-new/docs/zh/configure/experiments.mdx b/apps/website-new/docs/zh/configure/experiments.mdx index dd4e9357cb3..4b56867656a 100644 --- a/apps/website-new/docs/zh/configure/experiments.mdx +++ b/apps/website-new/docs/zh/configure/experiments.mdx @@ -53,7 +53,7 @@ - 是否必填:否 - 默认值:`false` -当设置为 `true` 时,该选项会在构建期间将全局常量 `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` 定义为 `true`。在 `@module-federation/runtime-core` 中,这会阻止 `snapshotPlugin()` 与 `generatePreloadAssetsPlugin()` 被打包并在 Module Federation 实例中初始化。 +当设置为 `true` 时,插件生成的 federation 启动代码不会导入 `@module-federation/runtime-core` 的 snapshot 能力,因此 `snapshotPlugin()` 与 `generatePreloadAssetsPlugin()` 不会被打包,也不会在 Module Federation 实例中初始化。 **影响:** diff --git a/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx index d5eb498de2c..14950ed127a 100644 --- a/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ export default { 上面的组合适用于一个非常精简的纯生产者:它只对外提供模块,不消费其他 Remote、不使用共享依赖,也不依赖 Manifest / Snapshot。实际项目应从默认值开始,只打开已经确认安全的选项。 -### 纯 Runtime 项目通过环境变量配置 +### 不使用 ModuleFederationPlugin 时按需组合 Runtime -如果项目直接打包 `@module-federation/runtime` 或 `@module-federation/runtime-core`,没有使用上面的 `ModuleFederationPlugin`,可以用环境变量控制构建,并在打包配置中把环境变量替换为编译期布尔值。 +如果项目不使用 `ModuleFederationPlugin`,而是直接打包 Runtime,请从 `@module-federation/runtime/compose` 只导入项目用到的能力。没有导入的能力不会进入模块图,与优化模式无关。 -例如,一个不消费 Remote、不使用共享依赖和 Snapshot,也不包含 `exposes` 的项目可以这样构建: +例如,一个只消费 Remote 的 Web Host: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -Webpack 配置需要显式完成替换: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -Rspack 使用 `rspack.DefinePlugin` 传入同一份 `federationDefines`。Vite 可以把它传给顶层 `define` 配置。 +构建需要时,再从 `@module-federation/runtime-core/shared` 导入 `shared`,从 `@module-federation/runtime-core/snapshot` 导入 `snapshot`。`import { init } from '@module-federation/runtime'` 仍然提供完整的 Runtime。 -环境变量不能直接作为字符串留在运行时代码中,必须在构建时替换成 `true` 或 `false`,压缩工具才能删除对应代码。没有显式配置时,上述能力都会保持开启,兼容现有行为。 +:::info +`FEDERATION_OPTIMIZE_NO_REMOTE`、`FEDERATION_OPTIMIZE_NO_SHARED`、`FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` 和 `FEDERATION_HAS_EXPOSES` 已被移除,Runtime 不再读取它们。 +::: ## 每个开关会移除什么 From cc8cf9a7887ba2dbad2dd1544591989f58773f31 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:45:07 +0000 Subject: [PATCH 11/38] refactor(managers,enhanced,rspack): let selectMode mark a requested full runtime selectMode flags the legacy mode that experiments.externalRuntime or provideExternalRuntime asks for, so both plugins skip the warning from one field instead of re-reading the experiments. The older-family test drops @ts-nocheck and types its stats. --- .../runtime/FederationCompositionPlugin.ts | 31 ++++++------------- .../container/olderRuntimeFamily.test.ts | 8 ++--- .../__tests__/composition/selectMode.spec.ts | 6 +++- .../managers/src/composition/selectMode.ts | 12 +++++-- packages/rspack/src/ComposedRuntimePlugin.ts | 9 +----- 5 files changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts index 8217d3feec6..6fa8c2f0278 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts @@ -13,6 +13,7 @@ import { type GraphModule, type Participant, type RuntimeFamily, + type RuntimeMode, } from '@module-federation/managers'; import { composeKeyWithSeparator, @@ -38,9 +39,8 @@ export interface ComposedEntry { interface Outcome { family: RuntimeFamily; + mode: RuntimeMode; entry?: ComposedEntry; - legacyReason?: string; - unsupportedReason?: string; } export interface CompositionSlot { @@ -160,17 +160,14 @@ class FederationCompositionPlugin { alias: compiler.options.resolve.alias as never, aliasExemptions: this._ownAliasTargets(compiler), }); - if (mode.mode === 'unsupported') { - return { family, unsupportedReason: mode.reason }; - } - if (mode.mode === 'legacy') return { family, legacyReason: mode.reason }; + if (mode.mode !== 'composed') return { family, mode }; const composition = renderComposition( plan, resolveImports(plan, family), this._buildId(compiler), ); slot.entry = { ...this._createEntry(composition), adapters: plan.adapters }; - return { family, entry: slot.entry }; + return { family, mode, entry: slot.entry }; } // FederationRuntimePlugin writes these aliases itself; they are not user aliases. @@ -196,29 +193,19 @@ class FederationCompositionPlugin { : undefined; } - private _check( - compilation: Compilation, - { family, entry, legacyReason, unsupportedReason }: Outcome, - ) { - if (unsupportedReason !== undefined) { + private _check(compilation: Compilation, { family, mode, entry }: Outcome) { + if (mode.mode === 'unsupported') { compilation.errors.push( new WebpackError( - `The federation runtime cannot be composed: ${unsupportedReason}.`, + `The federation runtime cannot be composed: ${mode.reason}.`, ), ); return; } - const { externalRuntime, provideExternalRuntime } = - this._options.experiments ?? {}; - // An externalized runtime is the full runtime by request; the other reasons are worth a warning. - if ( - legacyReason !== undefined && - !externalRuntime && - !provideExternalRuntime - ) { + if (mode.mode === 'legacy' && !mode.requested) { compilation.warnings.push( new WebpackError( - `This build uses the full federation runtime because ${legacyReason}.`, + `This build uses the full federation runtime because ${mode.reason}.`, ), ); } diff --git a/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts b/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts index 2e80f6886f1..4af538291af 100644 --- a/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts +++ b/packages/enhanced/test/compiler-unit/container/olderRuntimeFamily.test.ts @@ -1,10 +1,10 @@ -// @ts-nocheck /* * @rstest-environment node */ import fs from 'fs'; import os from 'os'; import path from 'path'; +import type { Stats } from 'webpack'; import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; @@ -36,7 +36,7 @@ describe('an older runtime family', () => { context = fs.mkdtempSync(path.join(os.tmpdir(), 'mf-older-family-')); fs.writeFileSync(path.join(context, 'index.js'), 'export default 1;'); - const stats = await new Promise((resolve, reject) => + const stats = await new Promise((resolve, reject) => webpack( { context, @@ -57,12 +57,12 @@ describe('an older runtime family', () => { }), ], }, - (err, result) => (err ? reject(err) : resolve(result)), + (err, result) => (err ? reject(err) : resolve(result!)), ), ); const errors = stats.toJson({ all: false, errors: true }).errors; - expect(errors.map(({ message }) => message)).toEqual([ + expect(errors?.map(({ message }) => message)).toEqual([ expect.stringMatching( new RegExp( `webpack-bundler-runtime at .* does not export "\\./compose"; the federation runtime packages must be ${MIN_RUNTIME_VERSION.replace(/\./g, '\\.')} or newer`, diff --git a/packages/managers/__tests__/composition/selectMode.spec.ts b/packages/managers/__tests__/composition/selectMode.spec.ts index 5b8ff86e0ed..bd895a4693b 100644 --- a/packages/managers/__tests__/composition/selectMode.spec.ts +++ b/packages/managers/__tests__/composition/selectMode.spec.ts @@ -92,7 +92,11 @@ describe('selectMode', () => { ['externalRuntime', { externalRuntime: true }], ['provideExternalRuntime', { provideExternalRuntime: true }], ])('selects legacy on experiments.%s', async (_, experiments) => { - expect(await legacyReason({ experiments })).toMatch(/experiments\./); + expect(await selectMode(composable(), { experiments })).toEqual({ + mode: 'legacy', + reason: expect.stringMatching(/experiments\./), + requested: true, + }); }); describe('externals', () => { diff --git a/packages/managers/src/composition/selectMode.ts b/packages/managers/src/composition/selectMode.ts index 4e88aa82f96..5e8d9a9d454 100644 --- a/packages/managers/src/composition/selectMode.ts +++ b/packages/managers/src/composition/selectMode.ts @@ -30,7 +30,12 @@ export interface ModeInputs { export type RuntimeMode = | { mode: 'composed' } - | { mode: 'legacy'; reason: string } + | { + mode: 'legacy'; + reason: string; + /** Set when experiments.externalRuntime or provideExternalRuntime asks for the full runtime. */ + requested?: true; + } | { mode: 'unsupported'; reason: string }; const hasOwn = (object: object, key: string) => @@ -47,8 +52,11 @@ export async function selectMode( reason: `${problem}; the federation runtime packages must be ${MIN_RUNTIME_VERSION} or newer`, }; } + const requested = experimentProblem(inputs); + if (requested !== undefined) { + return { mode: 'legacy', reason: requested, requested: true }; + } const reason = - experimentProblem(inputs) ?? (await externalsProblem(inputs)) ?? aliasProblem(inputs) ?? (inputs.virtualModulesPlugin === false diff --git a/packages/rspack/src/ComposedRuntimePlugin.ts b/packages/rspack/src/ComposedRuntimePlugin.ts index 84ab012b777..49f7b8d4edf 100644 --- a/packages/rspack/src/ComposedRuntimePlugin.ts +++ b/packages/rspack/src/ComposedRuntimePlugin.ts @@ -82,14 +82,7 @@ export class ComposedRuntimePlugin { ); return; } - const { externalRuntime, provideExternalRuntime } = - this._options.experiments ?? {}; - // An externalized runtime is the full runtime by request; the other reasons are worth a warning. - if ( - mode?.mode === 'legacy' && - !externalRuntime && - !provideExternalRuntime - ) { + if (mode?.mode === 'legacy' && !mode.requested) { compilation.warnings.push( new WebpackError( `This build uses the full federation runtime because ${mode.reason}.`, From 7b46e8566988946fe18a95943fb3546ff525f0a0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 14:56:43 +0000 Subject: [PATCH 12/38] fix(runtime-core): keep an empty default instance id Removing the build-id define left options.id undefined for init-created instances. Keep the empty string that getBuilderId returned without the define. --- packages/runtime-core/__tests__/kernel.spec.ts | 2 +- packages/runtime-core/src/core.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/__tests__/kernel.spec.ts b/packages/runtime-core/__tests__/kernel.spec.ts index 2ce0bcfab49..1726bfcee79 100644 --- a/packages/runtime-core/__tests__/kernel.spec.ts +++ b/packages/runtime-core/__tests__/kernel.spec.ts @@ -145,7 +145,7 @@ describe('root ModuleFederation', () => { expect(instance.loadShareSync<{ root: boolean }>('react')()).toEqual({ root: true, }); - expect(instance.options.id).toBeFalsy(); + expect(instance.options.id).toBe(''); expect(Module).toBe(RemoteModule); }); }); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 434d08ab5a9..7e451fe8a8f 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -296,6 +296,7 @@ export class FederationKernel { // TODO: Validate the details of the options // Initialize options with default values const defaultOptions: Options = { + id: '', name: userOptions.name, plugins, remotes: [], From 8bdf981150456c8bb3f337799900efeb93b7f24f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 15:12:00 +0000 Subject: [PATCH 13/38] fix(runtime-core): register the share scope under the id from options The composed bootstrap passes the build id as options.id, but the shared handler registers the global share scope in the constructor, before options are merged. Take the default id from the user options so the scope is keyed by the build id, as the build-id define did. --- packages/runtime-core/__tests__/kernel.spec.ts | 16 ++++++++++++++++ packages/runtime-core/src/core.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/__tests__/kernel.spec.ts b/packages/runtime-core/__tests__/kernel.spec.ts index 1726bfcee79..2b9af1eb63e 100644 --- a/packages/runtime-core/__tests__/kernel.spec.ts +++ b/packages/runtime-core/__tests__/kernel.spec.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, rs } from '@rstest/core'; import { FederationKernel } from '../src/kernel'; import { remote } from '../src/remote/capability'; +import { shared } from '../src/shared/capability'; +import { CurrentGlobal } from '../src/global'; import { PLATFORM_UNAVAILABLE_MESSAGE } from '../src/platform/unavailable'; import { Module, ModuleFederation } from '../src'; import { Module as RemoteModule } from '../src/module'; @@ -94,6 +96,20 @@ describe('FederationKernel', () => { expect(kernel.options.remotes).toEqual([]); }); + + it('registers its share scope under the id it is given', () => { + const kernel = new FederationKernel( + { name: 'kernel-share-id', id: 'kernel-share-id:1.0.0' }, + { shared }, + ); + + expect( + CurrentGlobal.__FEDERATION__.__SHARE__['kernel-share-id:1.0.0'], + ).toBe(kernel.shareScopeMap); + expect(CurrentGlobal.__FEDERATION__.__SHARE__['kernel-share-id']).toBe( + undefined, + ); + }); }); describe('root ModuleFederation', () => { diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 43e3692b1ee..99fe8e7217a 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -291,7 +291,7 @@ export class FederationCore { // TODO: Validate the details of the options // Initialize options with default values const defaultOptions: Options = { - id: '', + id: userOptions.id || '', name: userOptions.name, plugins, remotes: [], From f1fe068ddcd2a4fe34860126e3b987168785d85a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 15:12:00 +0000 Subject: [PATCH 14/38] test(enhanced): let the older-family build emit so the test can read its output --- .../compiler-unit/container/FederationCompositionPlugin.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts index 866acf49538..cc9687d06bf 100644 --- a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts @@ -141,6 +141,7 @@ describe('FederationCompositionPlugin', () => { it('fails the build for an older runtime family and names the minimum version', async () => { const context = fixture({ 'index.js': 'export default 1;' }); const { stats } = await compile(context, { + optimization: { minimize: false, emitOnErrors: true }, plugins: [host(undefined, { implementation: olderRuntimeTools() })], }); From 52ce3403859ca1339fb13b3bf057be79e709bc7d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 15:17:38 +0000 Subject: [PATCH 15/38] fix(rspack): plan the consumes adapter for async startup rspack's async startup runtime installs its consumes chunk handler without shared modules and calls bundlerRuntime.consumes, so a composed plan without the adapter crashed at startup. --- packages/rspack/__tests__/composedRuntime.spec.ts | 12 ++++++++++++ packages/rspack/src/ComposedRuntimePlugin.ts | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/rspack/__tests__/composedRuntime.spec.ts b/packages/rspack/__tests__/composedRuntime.spec.ts index 9fd683aab8e..9f56b5945a6 100644 --- a/packages/rspack/__tests__/composedRuntime.spec.ts +++ b/packages/rspack/__tests__/composedRuntime.spec.ts @@ -196,6 +196,18 @@ describe('composed runtime', () => { }); }); + it('keeps the consumes adapter that the async startup runtime calls', async () => { + const [b] = await harness([ + { + out: 'graph/async-host', + target: 'node', + mf: host({ experiments: { asyncStartup: true } }), + }, + ]); + expectComposed(b, 'host'); + expect(parts(b, ['consumes'])).toEqual({ consumes: true }); + }); + it('builds an exposes-only remote without a remote handler, loader, or share handler', async () => { const [b] = await harness([ { diff --git a/packages/rspack/src/ComposedRuntimePlugin.ts b/packages/rspack/src/ComposedRuntimePlugin.ts index b4a2e4b865f..41ebae376ee 100644 --- a/packages/rspack/src/ComposedRuntimePlugin.ts +++ b/packages/rspack/src/ComposedRuntimePlugin.ts @@ -8,6 +8,7 @@ import { resolveRuntimeFamily, optionsParticipant, selectMode, + type Participant, type RuntimeFamily, type RuntimeMode, } from '@module-federation/managers'; @@ -77,8 +78,13 @@ export class ComposedRuntimePlugin { const VirtualModulesPlugin = compiler.rspack.experiments.VirtualModulesPlugin; if (!VirtualModulesPlugin) return { family }; + const participants: Participant[] = [optionsParticipant(this._options)]; + // rspack's async startup runtime installs its consumes handler even without shared modules. + if (this._options.experiments?.asyncStartup) { + participants.push({ kind: 'needs', needs: ['consumes'] }); + } const plan = planComposition( - [optionsParticipant(this._options)], + participants, this._options.experiments?.optimization?.target ?? 'universal', ); let source: string; From 00109f5b6bef1e9867334bdc996dbdc111434b1b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 15:17:38 +0000 Subject: [PATCH 16/38] fix(rstest): keep the aliased bundler runtime bundled The rspack wrapper aliases the native plugin's absolute webpack-bundler-runtime path to a virtual composed module. Rstest externalized that path, so Node tried to import the virtual file from disk. --- packages/rstest/src/externals-bypass.ts | 8 +++++++- packages/rstest/src/index.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/rstest/src/externals-bypass.ts b/packages/rstest/src/externals-bypass.ts index b2b4af3839d..689211ff0b0 100644 --- a/packages/rstest/src/externals-bypass.ts +++ b/packages/rstest/src/externals-bypass.ts @@ -3,6 +3,9 @@ import type { Rspack } from '@rsbuild/core'; import { NODE_RUNTIME_PLUGIN } from './runtime-plugin'; const DATA_JAVASCRIPT_REQUEST = /!=!data:text\/javascript(?:;|,)/i; +// Rspack's native plugin imports this path, and the rspack wrapper aliases it to a virtual module. +const BUNDLER_RUNTIME_REQUEST = + /[\\/]webpack-bundler-runtime[\\/]dist[\\/]index\.c?js$/; const isFederationRemoteRequest = ( request: string, @@ -34,7 +37,10 @@ export const shouldKeepBundledForFederation = ( return true; } - if (request === NODE_RUNTIME_PLUGIN) { + if ( + request === NODE_RUNTIME_PLUGIN || + BUNDLER_RUNTIME_REQUEST.test(request) + ) { return true; } diff --git a/packages/rstest/src/index.test.ts b/packages/rstest/src/index.test.ts index 201187441f6..e55369d4e5a 100644 --- a/packages/rstest/src/index.test.ts +++ b/packages/rstest/src/index.test.ts @@ -292,6 +292,19 @@ describe('shouldKeepBundledForFederation', () => { ).toBe(true); }); + it('keeps the absolute bundler runtime path that rspack aliases to the composed bootstrap bundled', () => { + expect( + shouldKeepBundledForFederation( + '/app/node_modules/@module-federation/webpack-bundler-runtime/dist/index.cjs', + ), + ).toBe(true); + expect( + shouldKeepBundledForFederation( + 'C:\\app\\node_modules\\@module-federation\\webpack-bundler-runtime\\dist\\index.cjs', + ), + ).toBe(true); + }); + it('keeps webpack container reference requests bundled', () => { expect( shouldKeepBundledForFederation( From dd2a305bf6bfaf77f5707225556a158a749b8156 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 15:17:52 +0000 Subject: [PATCH 17/38] chore: list managers and rstest in the major changeset --- .changeset/composed-runtime-default-major.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index 6e6536999bb..0a70d1cc6fa 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -6,6 +6,8 @@ '@module-federation/rspack': major '@module-federation/sdk': major '@module-federation/node': major +'@module-federation/managers': major +'@module-federation/rstest': patch --- The composed federation runtime is the default and only bootstrap. `ModuleFederationPlugin` now always generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. From 5ac49cbd061806e0717f790a9900bae8da416150 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 16:59:06 +0000 Subject: [PATCH 18/38] feat(rspack)!: remove resolveRspackRuntimeAlias The wrapper stopped aliasing @module-federation/runtime$ in the composed runtime work; the deprecated helper is removed in this major. The resolution fallback tests now run against resolveRspackRuntimeImplementation. --- .changeset/composed-runtime-default-major.md | 1 + .../__tests__/ModuleFederationPlugin.spec.ts | 34 +++++++++++-------- packages/rspack/src/ModuleFederationPlugin.ts | 16 --------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index 0a70d1cc6fa..9011716c819 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -17,5 +17,6 @@ Removed: - The `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, `FEDERATION_HAS_EXPOSES`, and `FEDERATION_BUILD_IDENTIFIER` defines. The plugins no longer emit them and the runtime no longer reads them. Use `experiments.optimization.disable*` with the plugin, or import the capabilities you need from `@module-federation/runtime/compose` when you bundle the runtime yourself. - `__webpack_require__.federation.runtime`. Use `__webpack_require__.federation.instance`, and `instance.platform.loadScriptNode` for the Node loader. - The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. +- `resolveRspackRuntimeAlias` from `@module-federation/rspack/plugin`. The wrapper no longer aliases `@module-federation/runtime$`, so the helper has no use. A build whose installed runtime packages do not export the composition subpaths now fails with an error that names the minimum runtime version. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. diff --git a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts index 2e19fc65d29..61d03928d56 100644 --- a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts +++ b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts @@ -1,6 +1,6 @@ +import * as pluginEntry from '../src/plugin'; import { ModuleFederationPlugin, - resolveRspackRuntimeAlias, resolveRspackRuntimeImplementation, } from '../src/ModuleFederationPlugin'; @@ -56,21 +56,21 @@ describe('runtime resolution compatibility', () => { if ( basedFromLegacy && - request === '@module-federation/runtime/bundler' + request === '@module-federation/runtime-tools/bundler' ) { throw new Error(`Cannot find module '${request}'`); } - if (request === '@module-federation/runtime/dist/index.js') { - return '/legacy/runtime/dist/index.js'; + if (request === '@module-federation/runtime-tools/dist/index.js') { + return '/legacy/runtime-tools/dist/index.js'; } throw new Error(`Unexpected request: ${request}`); }, ) as typeof require.resolve; - expect(resolveRspackRuntimeAlias('/legacy/runtime-tools', resolve)).toBe( - '/legacy/runtime/dist/index.js', - ); + expect( + resolveRspackRuntimeImplementation('/legacy/runtime-tools', resolve), + ).toBe('/legacy/runtime-tools/dist/index.js'); }); it('falls back to legacy cjs runtime entries when esm legacy builds are unavailable', () => { @@ -80,22 +80,28 @@ describe('runtime resolution compatibility', () => { if ( basedFromLegacy && - (request === '@module-federation/runtime/bundler' || - request === '@module-federation/runtime/dist/index.js') + (request === '@module-federation/runtime-tools/bundler' || + request === '@module-federation/runtime-tools/dist/index.js') ) { throw new Error(`Cannot find module '${request}'`); } - if (request === '@module-federation/runtime/dist/index.cjs') { - return '/legacy/runtime/dist/index.cjs'; + if (request === '@module-federation/runtime-tools/dist/index.cjs') { + return '/legacy/runtime-tools/dist/index.cjs'; } throw new Error(`Unexpected request: ${request}`); }, ) as typeof require.resolve; - expect(resolveRspackRuntimeAlias('/legacy/runtime-tools', resolve)).toBe( - '/legacy/runtime/dist/index.cjs', - ); + expect( + resolveRspackRuntimeImplementation('/legacy/runtime-tools', resolve), + ).toBe('/legacy/runtime-tools/dist/index.cjs'); + }); +}); + +describe('@module-federation/rspack/plugin', () => { + it('no longer exports resolveRspackRuntimeAlias', () => { + expect(pluginEntry).not.toHaveProperty('resolveRspackRuntimeAlias'); }); }); diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index 1ace221ed35..c4c606391e0 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -75,22 +75,6 @@ export function resolveRspackRuntimeImplementation( ); } -/** @deprecated The wrapper no longer aliases `@module-federation/runtime$`. */ -export function resolveRspackRuntimeAlias( - implementation: string, - resolve: ResolveFn = require.resolve, -) { - return resolveRuntimeEntry( - { - bundler: '@module-federation/runtime/bundler', - esm: '@module-federation/runtime/dist/index.js', - cjs: '@module-federation/runtime/dist/index.cjs', - }, - implementation, - resolve, - ); -} - export class ModuleFederationPlugin implements RspackPluginInstance { readonly name = PLUGIN_NAME; private _options: moduleFederationPlugin.ModuleFederationPluginOptions; From 2840f62f53911d7e091b98ec0d413f87aa52cb5c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:03:38 +0000 Subject: [PATCH 19/38] fix(runtime-core): register the share scope once the options are final A beforeInit plugin can set options.id, but the shared handler registered the global share scope in its constructor, under the id from the constructor options. Register it from the init lifecycle instead. --- .../runtime-core/__tests__/kernel.spec.ts | 22 +++++++++++++++++++ packages/runtime-core/src/shared/index.ts | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/__tests__/kernel.spec.ts b/packages/runtime-core/__tests__/kernel.spec.ts index 2a005f695e7..23458a5fe93 100644 --- a/packages/runtime-core/__tests__/kernel.spec.ts +++ b/packages/runtime-core/__tests__/kernel.spec.ts @@ -140,6 +140,28 @@ describe('FederationKernel', () => { undefined, ); }); + + it('registers its share scope under an id a beforeInit plugin sets', () => { + const buildId: ModuleFederationRuntimePlugin = { + name: 'build-id', + beforeInit(args) { + args.userOptions.id ||= 'kernel-plugin-id:1.0.0'; + return args; + }, + }; + const kernel = new FederationKernel( + { name: 'kernel-plugin-id', plugins: [buildId] }, + { shared }, + ); + + expect(kernel.options.id).toBe('kernel-plugin-id:1.0.0'); + expect( + CurrentGlobal.__FEDERATION__.__SHARE__['kernel-plugin-id:1.0.0'], + ).toBe(kernel.shareScopeMap); + expect(CurrentGlobal.__FEDERATION__.__SHARE__['kernel-plugin-id']).toBe( + undefined, + ); + }); }); describe('root ModuleFederation', () => { diff --git a/packages/runtime-core/src/shared/index.ts b/packages/runtime-core/src/shared/index.ts index 004aeb2202a..4ff667276a3 100644 --- a/packages/runtime-core/src/shared/index.ts +++ b/packages/runtime-core/src/shared/index.ts @@ -138,7 +138,10 @@ export class SharedHandler { this.host = host; this.shareScopeMap = {}; this.initTokens = {}; - this._setGlobalShareScopeMap(host.options); + // A beforeInit plugin may set the id, so register once the options are final. + host.hooks.lifecycle.init.on(({ options }) => + this._setGlobalShareScopeMap(options), + ); } private emitAfterRegisterShare( From 453999c39315331b51bac02fb20bab3b2e3c8dbb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:03:39 +0000 Subject: [PATCH 20/38] fix(enhanced): pass the build id to the full-runtime bootstrap Without FEDERATION_BUILD_IDENTIFIER, a build that falls back to the full runtime had no id, so its share scope registered under the plain name and two versions of one container collided. The template now sets initOptions.id to name:version with the same rule as the composed bootstrap. --- .../runtime/FederationCompositionPlugin.ts | 29 ++-- .../runtime/FederationRuntimePlugin.ts | 8 ++ .../FederationCompositionPlugin.test.ts | 126 ++++++++++++------ .../container/FederationRuntimePlugin.test.ts | 1 + 4 files changed, 109 insertions(+), 55 deletions(-) diff --git a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts index 09946b5d16d..26528cc4a36 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts @@ -58,6 +58,21 @@ function slotOf(compiler: Compiler): CompositionSlot { return (target[SLOT] ??= { participants: [], sealed: false }); } +/** The build id both bootstraps pass as options.id: only with one ModuleFederationPlugin. */ +export function buildIdOf( + compiler: Compiler, + { name }: { name?: string }, +): string | undefined { + const federationPlugins = compiler.options.plugins.filter( + (plugin) => + !!plugin && + (plugin as { name?: unknown }).name === 'ModuleFederationPlugin', + ).length; + return name && federationPlugins < 2 + ? composeKeyWithSeparator(name, utils.getBuildVersion()) + : undefined; +} + export const composedEntryOf = (compiler: Compiler) => (compiler as SlotCompiler)[SLOT]?.entry; @@ -136,24 +151,12 @@ class FederationCompositionPlugin { const composition = renderComposition( plan, resolveImports(plan, family), - this._buildId(compiler), + buildIdOf(compiler, this._options), ); slot.entry = { ...this._createEntry(composition), adapters: plan.adapters }; return { family, mode, entry: slot.entry }; } - private _buildId(compiler: Compiler): string | undefined { - const federationPlugins = compiler.options.plugins.filter( - (plugin) => - !!plugin && - (plugin as { name?: unknown }).name === 'ModuleFederationPlugin', - ).length; - const { name } = this._options; - return name && federationPlugins < 2 - ? composeKeyWithSeparator(name, utils.getBuildVersion()) - : undefined; - } - private _check(compilation: Compilation, { family, mode, entry }: Outcome) { if (mode.mode === 'unsupported') { compilation.errors.push( diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index 80ce4738b3a..25f6072d9b1 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -21,6 +21,7 @@ import FederationModulesPlugin from './FederationModulesPlugin'; import HoistContainerReferences from '../HoistContainerReferencesPlugin'; import FederationRuntimeDependency from './FederationRuntimeDependency'; import FederationCompositionPlugin, { + buildIdOf, composedEntryOf, } from './FederationCompositionPlugin'; @@ -202,6 +203,10 @@ class FederationRuntimePlugin { '}', ]); + // createFederation passes the build id to a composed bootstrap. + const buildId = + composition === undefined ? buildIdOf(compiler, options) : undefined; + return Template.asString([ composition ?? `import federation from '${normalizedBundlerRuntimePath}';`, @@ -209,6 +214,9 @@ class FederationRuntimePlugin { embedRuntimeLines, `if(!${federationGlobal}.instance){`, Template.indent([ + buildId + ? `${federationGlobal}.initOptions.id = ${federationGlobal}.initOptions.id || ${JSON.stringify(buildId)};` + : '', runtimePluginCalls.length ? Template.asString([ `var pluginsToAdd = [`, diff --git a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts index 3dcdcb33b42..5ca204becb6 100644 --- a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts @@ -1,7 +1,9 @@ +import { execFile } from 'child_process'; import fs from 'fs'; import { createRequire } from 'module'; import os from 'os'; import path from 'path'; +import { promisify } from 'util'; import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; @@ -47,48 +49,48 @@ function fixture(files: Record) { function compile(context: string, config: Record) { const outputPath = path.join(context, `dist-${dirs.length}-${Math.random()}`); - return new Promise<{ stats: any; output: Record }>( - (resolve, reject) => { - webpack( - { - context, - mode: 'production', - devtool: false, - // Two builds of one checkout can otherwise concatenate around different roots. - parallelism: 1, - target: 'async-node', - entry: './index.js', - optimization: { minimize: false }, - output: { path: outputPath, uniqueName: 'composition-unit' }, - ...config, - }, - (err, stats) => { - if (err) return reject(err); - const output = {}; - const files = fs.existsSync(outputPath) - ? fs.readdirSync(outputPath) - : []; - for (const file of files.sort()) { - if (file.endsWith('.js')) - output[file] = fs.readFileSync( - path.join(outputPath, file), - 'utf8', - ); - } - resolve({ - stats: stats.toJson({ - all: false, - errors: true, - warnings: true, - modules: true, - nestedModules: true, - }), - output, - }); - }, - ); - }, - ); + return new Promise<{ + stats: any; + output: Record; + outputPath: string; + }>((resolve, reject) => { + webpack( + { + context, + mode: 'production', + devtool: false, + // Two builds of one checkout can otherwise concatenate around different roots. + parallelism: 1, + target: 'async-node', + entry: './index.js', + optimization: { minimize: false }, + output: { path: outputPath, uniqueName: 'composition-unit' }, + ...config, + }, + (err, stats) => { + if (err) return reject(err); + const output = {}; + const files = fs.existsSync(outputPath) + ? fs.readdirSync(outputPath) + : []; + for (const file of files.sort()) { + if (file.endsWith('.js')) + output[file] = fs.readFileSync(path.join(outputPath, file), 'utf8'); + } + resolve({ + stats: stats.toJson({ + all: false, + errors: true, + warnings: true, + modules: true, + nestedModules: true, + }), + output, + outputPath, + }); + }, + ); + }); } const moduleNames = (stats) => { @@ -188,6 +190,46 @@ describe('FederationCompositionPlugin', () => { expect(moduleNames(stats).some((name) => COMPOSE.test(name))).toBe(false); }); + it.each([ + ['the composed bootstrap', {}], + [ + 'the full bootstrap a user alias selects', + { + resolve: { + alias: { + '@module-federation/runtime$': path.join( + path.dirname(require.resolve('@module-federation/runtime')), + 'index.js', + ), + }, + }, + }, + ], + ])( + 'registers the share scope of %s under name:version', + async (_, config) => { + const previous = process.env['MF_BUILD_VERSION']; + process.env['MF_BUILD_VERSION'] = '9.9.9'; + try { + const context = fixture({ 'index.js': 'export default 1;' }); + const { stats, outputPath } = await compile(context, { + ...config, + plugins: [host()], + }); + expect(messages(stats.errors)).toEqual([]); + const { stdout } = await promisify(execFile)(process.execPath, [ + '-e', + `require(${JSON.stringify(path.join(outputPath, 'main.js'))}); + console.log(JSON.stringify(Object.keys(globalThis.__FEDERATION__.__SHARE__)));`, + ]); + expect(JSON.parse(stdout)).toEqual(['composition_host:9.9.9']); + } finally { + if (previous === undefined) delete process.env['MF_BUILD_VERSION']; + else process.env['MF_BUILD_VERSION'] = previous; + } + }, + ); + it('emits ENV_TARGET but no capability or build-id define', async () => { const context = fixture({ 'index.js': diff --git a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts index 2199f0aed82..8891d6fb109 100644 --- a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts +++ b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts @@ -22,6 +22,7 @@ describe('FederationRuntimePlugin runtimePluginCalls', () => { compiler = { options: { context: '/test/path', + plugins: [], }, hooks: { thisCompilation: { From 21e2f485082144e3561dda7f4881812b4e91dc47 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:07:36 +0000 Subject: [PATCH 21/38] fix(rspack): pass the build id to the native runtime rspack's native runtime passes no id to init, so a build that falls back to the full runtime keyed its share scope by name. A runtime plugin now sets name:version in beforeInit; the composed bootstrap already passes the same id. --- .../rspack/__tests__/composedRuntime.spec.ts | 26 +++++++++++++++++++ packages/rspack/src/ModuleFederationPlugin.ts | 12 ++++++++- packages/rspack/src/buildIdRuntimePlugin.ts | 16 ++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/rspack/src/buildIdRuntimePlugin.ts diff --git a/packages/rspack/__tests__/composedRuntime.spec.ts b/packages/rspack/__tests__/composedRuntime.spec.ts index f046a9574ec..1df9f0608b4 100644 --- a/packages/rspack/__tests__/composedRuntime.spec.ts +++ b/packages/rspack/__tests__/composedRuntime.spec.ts @@ -266,6 +266,32 @@ describe('composed runtime', () => { ]); }); + it.each([ + ['the composed bootstrap', {}], + [ + 'the full bootstrap a user alias selects', + { + alias: { + '@module-federation/runtime-core$': path.resolve( + __dirname, + '../../runtime-core/dist/index.js', + ), + }, + }, + ], + ])('registers the share scope of %s under name:version', async (_, extra) => { + const out = `build-id/${Object.keys(extra).length}`; + await harness([ + { out, target: 'node', buildVersion: '9.9.9', mf: host(), ...extra }, + ]); + const { stdout } = await promisify(execFile)(process.execPath, [ + '-e', + 'require(process.argv[1]).default.catch(() => {}); console.log(JSON.stringify(Object.keys(globalThis.__FEDERATION__.__SHARE__)))', + path.join(outRoot, out, 'main.js'), + ]); + expect(JSON.parse(stdout)).toEqual(['host:9.9.9']); + }); + it('keeps the full runtime and warns when resolve.alias already maps the bundler runtime', async () => { const bundlerRuntime = require.resolve( '@module-federation/webpack-bundler-runtime', diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index c4c606391e0..bbf2a84afc3 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -193,6 +193,16 @@ export class ModuleFederationPlugin implements RspackPluginInstance { } } + const buildId = composeKeyWithSeparator( + options.name, + utils.getBuildVersion(), + ); + options.runtimePlugins = [ + ...(options.runtimePlugins || []), + // The ESM build: the CommonJS one carries a Node-only import.meta shim. + [require.resolve('./buildIdRuntimePlugin.mjs'), { id: buildId }], + ]; + new compiler.webpack.container.ModuleFederationPlugin( options as unknown as ModuleFederationPluginOptions, ).apply(compiler); @@ -210,7 +220,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { paths: [implementationPath], }), }, - composeKeyWithSeparator(options.name, utils.getBuildVersion()), + buildId, ).apply(compiler); if (!disableManifest) { diff --git a/packages/rspack/src/buildIdRuntimePlugin.ts b/packages/rspack/src/buildIdRuntimePlugin.ts new file mode 100644 index 00000000000..5ac06f6bbce --- /dev/null +++ b/packages/rspack/src/buildIdRuntimePlugin.ts @@ -0,0 +1,16 @@ +import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime-tools'; + +// rspack's native runtime passes no build id, so the full runtime would key its share scope by name. +export default function buildIdRuntimePlugin({ + id, +}: { + id: string; +}): ModuleFederationRuntimePlugin { + return { + name: 'build-id-plugin', + beforeInit(args) { + args.userOptions.id ||= id; + return args; + }, + }; +} From 3052ce6115f6f312bfff5e826abbf0db3c90d863 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:09:13 +0000 Subject: [PATCH 22/38] fix(managers): say what an older runtime family lacks The error for a missing subpath export now says the installed runtime family lacks the subpath exports the build needs and names MIN_RUNTIME_VERSION only as the release that added them. A renamed or unresolvable runtime package keeps its own message without the version. --- .../container/FederationCompositionPlugin.test.ts | 2 +- .../__tests__/composition/selectMode.spec.ts | 15 ++++++++++----- packages/managers/src/composition/selectMode.ts | 11 ++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts index 5ca204becb6..11836b9ea20 100644 --- a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts @@ -133,7 +133,7 @@ describe('FederationCompositionPlugin', () => { expect(messages(stats.errors)).toEqual([ expect.stringContaining( - `does not export "./compose"; the federation runtime packages must be ${MIN_RUNTIME_VERSION} or newer`, + `does not export "./compose": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`, ), ]); }); diff --git a/packages/managers/__tests__/composition/selectMode.spec.ts b/packages/managers/__tests__/composition/selectMode.spec.ts index cfbfff7ab9e..d33262b4081 100644 --- a/packages/managers/__tests__/composition/selectMode.spec.ts +++ b/packages/managers/__tests__/composition/selectMode.spec.ts @@ -48,7 +48,10 @@ describe('selectMode', () => { expect(reason).toMatch( /@module-federation\/webpack-bundler-runtime at .* does not export "\.\/compose"/, ); - expect(reason).toContain(`${MIN_RUNTIME_VERSION} or newer`); + expect(reason).toContain( + `the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`, + ); + expect(reason).not.toContain('or newer'); }); it('does not count a "./*" pattern as the required key', async () => { @@ -66,17 +69,19 @@ describe('selectMode', () => { const root = composableFamily(tempDir(), { '@module-federation/runtime-core': { name: 'runtime-core-fork' }, }); - expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( - /is named "runtime-core-fork"/, - ); + const reason = await unsupportedReason(resolveRuntimeFamily(root)); + expect(reason).toMatch(/is named "runtime-core-fork"/); + expect(reason).not.toContain(MIN_RUNTIME_VERSION); }); it('rejects a member that does not resolve', async () => { const root = composableFamily(tempDir()); fs.rmSync(packageDir(root, '@module-federation/sdk'), { recursive: true }); - expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( + const reason = await unsupportedReason(resolveRuntimeFamily(root)); + expect(reason).toMatch( /@module-federation\/sdk could not be resolved from/, ); + expect(reason).not.toContain(MIN_RUNTIME_VERSION); }); it('rejects an exports string with no subpath keys', async () => { diff --git a/packages/managers/src/composition/selectMode.ts b/packages/managers/src/composition/selectMode.ts index 57c209878be..fe2a047e4a2 100644 --- a/packages/managers/src/composition/selectMode.ts +++ b/packages/managers/src/composition/selectMode.ts @@ -46,12 +46,7 @@ export async function selectMode( inputs: ModeInputs, ): Promise { const problem = familyProblem(family); - if (problem !== undefined) { - return { - mode: 'unsupported', - reason: `${problem}; the federation runtime packages must be ${MIN_RUNTIME_VERSION} or newer`, - }; - } + if (problem !== undefined) return { mode: 'unsupported', reason: problem }; const requested = experimentProblem(inputs); if (requested !== undefined) { return { mode: 'legacy', reason: requested, requested: true }; @@ -78,7 +73,9 @@ function familyProblem({ anchor, members }: RuntimeFamily): string | undefined { const missing = RUNTIME_FAMILY[pkg].find( (key) => !declaresExportKey(member.exports, key), ); - if (missing) return `${pkg} at ${member.root} does not export "${missing}"`; + if (missing) { + return `${pkg} at ${member.root} does not export "${missing}": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`; + } from = member.root; } return undefined; From 295d730b679939405d4a66b8e81af8b628c7e82c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:09:49 +0000 Subject: [PATCH 23/38] test(rspack): cover the build error for an older runtime family --- .../__tests__/ComposedRuntimePlugin.spec.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts b/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts index 4f6f34938aa..fa1ec1fa98d 100644 --- a/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts +++ b/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; import { ComposedRuntimePlugin } from '../src/ComposedRuntimePlugin'; import { resolveRspackRuntimeImplementation } from '../src/ModuleFederationPlugin'; @@ -56,3 +59,47 @@ it('keeps the full runtime and warns on an @rspack/core without compiler.rspack' ]); expect(compiler.options.resolve.alias).toEqual({}); }); + +it('fails the build for an older runtime family', async () => { + const store = path.resolve(__dirname, '../../../node_modules/.pnpm'); + const [older] = fs + .readdirSync(store) + .filter((dir) => + /^@module-federation\+runtime-tools@2\.\d+\.\d+$/.test(dir), + ); + const implementation = path.join( + store, + older, + 'node_modules/@module-federation/runtime-tools', + ); + const { compiler, taps } = rspack07Compiler(); + new ComposedRuntimePlugin( + { name: 'host' }, + { + runtimeTools: implementation, + bundlerRuntime: require.resolve( + '@module-federation/webpack-bundler-runtime', + { paths: [implementation] }, + ), + runtime: require.resolve('@module-federation/runtime', { + paths: [implementation], + }), + }, + 'host:1.0.0', + ).apply(compiler as never); + + taps['environment'](); + await taps['beforeRun'](); + const compilation = { errors: [] as Error[], warnings: [] as Error[] }; + taps['thisCompilation'](compilation); + + expect(compilation.errors.map((e) => e.message)).toEqual([ + expect.stringMatching( + new RegExp( + `^The federation runtime cannot be composed: @module-federation/webpack-bundler-runtime at .* does not export "\\./compose": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them \\(${MIN_RUNTIME_VERSION.replace(/\./g, '\\.')}\\)\\.$`, + ), + ), + ]); + expect(compilation.warnings).toEqual([]); + expect(compiler.options.resolve.alias).toEqual({}); +}); From b0a6c25afc5a3e0e397331430fa0a2f457d66a78 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:10:17 +0000 Subject: [PATCH 24/38] test(enhanced): assert the consumes path stays out of builds without shared The consumes markers are absent from the no-shared build again. They are also absent from the full build now, because no build in this case configures shared and the plan leaves out the consumes adapter. --- .../container/experiments-optimization/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/enhanced/test/configCases/container/experiments-optimization/index.js b/packages/enhanced/test/configCases/container/experiments-optimization/index.js index cbd4ffd1771..a41b32b7c0e 100644 --- a/packages/enhanced/test/configCases/container/experiments-optimization/index.js +++ b/packages/enhanced/test/configCases/container/experiments-optimization/index.js @@ -101,6 +101,13 @@ if (!globalThis.__EXPERIMENTS_OPTIMIZATION_CASE__) { shareScopeMarkers.forEach((marker) => { expect(noShared).toContain(marker); }); + // No build here configures shared, so no plan includes the consumes adapter. + ['No fallback item found for shareKey', 'tree-shake-plugin'].forEach( + (marker) => { + expect(fullCapabilities).not.toContain(marker); + expect(noShared).not.toContain(marker); + }, + ); }); it('should eliminate container initialization from the consumer entry without exposes', () => { From 001e19c875e67458d9146201f8f7b2503c614132 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:10:30 +0000 Subject: [PATCH 25/38] chore: name the removed runtime export in the major changeset rstest is in the fixed release group, so it takes the major without its own entry. --- .changeset/composed-runtime-default-major.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index 9011716c819..bc1f41e038e 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -7,7 +7,6 @@ '@module-federation/sdk': major '@module-federation/node': major '@module-federation/managers': major -'@module-federation/rstest': patch --- The composed federation runtime is the default and only bootstrap. `ModuleFederationPlugin` now always generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. @@ -15,8 +14,8 @@ The composed federation runtime is the default and only bootstrap. `ModuleFedera Removed: - The `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, `FEDERATION_HAS_EXPOSES`, and `FEDERATION_BUILD_IDENTIFIER` defines. The plugins no longer emit them and the runtime no longer reads them. Use `experiments.optimization.disable*` with the plugin, or import the capabilities you need from `@module-federation/runtime/compose` when you bundle the runtime yourself. -- `__webpack_require__.federation.runtime`. Use `__webpack_require__.federation.instance`, and `instance.platform.loadScriptNode` for the Node loader. +- `__webpack_require__.federation.runtime`, and the named `runtime` export and default `runtime` key of `@module-federation/webpack-bundler-runtime` (so also of `@module-federation/runtime-tools/webpack-bundler-runtime`). Use `__webpack_require__.federation.instance`, `instance.platform.loadScriptNode` for the Node loader, or import `@module-federation/runtime` directly. - The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. - `resolveRspackRuntimeAlias` from `@module-federation/rspack/plugin`. The wrapper no longer aliases `@module-federation/runtime$`, so the helper has no use. -A build whose installed runtime packages do not export the composition subpaths now fails with an error that names the minimum runtime version. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. +A build whose installed runtime packages lack the composition subpath exports now fails with an error that names the release that added them. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. From 4989a4adf66e9777bbfd019f660273ddbad9e38b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:11:05 +0000 Subject: [PATCH 26/38] docs: describe capabilities and the build id instead of the removed defines The architecture docs and the webpack-bundler-runtime README no longer describe the capability defines, getBuilderId, or the federation.runtime namespace. --- arch-doc/architecture-overview.md | 3 +- arch-doc/implementation-guide.md | 3 +- arch-doc/runtime-architecture.md | 66 +++++++++------------- arch-doc/runtime-loading-contract.md | 16 +++--- packages/webpack-bundler-runtime/README.md | 2 +- 5 files changed, 36 insertions(+), 54 deletions(-) diff --git a/arch-doc/architecture-overview.md b/arch-doc/architecture-overview.md index 32d1b7665ff..96439dcb93c 100644 --- a/arch-doc/architecture-overview.md +++ b/arch-doc/architecture-overview.md @@ -474,7 +474,6 @@ sequenceDiagram ```typescript // Pattern for any bundler interface BundlerRuntimeBridge { - runtime: typeof coreRuntime; // Core runtime reference instance?: ModuleFederation; // Runtime instance bundlerRuntime: { // Bundler-specific implementations remotes: (options) => Promise; @@ -726,7 +725,7 @@ graph TB subgraph "Snapshot Handler" SnapshotHandler[SnapshotHandler] - OptCheck["Optimization Check
FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN"] + OptCheck["Snapshot Capability
runtime-core/snapshot"] ManifestGen["Manifest Generation
generateSnapshotFromManifest"] end diff --git a/arch-doc/implementation-guide.md b/arch-doc/implementation-guide.md index 7d2cf61ea8d..be4bfffc7b1 100644 --- a/arch-doc/implementation-guide.md +++ b/arch-doc/implementation-guide.md @@ -657,13 +657,12 @@ export class FederationRuntimeModule extends RuntimeModule { return Template.asString([ `// Federation Runtime Module`, `var ${federationGlobal} = ${federationGlobal} || {};`, - `${federationGlobal}.runtime = __bundler_require__("${RUNTIME_PATH}");`, `${federationGlobal}.initOptions = ${JSON.stringify(this.initOptions)};`, // Initialize federation runtime `if (!${federationGlobal}.instance) {`, Template.indent([ - `${federationGlobal}.instance = ${federationGlobal}.runtime.init(${federationGlobal}.initOptions);` + `${federationGlobal}.instance = __bundler_require__("${RUNTIME_PATH}").init(${federationGlobal}.initOptions);` ]), `}`, diff --git a/arch-doc/runtime-architecture.md b/arch-doc/runtime-architecture.md index 1a3d15371d1..8580f8bb793 100644 --- a/arch-doc/runtime-architecture.md +++ b/arch-doc/runtime-architecture.md @@ -78,20 +78,19 @@ import { loadScript, loadScriptNode } from '@module-federation/sdk'; The foundation of the runtime system is the `ModuleFederation` class in `@module-federation/runtime-core`: -### Conditional Feature Inclusion +### Capabilities -The snapshot plugins (`snapshotPlugin()` and `generatePreloadAssetsPlugin()`) are conditionally registered based on the `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` build-time flag (the `SnapshotHandler` instance itself is always constructed): +`ModuleFederation` extends `FederationKernel`, which takes its handlers as capabilities: `shared`, `remote`, `snapshot`, and a `platform` loader. The public root constructs `ModuleFederation` with every capability and the universal platform. A generated bootstrap imports only the capabilities its build uses from the `@module-federation/runtime-core` subpaths and passes them to `@module-federation/runtime/compose`: ```typescript -// Declared in core.ts with DefinePlugin -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; -const USE_SNAPSHOT = - typeof FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN - : true; // Default to true (use snapshot) when not explicitly defined +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; + +const host = init({ name: 'host', remotes }, { remote, platform: web }); ``` -When `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` is `true`, snapshot functionality is disabled for smaller bundle sizes. +A capability that is not passed gets a disabled handler. The snapshot plugins (`snapshotPlugin()` and `generatePreloadAssetsPlugin()`) are registered only with the `snapshot` capability, and `snapshot` needs `remote`. ```mermaid classDiagram @@ -154,7 +153,7 @@ classDiagram -getManifestJson(url, moduleInfo, extraOptions): Promise~Manifest~ } - note "Snapshot plugins are conditionally registered based on the FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN flag" + note "Snapshot plugins are registered only with the snapshot capability" ModuleFederation --> SharedHandler ModuleFederation --> RemoteHandler @@ -293,38 +292,28 @@ export function loadShare(...args: Parameters) ``` ### Build Identifier Integration -```typescript -// Build identifier support for instance resolution -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} +The plugins pass `name:version` as `options.id` when a build has one `ModuleFederationPlugin`. The composed bootstrap passes it through `createFederation({ buildId })`, and the full-runtime bootstrap sets `initOptions.id`. The instance registers its share scope in `__FEDERATION__.__SHARE__` under that id. + +`init()` reuses the instance this bundle already created. Otherwise it looks for a page-global instance with the same name and version: + +```typescript export function getGlobalFederationInstance( name: string, version: string | undefined, + preferred?: ModuleFederation | null, ): ModuleFederation | undefined { - const buildId = getBuilderId(); - return CurrentGlobal.__FEDERATION__.__INSTANCES__.find((GMInstance) => { - // Priority 1: Build ID match (most specific) - if (buildId && GMInstance.options.id === buildId) { - return true; - } - - // Priority 2: Exact name match without version (both undefined) - if (GMInstance.options.name === name && !GMInstance.options.version && !version) { - return true; - } - - // Priority 3: Name + version exact match - if (GMInstance.options.name === name && version && GMInstance.options.version === version) { - return true; - } - return false; - }); + return [preferred, ...CurrentGlobal.__FEDERATION__.__INSTANCES__].find( + (GMInstance): GMInstance is ModuleFederation => { + if (!GMInstance) return false; + // Exact name match without version (both undefined) + if (GMInstance.options.name === name && !GMInstance.options.version && !version) { + return true; + } + // Name + version exact match + return Boolean(GMInstance.options.name === name && version && GMInstance.options.version === version); + }, + ); } ``` @@ -334,10 +323,7 @@ The `@module-federation/webpack-bundler-runtime` creates a bridge between webpac ### Federation Object Structure ```typescript -import * as runtime from '@module-federation/runtime'; - const federation: Federation = { - runtime, // Reference to convenience runtime instance: undefined, // Will hold the ModuleFederation instance initOptions: undefined, // Initialization options bundlerRuntime: { // Webpack-specific implementations diff --git a/arch-doc/runtime-loading-contract.md b/arch-doc/runtime-loading-contract.md index 492d9c732c2..c0b110c5a0d 100644 --- a/arch-doc/runtime-loading-contract.md +++ b/arch-doc/runtime-loading-contract.md @@ -241,7 +241,6 @@ export function isLegallyVersion(version: string): boolean { interface BundlerRuntimeIntegration { // 1. Create bundler-specific runtime bridge createBundlerRuntime(): { - runtime: typeof runtime; instance?: ModuleFederation; bundlerRuntime: { remotes: (options: BundlerRemoteOptions) => Promise; @@ -289,7 +288,7 @@ class ViteBundlerRuntime implements BundlerRuntimeIntegration { ### Build-Time Responsibilities The build-time layer handles: -- **DefinePlugin Integration**: Defines `FEDERATION_BUILD_IDENTIFIER` and `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` flags +- **Bootstrap Generation**: Generates a bootstrap that imports only the runtime capabilities and adapters the build uses, and passes the build id - **Bundle Generation**: Creates remote entry files and module manifests - **Static Analysis**: Determines shared dependencies and remote configurations - **Code Splitting**: Separates remote modules from host bundles @@ -306,13 +305,12 @@ The runtime layer handles: ### Critical Integration Points ```typescript -// Build-time defines these globals, runtime consumes them -declare const FEDERATION_BUILD_IDENTIFIER: string; -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; - -// Runtime uses build-time generated information -const buildId = getBuilderId(); // Reads FEDERATION_BUILD_IDENTIFIER -const useSnapshot = !FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN; // Feature flag +// The generated bootstrap passes the build's capabilities, adapters, and id +var federation = createFederation({ + buildId: 'host:1.0.0', + capabilities: { remote, snapshot, platform: web }, // no snapshot: no snapshot plugins + adapters: [remotes, shareScope], +}); // Build-time generates manifest, runtime consumes it const manifest = await fetch('./mf-manifest.json'); diff --git a/packages/webpack-bundler-runtime/README.md b/packages/webpack-bundler-runtime/README.md index b82a0435544..1b9739f3b48 100644 --- a/packages/webpack-bundler-runtime/README.md +++ b/packages/webpack-bundler-runtime/README.md @@ -5,7 +5,7 @@ ## Usage -The package needs to be used with webpack/rspack bundler. It will export federation object which includes runtime, instance, bundlerRuntime, initOptions, attachShareScopeMap, bundlerRuntimeOptions. +The package needs to be used with webpack/rspack bundler. It exports a federation object with instance, bundlerRuntime, initOptions, attachShareScopeMap, and bundlerRuntimeOptions. The root includes every adapter and the full runtime. `@module-federation/webpack-bundler-runtime/compose` exports `createFederation`, which the plugins use to build a federation object from only the adapters and capabilities a build needs. After referencing, mount it to the corresponding bundler runtime, and then use the corresponding api/instance. From 0e7bc2b2f1543fe6418593ded8eeecdbf9a3c47f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:12:05 +0000 Subject: [PATCH 27/38] feat(rspack)!: require @rspack/core 1.5 or newer @rspack/core 0.7 through 1.4 have no experiments.VirtualModulesPlugin, so every such build fell back to the full runtime with a warning. The fallback stays for safety. --- .changeset/composed-runtime-default-major.md | 2 ++ packages/rspack/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index bc1f41e038e..bde35099746 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -18,4 +18,6 @@ Removed: - The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. - `resolveRspackRuntimeAlias` from `@module-federation/rspack/plugin`. The wrapper no longer aliases `@module-federation/runtime$`, so the helper has no use. +`@module-federation/rspack` now needs `@rspack/core` 1.5 or newer, the first release with `experiments.VirtualModulesPlugin`. The wrapper still falls back to the full runtime, with a warning, when the plugin is missing. + A build whose installed runtime packages lack the composition subpath exports now fails with an error that names the release that added them. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 95c59b27dfb..3087d54461c 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -84,7 +84,7 @@ "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24", - "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0" + "@rspack/core": "^1.5.0 || ^2.0.0-0" }, "peerDependenciesMeta": { "typescript": { From 571ef30120697ac4209a0b21ceaaf92b29ba4d88 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:15:56 +0000 Subject: [PATCH 28/38] chore: describe the full-runtime fallback in the major changeset --- .changeset/composed-runtime-default-major.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index bde35099746..03b40139802 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -9,7 +9,7 @@ '@module-federation/managers': major --- -The composed federation runtime is the default and only bootstrap. `ModuleFederationPlugin` now always generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. +The composed federation runtime is the default. `ModuleFederationPlugin` generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. A build that cannot compose gets the define-free full-runtime bootstrap: an external runtime (`experiments.externalRuntime`, `provideExternalRuntime`), a runtime package in `externals` or in a user `resolve.alias`, or rspack without `experiments.VirtualModulesPlugin`. It warns, except for the two external-runtime experiments, which ask for the full runtime. Removed: From cf40a355e0cc3264c3fb1de35f3175a04aa614c3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:25:00 +0000 Subject: [PATCH 29/38] fix(rspack): pass the build id through a data: runtime plugin @rspack/core before 1.6 takes runtime plugin paths only, so the [path, params] form broke the playground's build. The plugin is now an inline data: module with the id in its source. --- packages/rspack/src/ModuleFederationPlugin.ts | 5 +++-- packages/rspack/src/buildIdRuntimePlugin.ts | 16 ---------------- 2 files changed, 3 insertions(+), 18 deletions(-) delete mode 100644 packages/rspack/src/buildIdRuntimePlugin.ts diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index bbf2a84afc3..26b23185010 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -197,10 +197,11 @@ export class ModuleFederationPlugin implements RspackPluginInstance { options.name, utils.getBuildVersion(), ); + // rspack's native runtime passes no id to init; this plugin sets it before the share scope registers. + // A data: module, because @rspack/core before 1.6 takes no runtime plugin params. options.runtimePlugins = [ ...(options.runtimePlugins || []), - // The ESM build: the CommonJS one carries a Node-only import.meta shim. - [require.resolve('./buildIdRuntimePlugin.mjs'), { id: buildId }], + `data:text/javascript,export default function(){return{name:"build-id-plugin",beforeInit(args){args.userOptions.id||=${JSON.stringify(buildId)};return args}}}`, ]; new compiler.webpack.container.ModuleFederationPlugin( diff --git a/packages/rspack/src/buildIdRuntimePlugin.ts b/packages/rspack/src/buildIdRuntimePlugin.ts deleted file mode 100644 index 5ac06f6bbce..00000000000 --- a/packages/rspack/src/buildIdRuntimePlugin.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime-tools'; - -// rspack's native runtime passes no build id, so the full runtime would key its share scope by name. -export default function buildIdRuntimePlugin({ - id, -}: { - id: string; -}): ModuleFederationRuntimePlugin { - return { - name: 'build-id-plugin', - beforeInit(args) { - args.userOptions.id ||= id; - return args; - }, - }; -} From 6fff0f9e81fac1ef18501c06132aee9dcc13bf21 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:25:01 +0000 Subject: [PATCH 30/38] fix(rstest): keep data: runtime plugins bundled --- packages/rstest/src/externals-bypass.ts | 2 +- packages/rstest/src/index.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/rstest/src/externals-bypass.ts b/packages/rstest/src/externals-bypass.ts index 689211ff0b0..be085dedf34 100644 --- a/packages/rstest/src/externals-bypass.ts +++ b/packages/rstest/src/externals-bypass.ts @@ -2,7 +2,7 @@ import type { Rspack } from '@rsbuild/core'; import { NODE_RUNTIME_PLUGIN } from './runtime-plugin'; -const DATA_JAVASCRIPT_REQUEST = /!=!data:text\/javascript(?:;|,)/i; +const DATA_JAVASCRIPT_REQUEST = /(?:^|!=!)data:text\/javascript(?:;|,)/i; // Rspack's native plugin imports this path, and the rspack wrapper aliases it to a virtual module. const BUNDLER_RUNTIME_REQUEST = /[\\/]webpack-bundler-runtime[\\/]dist[\\/]index\.c?js$/; diff --git a/packages/rstest/src/index.test.ts b/packages/rstest/src/index.test.ts index e55369d4e5a..f4036b402db 100644 --- a/packages/rstest/src/index.test.ts +++ b/packages/rstest/src/index.test.ts @@ -278,6 +278,14 @@ describe('shouldKeepBundledForFederation', () => { ).toBe(true); }); + it('keeps plain data: javascript requests bundled', () => { + expect( + shouldKeepBundledForFederation( + 'data:text/javascript,export default function(){}', + ), + ).toBe(true); + }); + it('keeps @module-federation/* requests bundled', () => { expect(shouldKeepBundledForFederation('@module-federation/node')).toBe( true, From a5cf4b0e54483a64313ce4db59eb7a83b63333da Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 17:26:49 +0000 Subject: [PATCH 31/38] feat(rspack)!: require @rspack/core 2.0.0-beta.1 or newer Native runtimes before 2.0.0-beta.1 (web-infra-dev/rspack cd402a1ef) initialize through federation.runtime.init, which this major removes, so hosts built with them fail at startup with "Cannot read properties of undefined (reading 'init')". --- packages/rspack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 3087d54461c..cbf75b83c35 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -84,7 +84,7 @@ "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24", - "@rspack/core": "^1.5.0 || ^2.0.0-0" + "@rspack/core": "^2.0.0-beta.1" }, "peerDependenciesMeta": { "typescript": { From 8f40ea816f2f195d3ddd5c3101b699af4be739e2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 19:51:13 +0000 Subject: [PATCH 32/38] fix(webpack-bundler-runtime): keep federation.runtime.init on the full-runtime root The native runtime of @rspack/core before 2.0.0-beta.1 copies the default export's keys onto __webpack_require__.federation and calls federation.runtime.init. The root keeps runtime: { init } for it. The runtime namespace, loadScriptNode, and the named runtime export stay removed. --- .changeset/composed-runtime-default-major.md | 2 +- .../__tests__/legacy-root.spec.ts | 16 ++++++++++++++-- packages/webpack-bundler-runtime/src/index.ts | 2 ++ packages/webpack-bundler-runtime/src/types.ts | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index 03b40139802..4ee61d1c213 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -14,7 +14,7 @@ The composed federation runtime is the default. `ModuleFederationPlugin` generat Removed: - The `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, `FEDERATION_HAS_EXPOSES`, and `FEDERATION_BUILD_IDENTIFIER` defines. The plugins no longer emit them and the runtime no longer reads them. Use `experiments.optimization.disable*` with the plugin, or import the capabilities you need from `@module-federation/runtime/compose` when you bundle the runtime yourself. -- `__webpack_require__.federation.runtime`, and the named `runtime` export and default `runtime` key of `@module-federation/webpack-bundler-runtime` (so also of `@module-federation/runtime-tools/webpack-bundler-runtime`). Use `__webpack_require__.federation.instance`, `instance.platform.loadScriptNode` for the Node loader, or import `@module-federation/runtime` directly. +- The `@module-federation/runtime` namespace on `__webpack_require__.federation.runtime`, `federation.runtime.loadScriptNode`, and the named `runtime` export of `@module-federation/webpack-bundler-runtime` (so also of `@module-federation/runtime-tools/webpack-bundler-runtime`). `federation.runtime` keeps only `init`, because the native runtime of `@rspack/core` before 2.0.0-beta.1 calls `federation.runtime.init`. Use `__webpack_require__.federation.instance`, `instance.platform.loadScriptNode` for the Node loader, or import `@module-federation/runtime` directly. - The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. - `resolveRspackRuntimeAlias` from `@module-federation/rspack/plugin`. The wrapper no longer aliases `@module-federation/runtime$`, so the helper has no use. diff --git a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts index ce30840819a..e8fe284cc63 100644 --- a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts @@ -8,6 +8,7 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { 'bundlerRuntime', 'attachShareScopeMap', 'bundlerRuntimeOptions', + 'runtime', ]); }); @@ -63,8 +64,19 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { ); }); - test('has no federation.runtime namespace', () => { - expect(legacyRoot.default).not.toHaveProperty('runtime'); + test('keeps only runtime.init, not the runtime namespace', () => { + expect(Object.keys(legacyRoot.default.runtime!)).toEqual(['init']); expect(legacyRoot).not.toHaveProperty('runtime'); }); + + test('rspack 1.x initializes through runtime.init on the copied keys', () => { + // @rspack/core 1.7.9 moduleFederationDefaultRuntime: copy the keys, then call runtime.init. + const federation: any = {}; + for (const key in legacyRoot.default) { + federation[key] = (legacyRoot.default as any)[key]; + } + federation.initOptions = { name: 'legacy-root-rspack1', remotes: [] }; + federation.instance = federation.runtime.init(federation.initOptions); + expect(federation.instance.name).toBe('legacy-root-rspack1'); + }); }); diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index a5cc4466732..fa7f12c2032 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -33,6 +33,8 @@ const federation: Federation = { bundlerRuntime, attachShareScopeMap, bundlerRuntimeOptions: {}, + // @rspack/core before 2.0.0-beta.1: its native runtime calls federation.runtime.init. + runtime: { init }, }; // Keep CJS interop stable for consumers that iterate required keys directly. diff --git a/packages/webpack-bundler-runtime/src/types.ts b/packages/webpack-bundler-runtime/src/types.ts index 8c7cbe4d2c5..a491c76f3c9 100644 --- a/packages/webpack-bundler-runtime/src/types.ts +++ b/packages/webpack-bundler-runtime/src/types.ts @@ -216,6 +216,7 @@ export interface GetSharedFallbackGetterOptions { } export interface Federation { + runtime?: { init: typeof runtime.init }; instance?: runtime.ModuleFederation; initOptions?: InitOptions; installInitialConsumes?: (options: InstallInitialConsumesOptions) => any; From 9805677ca675577975a507d408758b6763addcdd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 19:51:13 +0000 Subject: [PATCH 33/38] fix(rspack): accept @rspack/core 1.5 again federation.runtime.init is back for the rspack 1.x native runtime, so the peer range returns to every release with experiments.VirtualModulesPlugin. --- packages/rspack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rspack/package.json b/packages/rspack/package.json index cbf75b83c35..3087d54461c 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -84,7 +84,7 @@ "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24", - "@rspack/core": "^2.0.0-beta.1" + "@rspack/core": "^1.5.0 || ^2.0.0-0" }, "peerDependenciesMeta": { "typescript": { From df9aa3ae63dba0a67f0acbcd6e36c400d03f4d23 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 20:26:31 +0000 Subject: [PATCH 34/38] chore: quote the older runtime family error in the major changeset --- .changeset/composed-runtime-default-major.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index 4ee61d1c213..e964e767696 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -20,4 +20,4 @@ Removed: `@module-federation/rspack` now needs `@rspack/core` 1.5 or newer, the first release with `experiments.VirtualModulesPlugin`. The wrapper still falls back to the full runtime, with a warning, when the plugin is missing. -A build whose installed runtime packages lack the composition subpath exports now fails with an error that names the release that added them. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. +A build whose installed runtime packages lack the composition subpath exports now fails with an error: ` at does not export "": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (2.10.0)`. The version is `MIN_RUNTIME_VERSION` from `@module-federation/managers`, a placeholder for the release that ships the subpath exports. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. From 1f512179d6b5f996afb285f2fe2400dcbeee41c0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 21:28:40 +0000 Subject: [PATCH 35/38] chore: name the runtime family errors in the major changeset The changeset lists the unresolvable and renamed runtime package errors next to the older family error and states 2.10.0 as the release that adds the subpath exports. MIN_RUNTIME_VERSION says it must match the released minor. --- .changeset/composed-runtime-default-major.md | 2 +- packages/managers/src/composition/family.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md index e964e767696..b335595305c 100644 --- a/.changeset/composed-runtime-default-major.md +++ b/.changeset/composed-runtime-default-major.md @@ -20,4 +20,4 @@ Removed: `@module-federation/rspack` now needs `@rspack/core` 1.5 or newer, the first release with `experiments.VirtualModulesPlugin`. The wrapper still falls back to the full runtime, with a warning, when the plugin is missing. -A build whose installed runtime packages lack the composition subpath exports now fails with an error: ` at does not export "": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (2.10.0)`. The version is `MIN_RUNTIME_VERSION` from `@module-federation/managers`, a placeholder for the release that ships the subpath exports. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. +The build now fails when the runtime packages it resolves cannot compose. It fails when a runtime package cannot be resolved (` could not be resolved from `), when the resolved package has another name (` at is named ""`), or when an older release lacks the subpath exports: ` at does not export "": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (2.10.0)`. 2.10.0 is the release that adds the subpath exports. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. diff --git a/packages/managers/src/composition/family.ts b/packages/managers/src/composition/family.ts index b352c08ca12..7965e583f25 100644 --- a/packages/managers/src/composition/family.ts +++ b/packages/managers/src/composition/family.ts @@ -4,6 +4,7 @@ import enhancedResolve from 'enhanced-resolve'; import { findPackageJson } from '../findPackageJson'; /** The first runtime release that exports every subpath in RUNTIME_FAMILY. */ +// Set to the actual minor that ships the subpath exports when it is released. export const MIN_RUNTIME_VERSION = '2.10.0'; export const RUNTIME_FAMILY = { From f6d2e8bbd3ff69624b1b9490c7befb663af58144 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 21:29:29 +0000 Subject: [PATCH 36/38] fix(node): name a missing federation instance in the script loader Loading a remote entry before the federation runtime initialized reported a missing Node platform. It now says there is no federation instance. --- .../node/src/__tests__/runtimePlugin.test.ts | 1 + packages/node/src/runtimePlugin.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/node/src/__tests__/runtimePlugin.test.ts b/packages/node/src/__tests__/runtimePlugin.test.ts index b2633ac17b0..28b01fd02ef 100644 --- a/packages/node/src/__tests__/runtimePlugin.test.ts +++ b/packages/node/src/__tests__/runtimePlugin.test.ts @@ -886,6 +886,7 @@ describe('runtimePlugin', () => { await new Promise(process.nextTick); expect(done).toHaveBeenCalledWith(expect.any(Error)); + expect(done.mock.calls[0][0].message).toMatch(/no federation instance/); } finally { federation.instance = originalInstance; } diff --git a/packages/node/src/runtimePlugin.ts b/packages/node/src/runtimePlugin.ts index 0d03bd030a1..113da0f26af 100644 --- a/packages/node/src/runtimePlugin.ts +++ b/packages/node/src/runtimePlugin.ts @@ -384,10 +384,19 @@ export const setupScriptLoader = (): void => { ): void => { if (!key || chunkId) throw new Error(`__webpack_require__.l name is required for ${url}`); - const platform = __webpack_require__.federation.instance?.platform as - | (ModuleFederation['platform'] & { loadScriptNode?: LoadScriptNode }) - | undefined; - if (!platform?.loadScriptNode) { + const { instance } = __webpack_require__.federation; + if (!instance) { + done( + new Error( + `Cannot load ${url}: there is no federation instance; the federation runtime has not been initialized.`, + ), + ); + return; + } + const platform = instance.platform as ModuleFederation['platform'] & { + loadScriptNode?: LoadScriptNode; + }; + if (!platform.loadScriptNode) { done(new Error(NO_NODE_LOADER)); return; } From 9f596f0058b8a4cbd1d08fdebd9b265160f07fec Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 23:17:16 +0000 Subject: [PATCH 37/38] test(runtime): compose the optimized-runtime scenarios instead of reading removed defines --- .../__tests__/fixtures/optimized-runtimes.mjs | 140 ++++++++---------- 1 file changed, 64 insertions(+), 76 deletions(-) diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs index dc1ee11c532..c58c4187d76 100644 --- a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -1,6 +1,6 @@ -// Bundles independent copies of @module-federation/runtime, each with its own build-time -// flags, and runs one scenario. Each scenario runs in its own process, so every scenario -// starts with an empty __FEDERATION__ global. +// Bundles independent copies of @module-federation/runtime, each composed with its own +// capabilities and build id, and runs one scenario. Each scenario runs in its own +// process, so every scenario starts with an empty __FEDERATION__ global. import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -13,20 +13,17 @@ const coreSrc = path.join(packageDir, 'runtime-core/src'); const bundleEntry = ` export * from ${JSON.stringify(path.join(runtimeDir, 'src/index.ts'))}; -export { init as composeInit } from ${JSON.stringify(path.join(runtimeDir, 'src/compose.ts'))}; +export { init as composeInit, createInstance as composeCreateInstance } from ${JSON.stringify(path.join(runtimeDir, 'src/compose.ts'))}; export { remote } from ${JSON.stringify(path.join(coreSrc, 'remote/capability.ts'))}; export { shared } from ${JSON.stringify(path.join(coreSrc, 'shared/capability.ts'))}; export { snapshot } from ${JSON.stringify(path.join(coreSrc, 'plugins/snapshot/capability.ts'))}; export { node } from ${JSON.stringify(path.join(coreSrc, 'platform/node.ts'))}; +export { web } from ${JSON.stringify(path.join(coreSrc, 'platform/web.ts'))}; `; -async function bundle({ - buildId, - disableRemote = false, - disableShared = false, - disableSnapshot = false, - target = 'node', -}) { +// Like the bundler's composed bootstrap, init and createInstance pass the bundle's +// capabilities to runtime/compose, and init passes the build id as options.id. +async function bundle({ id, without = [], target = 'node' } = {}) { const result = await build({ stdin: { contents: bundleEntry, resolveDir: runtimeDir, loader: 'ts' }, bundle: true, @@ -47,10 +44,6 @@ async function bundle({ define: { __VERSION__: '"test"', FEDERATION_DEBUG: '"true"', - FEDERATION_OPTIMIZE_NO_REMOTE: String(disableRemote), - FEDERATION_OPTIMIZE_NO_SHARED: String(disableShared), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: String(disableSnapshot), - FEDERATION_BUILD_IDENTIFIER: JSON.stringify(buildId), ENV_TARGET: JSON.stringify(target), }, }); @@ -60,9 +53,27 @@ async function bundle({ module, module.exports, ); - return module.exports; + const runtime = module.exports; + const capabilities = Object.fromEntries( + Object.entries({ + remote: runtime.remote, + shared: runtime.shared, + snapshot: runtime.snapshot, + platform: runtime[target], + }).filter(([part]) => !without.includes(part)), + ); + return { + ...runtime, + rootInit: runtime.init, + init: (options) => + runtime.composeInit({ ...options, id: options.id || id }, capabilities), + createInstance: (options) => + runtime.composeCreateInstance(options, capabilities), + }; } +const noRemote = ['remote', 'snapshot']; + const server = createServer((request, response) => { if (request.url === '/mf-manifest.json') { response.setHeader('Content-Type', 'application/json'); @@ -127,10 +138,10 @@ const instances = (pick) => globalThis.__FEDERATION__.__INSTANCES__.map(pick); const scenarios = { async 'distinct-last-disabled'() { - const full = await bundle({ buildId: 'full-app@1.0.0' }); + const full = await bundle({ id: 'full-app@1.0.0' }); const disabled = await bundle({ - buildId: 'disabled-app@1.0.0', - disableRemote: true, + id: 'disabled-app@1.0.0', + without: noRemote, }); const disabledInstance = disabled.createInstance(options('disabled-app')); const created = full.createInstance(options('full-created')); @@ -146,10 +157,10 @@ const scenarios = { async 'distinct-last-full'() { const disabled = await bundle({ - buildId: 'disabled-app@1.0.0', - disableRemote: true, + id: 'disabled-app@1.0.0', + without: noRemote, }); - const full = await bundle({ buildId: 'full-app@1.0.0' }); + const full = await bundle({ id: 'full-app@1.0.0' }); const initialized = full.init(options('full-app')); const disabledInstance = disabled.init(options('disabled-app')); return { @@ -162,10 +173,10 @@ const scenarios = { }, async 'version-isolated'() { - const full = await bundle({ buildId: 'app@2.0.0' }); + const full = await bundle({ id: 'app@2.0.0' }); const disabled = await bundle({ - buildId: 'app@1.0.0', - disableRemote: true, + id: 'app@1.0.0', + without: noRemote, }); const disabledInstance = disabled.init(options('app', '1.0.0')); const initialized = full.init(options('app', '2.0.0')); @@ -178,8 +189,8 @@ const scenarios = { }, async 'compatible-reuse'() { - const full = await bundle({ buildId: 'app@1.0.0' }); - const compatible = await bundle({ buildId: 'app@2.0.0' }); + const full = await bundle({ id: 'app@1.0.0' }); + const compatible = await bundle({ id: 'app@2.0.0' }); const first = full.init({ ...options('app', '1.0.0'), shared: sharedToken, @@ -198,20 +209,19 @@ const scenarios = { }; }, - 'collision-different-build': () => - disabledThenFull({ fullBuildId: 'app@2.0.0' }), + 'collision-different-build': () => disabledThenFull({ fullId: 'app@2.0.0' }), - 'collision-same-build': () => disabledThenFull({ fullBuildId: 'app@1.0.0' }), + 'collision-same-build': () => disabledThenFull({ fullId: 'app@1.0.0' }), // Without snapshot plugins, only the remote capability tells the two builds apart. 'collision-remote-without-snapshot': () => - disabledThenFull({ fullBuildId: 'app@1.0.0', disableSnapshot: true }), + disabledThenFull({ fullId: 'app@1.0.0', without: ['snapshot'] }), async 'collision-full-first'() { - const full = await bundle({ buildId: 'app@2.0.0' }); + const full = await bundle({ id: 'app@2.0.0' }); const disabled = await bundle({ - buildId: 'app@1.0.0', - disableRemote: true, + id: 'app@1.0.0', + without: noRemote, }); const initialized = full.init(options('app', '1.0.0')); const disabledInstance = disabled.init(options('app', '1.0.0')); @@ -224,10 +234,10 @@ const scenarios = { }, async 'collision-shared'() { - const full = await bundle({ buildId: 'app@2.0.0' }); + const full = await bundle({ id: 'app@2.0.0' }); const disabled = await bundle({ - buildId: 'app@1.0.0', - disableShared: true, + id: 'app@1.0.0', + without: ['shared'], }); const sharedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; const disabledInstance = disabled.init(sharedOptions); @@ -244,11 +254,8 @@ const scenarios = { }, async 'collision-snapshot'() { - const full = await bundle({ buildId: 'app@2.0.0' }); - const disabled = await bundle({ - buildId: 'app@1.0.0', - disableSnapshot: true, - }); + const full = await bundle({ id: 'app@2.0.0' }); + const disabled = await bundle({ id: 'app@1.0.0', without: ['snapshot'] }); const direct = disabled.init(options('app', '1.0.0')); const initialized = full.init({ name: 'app', @@ -269,8 +276,8 @@ const scenarios = { // A web-target runtime loads entries with the DOM loader, which cannot run in Node. async 'collision-target'() { - const web = await bundle({ buildId: 'app@1.0.0', target: 'web' }); - const node = await bundle({ buildId: 'app@1.0.0' }); + const web = await bundle({ id: 'app@1.0.0', target: 'web' }); + const node = await bundle({ id: 'app@1.0.0' }); const webInstance = web.init(options('app', '1.0.0')); const nodeInstance = node.init(options('app', '1.0.0')); return { @@ -281,17 +288,11 @@ const scenarios = { }, async 'compose-distinct-capabilities'() { - const full = await bundle({ buildId: 'app@1.0.0' }); - const sharedOnly = await bundle({ buildId: 'app@1.0.0' }); + const full = await bundle(); + const sharedOnly = await bundle({ without: noRemote }); const composedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; - const remoteInstance = full.composeInit( - composedOptions, - fullCapabilities(full), - ); - const sharedInstance = sharedOnly.composeInit(composedOptions, { - shared: sharedOnly.shared, - platform: sharedOnly.node, - }); + const remoteInstance = full.init(composedOptions); + const sharedInstance = sharedOnly.init(composedOptions); const shared = await sharedInstance.loadShare('token'); return { separate: sharedInstance !== remoteInstance, @@ -303,13 +304,10 @@ const scenarios = { }, async 'compose-root-reuse'() { - const root = await bundle({ buildId: 'app@1.0.0' }); - const composed = await bundle({ buildId: 'app@1.0.0' }); - const rootInstance = root.init(options('app', '1.0.0')); - const kernel = composed.composeInit( - options('app', '1.0.0'), - fullCapabilities(composed), - ); + const root = await bundle(); + const composed = await bundle(); + const rootInstance = root.rootInit(options('app', '1.0.0')); + const kernel = composed.init(options('app', '1.0.0')); return { reused: kernel === rootInstance, moduleValue: await value(composed), @@ -318,11 +316,8 @@ const scenarios = { }, async 'compose-capabilities-string'() { - const runtime = await bundle({ buildId: 'app@1.0.0' }); - const kernel = runtime.composeInit( - options('app', '1.0.0'), - fullCapabilities(runtime), - ); + const runtime = await bundle(); + const kernel = runtime.init(options('app', '1.0.0')); return { root: runtime.ModuleFederation.runtimeCapabilities, rootInstance: new runtime.ModuleFederation({ name: 'root' }) @@ -332,16 +327,9 @@ const scenarios = { }, }; -const fullCapabilities = ({ shared, remote, snapshot, node }) => ({ - shared, - remote, - snapshot, - platform: node, -}); - -async function disabledThenFull({ fullBuildId, disableSnapshot = false }) { - const full = await bundle({ buildId: fullBuildId, disableSnapshot }); - const disabled = await bundle({ buildId: 'app@1.0.0', disableRemote: true }); +async function disabledThenFull({ fullId, without = [] }) { + const full = await bundle({ id: fullId, without }); + const disabled = await bundle({ id: 'app@1.0.0', without: noRemote }); const disabledInstance = disabled.init(options('app', '1.0.0')); const initialized = full.init(options('app', '1.0.0')); const repeated = full.init(options('app', '1.0.0')); From 9861d70258ddb4c0908c4b0e6a0716ec06f24ea8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 23:29:31 +0000 Subject: [PATCH 38/38] Revert "test(runtime): compose the optimized-runtime scenarios instead of reading removed defines" This reverts commit 9f596f0058b8a4cbd1d08fdebd9b265160f07fec. --- .../__tests__/fixtures/optimized-runtimes.mjs | 140 ++++++++++-------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs index c58c4187d76..dc1ee11c532 100644 --- a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -1,6 +1,6 @@ -// Bundles independent copies of @module-federation/runtime, each composed with its own -// capabilities and build id, and runs one scenario. Each scenario runs in its own -// process, so every scenario starts with an empty __FEDERATION__ global. +// Bundles independent copies of @module-federation/runtime, each with its own build-time +// flags, and runs one scenario. Each scenario runs in its own process, so every scenario +// starts with an empty __FEDERATION__ global. import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -13,17 +13,20 @@ const coreSrc = path.join(packageDir, 'runtime-core/src'); const bundleEntry = ` export * from ${JSON.stringify(path.join(runtimeDir, 'src/index.ts'))}; -export { init as composeInit, createInstance as composeCreateInstance } from ${JSON.stringify(path.join(runtimeDir, 'src/compose.ts'))}; +export { init as composeInit } from ${JSON.stringify(path.join(runtimeDir, 'src/compose.ts'))}; export { remote } from ${JSON.stringify(path.join(coreSrc, 'remote/capability.ts'))}; export { shared } from ${JSON.stringify(path.join(coreSrc, 'shared/capability.ts'))}; export { snapshot } from ${JSON.stringify(path.join(coreSrc, 'plugins/snapshot/capability.ts'))}; export { node } from ${JSON.stringify(path.join(coreSrc, 'platform/node.ts'))}; -export { web } from ${JSON.stringify(path.join(coreSrc, 'platform/web.ts'))}; `; -// Like the bundler's composed bootstrap, init and createInstance pass the bundle's -// capabilities to runtime/compose, and init passes the build id as options.id. -async function bundle({ id, without = [], target = 'node' } = {}) { +async function bundle({ + buildId, + disableRemote = false, + disableShared = false, + disableSnapshot = false, + target = 'node', +}) { const result = await build({ stdin: { contents: bundleEntry, resolveDir: runtimeDir, loader: 'ts' }, bundle: true, @@ -44,6 +47,10 @@ async function bundle({ id, without = [], target = 'node' } = {}) { define: { __VERSION__: '"test"', FEDERATION_DEBUG: '"true"', + FEDERATION_OPTIMIZE_NO_REMOTE: String(disableRemote), + FEDERATION_OPTIMIZE_NO_SHARED: String(disableShared), + FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: String(disableSnapshot), + FEDERATION_BUILD_IDENTIFIER: JSON.stringify(buildId), ENV_TARGET: JSON.stringify(target), }, }); @@ -53,27 +60,9 @@ async function bundle({ id, without = [], target = 'node' } = {}) { module, module.exports, ); - const runtime = module.exports; - const capabilities = Object.fromEntries( - Object.entries({ - remote: runtime.remote, - shared: runtime.shared, - snapshot: runtime.snapshot, - platform: runtime[target], - }).filter(([part]) => !without.includes(part)), - ); - return { - ...runtime, - rootInit: runtime.init, - init: (options) => - runtime.composeInit({ ...options, id: options.id || id }, capabilities), - createInstance: (options) => - runtime.composeCreateInstance(options, capabilities), - }; + return module.exports; } -const noRemote = ['remote', 'snapshot']; - const server = createServer((request, response) => { if (request.url === '/mf-manifest.json') { response.setHeader('Content-Type', 'application/json'); @@ -138,10 +127,10 @@ const instances = (pick) => globalThis.__FEDERATION__.__INSTANCES__.map(pick); const scenarios = { async 'distinct-last-disabled'() { - const full = await bundle({ id: 'full-app@1.0.0' }); + const full = await bundle({ buildId: 'full-app@1.0.0' }); const disabled = await bundle({ - id: 'disabled-app@1.0.0', - without: noRemote, + buildId: 'disabled-app@1.0.0', + disableRemote: true, }); const disabledInstance = disabled.createInstance(options('disabled-app')); const created = full.createInstance(options('full-created')); @@ -157,10 +146,10 @@ const scenarios = { async 'distinct-last-full'() { const disabled = await bundle({ - id: 'disabled-app@1.0.0', - without: noRemote, + buildId: 'disabled-app@1.0.0', + disableRemote: true, }); - const full = await bundle({ id: 'full-app@1.0.0' }); + const full = await bundle({ buildId: 'full-app@1.0.0' }); const initialized = full.init(options('full-app')); const disabledInstance = disabled.init(options('disabled-app')); return { @@ -173,10 +162,10 @@ const scenarios = { }, async 'version-isolated'() { - const full = await bundle({ id: 'app@2.0.0' }); + const full = await bundle({ buildId: 'app@2.0.0' }); const disabled = await bundle({ - id: 'app@1.0.0', - without: noRemote, + buildId: 'app@1.0.0', + disableRemote: true, }); const disabledInstance = disabled.init(options('app', '1.0.0')); const initialized = full.init(options('app', '2.0.0')); @@ -189,8 +178,8 @@ const scenarios = { }, async 'compatible-reuse'() { - const full = await bundle({ id: 'app@1.0.0' }); - const compatible = await bundle({ id: 'app@2.0.0' }); + const full = await bundle({ buildId: 'app@1.0.0' }); + const compatible = await bundle({ buildId: 'app@2.0.0' }); const first = full.init({ ...options('app', '1.0.0'), shared: sharedToken, @@ -209,19 +198,20 @@ const scenarios = { }; }, - 'collision-different-build': () => disabledThenFull({ fullId: 'app@2.0.0' }), + 'collision-different-build': () => + disabledThenFull({ fullBuildId: 'app@2.0.0' }), - 'collision-same-build': () => disabledThenFull({ fullId: 'app@1.0.0' }), + 'collision-same-build': () => disabledThenFull({ fullBuildId: 'app@1.0.0' }), // Without snapshot plugins, only the remote capability tells the two builds apart. 'collision-remote-without-snapshot': () => - disabledThenFull({ fullId: 'app@1.0.0', without: ['snapshot'] }), + disabledThenFull({ fullBuildId: 'app@1.0.0', disableSnapshot: true }), async 'collision-full-first'() { - const full = await bundle({ id: 'app@2.0.0' }); + const full = await bundle({ buildId: 'app@2.0.0' }); const disabled = await bundle({ - id: 'app@1.0.0', - without: noRemote, + buildId: 'app@1.0.0', + disableRemote: true, }); const initialized = full.init(options('app', '1.0.0')); const disabledInstance = disabled.init(options('app', '1.0.0')); @@ -234,10 +224,10 @@ const scenarios = { }, async 'collision-shared'() { - const full = await bundle({ id: 'app@2.0.0' }); + const full = await bundle({ buildId: 'app@2.0.0' }); const disabled = await bundle({ - id: 'app@1.0.0', - without: ['shared'], + buildId: 'app@1.0.0', + disableShared: true, }); const sharedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; const disabledInstance = disabled.init(sharedOptions); @@ -254,8 +244,11 @@ const scenarios = { }, async 'collision-snapshot'() { - const full = await bundle({ id: 'app@2.0.0' }); - const disabled = await bundle({ id: 'app@1.0.0', without: ['snapshot'] }); + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableSnapshot: true, + }); const direct = disabled.init(options('app', '1.0.0')); const initialized = full.init({ name: 'app', @@ -276,8 +269,8 @@ const scenarios = { // A web-target runtime loads entries with the DOM loader, which cannot run in Node. async 'collision-target'() { - const web = await bundle({ id: 'app@1.0.0', target: 'web' }); - const node = await bundle({ id: 'app@1.0.0' }); + const web = await bundle({ buildId: 'app@1.0.0', target: 'web' }); + const node = await bundle({ buildId: 'app@1.0.0' }); const webInstance = web.init(options('app', '1.0.0')); const nodeInstance = node.init(options('app', '1.0.0')); return { @@ -288,11 +281,17 @@ const scenarios = { }, async 'compose-distinct-capabilities'() { - const full = await bundle(); - const sharedOnly = await bundle({ without: noRemote }); + const full = await bundle({ buildId: 'app@1.0.0' }); + const sharedOnly = await bundle({ buildId: 'app@1.0.0' }); const composedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; - const remoteInstance = full.init(composedOptions); - const sharedInstance = sharedOnly.init(composedOptions); + const remoteInstance = full.composeInit( + composedOptions, + fullCapabilities(full), + ); + const sharedInstance = sharedOnly.composeInit(composedOptions, { + shared: sharedOnly.shared, + platform: sharedOnly.node, + }); const shared = await sharedInstance.loadShare('token'); return { separate: sharedInstance !== remoteInstance, @@ -304,10 +303,13 @@ const scenarios = { }, async 'compose-root-reuse'() { - const root = await bundle(); - const composed = await bundle(); - const rootInstance = root.rootInit(options('app', '1.0.0')); - const kernel = composed.init(options('app', '1.0.0')); + const root = await bundle({ buildId: 'app@1.0.0' }); + const composed = await bundle({ buildId: 'app@1.0.0' }); + const rootInstance = root.init(options('app', '1.0.0')); + const kernel = composed.composeInit( + options('app', '1.0.0'), + fullCapabilities(composed), + ); return { reused: kernel === rootInstance, moduleValue: await value(composed), @@ -316,8 +318,11 @@ const scenarios = { }, async 'compose-capabilities-string'() { - const runtime = await bundle(); - const kernel = runtime.init(options('app', '1.0.0')); + const runtime = await bundle({ buildId: 'app@1.0.0' }); + const kernel = runtime.composeInit( + options('app', '1.0.0'), + fullCapabilities(runtime), + ); return { root: runtime.ModuleFederation.runtimeCapabilities, rootInstance: new runtime.ModuleFederation({ name: 'root' }) @@ -327,9 +332,16 @@ const scenarios = { }, }; -async function disabledThenFull({ fullId, without = [] }) { - const full = await bundle({ id: fullId, without }); - const disabled = await bundle({ id: 'app@1.0.0', without: noRemote }); +const fullCapabilities = ({ shared, remote, snapshot, node }) => ({ + shared, + remote, + snapshot, + platform: node, +}); + +async function disabledThenFull({ fullBuildId, disableSnapshot = false }) { + const full = await bundle({ buildId: fullBuildId, disableSnapshot }); + const disabled = await bundle({ buildId: 'app@1.0.0', disableRemote: true }); const disabledInstance = disabled.init(options('app', '1.0.0')); const initialized = full.init(options('app', '1.0.0')); const repeated = full.init(options('app', '1.0.0'));