diff --git a/.changeset/external-runtime-minimum-contract.md b/.changeset/external-runtime-minimum-contract.md new file mode 100644 index 00000000000..d6efcc36868 --- /dev/null +++ b/.changeset/external-runtime-minimum-contract.md @@ -0,0 +1,5 @@ +--- +'@module-federation/inject-external-runtime-core-plugin': patch +--- + +Keep legacy providers compatible with older runtime cores and report a minimum-contract error before publishing or reusing runtime-image metadata when the core cannot check image compatibility. diff --git a/.changeset/quiet-node-payload.md b/.changeset/quiet-node-payload.md new file mode 100644 index 00000000000..b1739d59623 --- /dev/null +++ b/.changeset/quiet-node-payload.md @@ -0,0 +1,5 @@ +--- +'@module-federation/runtime-core': patch +--- + +Reject malformed image-backed Node entry exports instead of falling back to a mutable global container from another evaluator. diff --git a/.changeset/rfc5036-entry-evaluator-isolation.md b/.changeset/rfc5036-entry-evaluator-isolation.md new file mode 100644 index 00000000000..6d61d3efb8d --- /dev/null +++ b/.changeset/rfc5036-entry-evaluator-isolation.md @@ -0,0 +1,5 @@ +--- +'@module-federation/runtime-core': patch +--- + +Isolate image-backed remote entry caches for host-specific evaluators and URL transforms while preserving default loader deduplication and legacy reuse. diff --git a/.changeset/runtime-image-compatibility.md b/.changeset/runtime-image-compatibility.md new file mode 100644 index 00000000000..928eb7aee20 --- /dev/null +++ b/.changeset/runtime-image-compatibility.md @@ -0,0 +1,16 @@ +--- +'@module-federation/enhanced': minor +'@module-federation/runtime': minor +'@module-federation/runtime-core': minor +'@module-federation/inject-external-runtime-core-plugin': patch +--- + +Carry runtime-image metadata on runtime instances, the external runtime-core +provider, and remote-entry cache entries. When both sides carry metadata, a +known family, target, capability, or entry-loader mismatch fails before the +instance runs plugins, before the external core is reused, and before a cached +remote entry is reused. + +Every check is inert without metadata. Builds that do not use +`module-federation:*` conditions keep today's behavior, and the remote-entry +cache key stays `name` plus `entry`. diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index b5242658215..4044e32779f 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -14,6 +14,7 @@ import { normalizeRuntimeInitOptionsWithOutShared, createHash, normalizeToPosixPath, + type NormalizedRuntimeInitOptionsWithOutShared, } from './utils'; import { expectedEntry, @@ -118,6 +119,7 @@ class FederationRuntimePlugin { bundlerRuntimePath: string; runtimePath: string; runtimeToolsPath: string; + runtimeInitOptions?: NormalizedRuntimeInitOptionsWithOutShared; federationRuntimeDependency?: FederationRuntimeDependency; // Add this line constructor(options?: moduleFederationPlugin.ModuleFederationPluginOptions) { @@ -345,6 +347,7 @@ class FederationRuntimePlugin { const initOptionsWithoutShared = normalizeRuntimeInitOptionsWithOutShared( this.options, ); + this.runtimeInitOptions = initOptionsWithoutShared; const federationGlobal = getFederationGlobalScope( RuntimeGlobals || ({} as typeof RuntimeGlobals), ); @@ -422,6 +425,33 @@ class FederationRuntimePlugin { this.runtimeToolsPath = expectedEntry(image, '@module-federation/runtime-tools$') ?? image.family.members['runtime-tools'].entry; + if ( + image.mode === 'conditions' && + selection.profile && + this.runtimeInitOptions + ) { + const capabilities = [ + 'remote', + 'shared', + 'snapshotPlugins', + 'containerEntry', + ] as const; + this.runtimeInitOptions.runtimeImage = { + contract: 1, + compatibilityId: image.family.compatibilityId, + required: capabilities.filter( + (capability) => selection.profile?.[capability] === 'required', + ), + forbidden: capabilities.filter( + (capability) => selection.profile?.[capability] === 'forbidden', + ), + available: capabilities.filter( + (capability) => selection.profile?.[capability] !== 'forbidden', + ), + target: selection.profile.target, + entryLoadingIdentity: image.entryLoadingIdentity, + }; + } this.setRuntimeAlias(compiler); this.entryFilePath = this.getFilePath(compiler); } diff --git a/packages/enhanced/src/lib/container/runtime/utils.ts b/packages/enhanced/src/lib/container/runtime/utils.ts index bae723e5d78..e0127b076b8 100644 --- a/packages/enhanced/src/lib/container/runtime/utils.ts +++ b/packages/enhanced/src/lib/container/runtime/utils.ts @@ -17,6 +17,15 @@ export interface NormalizedRuntimeInitOptionsWithOutShared { remotes: Array< Remotes[0] & { externalType: moduleFederationPlugin.ExternalsType } >; + runtimeImage?: { + contract: 1; + compatibilityId: string; + required: string[]; + forbidden: string[]; + available: string[]; + target: string; + entryLoadingIdentity: string; + }; } const extractUrlAndGlobal = require( diff --git a/packages/runtime-core/__tests__/load.spec.ts b/packages/runtime-core/__tests__/load.spec.ts index 90c17f595b5..c703521469d 100644 --- a/packages/runtime-core/__tests__/load.spec.ts +++ b/packages/runtime-core/__tests__/load.spec.ts @@ -5,7 +5,15 @@ import { getRemoteInfo, } from '../src/utils/load'; import { ModuleFederation } from '../src/core'; -import { globalLoading, resetFederationGlobalInfo } from '../src/global'; +import { + globalLoading, + globalLoadingMeta, + resetFederationGlobalInfo, +} from '../src/global'; +import { + attachRuntimeImage, + type RuntimeImageDescriptorV1, +} from '../src/runtimeImage'; import { RUNTIME_001, RUNTIME_008, @@ -13,6 +21,7 @@ import { } from '@module-federation/error-codes'; import { mockStaticServer, removeScriptTags } from './mock/utils'; import type { ModuleFederationRuntimePlugin } from '../src/type/plugin'; +import { logger } from '../src/utils/logger'; // All fixture URLs are served via two complementary mechanisms both pointing to __tests__/: // 1. mockScriptDomResponse (setup.ts) — patches Element.prototype.appendChild, executes @@ -28,6 +37,18 @@ mockStaticServer({ }); const createMF = () => new ModuleFederation({ name: 'test-host', remotes: [] }); +const runtimeImage = ( + overrides: Partial = {}, +): RuntimeImageDescriptorV1 => ({ + contract: 1, + compatibilityId: 'runtime-family', + required: ['remote'], + forbidden: [], + available: ['remote', 'shared'], + target: 'web', + entryLoadingIdentity: 'web-loader', + ...overrides, +}); const createDataUrlEntry = (code: string) => `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`; @@ -495,6 +516,521 @@ describe('getRemoteEntry - globalLoading rejection cache', () => { await expect(cached).resolves.toBe(container); }); + it('rejects cache reuse across known runtime families', async () => { + const container = { get: rs.fn(), init: rs.fn() }; + let secondOriginAttempts = 0; + const firstOrigin = new ModuleFederation({ + name: 'cache-family-a', + remotes: [], + plugins: [ + { + name: 'family-a-entry', + loadEntry() { + return container; + }, + }, + ], + }); + const secondOrigin = new ModuleFederation({ + name: 'cache-family-b', + remotes: [], + plugins: [ + { + name: 'family-b-entry', + loadEntry() { + secondOriginAttempts += 1; + return container; + }, + }, + ], + }); + attachRuntimeImage(firstOrigin, runtimeImage()); + attachRuntimeImage( + secondOrigin, + runtimeImage({ compatibilityId: 'other-family' }), + ); + const remoteInfo = getRemoteInfo({ + name: 'family-cache-remote', + entry: 'https://remote.test/family-cache.js', + }); + + await expect( + getRemoteEntry({ origin: firstOrigin, remoteInfo }), + ).resolves.toBe(container); + await expect( + getRemoteEntry({ origin: secondOrigin, remoteInfo }), + ).rejects.toThrow( + 'compatibilityId changed from runtime-family to other-family', + ); + expect(secondOriginAttempts).toBe(0); + }); + + it.each([false, true])( + 'isolates distinct host evaluators with matching image metadata (reverse=%s)', + async (reverse) => { + const containers = [ + { get: rs.fn(), init: rs.fn() }, + { get: rs.fn(), init: rs.fn() }, + ]; + const evaluators = containers.map((container, index) => ({ + name: `distinct-evaluator-${index}`, + loadEntry: rs.fn(() => container), + })); + const hosts = evaluators.map((plugin, index) => { + const host = new ModuleFederation({ + name: `evaluator-host-${index}`, + remotes: [], + plugins: [plugin], + }); + attachRuntimeImage(host, runtimeImage()); + return host; + }); + const first = reverse ? 1 : 0; + const second = 1 - first; + const remoteInfo = getRemoteInfo({ + name: 'evaluator-remote', + entry: 'https://remote.test/evaluator.js', + }); + await expect( + getRemoteEntry({ origin: hosts[first], remoteInfo }), + ).resolves.toBe(containers[first]); + await expect( + getRemoteEntry({ origin: hosts[second], remoteInfo }), + ).resolves.toBe(containers[second]); + expect(evaluators[second].loadEntry).toHaveBeenCalledTimes(1); + }, + ); + + it.each([false, true])( + 'isolates distinct createScript evaluators while a load is pending (reverse=%s)', + async (reverse) => { + const container = { get: rs.fn(), init: rs.fn() }; + const finishes: Array<(value: typeof container) => void> = []; + const loadEntry = rs.fn( + () => + new Promise((resolve) => { + finishes.push(resolve); + }), + ); + const hosts = [0, 1].map((index) => { + const host = new ModuleFederation({ + name: `script-evaluator-host-${index}`, + remotes: [], + plugins: [ + { + name: `script-evaluator-${index}`, + loadEntry, + createScript: rs.fn(), + }, + ], + }); + attachRuntimeImage(host, runtimeImage()); + return host; + }); + const remoteInfo = getRemoteInfo({ + name: 'pending-evaluator-remote', + entry: 'https://remote.test/pending-evaluator.js', + }); + const first = reverse ? 1 : 0; + const pending = getRemoteEntry({ origin: hosts[first], remoteInfo }); + const otherPending = getRemoteEntry({ + origin: hosts[1 - first], + remoteInfo, + }); + expect(loadEntry).toHaveBeenCalledTimes(2); + for (const finish of finishes) finish(container); + await expect(pending).resolves.toBe(container); + await expect(otherPending).resolves.toBe(container); + }, + ); + + it.each([false, true])( + 'allows a different evaluator after rejection and reset (reverse=%s)', + async (reverse) => { + const container = { get: rs.fn(), init: rs.fn() }; + const evaluators = [0, 1].map((index) => ({ + name: `retry-evaluator-${index}`, + loadEntry: rs.fn(() => Promise.resolve(container)), + })); + const first = reverse ? 1 : 0; + evaluators[first].loadEntry.mockRejectedValueOnce( + new Error('evaluator transient failure'), + ); + const hosts = evaluators.map((plugin, index) => { + const host = new ModuleFederation({ + name: `retry-evaluator-host-${index}`, + remotes: [], + plugins: [plugin], + }); + attachRuntimeImage(host, runtimeImage()); + return host; + }); + const remoteInfo = getRemoteInfo({ + name: 'retry-evaluator-remote', + entry: 'https://remote.test/retry-evaluator.js', + }); + const key = getRemoteEntryUniqueKey(remoteInfo); + await expect( + getRemoteEntry({ origin: hosts[first], remoteInfo }), + ).rejects.toThrow('evaluator transient failure'); + expect(globalLoading[key]).toBeUndefined(); + expect(globalLoadingMeta[key]).toBeUndefined(); + await expect( + getRemoteEntry({ origin: hosts[1 - first], remoteInfo }), + ).resolves.toBe(container); + resetFederationGlobalInfo(); + expect(globalLoading[key]).toBeUndefined(); + expect(globalLoadingMeta[key]).toBeUndefined(); + await expect( + getRemoteEntry({ origin: hosts[first], remoteInfo }), + ).resolves.toBe(container); + }, + ); + + it.each([false, true])( + 'isolates shared callbacks across hosts and deduplicates each host (reverse=%s)', + async (reverse) => { + const container = { get: rs.fn(), init: rs.fn() }; + const loadEntry = rs.fn(async () => { + await Promise.resolve(); + return container; + }); + const plugin = { name: 'compatible-entry-evaluator', loadEntry }; + const hosts = [0, 1].map((index) => { + const host = new ModuleFederation({ + name: `compatible-evaluator-host-${index}`, + remotes: [], + plugins: [plugin], + }); + attachRuntimeImage(host, runtimeImage()); + return host; + }); + if (reverse) hosts.reverse(); + const remoteInfo = getRemoteInfo({ + name: 'compatible-evaluator-remote', + entry: 'https://remote.test/compatible-evaluator.js', + }); + const results = await Promise.all( + hosts.map((origin) => getRemoteEntry({ origin, remoteInfo })), + ); + expect(results).toEqual([container, container]); + expect(loadEntry).toHaveBeenCalledTimes(2); + await expect( + getRemoteEntry({ origin: hosts[1], remoteInfo }), + ).resolves.toBe(container); + expect(loadEntry).toHaveBeenCalledTimes(2); + }, + ); + + it.each([false, true])( + 'refuses distinct actual browser IIFE evaluators sharing a physical global (reverse=%s)', + async (reverse) => { + Reflect.deleteProperty(globalThis, 'remote'); + const labels = reverse ? ['b', 'a'] : ['a', 'b']; + const origins = labels.map((label) => { + const origin = new ModuleFederation({ + name: `browser-script-${label}`, + remotes: [], + plugins: [ + { + name: `browser-script-${label}`, + createScript() { + const script = document.createElement('script'); + script.src = `${BASE}/evaluator-${label}.js`; + return script; + }, + }, + ], + }); + attachRuntimeImage(origin, runtimeImage()); + return origin; + }); + const remoteInfo = getRemoteInfo({ + name: 'remote', + entry: `${BASE}/original-evaluator.js`, + }); + const results = await Promise.allSettled( + origins.map((origin) => getRemoteEntry({ origin, remoteInfo })), + ); + expect(results[0].status).toBe('fulfilled'); + if (results[0].status !== 'fulfilled' || !results[0].value) + throw new Error('first IIFE evaluator did not complete'); + expect((await results[0].value.get('./value'))()).toBe( + `literal-${labels[0]}`, + ); + expect(results[1].status).toBe('rejected'); + if (results[1].status !== 'rejected') + throw new Error('physical global conflict was accepted'); + expect(results[1].reason.message).toContain( + 'Distinct browser script evaluators share physical global remote', + ); + Reflect.deleteProperty(globalThis, 'remote'); + removeScriptTags(); + }, + ); + + it('keeps scoped cache keys distinct from a real entry URL with the same suffix', async () => { + const base = 'https://remote.test/key-collision.js'; + const firstContainer = { get: rs.fn(), init: rs.fn() }; + const secondContainer = { get: rs.fn(), init: rs.fn() }; + const suffixContainer = { get: rs.fn(), init: rs.fn() }; + const first = new ModuleFederation({ + name: 'collision-first', + remotes: [], + plugins: [ + { name: 'collision-first-entry', loadEntry: () => firstContainer }, + ], + }); + const second = new ModuleFederation({ + name: 'collision-second', + remotes: [], + plugins: [ + { + name: 'collision-second-entry', + loadEntry: ({ remoteInfo }) => + remoteInfo.entry === base ? secondContainer : suffixContainer, + }, + ], + }); + attachRuntimeImage(first, runtimeImage()); + attachRuntimeImage(second, runtimeImage()); + const remoteInfo = getRemoteInfo({ name: 'collision-remote', entry: base }); + await expect(getRemoteEntry({ origin: first, remoteInfo })).resolves.toBe( + firstContainer, + ); + await expect(getRemoteEntry({ origin: second, remoteInfo })).resolves.toBe( + secondContainer, + ); + await expect( + getRemoteEntry({ + origin: second, + remoteInfo: getRemoteInfo({ + name: 'collision-remote', + entry: `${base}:evaluator:1`, + }), + }), + ).resolves.toBe(suffixContainer); + }); + + it('deduplicates default platform loading across image-backed hosts', async () => { + Reflect.deleteProperty(globalThis, 'remote'); + const hosts = [0, 1].map((index) => { + const host = new ModuleFederation({ + name: `default-loader-host-${index}`, + remotes: [], + }); + attachRuntimeImage(host, runtimeImage()); + return host; + }); + const firstLoad = rs.spyOn( + hosts[0].loaderHook.lifecycle.createScript, + 'emit', + ); + const secondLoad = rs.spyOn( + hosts[1].loaderHook.lifecycle.createScript, + 'emit', + ); + const remoteInfo = getRemoteInfo({ + name: 'remote', + entry: `${BASE}/success.js`, + }); + const results = await Promise.all( + hosts.map((origin) => getRemoteEntry({ origin, remoteInfo })), + ); + expect(results[0]).toBe(results[1]); + expect(firstLoad).toHaveBeenCalledTimes(1); + expect(secondLoad).not.toHaveBeenCalled(); + firstLoad.mockRestore(); + secondLoad.mockRestore(); + Reflect.deleteProperty(globalThis, 'remote'); + removeScriptTags(); + }); + + it.each(['createScript', 'fetch', 'loadEntryError'] as const)( + 'isolates changed %s hooks within one image-backed host', + async (hook) => { + const container = { get: rs.fn(), init: rs.fn() }; + const loadEntry = rs.fn(() => container); + const origin = new ModuleFederation({ + name: `changed-${hook}-host`, + remotes: [], + plugins: [{ name: 'changed-hook-entry', loadEntry }], + }); + attachRuntimeImage(origin, runtimeImage()); + const remoteInfo = getRemoteInfo({ + name: `changed-${hook}-remote`, + entry: `https://remote.test/changed-${hook}.js`, + }); + await expect(getRemoteEntry({ origin, remoteInfo })).resolves.toBe( + container, + ); + origin.loaderHook.lifecycle[hook].on(rs.fn()); + await expect(getRemoteEntry({ origin, remoteInfo })).resolves.toBe( + container, + ); + expect(loadEntry).toHaveBeenCalledTimes(2); + }, + ); + + it.each([false, true])( + 'isolates same-host URL transforms for actual ESM evaluation (reverse=%s)', + async (reverse) => { + const origin = new ModuleFederation({ + name: 'esm-transform-host', + remotes: [], + }); + attachRuntimeImage(origin, runtimeImage()); + const remoteInfo = getRemoteInfo({ + name: 'esm-transform-remote', + entry: 'https://remote.test/original.js', + type: 'module', + }); + const transforms = ['literal-first', 'literal-second'].map((label) => ({ + label, + getEntryUrl: () => + createDataUrlEntry( + `export function get() { return () => '${label}'; } export function init() {}`, + ), + })); + if (reverse) transforms.reverse(); + for (const transform of transforms) { + const result = await getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: transform.getEntryUrl, + }); + expect(result).toBeTruthy(); + if (!result) throw new Error('ESM entry did not load'); + expect((await result.get('./value'))()).toBe(transform.label); + } + }, + ); + + it('reuses a cached entry when the retry URL policy changes', async () => { + const container = { get: rs.fn(), init: rs.fn() }; + let attempts = 0; + const origin = new ModuleFederation({ + name: 'cache-url-policy', + remotes: [], + plugins: [ + { + name: 'url-policy-entry', + loadEntry() { + attempts += 1; + return container; + }, + }, + ], + }); + const remoteInfo = getRemoteInfo({ + name: 'url-policy-remote', + entry: 'https://remote.test/url-policy.js', + }); + + await expect( + getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: (url) => `${url}?retry=1`, + }), + ).resolves.toBe(container); + await expect( + getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: (url) => `${url}?retry=2`, + }), + ).resolves.toBe(container); + await expect(getRemoteEntry({ origin, remoteInfo })).resolves.toBe( + container, + ); + expect(attempts).toBe(1); + }); + + it('ignores cache identity fields without runtime images', async () => { + const container = { get: rs.fn(), init: rs.fn() }; + const warnSpy = rs.spyOn(logger, 'warn').mockImplementation(() => {}); + const imageOrigin = new ModuleFederation({ + name: 'cache-legacy-image', + remotes: [], + plugins: [ + { + name: 'legacy-image-entry', + loadEntry() { + return container; + }, + }, + ], + }); + attachRuntimeImage(imageOrigin, runtimeImage()); + const legacyOrigin = new ModuleFederation({ + name: 'cache-legacy-plain', + remotes: [], + }); + const remote = { + name: 'legacy-identity-remote', + entry: 'https://remote.test/legacy-identity.js', + }; + + await expect( + getRemoteEntry({ + origin: imageOrigin, + remoteInfo: getRemoteInfo(remote), + }), + ).resolves.toBe(container); + await expect( + getRemoteEntry({ + origin: legacyOrigin, + remoteInfo: getRemoteInfo({ + ...remote, + type: 'module', + entryGlobalName: 'renamed-global', + }), + }), + ).resolves.toBe(container); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('does not pair stale metadata with a replacement promise', async () => { + const firstContainer = { get: rs.fn(), init: rs.fn() }; + const replacementContainer = { get: rs.fn(), init: rs.fn() }; + const firstOrigin = new ModuleFederation({ + name: 'cache-metadata-first', + remotes: [], + plugins: [ + { + name: 'cache-metadata-entry', + loadEntry() { + return firstContainer; + }, + }, + ], + }); + const secondOrigin = new ModuleFederation({ + name: 'cache-metadata-second', + remotes: [], + }); + attachRuntimeImage(firstOrigin, runtimeImage()); + attachRuntimeImage( + secondOrigin, + runtimeImage({ compatibilityId: 'replacement-family' }), + ); + const remoteInfo = getRemoteInfo({ + name: 'metadata-replacement-remote', + entry: 'https://remote.test/metadata-replacement.js', + }); + const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); + + await getRemoteEntry({ origin: firstOrigin, remoteInfo }); + globalLoading[uniqueKey] = Promise.resolve(replacementContainer); + + await expect( + getRemoteEntry({ origin: secondOrigin, remoteInfo }), + ).resolves.toBe(replacementContainer); + expect(globalLoadingMeta[uniqueKey]).toBeUndefined(); + }); + it('shares one in-flight promise across concurrent callers', async () => { const container = { get: rs.fn(), init: rs.fn() }; let attempts = 0; diff --git a/packages/runtime-core/__tests__/node-entry-payload.spec.ts b/packages/runtime-core/__tests__/node-entry-payload.spec.ts new file mode 100644 index 00000000000..eb275b8e87a --- /dev/null +++ b/packages/runtime-core/__tests__/node-entry-payload.spec.ts @@ -0,0 +1,19 @@ +import path from 'node:path'; +import { runNodeWithConditions } from '../../../tools/testing/runNodeWithConditions'; + +const packageDir = path.resolve(__dirname, '..'); + +describe('image-backed Node entry payload', () => { + it.each(['malformed-payload', 'restored-global'])( + 'rejects %s through the actual SDK evaluator', + (scenario) => { + expect( + runNodeWithConditions( + packageDir, + [], + `process.argv[2] = '${scenario}'; require('../../tools/scripts/prove-runtime-node-payload.cjs');`, + ), + ).toBe(`PASS ${scenario} rejection`); + }, + ); +}); diff --git a/packages/runtime-core/__tests__/resources/load/evaluator-a.js b/packages/runtime-core/__tests__/resources/load/evaluator-a.js new file mode 100644 index 00000000000..400bf66dcd0 --- /dev/null +++ b/packages/runtime-core/__tests__/resources/load/evaluator-a.js @@ -0,0 +1,8 @@ +globalThis.remote = { + get: function () { + return function () { + return 'literal-a'; + }; + }, + init: function () {}, +}; diff --git a/packages/runtime-core/__tests__/resources/load/evaluator-b.js b/packages/runtime-core/__tests__/resources/load/evaluator-b.js new file mode 100644 index 00000000000..b038b0517fc --- /dev/null +++ b/packages/runtime-core/__tests__/resources/load/evaluator-b.js @@ -0,0 +1,8 @@ +globalThis.remote = { + get: function () { + return function () { + return 'literal-b'; + }; + }, + init: function () {}, +}; diff --git a/packages/runtime-core/__tests__/runtime-image.spec.ts b/packages/runtime-core/__tests__/runtime-image.spec.ts new file mode 100644 index 00000000000..46c12918a45 --- /dev/null +++ b/packages/runtime-core/__tests__/runtime-image.spec.ts @@ -0,0 +1,64 @@ +import { + assertRuntimeImageCompatible, + attachRuntimeImage, + readRuntimeImage, + type RuntimeImageDescriptorV1, +} from '../src/runtimeImage'; + +const image = ( + overrides: Partial = {}, +): RuntimeImageDescriptorV1 => ({ + contract: 1, + compatibilityId: 'module-federation.runtime-family.1', + required: ['remote'], + forbidden: [], + available: ['remote', 'shared'], + target: 'web', + entryLoadingIdentity: 'sdk-node@1', + ...overrides, +}); + +describe('runtime image compatibility', () => { + it('rejects a different family before state is reused', () => { + expect(() => + assertRuntimeImageCompatible( + image(), + image({ compatibilityId: 'other-family' }), + ), + ).toThrow(/other-family/); + }); + + it('rejects a provider that exposes a forbidden capability', () => { + expect(() => + assertRuntimeImageCompatible( + image({ available: ['remote', 'shared'] }), + image({ forbidden: ['shared'], required: ['remote'] }), + ), + ).toThrow(/forbidden capability shared/); + }); + + it('rejects reuse in either order when only one image can load shared modules', () => { + const withoutShared = image({ + forbidden: ['shared'], + available: ['remote'], + }); + const withShared = image({ available: ['remote', 'shared'] }); + + expect(() => + assertRuntimeImageCompatible(withoutShared, withShared), + ).toThrow(/missing capability shared/); + expect(() => + assertRuntimeImageCompatible(withShared, withoutShared), + ).toThrow(/forbidden capability shared/); + expect(() => + assertRuntimeImageCompatible(withShared, image()), + ).not.toThrow(); + }); + + it('stores the descriptor on the instance without changing enumerable keys', () => { + const instance = {}; + attachRuntimeImage(instance, image()); + expect(readRuntimeImage(instance)?.entryLoadingIdentity).toBe('sdk-node@1'); + expect(Object.keys(instance)).toEqual([]); + }); +}); diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 629d86c7469..a85ca39498b 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -297,6 +297,7 @@ export class ModuleFederation { remotes: [], shared: {}, inBrowser: isBrowserEnvValue, + runtimeImage: userOptions.runtimeImage, }; this.name = userOptions.name; diff --git a/packages/runtime-core/src/global.ts b/packages/runtime-core/src/global.ts index cf5cd542d7a..4b0701b03fb 100644 --- a/packages/runtime-core/src/global.ts +++ b/packages/runtime-core/src/global.ts @@ -26,6 +26,30 @@ export interface Federation { __PRELOADED_ASSETS__: Set; } +export interface RemoteEntryCacheDescriptorV1 { + contract: 1; + compatibilityId: string; + target: string; + entryLoadingIdentity: string; + remoteType: string; + entryGlobalName: string; + remoteEntryKey?: string; + evaluatorOrigin?: object; + entryUrlTransform?: object; + browserScript?: boolean; + entryEvaluators?: { + loadEntry: readonly object[]; + createScript: readonly object[]; + loadEntryError: readonly object[]; + fetch: readonly object[]; + }; +} + +export interface RemoteEntryCacheMetadataV1 { + promise: Promise; + descriptor: RemoteEntryCacheDescriptorV1; +} + const MAX_PRELOADED_ASSETS = 2000; export const CurrentGlobal = typeof globalThis === 'object' ? globalThis : window; @@ -50,6 +74,11 @@ declare global { string, undefined | Promise >; + // eslint-disable-next-line no-var + var __GLOBAL_LOADING_REMOTE_ENTRY_META__: Record< + string, + RemoteEntryCacheMetadataV1 | undefined + >; } function definePropertyGlobalVal( @@ -74,8 +103,19 @@ function includeOwnProperty(target: typeof CurrentGlobal, key: string) { if (!includeOwnProperty(CurrentGlobal, '__GLOBAL_LOADING_REMOTE_ENTRY__')) { definePropertyGlobalVal(CurrentGlobal, '__GLOBAL_LOADING_REMOTE_ENTRY__', {}); } +if ( + !includeOwnProperty(CurrentGlobal, '__GLOBAL_LOADING_REMOTE_ENTRY_META__') +) { + definePropertyGlobalVal( + CurrentGlobal, + '__GLOBAL_LOADING_REMOTE_ENTRY_META__', + {}, + ); +} export const globalLoading = CurrentGlobal.__GLOBAL_LOADING_REMOTE_ENTRY__; +export const globalLoadingMeta = + CurrentGlobal.__GLOBAL_LOADING_REMOTE_ENTRY_META__; function setGlobalDefaultVal(target: typeof CurrentGlobal) { if ( @@ -122,6 +162,9 @@ export function resetFederationGlobalInfo(): void { Object.keys(globalLoading).forEach((key) => { delete globalLoading[key]; }); + Object.keys(globalLoadingMeta).forEach((key) => { + delete globalLoadingMeta[key]; + }); } export function setGlobalFederationInstance( diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 04f7c484d8c..2371b927d11 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -22,6 +22,12 @@ export type { } from './type'; export { assert, error } from './utils/logger'; export { registerGlobalPlugins } from './global'; +export { + assertRuntimeImageCompatible, + attachRuntimeImage, + readRuntimeImage, + type RuntimeImageDescriptorV1, +} from './runtimeImage'; export { getRemoteEntry, getRemoteInfo, diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index 40053f79be8..a489a29a76f 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -5,12 +5,7 @@ import { ModuleInfo, } from '@module-federation/sdk'; import { RUNTIME_004, runtimeDescMap } from '@module-federation/error-codes'; -import { - Global, - getInfoWithoutType, - globalLoading, - CurrentGlobal, -} from '../global'; +import { Global, getInfoWithoutType, CurrentGlobal } from '../global'; import { Options, UserOptions, @@ -27,13 +22,13 @@ import { assert, error, getRemoteInfo, - getRemoteEntryUniqueKey, getFMId, composeRemoteRequestId, matchRemoteWithNameAndExpose, optionsToMFContext, logger, } from '../utils'; +import { clearRemoteEntryCache } from '../utils/load'; import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant'; import { Module, ModuleOptions } from '../module'; import { createRemoteHandlerHooks, type RemoteHandlerHooks } from './hooks'; @@ -603,13 +598,7 @@ export class RemoteHandler { CurrentGlobal[key] = undefined; } } - const remoteEntryUniqueKey = getRemoteEntryUniqueKey( - loadedModule.remoteInfo, - ); - - if (globalLoading[remoteEntryUniqueKey]) { - delete globalLoading[remoteEntryUniqueKey]; - } + clearRemoteEntryCache(loadedModule.remoteInfo); // delete unloaded shared and instance let remoteInsId = remoteInfo.buildVersion diff --git a/packages/runtime-core/src/runtimeImage.ts b/packages/runtime-core/src/runtimeImage.ts new file mode 100644 index 00000000000..e4764162a82 --- /dev/null +++ b/packages/runtime-core/src/runtimeImage.ts @@ -0,0 +1,72 @@ +import { error, warn } from './utils/logger'; + +export const RUNTIME_IMAGE = Symbol.for('module-federation.runtime-image.v1'); + +export interface RuntimeImageDescriptorV1 { + contract: 1; + compatibilityId: string; + required: readonly string[]; + forbidden: readonly string[]; + available: readonly string[]; + target: string; + entryLoadingIdentity: string; +} + +export function readRuntimeImage( + instance: object, +): RuntimeImageDescriptorV1 | undefined { + return (instance as { [RUNTIME_IMAGE]?: RuntimeImageDescriptorV1 })[ + RUNTIME_IMAGE + ]; +} + +export function attachRuntimeImage( + instance: object, + image: RuntimeImageDescriptorV1, +): void { + Object.defineProperty(instance, RUNTIME_IMAGE, { + value: image, + enumerable: false, + configurable: true, + }); +} + +export function assertRuntimeImageCompatible( + current: RuntimeImageDescriptorV1 | undefined, + next: RuntimeImageDescriptorV1 | undefined, +): void { + if (!current || !next) { + if (current || next) { + warn( + 'Runtime image metadata is missing. Reuse stays allowed until a mismatch is known.', + ); + } + return; + } + if (current.compatibilityId !== next.compatibilityId) { + error( + `Refusing to reuse runtime state from ${current.compatibilityId} with ${next.compatibilityId}.`, + ); + } + if (current.target !== next.target) { + error( + `Refusing to reuse a ${current.target} runtime image for ${next.target}.`, + ); + } + if (current.entryLoadingIdentity !== next.entryLoadingIdentity) { + error( + `Refusing to reuse entry loader ${current.entryLoadingIdentity} with ${next.entryLoadingIdentity}.`, + ); + } + const provided = new Set([...current.available, ...current.required]); + for (const capability of new Set([...next.required, ...next.available])) { + if (!provided.has(capability)) { + error(`Runtime image is missing capability ${capability}.`); + } + } + for (const capability of next.forbidden) { + if (provided.has(capability)) { + error(`Runtime image exposes forbidden capability ${capability}.`); + } + } +} diff --git a/packages/runtime-core/src/type/config.ts b/packages/runtime-core/src/type/config.ts index ae0f26bf8f8..b31af6fecfb 100644 --- a/packages/runtime-core/src/type/config.ts +++ b/packages/runtime-core/src/type/config.ts @@ -6,6 +6,7 @@ import type { TreeShakingStatus, } from '@module-federation/sdk'; import { ModuleFederationRuntimePlugin } from './plugin'; +import type { RuntimeImageDescriptorV1 } from '../runtimeImage'; export type Optional = Omit & Partial; export type PartialOptional = Omit & { @@ -141,6 +142,7 @@ export interface Options { plugins: Array; inBrowser: boolean; shareStrategy?: ShareStrategy; + runtimeImage?: RuntimeImageDescriptorV1; } export type UserOptions = Omit< diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 13c35ad1565..3908452aea7 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -6,7 +6,13 @@ import { } from '@module-federation/sdk'; import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant'; import { ModuleFederation } from '../core'; -import { globalLoading, getRemoteEntryExports } from '../global'; +import { + globalLoading, + globalLoadingMeta, + getRemoteEntryExports, + type RemoteEntryCacheDescriptorV1, +} from '../global'; +import { readRuntimeImage } from '../runtimeImage'; import { Remote, RemoteEntryExports, @@ -20,8 +26,6 @@ import { runtimeDescMap, } from '@module-federation/error-codes'; -// Declare the ENV_TARGET constant that will be defined by DefinePlugin -declare const ENV_TARGET: 'web' | 'node'; const importCallback = '.then(callbacks[0]).catch(callbacks[1])'; const remoteEntryLoadingOrigins = new WeakMap< Promise, @@ -165,6 +169,7 @@ async function loadEntryScript({ loaderHook, getEntryUrl, resourceContext, + ignoreGlobalExports, }: { name: string; globalName: string; @@ -173,13 +178,14 @@ async function loadEntryScript({ loaderHook: ModuleFederation['loaderHook']; getEntryUrl?: (url: string) => string; resourceContext?: ResourceLoadContext; + ignoreGlobalExports?: boolean; }): Promise { const { entryExports: remoteEntryExports } = getRemoteEntryExports( name, globalName, ); - if (remoteEntryExports) { + if (remoteEntryExports && !ignoreGlobalExports) { return remoteEntryExports; } @@ -244,12 +250,14 @@ async function loadEntryDom({ loaderHook, getEntryUrl, resourceContext, + ignoreGlobalExports, }: { remoteInfo: RemoteInfo; remoteEntryExports?: RemoteEntryExports; loaderHook: ModuleFederation['loaderHook']; getEntryUrl?: (url: string) => string; resourceContext?: ResourceLoadContext; + ignoreGlobalExports?: boolean; }) { const { entry, entryGlobalName: globalName, name, type } = remoteInfo; if (isEsmRemoteType(type)) { @@ -268,17 +276,33 @@ async function loadEntryDom({ loaderHook, getEntryUrl, resourceContext, + ignoreGlobalExports, }); } +function isRemoteEntryExports(value: unknown): value is RemoteEntryExports { + return ( + typeof value === 'object' && + value !== null && + 'get' in value && + typeof value.get === 'function' && + 'init' in value && + typeof value.init === 'function' + ); +} + async function loadEntryNode({ remoteInfo, loaderHook, resourceContext, + ignoreGlobalExports, + getEntryUrl, }: { remoteInfo: RemoteInfo; loaderHook: ModuleFederation['loaderHook']; resourceContext?: ResourceLoadContext; + ignoreGlobalExports?: boolean; + getEntryUrl?: (url: string) => string; }) { const { entry, entryGlobalName: globalName, name, type } = remoteInfo; const { entryExports: remoteEntryExports } = getRemoteEntryExports( @@ -286,11 +310,12 @@ async function loadEntryNode({ globalName, ); - if (remoteEntryExports) { + if (remoteEntryExports && !ignoreGlobalExports) { return remoteEntryExports; } - return loadScriptNode(entry, { + const url = getEntryUrl ? getEntryUrl(entry) : entry; + return loadScriptNode(url, { attrs: { name, globalName, type }, loaderHook: { createScriptHook: (url: string, attrs: Record = {}) => { @@ -316,7 +341,17 @@ async function loadEntryNode({ }, }, }) - .then(() => { + .then((entryExports: unknown) => { + // The SDK resolves the container evaluated by this attempt. Validate the + // callback boundary before using it instead of rereading mutable globals. + if (isRemoteEntryExports(entryExports)) { + return entryExports; + } + if (ignoreGlobalExports) { + throw new Error( + 'Node.js entry evaluator did not return callable get and init exports', + ); + } return handleRemoteEntryLoaded(name, globalName, entry); }) .catch((e) => { @@ -332,6 +367,144 @@ export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { return composeKeyWithSeparator(name, entry); } +function getRemoteEntryCacheDescriptor( + origin: ModuleFederation, + remoteInfo: RemoteInfo, + getEntryUrl?: (url: string) => string, +): RemoteEntryCacheDescriptorV1 | undefined { + const image = readRuntimeImage(origin); + if (!image) { + return undefined; + } + return { + contract: 1, + compatibilityId: image.compatibilityId, + target: image.target, + entryLoadingIdentity: image.entryLoadingIdentity, + remoteType: remoteInfo.type, + entryGlobalName: remoteInfo.entryGlobalName, + remoteEntryKey: getRemoteEntryUniqueKey(remoteInfo), + entryUrlTransform: getEntryUrl, + evaluatorOrigin: + origin.remoteHandler.hooks.lifecycle.loadEntry.listeners.size || + origin.loaderHook.lifecycle.createScript.listeners.size || + origin.loaderHook.lifecycle.loadEntryError.listeners.size || + origin.loaderHook.lifecycle.fetch.listeners.size + ? origin + : undefined, + entryEvaluators: { + loadEntry: [...origin.remoteHandler.hooks.lifecycle.loadEntry.listeners], + createScript: [...origin.loaderHook.lifecycle.createScript.listeners], + loadEntryError: [...origin.loaderHook.lifecycle.loadEntryError.listeners], + fetch: [...origin.loaderHook.lifecycle.fetch.listeners], + }, + }; +} + +const cacheIdentityFields = [ + 'compatibilityId', + 'target', + 'entryLoadingIdentity', + 'remoteType', + 'entryGlobalName', +] as const; + +function assertRemoteEntryCacheCompatible( + uniqueKey: string, + promise: Promise, + next: RemoteEntryCacheDescriptorV1, +): void { + const metadata = globalLoadingMeta[uniqueKey]; + if (!metadata) { + return; + } + if (metadata.promise !== promise) { + delete globalLoadingMeta[uniqueKey]; + return; + } + for (const field of cacheIdentityFields) { + if (metadata.descriptor[field] !== next[field]) { + error( + `Refusing to reuse remote entry ${uniqueKey}. ${field} changed from ${metadata.descriptor[field]} to ${next[field]}.`, + ); + } + } +} + +// Callback identity scopes cached evaluations; it does not claim semantic incompatibility. +function sameEntryEvaluator( + current: RemoteEntryCacheDescriptorV1, + next: RemoteEntryCacheDescriptorV1, +): boolean { + if ( + current.evaluatorOrigin !== next.evaluatorOrigin || + current.entryUrlTransform !== next.entryUrlTransform + ) + return false; + return ( + ['loadEntry', 'createScript', 'loadEntryError', 'fetch'] as const + ).every((hook) => { + const currentCallbacks = current.entryEvaluators?.[hook] ?? []; + const nextCallbacks = next.entryEvaluators?.[hook] ?? []; + return ( + currentCallbacks.length === nextCallbacks.length && + currentCallbacks.every( + (callback, index) => callback === nextCallbacks[index], + ) + ); + }); +} + +function selectRemoteEntryCacheKey( + uniqueKey: string, + descriptor: RemoteEntryCacheDescriptorV1 | undefined, +): string { + if (!descriptor) return uniqueKey; + const keys = new Set([ + uniqueKey, + ...Object.entries(globalLoadingMeta) + .filter( + ([, metadata]) => metadata?.descriptor.remoteEntryKey === uniqueKey, + ) + .map(([key]) => key), + ]); + let compatibleKey: string | undefined; + for (const key of keys) { + const loading = globalLoading[key]; + const remoteEntryKey = globalLoadingMeta[key]?.descriptor.remoteEntryKey; + if ( + !loading || + (remoteEntryKey !== undefined && remoteEntryKey !== uniqueKey) + ) + continue; + assertRemoteEntryCacheCompatible(key, loading, descriptor); + const current = globalLoadingMeta[key]?.descriptor; + // An unannotated legacy cache entry retains its existing reuse policy. + if (!current || sameEntryEvaluator(current, descriptor)) { + compatibleKey ??= key; + } + } + if (compatibleKey) return compatibleKey; + if (!globalLoading[uniqueKey]) return uniqueKey; + let scope = 1; + while (globalLoading[`${uniqueKey}:evaluator:${scope}`]) scope += 1; + return `${uniqueKey}:evaluator:${scope}`; +} + +export function clearRemoteEntryCache(remoteInfo: RemoteInfo): void { + const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); + for (const key of new Set([uniqueKey, ...Object.keys(globalLoadingMeta)])) { + const remoteEntryKey = globalLoadingMeta[key]?.descriptor.remoteEntryKey; + if ( + remoteEntryKey === uniqueKey || + (key === uniqueKey && remoteEntryKey === undefined) + ) { + delete globalLoading[key]; + delete globalLoadingMeta[key]; + } + } +} + export async function getRemoteEntry(params: { origin: ModuleFederation; remoteInfo: RemoteInfo; @@ -348,8 +521,6 @@ export async function getRemoteEntry(params: { resourceContext, _inErrorHandling = false, } = params; - const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); - if (remoteEntryExports) { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ origin, @@ -361,6 +532,25 @@ export async function getRemoteEntry(params: { return remoteEntryExports; } + const cacheDescriptor = getRemoteEntryCacheDescriptor( + origin, + remoteInfo, + getEntryUrl, + ); + const baseUniqueKey = getRemoteEntryUniqueKey(remoteInfo); + const uniqueKey = selectRemoteEntryCacheKey(baseUniqueKey, cacheDescriptor); + // Image-backed cache misses must evaluate the selected entry instead of + // accepting exports left in a process-wide global by another evaluator. + const ignoreGlobalExports = cacheDescriptor !== undefined; + + if (cacheDescriptor && globalLoading[uniqueKey]) { + assertRemoteEntryCacheCompatible( + uniqueKey, + globalLoading[uniqueKey], + cacheDescriptor, + ); + } + if (!globalLoading[uniqueKey]) { const loadEntryHook = origin.remoteHandler.hooks.lifecycle.loadEntry; const loaderHook = origin.loaderHook; @@ -376,10 +566,30 @@ export async function getRemoteEntry(params: { if (res) { return res; } - const isWebEnvironment = - typeof ENV_TARGET !== 'undefined' - ? ENV_TARGET === 'web' - : isBrowserEnvValue; + const isWebEnvironment = isBrowserEnvValue; + if ( + isWebEnvironment && + cacheDescriptor && + !isEsmRemoteType(remoteInfo.type) && + remoteInfo.type !== 'system' + ) { + for (const [key, metadata] of Object.entries(globalLoadingMeta)) { + if ( + metadata && + metadata.promise === globalLoading[key] && + metadata.descriptor !== cacheDescriptor && + metadata.descriptor.browserScript && + metadata.descriptor.entryGlobalName === + cacheDescriptor.entryGlobalName && + !sameEntryEvaluator(metadata.descriptor, cacheDescriptor) + ) { + error( + `Refusing to evaluate remote entry ${baseUniqueKey}. Distinct browser script evaluators share physical global ${cacheDescriptor.entryGlobalName}.`, + ); + } + } + cacheDescriptor.browserScript = true; + } return isWebEnvironment ? loadEntryDom({ @@ -388,8 +598,15 @@ export async function getRemoteEntry(params: { loaderHook, getEntryUrl, resourceContext, + ignoreGlobalExports, }) - : loadEntryNode({ remoteInfo, loaderHook, resourceContext }); + : loadEntryNode({ + remoteInfo, + loaderHook, + resourceContext, + ignoreGlobalExports, + getEntryUrl, + }); }) .then(async (res) => { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ @@ -449,12 +666,21 @@ export async function getRemoteEntry(params: { }); globalLoading[uniqueKey] = loading; + if (cacheDescriptor) { + globalLoadingMeta[uniqueKey] = { + promise: loading, + descriptor: cacheDescriptor, + }; + } // Clear rejected entries so a later call can retry. Keep the original // promise identity in the cache (do not replace with a cleanup thenable). // Identity check: an older rejection must not delete a newer in-flight request. loading.then(undefined, () => { if (globalLoading[uniqueKey] === loading) { delete globalLoading[uniqueKey]; + if (globalLoadingMeta[uniqueKey]?.promise === loading) { + delete globalLoadingMeta[uniqueKey]; + } } }); remoteEntryLoadingOrigins.set(loading, origin); diff --git a/packages/runtime-plugins/inject-external-runtime-core-plugin/__tests__/esm-import.spec.ts b/packages/runtime-plugins/inject-external-runtime-core-plugin/__tests__/esm-import.spec.ts index 10dba4b0554..e60de5c43f6 100644 --- a/packages/runtime-plugins/inject-external-runtime-core-plugin/__tests__/esm-import.spec.ts +++ b/packages/runtime-plugins/inject-external-runtime-core-plugin/__tests__/esm-import.spec.ts @@ -3,19 +3,55 @@ import { pathToFileURL } from 'node:url'; type PluginFactoryModule = { default: () => { - beforeInit(args: { options: { name: string } }): unknown; + beforeInit(args: { + options: { name: string; runtimeImage?: RuntimeImage }; + userOptions?: { runtimeImage?: RuntimeImage }; + }): unknown; version: string; }; }; +type RuntimeImage = { + contract: 1; + compatibilityId: string; + required: string[]; + forbidden: string[]; + available: string[]; + target: string; + entryLoadingIdentity: string; +}; + function expectInjectedRuntime(appName: string, version: string) { - expect(globalThis._FEDERATION_RUNTIME_CORE).toBeDefined(); + expect(typeof globalThis._FEDERATION_RUNTIME_CORE.ModuleFederation).toBe( + 'function', + ); expect(globalThis._FEDERATION_RUNTIME_CORE_FROM).toEqual({ name: appName, version, }); } +function withLegacyRuntimeCore( + run: (plugin: ReturnType) => void, +) { + jest.doMock('@module-federation/runtime-tools/runtime-core', () => ({ + ...jest.requireActual< + typeof import('@module-federation/runtime-tools/runtime-core') + >('@module-federation/runtime-tools/runtime-core'), + assertRuntimeImageCompatible: undefined, + })); + try { + jest.isolateModules(() => { + const createPlugin = require( + path.join(__dirname, '..', 'dist', 'index.cjs'), + ) as PluginFactoryModule['default']; + run(createPlugin()); + }); + } finally { + jest.dontMock('@module-federation/runtime-tools/runtime-core'); + } +} + describe('@module-federation/inject-external-runtime-core-plugin', () => { beforeEach(() => { delete globalThis._FEDERATION_RUNTIME_CORE; @@ -61,4 +97,112 @@ describe('@module-federation/inject-external-runtime-core-plugin', () => { plugin.beforeInit({ options: { name: 'cjs-test-app' } }); expectInjectedRuntime('cjs-test-app', plugin.version); }); + + it('stays silent and re-publishes for providers without runtime images', () => { + const cjsEntry = path.join(__dirname, '..', 'dist', 'index.cjs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const createPlugin = require(cjsEntry) as PluginFactoryModule['default']; + const plugin = createPlugin(); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + plugin.beforeInit({ options: { name: 'legacy-provider' } }); + expectInjectedRuntime('legacy-provider', plugin.version); + + globalThis._FEDERATION_RUNTIME_CORE = + {} as typeof globalThis._FEDERATION_RUNTIME_CORE; + plugin.beforeInit({ options: { name: 'legacy-provider' } }); + expectInjectedRuntime('legacy-provider', plugin.version); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('publishes runtime-image metadata and rejects an incompatible provider', () => { + const cjsEntry = path.join(__dirname, '..', 'dist', 'index.cjs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const createPlugin = require(cjsEntry) as PluginFactoryModule['default']; + const plugin = createPlugin(); + const runtimeImage: RuntimeImage = { + contract: 1, + compatibilityId: 'runtime-family', + required: ['remote'], + forbidden: [], + available: ['remote'], + target: 'web', + entryLoadingIdentity: 'web-loader', + }; + + plugin.beforeInit({ + options: { name: 'metadata-provider', runtimeImage }, + }); + expect(globalThis._FEDERATION_RUNTIME_CORE_FROM.runtimeImage).toEqual( + runtimeImage, + ); + expect(() => + plugin.beforeInit({ + options: { + name: 'other-provider', + runtimeImage: { + ...runtimeImage, + compatibilityId: 'other-family', + }, + }, + }), + ).toThrow( + 'Refusing to reuse runtime state from runtime-family with other-family.', + ); + expect( + globalThis._FEDERATION_RUNTIME_CORE_FROM.runtimeImage?.compatibilityId, + ).toBe('runtime-family'); + }); + + it('keeps legacy first use and reuse working without an image checker', () => { + withLegacyRuntimeCore((plugin) => { + plugin.beforeInit({ options: { name: 'legacy-provider' } }); + expectInjectedRuntime('legacy-provider', plugin.version); + plugin.beforeInit({ options: { name: 'legacy-provider' } }); + expectInjectedRuntime('legacy-provider', plugin.version); + }); + }); + + it('rejects image metadata on first use before publishing with an old core', () => { + withLegacyRuntimeCore((plugin) => { + const runtimeImage: RuntimeImage = { + contract: 1, + compatibilityId: 'runtime-family', + required: [], + forbidden: [], + available: [], + target: 'web', + entryLoadingIdentity: 'web-loader', + }; + expect(() => + plugin.beforeInit({ options: { name: 'provider', runtimeImage } }), + ).toThrow('[RuntimeImageMinimumContract]'); + expect(globalThis._FEDERATION_RUNTIME_CORE).toBeUndefined(); + expect(globalThis._FEDERATION_RUNTIME_CORE_FROM).toBeUndefined(); + }); + }); + + it('rejects reuse of image metadata without replacing the old provider', () => { + withLegacyRuntimeCore((plugin) => { + plugin.beforeInit({ options: { name: 'legacy-provider' } }); + const provider = globalThis._FEDERATION_RUNTIME_CORE_FROM; + provider.runtimeImage = { + contract: 1, + compatibilityId: 'runtime-family', + required: [], + forbidden: [], + available: [], + target: 'web', + entryLoadingIdentity: 'web-loader', + }; + const providerCore = globalThis._FEDERATION_RUNTIME_CORE; + expect(() => + plugin.beforeInit({ options: { name: 'legacy-provider' } }), + ).toThrow('[RuntimeImageMinimumContract]'); + expect(globalThis._FEDERATION_RUNTIME_CORE_FROM).toBe(provider); + expect(globalThis._FEDERATION_RUNTIME_CORE).toBe(providerCore); + }); + }); }); diff --git a/packages/runtime-plugins/inject-external-runtime-core-plugin/src/index.ts b/packages/runtime-plugins/inject-external-runtime-core-plugin/src/index.ts index 6e86653da60..7f82d2d7798 100644 --- a/packages/runtime-plugins/inject-external-runtime-core-plugin/src/index.ts +++ b/packages/runtime-plugins/inject-external-runtime-core-plugin/src/index.ts @@ -1,12 +1,16 @@ import * as runtimeCore from '@module-federation/runtime-tools/runtime-core'; -import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime-tools/runtime-core'; +import type { + ModuleFederationRuntimePlugin, + RuntimeImageDescriptorV1, +} from '@module-federation/runtime-tools/runtime-core'; declare global { var __VERSION__: string; var _FEDERATION_RUNTIME_CORE: typeof runtimeCore; var _FEDERATION_RUNTIME_CORE_FROM: { version: string; name: string; + runtimeImage?: RuntimeImageDescriptorV1; }; } @@ -25,21 +29,34 @@ function injectExternalRuntimeCorePlugin(): ModuleFederationRuntimePlugin { } const name = args.options.name; const version = __VERSION__; + const runtimeImage = + args.userOptions?.runtimeImage ?? args.options.runtimeImage; + const provider = globalRef._FEDERATION_RUNTIME_CORE_FROM; + const assertCompatible = runtimeCore.assertRuntimeImageCompatible; if ( - globalRef._FEDERATION_RUNTIME_CORE && - globalRef._FEDERATION_RUNTIME_CORE_FROM && - (globalRef._FEDERATION_RUNTIME_CORE_FROM.name !== name || - globalRef._FEDERATION_RUNTIME_CORE_FROM.version !== version) + typeof assertCompatible !== 'function' && + (provider?.runtimeImage || runtimeImage) ) { - console.warn( - `Detect multiple module federation runtime! Injected runtime from ${globalRef._FEDERATION_RUNTIME_CORE_FROM.name}@${globalRef._FEDERATION_RUNTIME_CORE_FROM.version} and current is ${name}@${version}, pleasure ensure there is only one consumer to provider runtime!`, + throw new Error( + '[RuntimeImageMinimumContract] External runtime-image metadata requires a runtime core with assertRuntimeImageCompatible. Upgrade the runtime core before providing or reusing a runtime image.', ); - return args; + } + if (globalRef._FEDERATION_RUNTIME_CORE && provider) { + if (typeof assertCompatible === 'function') { + assertCompatible(provider.runtimeImage, runtimeImage); + } + if (provider.name !== name || provider.version !== version) { + console.warn( + `Detect multiple module federation runtime! Injected runtime from ${provider.name}@${provider.version} and current is ${name}@${version}, pleasure ensure there is only one consumer to provider runtime!`, + ); + return args; + } } globalRef._FEDERATION_RUNTIME_CORE = runtimeCore; globalRef._FEDERATION_RUNTIME_CORE_FROM = { version, name, + ...(runtimeImage ? { runtimeImage } : {}), }; return args; }, diff --git a/packages/runtime/__tests__/runtime-image.spec.ts b/packages/runtime/__tests__/runtime-image.spec.ts new file mode 100644 index 00000000000..7db664efea0 --- /dev/null +++ b/packages/runtime/__tests__/runtime-image.spec.ts @@ -0,0 +1,90 @@ +import { + CurrentGlobal, + readRuntimeImage, + type RuntimeImageDescriptorV1, +} from '@module-federation/runtime-core'; +import { rs } from '@rstest/core'; +import { createInstance, getInstance, init } from '../src'; + +const image = ( + overrides: Partial = {}, +): RuntimeImageDescriptorV1 => ({ + contract: 1, + compatibilityId: 'runtime-family', + required: ['remote'], + forbidden: [], + available: ['remote', 'shared'], + target: 'web', + entryLoadingIdentity: 'web-loader', + ...overrides, +}); + +describe('runtime image reuse', () => { + it('keeps getInstance bound to init in this runtime copy', () => { + const instance = createInstance({ + name: 'created-not-default', + runtimeImage: image(), + }); + + expect(getInstance()).not.toBe(instance); + expect(readRuntimeImage(instance)).toEqual(image()); + }); + + it('registers separately built instances with different targets and capabilities', () => { + const host = createInstance({ + name: 'image-host', + runtimeImage: image({ target: 'web', available: ['remote', 'shared'] }), + }); + const remote = createInstance({ + name: 'image-remote', + runtimeImage: image({ + target: 'universal', + forbidden: ['shared'], + available: ['remote'], + }), + }); + + expect(CurrentGlobal.__FEDERATION__.__INSTANCES__).toEqual( + expect.arrayContaining([host, remote]), + ); + }); + + it('rejects a different family before plugins run', () => { + const beforeInit = rs.fn((args) => args); + createInstance({ name: 'family-a-host', runtimeImage: image() }); + + expect(() => + createInstance({ + name: 'family-b-host', + runtimeImage: image({ compatibilityId: 'other-family' }), + plugins: [{ name: 'observe-before-init', beforeInit }], + }), + ).toThrow(/other-family/); + expect(beforeInit).not.toHaveBeenCalled(); + expect( + CurrentGlobal.__FEDERATION__.__INSTANCES__.map((i) => i.name), + ).not.toContain('family-b-host'); + }); + + it('reuses a compatible image and rejects an incompatible family', () => { + const first = init({ + name: 'runtime-image-host', + runtimeImage: image(), + }); + const reused = init({ + name: 'runtime-image-host', + runtimeImage: image(), + }); + + expect(reused).toBe(first); + expect(() => + init({ + name: 'runtime-image-host', + runtimeImage: image({ compatibilityId: 'other-family' }), + }), + ).toThrow( + 'Refusing to reuse runtime state from runtime-family with other-family.', + ); + expect(readRuntimeImage(first)?.compatibilityId).toBe('runtime-family'); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4f3254b33d4..68cb7aea18c 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -5,6 +5,11 @@ import { getGlobalFederationConstructor, setGlobalFederationInstance, assert, + error, + assertRuntimeImageCompatible, + attachRuntimeImage, + readRuntimeImage, + type RuntimeImageDescriptorV1, setGlobalFederationConstructor, } from '@module-federation/runtime-core'; import { runtimeDescMap, RUNTIME_009 } from '@module-federation/error-codes'; @@ -25,14 +30,34 @@ export { export { ModuleFederation }; +function assertRuntimeFamilyRegistrable( + name: string, + next: RuntimeImageDescriptorV1, +): void { + for (const registered of CurrentGlobal.__FEDERATION__.__INSTANCES__) { + const current = readRuntimeImage(registered); + if (current && current.compatibilityId !== next.compatibilityId) { + error( + `Refusing to register ${name} from runtime family ${next.compatibilityId} beside ${registered.name} from ${current.compatibilityId}.`, + ); + } + } +} + export function createInstance(options: UserOptions) { // Retrieve debug constructor const ModuleFederationConstructor = getGlobalFederationConstructor() || ModuleFederation; + if (options.runtimeImage) { + assertRuntimeFamilyRegistrable(options.name, options.runtimeImage); + } const instance = new ModuleFederationConstructor({ id: `${options.name}@${options.version || Date.now()}`, ...options, }); + if (options.runtimeImage) { + attachRuntimeImage(instance, options.runtimeImage); + } setGlobalFederationInstance(instance); return instance; } @@ -46,6 +71,10 @@ export function init(options: UserOptions): ModuleFederation { FederationInstance = createInstance(normalizedOptions); return FederationInstance; } else { + assertRuntimeImageCompatible( + readRuntimeImage(instance), + normalizedOptions.runtimeImage, + ); // Merge options instance.initOptions(normalizedOptions); if (!FederationInstance) { diff --git a/tools/scripts/prove-runtime-node-payload.cjs b/tools/scripts/prove-runtime-node-payload.cjs new file mode 100644 index 00000000000..56de294c5e4 --- /dev/null +++ b/tools/scripts/prove-runtime-node-payload.cjs @@ -0,0 +1,70 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); + +process.env.IS_ESM_BUILD = 'true'; +const core = require( + path.resolve(__dirname, '../../packages/runtime-core/dist/index.cjs'), +); +const data = (source) => `data:text/javascript,${encodeURIComponent(source)}`; +const globalName = 'runtimeNodePayloadProof'; +const image = { + contract: 1, + compatibilityId: 'node-payload-proof-family', + required: ['remote'], + forbidden: [], + available: ['remote', 'shared'], + target: 'node', + entryLoadingIdentity: 'sdk-node', +}; + +async function prove(restoredGlobal) { + core.resetFederationGlobalInfo(); + delete globalThis[globalName]; + const hosts = ['first', 'second'].map((label) => { + const host = new core.ModuleFederation({ + name: `node-payload-${label}`, + remotes: [], + }); + core.attachRuntimeImage(host, image); + return host; + }); + const remoteInfo = core.getRemoteInfo({ + name: 'node-payload-remote', + entry: data('module.exports={init(){},get(){return()=> "first"}}'), + entryGlobalName: globalName, + }); + const first = await core.getRemoteEntry({ + origin: hosts[0], + remoteInfo, + getEntryUrl: () => remoteInfo.entry, + }); + assert.equal(await (await first.get('./value'))(), 'first'); + const malformed = restoredGlobal + ? `const previous=globalThis.${globalName};module.exports={};queueMicrotask(()=>{globalThis.${globalName}=previous;});` + : 'module.exports={};'; + await assert.rejects( + core.getRemoteEntry({ + origin: hosts[1], + remoteInfo, + getEntryUrl: () => data(malformed), + }), + /Node\.js entry evaluator did not return callable get and init exports/, + ); + if (restoredGlobal) { + assert.equal(globalThis[globalName], first); + } + console.log( + `PASS ${restoredGlobal ? 'restored-global' : 'malformed-payload'} rejection`, + ); +} + +async function main() { + const selected = process.argv[2]; + if (selected !== 'restored-global') await prove(false); + if (selected !== 'malformed-payload') await prove(true); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});