diff --git a/.changeset/bright-runtimes-load.md b/.changeset/bright-runtimes-load.md new file mode 100644 index 00000000000..264e780252a --- /dev/null +++ b/.changeset/bright-runtimes-load.md @@ -0,0 +1,6 @@ +--- +'@module-federation/runtime': patch +'@module-federation/runtime-core': patch +--- + +`createInstance` now constructs the runtime's own `ModuleFederation` class, not a debug constructor that another bundle set on the global. `init` reuses a global instance only when it supports the same remote loading, shared loading, snapshot plugins, and build target as the calling runtime. Updated runtimes do not reuse instances created by older runtimes. diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index d514911436e..6a80a7137b5 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -53,6 +53,7 @@ import { formatShareConfigs } from './utils/share'; declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; +declare const ENV_TARGET: 'web' | 'node'; type BridgeHookContext = object; type BridgeHookResult = { @@ -72,7 +73,26 @@ const USE_SHARED = ? !FEDERATION_OPTIMIZE_NO_SHARED : true; +// What this build of the runtime can do. `init` in another bundle reuses an instance only +// when this string matches its own: remote loading, shared loading, snapshot plugins, and +// the entry loader the build target selects. +const RUNTIME_CAPABILITIES = [ + USE_REMOTE && 'remote', + USE_SHARED && 'shared', + USE_REMOTE && USE_SNAPSHOT && 'snapshot', + typeof ENV_TARGET !== 'undefined' ? ENV_TARGET : 'universal', +] + .filter(Boolean) + .join(','); + export class ModuleFederation { + /** @internal A getter, because a static field compiles to a static block that Metro's Babel rejects. */ + static get runtimeCapabilities(): string { + return RUNTIME_CAPABILITIES; + } + /** @internal Read by other bundles, so it lives on the instance. */ + readonly runtimeCapabilities = RUNTIME_CAPABILITIES; + options: Options; hooks = new PluginSystem({ beforeInit: new SyncWaterfallHook<{ diff --git a/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs new file mode 100644 index 00000000000..a9cb6b22db1 --- /dev/null +++ b/packages/runtime/__tests__/fixtures/optimized-runtimes.mjs @@ -0,0 +1,298 @@ +// 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'; +import { build } from 'esbuild'; + +const runtimeDir = path.resolve(import.meta.dirname, '../..'); +const packageDir = path.resolve(runtimeDir, '..'); +const require = createRequire(path.join(runtimeDir, 'package.json')); + +async function bundle({ + buildId, + disableRemote = false, + disableShared = false, + disableSnapshot = false, + target = 'node', +}) { + const result = await build({ + entryPoints: [path.join(runtimeDir, 'src/index.ts')], + bundle: true, + write: false, + platform: 'node', + format: 'cjs', + alias: { + '@module-federation/runtime-core': path.join( + packageDir, + 'runtime-core/src/index.ts', + ), + '@module-federation/error-codes/browser': path.join( + packageDir, + 'error-codes/src/browser.ts', + ), + '@module-federation/error-codes': path.join( + packageDir, + 'error-codes/src/index.ts', + ), + }, + 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), + }, + }); + const module = { exports: {} }; + new Function('require', 'module', 'exports', result.outputFiles[0].text)( + require, + module, + module.exports, + ); + return module.exports; +} + +const server = createServer((request, response) => { + if (request.url === '/mf-manifest.json') { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + id: 'manifest-tiny', + name: 'manifest-tiny', + metaData: { + globalName: 'manifestTiny', + publicPath: `http://${request.headers.host}/`, + buildInfo: { buildVersion: '1.0.0' }, + remoteEntry: { + name: 'remoteEntry.js', + path: '', + type: 'commonjs-module', + }, + }, + remotes: [], + shared: [], + exposes: [], + }), + ); + return; + } + response.setHeader('Content-Type', 'text/javascript'); + response.end(`module.exports = { + init() {}, + get(expose) { + if (expose !== './value') throw new Error('Unexpected expose: ' + expose); + return () => ({ value: 'remote-value' }); + } + };`); +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const entry = `http://127.0.0.1:${server.address().port}/remoteEntry.js`; + +const options = (name, version, remoteName = 'tiny') => ({ + name, + version, + remotes: [{ name: remoteName, entry }], +}); +const sharedToken = { + token: { + version: '1.0.0', + get: async () => () => ({ value: 'shared-value' }), + }, +}; +const value = async (runtime, id = 'tiny/value') => + (await runtime.loadRemote(id))?.value; +const errorOf = (promise, what) => + promise.then( + () => `${what} unexpectedly succeeded`, + (error) => error.message, + ); +const loadError = (runtime) => + errorOf( + Promise.resolve().then(() => runtime.loadRemote('tiny/value')), + 'Remote loading', + ); +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 disabled = await bundle({ + buildId: 'disabled-app@1.0.0', + disableRemote: true, + }); + const disabledInstance = disabled.createInstance(options('disabled-app')); + const created = full.createInstance(options('full-created')); + const initialized = full.init(options('full-initialized')); + return { + disabledError: await loadError(disabledInstance), + createdValue: await value(created), + initializedValue: await value(initialized), + moduleValue: await value(full), + instanceNames: instances((instance) => instance.name), + }; + }, + + async 'distinct-last-full'() { + const disabled = await bundle({ + buildId: 'disabled-app@1.0.0', + disableRemote: true, + }); + 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 { + fullValue: await value(initialized), + fullModuleValue: await value(full), + disabledError: await loadError(disabledInstance), + disabledModuleError: await loadError(disabled), + instanceNames: instances((instance) => instance.name), + }; + }, + + async 'version-isolated'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + 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', '2.0.0')); + return { + disabledError: await loadError(disabledInstance), + fullValue: await value(initialized), + fullModuleValue: await value(full), + versions: instances((instance) => instance.options.version), + }; + }, + + async 'compatible-reuse'() { + 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, + }); + const fresh = full.createInstance(options('app', '1.0.0', 'fresh')); + const repeated = compatible.init(options('app', '1.0.0')); + const shared = await repeated.loadShare('token'); + return { + firstValue: await value(first), + repeatedValue: await value(repeated), + moduleValue: await value(full), + compatibleModuleValue: await value(compatible), + freshValue: await value(fresh, 'fresh/value'), + sharedValue: shared?.()?.value, + instanceCount: globalThis.__FEDERATION__.__INSTANCES__.length, + }; + }, + + 'collision-different-build': () => + disabledThenFull({ fullBuildId: 'app@2.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({ fullBuildId: 'app@1.0.0', disableSnapshot: true }), + + async 'collision-full-first'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + 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')); + return { + fullValue: await value(initialized), + disabledError: await loadError(disabledInstance), + disabledModuleError: await loadError(disabled), + registeredIds: instances((instance) => instance.options.id), + }; + }, + + async 'collision-shared'() { + const full = await bundle({ buildId: 'app@2.0.0' }); + const disabled = await bundle({ + buildId: 'app@1.0.0', + disableShared: true, + }); + const sharedOptions = { ...options('app', '1.0.0'), shared: sharedToken }; + const disabledInstance = disabled.init(sharedOptions); + const initialized = full.init(sharedOptions); + const shared = await initialized.loadShare('token'); + return { + disabledError: await errorOf( + disabledInstance.loadShare('token'), + 'Shared loading', + ), + sharedValue: shared?.()?.value, + fullValue: await value(initialized), + }; + }, + + async 'collision-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', + version: '1.0.0', + remotes: [ + { + name: 'manifest-tiny', + entry: entry.replace('remoteEntry.js', 'mf-manifest.json'), + }, + ], + }); + return { + directValue: await value(direct), + manifestValue: await value(initialized, 'manifest-tiny/value'), + moduleValue: await value(full, 'manifest-tiny/value'), + }; + }, + + // 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 webInstance = web.init(options('app', '1.0.0')); + const nodeInstance = node.init(options('app', '1.0.0')); + return { + separate: webInstance !== nodeInstance, + nodeValue: await value(nodeInstance), + nodeModuleValue: await value(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')); + return { + disabledError: await loadError(disabledInstance), + fullValue: await value(initialized), + fullModuleValue: await value(full), + repeatedValue: await value(repeated), + registeredIds: instances((instance) => instance.options.id), + }; +} + +const scenario = process.argv[2]; +try { + if (!scenarios[scenario]) throw new Error(`Unknown scenario: ${scenario}`); + console.log(JSON.stringify(await scenarios[scenario]())); +} finally { + await new Promise((resolve) => server.close(resolve)); +} diff --git a/packages/runtime/__tests__/global.spec.ts b/packages/runtime/__tests__/global.spec.ts index 73f58bcedf4..6a40d2fae5e 100644 --- a/packages/runtime/__tests__/global.spec.ts +++ b/packages/runtime/__tests__/global.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, rs, expect } from '@rstest/core'; -import { init, loadRemote, loadShare, loadShareSync } from '../src/index'; +import { describe, it, expect } from '@rstest/core'; +import { loadRemote, loadShare, loadShareSync } from '../src/index'; import { getInfoWithoutType } from '@module-federation/runtime-core'; type IsAssignable = [Actual] extends [Expected] @@ -8,22 +8,6 @@ type IsAssignable = [Actual] extends [Expected] type ExpectFalse = T; describe('global', () => { - it('inject mode', () => { - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = rs.fn(); - const injectArgs = { - name: '@federation/inject-mode', - remotes: [], - }; - const GM = init(injectArgs); - expect(GM.constructor).toBe( - globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__, - ); - expect(globalThis.__FEDERATION__.__DEBUG_CONSTRUCTOR__).toBeCalledWith({ - ...injectArgs, - id: '', - }); - }); - it('getInfoWithoutType', () => { const snapshot = { '@federation/app1': 1, diff --git a/packages/runtime/__tests__/optimized-runtimes.spec.ts b/packages/runtime/__tests__/optimized-runtimes.spec.ts new file mode 100644 index 00000000000..a830f386eda --- /dev/null +++ b/packages/runtime/__tests__/optimized-runtimes.spec.ts @@ -0,0 +1,110 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from '@rstest/core'; + +const execFileAsync = promisify(execFile); +const remoteDisabledError = + 'Remote loading is disabled by experiments.optimization.disableRemote.'; + +async function runScenario(scenario: string) { + const { stdout } = await execFileAsync( + process.execPath, + [path.resolve(__dirname, 'fixtures/optimized-runtimes.mjs'), scenario], + { cwd: path.resolve(__dirname, '..') }, + ); + return JSON.parse(stdout); +} + +describe('independently optimized runtimes', () => { + it('loads a remote through full instances when a remote-disabled runtime registers last', async () => { + expect(await runScenario('distinct-last-disabled')).toEqual({ + disabledError: remoteDisabledError, + createdValue: 'remote-value', + initializedValue: 'remote-value', + moduleValue: 'remote-value', + instanceNames: ['disabled-app', 'full-created', 'full-initialized'], + }); + }); + + it('keeps both capabilities when the full runtime registers last', async () => { + expect(await runScenario('distinct-last-full')).toEqual({ + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + disabledError: remoteDisabledError, + disabledModuleError: remoteDisabledError, + instanceNames: ['full-app', 'disabled-app'], + }); + }); + + it('keeps same-name apps separate when their configured app versions differ', async () => { + expect(await runScenario('version-isolated')).toEqual({ + disabledError: remoteDisabledError, + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + versions: ['1.0.0', '2.0.0'], + }); + }); + + it('preserves shared state across compatible bundles while createInstance stays fresh', async () => { + expect(await runScenario('compatible-reuse')).toEqual({ + firstValue: 'remote-value', + repeatedValue: 'remote-value', + moduleValue: 'remote-value', + compatibleModuleValue: 'remote-value', + freshValue: 'remote-value', + sharedValue: 'shared-value', + instanceCount: 2, + }); + }); + + for (const [scenario, registeredIds] of [ + ['collision-different-build', ['app@1.0.0', 'app@2.0.0']], + ['collision-same-build', ['app@1.0.0', 'app@1.0.0']], + ['collision-remote-without-snapshot', ['app@1.0.0', 'app@1.0.0']], + ] as const) { + it(`skips incompatible instances with ${scenario}`, async () => { + expect(await runScenario(scenario)).toEqual({ + disabledError: remoteDisabledError, + fullValue: 'remote-value', + fullModuleValue: 'remote-value', + repeatedValue: 'remote-value', + registeredIds, + }); + }); + } + + it('keeps the disabled runtime disabled when the full instance registers first', async () => { + expect(await runScenario('collision-full-first')).toEqual({ + fullValue: 'remote-value', + disabledError: remoteDisabledError, + disabledModuleError: remoteDisabledError, + registeredIds: ['app@2.0.0', 'app@1.0.0'], + }); + }); + + it('keeps shared loading when a same-name instance disables sharing', async () => { + expect(await runScenario('collision-shared')).toEqual({ + disabledError: + 'Shared dependency loading is disabled by experiments.optimization.disableShared.', + sharedValue: 'shared-value', + fullValue: 'remote-value', + }); + }); + + it('keeps a Node runtime separate from a same-identity web runtime', async () => { + expect(await runScenario('collision-target')).toEqual({ + separate: true, + nodeValue: 'remote-value', + nodeModuleValue: 'remote-value', + }); + }); + + it('loads a manifest when a same-name instance disables snapshot plugins', async () => { + expect(await runScenario('collision-snapshot')).toEqual({ + directValue: 'remote-value', + manifestValue: 'remote-value', + moduleValue: 'remote-value', + }); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4f3254b33d4..58294e915d3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,7 +2,6 @@ import { ModuleFederation, type UserOptions, CurrentGlobal, - getGlobalFederationConstructor, setGlobalFederationInstance, assert, setGlobalFederationConstructor, @@ -26,10 +25,7 @@ export { export { ModuleFederation }; export function createInstance(options: UserOptions) { - // Retrieve debug constructor - const ModuleFederationConstructor = - getGlobalFederationConstructor() || ModuleFederation; - const instance = new ModuleFederationConstructor({ + const instance = new ModuleFederation({ id: `${options.name}@${options.version || Date.now()}`, ...options, }); diff --git a/packages/runtime/src/utils.ts b/packages/runtime/src/utils.ts index 5b71cf2d5c2..8f2c8494aa2 100644 --- a/packages/runtime/src/utils.ts +++ b/packages/runtime/src/utils.ts @@ -1,5 +1,7 @@ -import type { ModuleFederation } from '@module-federation/runtime-core'; -import { CurrentGlobal } from '@module-federation/runtime-core'; +import { + CurrentGlobal, + ModuleFederation, +} from '@module-federation/runtime-core'; // injected by bundler, so it can not use runtime-core stuff export function getBuilderId(): string { @@ -17,6 +19,13 @@ export function getGlobalFederationInstance( const buildId = getBuilderId(); return CurrentGlobal.__FEDERATION__.__INSTANCES__.find( (GMInstance: ModuleFederation) => { + // An instance from an older runtime has no capabilities to compare, so it is not reused. + if ( + GMInstance.runtimeCapabilities !== ModuleFederation.runtimeCapabilities + ) { + return false; + } + if (buildId && GMInstance.options.id === buildId) { return true; }