From c919e8917fc8f038b577da906c6bd1d803e489b9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 18 Sep 2026 15:06:53 +0000 Subject: [PATCH 1/9] feat(runtime): reject incompatible runtime image reuse Attach runtime-image metadata before instances enter global state. Reject known family, target, capability, loader, and URL-policy mismatches before reuse. Keep legacy callers compatible and preserve the remote-entry cache key. --- .changeset/runtime-image-compatibility.md | 12 ++ .../runtime/FederationRuntimePlugin.ts | 30 ++++ .../src/lib/container/runtime/utils.ts | 9 ++ packages/runtime-core/__tests__/load.spec.ts | 148 +++++++++++++++++- .../__tests__/runtime-image.spec.ts | 46 ++++++ packages/runtime-core/src/core.ts | 1 + packages/runtime-core/src/global.ts | 58 +++++++ packages/runtime-core/src/index.ts | 6 + packages/runtime-core/src/remote/index.ts | 2 + packages/runtime-core/src/runtimeImage.ts | 81 ++++++++++ packages/runtime-core/src/type/config.ts | 2 + packages/runtime-core/src/utils/load.ts | 131 +++++++++++++++- .../__tests__/esm-import.spec.ts | 59 ++++++- .../src/index.ts | 40 +++-- .../runtime/__tests__/runtime-image.spec.ts | 52 ++++++ packages/runtime/src/index.ts | 78 ++++++--- 16 files changed, 712 insertions(+), 43 deletions(-) create mode 100644 .changeset/runtime-image-compatibility.md create mode 100644 packages/runtime-core/__tests__/runtime-image.spec.ts create mode 100644 packages/runtime-core/src/runtimeImage.ts create mode 100644 packages/runtime/__tests__/runtime-image.spec.ts diff --git a/.changeset/runtime-image-compatibility.md b/.changeset/runtime-image-compatibility.md new file mode 100644 index 00000000000..d462e0cce13 --- /dev/null +++ b/.changeset/runtime-image-compatibility.md @@ -0,0 +1,12 @@ +--- +'@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 into runtime instances and global external-core +state. Known incompatible families, targets, capabilities, and entry loaders +now fail before shared state or a cached remote entry is reused. + +The remote-entry cache key remains unchanged. diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index 62407e910ad..29e008c7862 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, @@ -145,6 +146,7 @@ class FederationRuntimePlugin { bundlerRuntimePath: string; runtimePath: string; runtimeToolsPath: string; + runtimeInitOptions?: NormalizedRuntimeInitOptionsWithOutShared; federationRuntimeDependency?: FederationRuntimeDependency; // Add this line constructor(options?: moduleFederationPlugin.ModuleFederationPluginOptions) { @@ -372,6 +374,7 @@ class FederationRuntimePlugin { const initOptionsWithoutShared = normalizeRuntimeInitOptionsWithOutShared( this.options, ); + this.runtimeInitOptions = initOptionsWithoutShared; const federationGlobal = getFederationGlobalScope( RuntimeGlobals || ({} as typeof RuntimeGlobals), ); @@ -468,6 +471,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, true); 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..0b5fd70eadb 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, @@ -28,6 +36,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 +515,132 @@ 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('rejects cache reuse with a different URL policy', async () => { + const container = { get: rs.fn(), init: rs.fn() }; + const origin = new ModuleFederation({ + name: 'cache-url-policy', + remotes: [], + plugins: [ + { + name: 'url-policy-entry', + loadEntry() { + return container; + }, + }, + ], + }); + attachRuntimeImage(origin, runtimeImage()); + const remoteInfo = getRemoteInfo({ + name: 'url-policy-remote', + entry: 'https://remote.test/url-policy.js', + }); + const firstPolicy = (url: string) => `${url}?source=first`; + const secondPolicy = (url: string) => `${url}?source=second`; + + await expect( + getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: firstPolicy, + }), + ).resolves.toBe(container); + await expect( + getRemoteEntry({ + origin, + remoteInfo, + getEntryUrl: secondPolicy, + }), + ).rejects.toThrow('different URL policy'); + }); + + 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__/runtime-image.spec.ts b/packages/runtime-core/__tests__/runtime-image.spec.ts new file mode 100644 index 00000000000..a2d140d6be5 --- /dev/null +++ b/packages/runtime-core/__tests__/runtime-image.spec.ts @@ -0,0 +1,46 @@ +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('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 d07163a8e1f..d875298750b 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -296,6 +296,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..31502cc3cdd 100644 --- a/packages/runtime-core/src/global.ts +++ b/packages/runtime-core/src/global.ts @@ -12,6 +12,11 @@ import { isDebugMode, } from '@module-federation/sdk'; import { warn, error } from './utils/logger'; +import { + assertRuntimeImageCompatible, + readRuntimeImage, + type RuntimeImageDescriptorV1, +} from './runtimeImage'; import { ModuleFederationRuntimePlugin } from './type/plugin'; export interface Federation { @@ -19,6 +24,8 @@ export interface Federation { __DEBUG_CONSTRUCTOR_VERSION__?: string; moduleInfo: GlobalModuleInfo; __DEBUG_CONSTRUCTOR__?: typeof ModuleFederation; + __DEBUG_CONSTRUCTOR_RUNTIME_IMAGE__?: RuntimeImageDescriptorV1; + __DEFAULT_INSTANCE__?: ModuleFederation; __INSTANCES__: Array; __SHARE__: GlobalShareScopeMap; __MANIFEST_LOADING__: Record>; @@ -26,6 +33,21 @@ export interface Federation { __PRELOADED_ASSETS__: Set; } +export interface RemoteEntryCacheDescriptorV1 { + contract: 1; + compatibilityId?: string; + target?: string; + entryLoadingIdentity?: string; + remoteType: string; + entryGlobalName: string; + loaderPolicy?: (url: string) => string; +} + +export interface RemoteEntryCacheMetadataV1 { + promise: Promise; + descriptor: RemoteEntryCacheDescriptorV1; +} + const MAX_PRELOADED_ASSETS = 2000; export const CurrentGlobal = typeof globalThis === 'object' ? globalThis : window; @@ -50,6 +72,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 +101,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 ( @@ -118,16 +156,25 @@ export function resetFederationGlobalInfo(): void { CurrentGlobal.__FEDERATION__.__SHARE__ = {}; CurrentGlobal.__FEDERATION__.__MANIFEST_LOADING__ = {}; CurrentGlobal.__FEDERATION__.__PRELOADED_ASSETS__.clear(); + CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ = undefined; Object.keys(globalLoading).forEach((key) => { delete globalLoading[key]; }); + Object.keys(globalLoadingMeta).forEach((key) => { + delete globalLoadingMeta[key]; + }); } export function setGlobalFederationInstance( FederationInstance: ModuleFederation, ): void { + const next = readRuntimeImage(FederationInstance); + for (const instance of CurrentGlobal.__FEDERATION__.__INSTANCES__) { + assertRuntimeImageCompatible(readRuntimeImage(instance), next); + } CurrentGlobal.__FEDERATION__.__INSTANCES__.push(FederationInstance); + CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ ??= FederationInstance; } export function getGlobalFederationConstructor(): @@ -141,8 +188,19 @@ export function setGlobalFederationConstructor( isDebug = isDebugMode(), ): void { if (isDebug) { + const current = CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR__; + if (current && FederationConstructor) { + assertRuntimeImageCompatible( + readRuntimeImage(current), + readRuntimeImage(FederationConstructor), + ); + } CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = FederationConstructor; CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR_VERSION__ = __VERSION__; + CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR_RUNTIME_IMAGE__ = + FederationConstructor + ? readRuntimeImage(FederationConstructor) + : undefined; } } 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 4bb4d3db0fe..7af1e225765 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -10,6 +10,7 @@ import { Global, getInfoWithoutType, globalLoading, + globalLoadingMeta, CurrentGlobal, } from '../global'; import { @@ -756,6 +757,7 @@ export class RemoteHandler { if (globalLoading[remoteEntryUniqueKey]) { delete globalLoading[remoteEntryUniqueKey]; + delete globalLoadingMeta[remoteEntryUniqueKey]; } // delete unloaded shared and instance diff --git a/packages/runtime-core/src/runtimeImage.ts b/packages/runtime-core/src/runtimeImage.ts new file mode 100644 index 00000000000..c0b514fbaaa --- /dev/null +++ b/packages/runtime-core/src/runtimeImage.ts @@ -0,0 +1,81 @@ +import { 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) { + throw new Error( + `Refusing to reuse runtime state from ${current.compatibilityId} with ${next.compatibilityId}.`, + ); + } + if (current.target !== next.target) { + throw new Error( + `Refusing to reuse a ${current.target} runtime image for ${next.target}.`, + ); + } + if (current.entryLoadingIdentity !== next.entryLoadingIdentity) { + throw new Error( + `Refusing to reuse entry loader ${current.entryLoadingIdentity} with ${next.entryLoadingIdentity}.`, + ); + } + for (const capability of next.required) { + const provided = + current.available.includes(capability) || + current.required.includes(capability); + if (!provided) { + throw new Error( + `Runtime image is missing required capability ${capability}.`, + ); + } + } + for (const capability of next.forbidden) { + const exposed = + current.available.includes(capability) || + current.required.includes(capability); + if (exposed) { + throw new 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..3ba18dba543 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -6,22 +6,27 @@ 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, + type RemoteEntryCacheMetadataV1, +} from '../global'; +import { readRuntimeImage } from '../runtimeImage'; import { Remote, RemoteEntryExports, RemoteInfo, ResourceLoadContext, } from '../type'; -import { assert, error } from './logger'; +import { assert, error, warn } from './logger'; import { RUNTIME_001, RUNTIME_008, 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, @@ -332,6 +337,97 @@ export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { return composeKeyWithSeparator(name, entry); } +const warnedLegacyCacheEntries = new WeakSet< + Promise +>(); + +function getRemoteEntryCacheDescriptor( + origin: ModuleFederation, + remoteInfo: RemoteInfo, + loaderPolicy: ((url: string) => string) | undefined, +): RemoteEntryCacheDescriptorV1 { + const image = readRuntimeImage(origin); + return { + contract: 1, + compatibilityId: image?.compatibilityId, + target: image?.target, + entryLoadingIdentity: image?.entryLoadingIdentity, + remoteType: remoteInfo.type, + entryGlobalName: remoteInfo.entryGlobalName, + loaderPolicy, + }; +} + +function assertRemoteEntryCacheCompatible( + uniqueKey: string, + promise: Promise, + metadata: RemoteEntryCacheMetadataV1 | undefined, + next: RemoteEntryCacheDescriptorV1, +): void { + if (!metadata || metadata.promise !== promise) { + if (globalLoadingMeta[uniqueKey] === metadata) { + delete globalLoadingMeta[uniqueKey]; + } + if (!warnedLegacyCacheEntries.has(promise)) { + warnedLegacyCacheEntries.add(promise); + warn( + `Remote entry ${uniqueKey} has no cache compatibility metadata. Reusing the legacy entry.`, + ); + } + return; + } + const current = metadata.descriptor; + const knownFields: Array< + keyof Pick< + RemoteEntryCacheDescriptorV1, + | 'compatibilityId' + | 'target' + | 'entryLoadingIdentity' + | 'remoteType' + | 'entryGlobalName' + > + > = [ + 'compatibilityId', + 'target', + 'entryLoadingIdentity', + 'remoteType', + 'entryGlobalName', + ]; + for (const field of knownFields) { + const currentValue = current[field]; + const nextValue = next[field]; + if ( + currentValue !== undefined && + nextValue !== undefined && + currentValue !== nextValue + ) { + throw new Error( + `Refusing to reuse remote entry ${uniqueKey}. ${field} changed from ${currentValue} to ${nextValue}.`, + ); + } + } + if ( + (current.loaderPolicy || next.loaderPolicy) && + current.loaderPolicy !== next.loaderPolicy + ) { + throw new Error( + `Refusing to reuse remote entry ${uniqueKey} with a different URL policy.`, + ); + } + if ( + (!current.compatibilityId || + !next.compatibilityId || + !current.entryLoadingIdentity || + !next.entryLoadingIdentity) && + !warnedLegacyCacheEntries.has(promise) + ) { + warnedLegacyCacheEntries.add(promise); + warn( + `Remote entry ${uniqueKey} has incomplete cache compatibility metadata. Reusing the legacy entry.`, + ); + } +} + export async function getRemoteEntry(params: { origin: ModuleFederation; remoteInfo: RemoteInfo; @@ -349,6 +445,11 @@ export async function getRemoteEntry(params: { _inErrorHandling = false, } = params; const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); + const cacheDescriptor = getRemoteEntryCacheDescriptor( + origin, + remoteInfo, + getEntryUrl, + ); if (remoteEntryExports) { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ @@ -361,6 +462,16 @@ export async function getRemoteEntry(params: { return remoteEntryExports; } + if (globalLoading[uniqueKey]) { + const cachedPromise = globalLoading[uniqueKey]; + assertRemoteEntryCacheCompatible( + uniqueKey, + cachedPromise, + globalLoadingMeta[uniqueKey], + cacheDescriptor, + ); + } + if (!globalLoading[uniqueKey]) { const loadEntryHook = origin.remoteHandler.hooks.lifecycle.loadEntry; const loaderHook = origin.loaderHook; @@ -376,10 +487,7 @@ export async function getRemoteEntry(params: { if (res) { return res; } - const isWebEnvironment = - typeof ENV_TARGET !== 'undefined' - ? ENV_TARGET === 'web' - : isBrowserEnvValue; + const isWebEnvironment = isBrowserEnvValue; return isWebEnvironment ? loadEntryDom({ @@ -449,12 +557,19 @@ export async function getRemoteEntry(params: { }); globalLoading[uniqueKey] = loading; + 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..151faf64c6e 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,16 +3,32 @@ 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, + entryLoadingIdentity: `@module-federation/runtime-core@${version}:1`, }); } @@ -61,4 +77,43 @@ describe('@module-federation/inject-external-runtime-core-plugin', () => { plugin.beforeInit({ options: { name: 'cjs-test-app' } }); expectInjectedRuntime('cjs-test-app', plugin.version); }); + + 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'); + }); }); 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..d85d66e242e 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,17 @@ 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; + entryLoadingIdentity?: string; + runtimeImage?: RuntimeImageDescriptorV1; }; } @@ -25,21 +30,38 @@ function injectExternalRuntimeCorePlugin(): ModuleFederationRuntimePlugin { } const name = args.options.name; const version = __VERSION__; - if ( - globalRef._FEDERATION_RUNTIME_CORE && - globalRef._FEDERATION_RUNTIME_CORE_FROM && - (globalRef._FEDERATION_RUNTIME_CORE_FROM.name !== name || - globalRef._FEDERATION_RUNTIME_CORE_FROM.version !== version) - ) { + const runtimeImage = + args.userOptions?.runtimeImage ?? args.options.runtimeImage; + if (globalRef._FEDERATION_RUNTIME_CORE) { + const provider = globalRef._FEDERATION_RUNTIME_CORE_FROM; + if (provider) { + runtimeCore.assertRuntimeImageCompatible( + provider.runtimeImage, + runtimeImage, + ); + } + if ( + provider && + !provider.runtimeImage && + (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; + } + if (!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!`, + 'External runtime-core metadata is missing. Reuse stays in legacy compatibility mode.', ); - return args; } globalRef._FEDERATION_RUNTIME_CORE = runtimeCore; globalRef._FEDERATION_RUNTIME_CORE_FROM = { version, name, + entryLoadingIdentity: `@module-federation/runtime-core@${version}:1`, + ...(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..58a2912db06 --- /dev/null +++ b/packages/runtime/__tests__/runtime-image.spec.ts @@ -0,0 +1,52 @@ +import { + readRuntimeImage, + type RuntimeImageDescriptorV1, +} from '@module-federation/runtime-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('publishes the created instance through the global default pointer', () => { + const instance = createInstance({ + name: 'global-default', + runtimeImage: image(), + }); + + expect(getInstance()).toBe(instance); + expect(readRuntimeImage(instance)).toEqual(image()); + }); + + 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..91e5800fa3d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -5,6 +5,9 @@ import { getGlobalFederationConstructor, setGlobalFederationInstance, assert, + assertRuntimeImageCompatible, + attachRuntimeImage, + readRuntimeImage, setGlobalFederationConstructor, } from '@module-federation/runtime-core'; import { runtimeDescMap, RUNTIME_009 } from '@module-federation/error-codes'; @@ -27,17 +30,37 @@ export { ModuleFederation }; export function createInstance(options: UserOptions) { // Retrieve debug constructor - const ModuleFederationConstructor = - getGlobalFederationConstructor() || ModuleFederation; + const globalConstructor = getGlobalFederationConstructor(); + const ModuleFederationConstructor = globalConstructor || ModuleFederation; + if (options.runtimeImage) { + if (globalConstructor && globalConstructor !== ModuleFederation) { + assertRuntimeImageCompatible( + readRuntimeImage(globalConstructor), + options.runtimeImage, + ); + } + attachRuntimeImage(ModuleFederationConstructor, 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; } let FederationInstance: ModuleFederation | null = null; +function getDefaultFederationInstance(): ModuleFederation | null { + return ( + FederationInstance ?? + CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ ?? + null + ); +} + export function init(options: UserOptions): ModuleFederation { // Retrieve the same instance with the same name const instance = getGlobalFederationInstance(options.name, options.version); @@ -46,6 +69,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) { @@ -58,55 +85,59 @@ export function init(options: UserOptions): ModuleFederation { export function loadRemote( ...args: Parameters ): Promise { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); - const loadRemote: typeof FederationInstance.loadRemote = - FederationInstance.loadRemote; + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); + const loadRemote: typeof instance.loadRemote = instance.loadRemote; // eslint-disable-next-line prefer-spread - return loadRemote.apply(FederationInstance, args); + return loadRemote.apply(instance, args); } export function loadShare( ...args: Parameters ): Promise T | undefined)> { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - const loadShare: typeof FederationInstance.loadShare = - FederationInstance.loadShare; - return loadShare.apply(FederationInstance, args); + const loadShare: typeof instance.loadShare = instance.loadShare; + return loadShare.apply(instance, args); } export function loadShareSync( ...args: Parameters ): () => T | never { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); - const loadShareSync: typeof FederationInstance.loadShareSync = - FederationInstance.loadShareSync; + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); + const loadShareSync: typeof instance.loadShareSync = + instance.loadShareSync; // eslint-disable-next-line prefer-spread - return loadShareSync.apply(FederationInstance, args); + return loadShareSync.apply(instance, args); } export function preloadRemote( ...args: Parameters ): ReturnType { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return FederationInstance.preloadRemote.apply(FederationInstance, args); + return instance.preloadRemote.apply(instance, args); } export function registerRemotes( ...args: Parameters ): ReturnType { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return FederationInstance.registerRemotes.apply(FederationInstance, args); + return instance.registerRemotes.apply(instance, args); } export function registerPlugins( ...args: Parameters ): ReturnType { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return FederationInstance.registerPlugins.apply(FederationInstance, args); + return instance.registerPlugins.apply(instance, args); } export function getInstance(): ModuleFederation | null; @@ -115,7 +146,7 @@ export function getInstance( ): ModuleFederation | null; export function getInstance(finder?: (instance: ModuleFederation) => boolean) { if (!finder) { - return FederationInstance; + return getDefaultFederationInstance(); } return CurrentGlobal.__FEDERATION__.__INSTANCES__.find(finder) || null; @@ -124,9 +155,10 @@ export function getInstance(finder?: (instance: ModuleFederation) => boolean) { export function registerShared( ...args: Parameters ): ReturnType { - assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const instance = getDefaultFederationInstance(); + assert(instance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return FederationInstance.registerShared.apply(FederationInstance, args); + return instance.registerShared.apply(instance, args); } // Inject for debug From fbbfe823d0cc6ec1b27052c0a00b678f794dec1b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 21 Sep 2026 07:11:08 +0000 Subject: [PATCH 2/9] test(runtime): stop minifying selector bundles The selector checks only need the program result. Production minification did not finish before the test runner reached its limit, so the assertion never ran. --- .../runtime-core/__tests__/remote-module-selector.spec.ts | 6 ++---- packages/sdk/__tests__/platform-loader-selector.spec.ts | 6 ++---- .../__tests__/container-entry-selector.spec.ts | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/runtime-core/__tests__/remote-module-selector.spec.ts b/packages/runtime-core/__tests__/remote-module-selector.spec.ts index f87d0859333..aa97627eb36 100644 --- a/packages/runtime-core/__tests__/remote-module-selector.spec.ts +++ b/packages/runtime-core/__tests__/remote-module-selector.spec.ts @@ -64,7 +64,7 @@ function selectedModules( return new Promise((resolve, reject) => { const compiler = compilerFactory({ context: packageDir, - mode: 'production', + mode: 'none', entry: path.join(root, 'entry.js'), output: { path: path.join(root, name), filename: 'out.js' }, resolve: { @@ -77,9 +77,7 @@ function selectedModules( conditionNames: ['module-federation:no-remote', 'import', '...'], }, optimization: { - concatenateModules: false, - minimize: true, - usedExports: false, + minimize: false, }, externals: [ ( diff --git a/packages/sdk/__tests__/platform-loader-selector.spec.ts b/packages/sdk/__tests__/platform-loader-selector.spec.ts index a5d65a9305c..1238da9cf4e 100644 --- a/packages/sdk/__tests__/platform-loader-selector.spec.ts +++ b/packages/sdk/__tests__/platform-loader-selector.spec.ts @@ -66,7 +66,7 @@ function compileSdk( return new Promise((resolve, reject) => { const compiler = compilerFactory({ context: packageDir, - mode: 'production', + mode: 'none', entry: path.join(root, 'entry.js'), output: { path: path.join(root, compilerName), @@ -79,9 +79,7 @@ function compileSdk( conditionNames: [condition, 'import', '...'], }, optimization: { - concatenateModules: false, - minimize: true, - usedExports: false, + minimize: false, }, }); compiler.run((error, stats) => { diff --git a/packages/webpack-bundler-runtime/__tests__/container-entry-selector.spec.ts b/packages/webpack-bundler-runtime/__tests__/container-entry-selector.spec.ts index a885a5339de..1b00c32fea7 100644 --- a/packages/webpack-bundler-runtime/__tests__/container-entry-selector.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/container-entry-selector.spec.ts @@ -69,7 +69,7 @@ function compileRuntime( return new Promise((resolve, reject) => { const compiler = compilerFactory({ context: packageDir, - mode: 'production', + mode: 'none', entry: path.join(root, 'entry.js'), output: { path: path.join(root, compilerName), @@ -89,9 +89,7 @@ function compileRuntime( ], }, optimization: { - concatenateModules: false, - minimize: true, - usedExports: false, + minimize: false, }, externals: [ ( From 516abbf9e5e6395fbac1f6a89cf39e4ee33df235 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:04:52 +0000 Subject: [PATCH 3/9] fix(runtime): check runtime families before instances register Compare remote-entry cache identity only when both the cached entry and the caller carry a runtime image. Drop the URL policy from the identity so retry plugins can change it between attempts, and stop storing the closure in global state. Compare separately built instances by family only, and run that check before the constructor runs beforeInit hooks, global plugins, and registerShared. Remove the global default-instance pointer and the constructor-level image so loadRemote, getInstance, and the debug constructor behave as on main. --- packages/runtime-core/__tests__/load.spec.ts | 63 ++++++++-- packages/runtime-core/src/global.ts | 31 +---- packages/runtime-core/src/runtimeImage.ts | 16 +-- packages/runtime-core/src/utils/load.ts | 118 ++++++------------ .../runtime/__tests__/runtime-image.spec.ts | 44 ++++++- packages/runtime/src/index.ts | 83 ++++++------ 6 files changed, 181 insertions(+), 174 deletions(-) diff --git a/packages/runtime-core/__tests__/load.spec.ts b/packages/runtime-core/__tests__/load.spec.ts index 0b5fd70eadb..ca6355a6bb1 100644 --- a/packages/runtime-core/__tests__/load.spec.ts +++ b/packages/runtime-core/__tests__/load.spec.ts @@ -21,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 @@ -564,8 +565,9 @@ describe('getRemoteEntry - globalLoading rejection cache', () => { expect(secondOriginAttempts).toBe(0); }); - it('rejects cache reuse with a different URL policy', async () => { + 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: [], @@ -573,33 +575,80 @@ describe('getRemoteEntry - globalLoading rejection cache', () => { { name: 'url-policy-entry', loadEntry() { + attempts += 1; return container; }, }, ], }); - attachRuntimeImage(origin, runtimeImage()); const remoteInfo = getRemoteInfo({ name: 'url-policy-remote', entry: 'https://remote.test/url-policy.js', }); - const firstPolicy = (url: string) => `${url}?source=first`; - const secondPolicy = (url: string) => `${url}?source=second`; await expect( getRemoteEntry({ origin, remoteInfo, - getEntryUrl: firstPolicy, + getEntryUrl: (url) => `${url}?retry=1`, }), ).resolves.toBe(container); await expect( getRemoteEntry({ origin, remoteInfo, - getEntryUrl: secondPolicy, + 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), }), - ).rejects.toThrow('different URL policy'); + ).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 () => { diff --git a/packages/runtime-core/src/global.ts b/packages/runtime-core/src/global.ts index 31502cc3cdd..1bdf7a97ed3 100644 --- a/packages/runtime-core/src/global.ts +++ b/packages/runtime-core/src/global.ts @@ -12,11 +12,6 @@ import { isDebugMode, } from '@module-federation/sdk'; import { warn, error } from './utils/logger'; -import { - assertRuntimeImageCompatible, - readRuntimeImage, - type RuntimeImageDescriptorV1, -} from './runtimeImage'; import { ModuleFederationRuntimePlugin } from './type/plugin'; export interface Federation { @@ -24,8 +19,6 @@ export interface Federation { __DEBUG_CONSTRUCTOR_VERSION__?: string; moduleInfo: GlobalModuleInfo; __DEBUG_CONSTRUCTOR__?: typeof ModuleFederation; - __DEBUG_CONSTRUCTOR_RUNTIME_IMAGE__?: RuntimeImageDescriptorV1; - __DEFAULT_INSTANCE__?: ModuleFederation; __INSTANCES__: Array; __SHARE__: GlobalShareScopeMap; __MANIFEST_LOADING__: Record>; @@ -35,12 +28,11 @@ export interface Federation { export interface RemoteEntryCacheDescriptorV1 { contract: 1; - compatibilityId?: string; - target?: string; - entryLoadingIdentity?: string; + compatibilityId: string; + target: string; + entryLoadingIdentity: string; remoteType: string; entryGlobalName: string; - loaderPolicy?: (url: string) => string; } export interface RemoteEntryCacheMetadataV1 { @@ -156,7 +148,6 @@ export function resetFederationGlobalInfo(): void { CurrentGlobal.__FEDERATION__.__SHARE__ = {}; CurrentGlobal.__FEDERATION__.__MANIFEST_LOADING__ = {}; CurrentGlobal.__FEDERATION__.__PRELOADED_ASSETS__.clear(); - CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ = undefined; Object.keys(globalLoading).forEach((key) => { delete globalLoading[key]; @@ -169,12 +160,7 @@ export function resetFederationGlobalInfo(): void { export function setGlobalFederationInstance( FederationInstance: ModuleFederation, ): void { - const next = readRuntimeImage(FederationInstance); - for (const instance of CurrentGlobal.__FEDERATION__.__INSTANCES__) { - assertRuntimeImageCompatible(readRuntimeImage(instance), next); - } CurrentGlobal.__FEDERATION__.__INSTANCES__.push(FederationInstance); - CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ ??= FederationInstance; } export function getGlobalFederationConstructor(): @@ -188,19 +174,8 @@ export function setGlobalFederationConstructor( isDebug = isDebugMode(), ): void { if (isDebug) { - const current = CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR__; - if (current && FederationConstructor) { - assertRuntimeImageCompatible( - readRuntimeImage(current), - readRuntimeImage(FederationConstructor), - ); - } CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR__ = FederationConstructor; CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR_VERSION__ = __VERSION__; - CurrentGlobal.__FEDERATION__.__DEBUG_CONSTRUCTOR_RUNTIME_IMAGE__ = - FederationConstructor - ? readRuntimeImage(FederationConstructor) - : undefined; } } diff --git a/packages/runtime-core/src/runtimeImage.ts b/packages/runtime-core/src/runtimeImage.ts index c0b514fbaaa..9a2f5a389ba 100644 --- a/packages/runtime-core/src/runtimeImage.ts +++ b/packages/runtime-core/src/runtimeImage.ts @@ -1,4 +1,4 @@ -import { warn } from './utils/logger'; +import { error, warn } from './utils/logger'; export const RUNTIME_IMAGE = Symbol.for('module-federation.runtime-image.v1'); @@ -44,17 +44,17 @@ export function assertRuntimeImageCompatible( return; } if (current.compatibilityId !== next.compatibilityId) { - throw new Error( + error( `Refusing to reuse runtime state from ${current.compatibilityId} with ${next.compatibilityId}.`, ); } if (current.target !== next.target) { - throw new Error( + error( `Refusing to reuse a ${current.target} runtime image for ${next.target}.`, ); } if (current.entryLoadingIdentity !== next.entryLoadingIdentity) { - throw new Error( + error( `Refusing to reuse entry loader ${current.entryLoadingIdentity} with ${next.entryLoadingIdentity}.`, ); } @@ -63,9 +63,7 @@ export function assertRuntimeImageCompatible( current.available.includes(capability) || current.required.includes(capability); if (!provided) { - throw new Error( - `Runtime image is missing required capability ${capability}.`, - ); + error(`Runtime image is missing required capability ${capability}.`); } } for (const capability of next.forbidden) { @@ -73,9 +71,7 @@ export function assertRuntimeImageCompatible( current.available.includes(capability) || current.required.includes(capability); if (exposed) { - throw new Error( - `Runtime image exposes forbidden capability ${capability}.`, - ); + error(`Runtime image exposes forbidden capability ${capability}.`); } } } diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 3ba18dba543..27b9b60a872 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -11,7 +11,6 @@ import { globalLoadingMeta, getRemoteEntryExports, type RemoteEntryCacheDescriptorV1, - type RemoteEntryCacheMetadataV1, } from '../global'; import { readRuntimeImage } from '../runtimeImage'; import { @@ -20,7 +19,7 @@ import { RemoteInfo, ResourceLoadContext, } from '../type'; -import { assert, error, warn } from './logger'; +import { assert, error } from './logger'; import { RUNTIME_001, RUNTIME_008, @@ -337,95 +336,52 @@ export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { return composeKeyWithSeparator(name, entry); } -const warnedLegacyCacheEntries = new WeakSet< - Promise ->(); - function getRemoteEntryCacheDescriptor( origin: ModuleFederation, remoteInfo: RemoteInfo, - loaderPolicy: ((url: string) => string) | undefined, -): RemoteEntryCacheDescriptorV1 { +): RemoteEntryCacheDescriptorV1 | undefined { const image = readRuntimeImage(origin); + if (!image) { + return undefined; + } return { contract: 1, - compatibilityId: image?.compatibilityId, - target: image?.target, - entryLoadingIdentity: image?.entryLoadingIdentity, + compatibilityId: image.compatibilityId, + target: image.target, + entryLoadingIdentity: image.entryLoadingIdentity, remoteType: remoteInfo.type, entryGlobalName: remoteInfo.entryGlobalName, - loaderPolicy, }; } +const cacheIdentityFields = [ + 'compatibilityId', + 'target', + 'entryLoadingIdentity', + 'remoteType', + 'entryGlobalName', +] as const; + function assertRemoteEntryCacheCompatible( uniqueKey: string, promise: Promise, - metadata: RemoteEntryCacheMetadataV1 | undefined, next: RemoteEntryCacheDescriptorV1, ): void { - if (!metadata || metadata.promise !== promise) { - if (globalLoadingMeta[uniqueKey] === metadata) { - delete globalLoadingMeta[uniqueKey]; - } - if (!warnedLegacyCacheEntries.has(promise)) { - warnedLegacyCacheEntries.add(promise); - warn( - `Remote entry ${uniqueKey} has no cache compatibility metadata. Reusing the legacy entry.`, - ); - } + const metadata = globalLoadingMeta[uniqueKey]; + if (!metadata) { return; } - const current = metadata.descriptor; - const knownFields: Array< - keyof Pick< - RemoteEntryCacheDescriptorV1, - | 'compatibilityId' - | 'target' - | 'entryLoadingIdentity' - | 'remoteType' - | 'entryGlobalName' - > - > = [ - 'compatibilityId', - 'target', - 'entryLoadingIdentity', - 'remoteType', - 'entryGlobalName', - ]; - for (const field of knownFields) { - const currentValue = current[field]; - const nextValue = next[field]; - if ( - currentValue !== undefined && - nextValue !== undefined && - currentValue !== nextValue - ) { - throw new Error( - `Refusing to reuse remote entry ${uniqueKey}. ${field} changed from ${currentValue} to ${nextValue}.`, + 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]}.`, ); } } - if ( - (current.loaderPolicy || next.loaderPolicy) && - current.loaderPolicy !== next.loaderPolicy - ) { - throw new Error( - `Refusing to reuse remote entry ${uniqueKey} with a different URL policy.`, - ); - } - if ( - (!current.compatibilityId || - !next.compatibilityId || - !current.entryLoadingIdentity || - !next.entryLoadingIdentity) && - !warnedLegacyCacheEntries.has(promise) - ) { - warnedLegacyCacheEntries.add(promise); - warn( - `Remote entry ${uniqueKey} has incomplete cache compatibility metadata. Reusing the legacy entry.`, - ); - } } export async function getRemoteEntry(params: { @@ -445,11 +401,7 @@ export async function getRemoteEntry(params: { _inErrorHandling = false, } = params; const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); - const cacheDescriptor = getRemoteEntryCacheDescriptor( - origin, - remoteInfo, - getEntryUrl, - ); + const cacheDescriptor = getRemoteEntryCacheDescriptor(origin, remoteInfo); if (remoteEntryExports) { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ @@ -462,12 +414,10 @@ export async function getRemoteEntry(params: { return remoteEntryExports; } - if (globalLoading[uniqueKey]) { - const cachedPromise = globalLoading[uniqueKey]; + if (cacheDescriptor && globalLoading[uniqueKey]) { assertRemoteEntryCacheCompatible( uniqueKey, - cachedPromise, - globalLoadingMeta[uniqueKey], + globalLoading[uniqueKey], cacheDescriptor, ); } @@ -557,10 +507,12 @@ export async function getRemoteEntry(params: { }); globalLoading[uniqueKey] = loading; - globalLoadingMeta[uniqueKey] = { - promise: loading, - descriptor: cacheDescriptor, - }; + 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. diff --git a/packages/runtime/__tests__/runtime-image.spec.ts b/packages/runtime/__tests__/runtime-image.spec.ts index 58a2912db06..7db664efea0 100644 --- a/packages/runtime/__tests__/runtime-image.spec.ts +++ b/packages/runtime/__tests__/runtime-image.spec.ts @@ -1,7 +1,9 @@ import { + CurrentGlobal, readRuntimeImage, type RuntimeImageDescriptorV1, } from '@module-federation/runtime-core'; +import { rs } from '@rstest/core'; import { createInstance, getInstance, init } from '../src'; const image = ( @@ -18,16 +20,52 @@ const image = ( }); describe('runtime image reuse', () => { - it('publishes the created instance through the global default pointer', () => { + it('keeps getInstance bound to init in this runtime copy', () => { const instance = createInstance({ - name: 'global-default', + name: 'created-not-default', runtimeImage: image(), }); - expect(getInstance()).toBe(instance); + 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', diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 91e5800fa3d..68cb7aea18c 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -5,9 +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'; @@ -28,18 +30,26 @@ 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 globalConstructor = getGlobalFederationConstructor(); - const ModuleFederationConstructor = globalConstructor || ModuleFederation; + const ModuleFederationConstructor = + getGlobalFederationConstructor() || ModuleFederation; if (options.runtimeImage) { - if (globalConstructor && globalConstructor !== ModuleFederation) { - assertRuntimeImageCompatible( - readRuntimeImage(globalConstructor), - options.runtimeImage, - ); - } - attachRuntimeImage(ModuleFederationConstructor, options.runtimeImage); + assertRuntimeFamilyRegistrable(options.name, options.runtimeImage); } const instance = new ModuleFederationConstructor({ id: `${options.name}@${options.version || Date.now()}`, @@ -53,14 +63,6 @@ export function createInstance(options: UserOptions) { } let FederationInstance: ModuleFederation | null = null; -function getDefaultFederationInstance(): ModuleFederation | null { - return ( - FederationInstance ?? - CurrentGlobal.__FEDERATION__.__DEFAULT_INSTANCE__ ?? - null - ); -} - export function init(options: UserOptions): ModuleFederation { // Retrieve the same instance with the same name const instance = getGlobalFederationInstance(options.name, options.version); @@ -85,59 +87,55 @@ export function init(options: UserOptions): ModuleFederation { export function loadRemote( ...args: Parameters ): Promise { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); - const loadRemote: typeof instance.loadRemote = instance.loadRemote; + assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const loadRemote: typeof FederationInstance.loadRemote = + FederationInstance.loadRemote; // eslint-disable-next-line prefer-spread - return loadRemote.apply(instance, args); + return loadRemote.apply(FederationInstance, args); } export function loadShare( ...args: Parameters ): Promise T | undefined)> { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); + assert(FederationInstance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - const loadShare: typeof instance.loadShare = instance.loadShare; - return loadShare.apply(instance, args); + const loadShare: typeof FederationInstance.loadShare = + FederationInstance.loadShare; + return loadShare.apply(FederationInstance, args); } export function loadShareSync( ...args: Parameters ): () => T | never { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); - const loadShareSync: typeof instance.loadShareSync = - instance.loadShareSync; + assert(FederationInstance, RUNTIME_009, runtimeDescMap); + const loadShareSync: typeof FederationInstance.loadShareSync = + FederationInstance.loadShareSync; // eslint-disable-next-line prefer-spread - return loadShareSync.apply(instance, args); + return loadShareSync.apply(FederationInstance, args); } export function preloadRemote( ...args: Parameters ): ReturnType { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); + assert(FederationInstance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return instance.preloadRemote.apply(instance, args); + return FederationInstance.preloadRemote.apply(FederationInstance, args); } export function registerRemotes( ...args: Parameters ): ReturnType { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); + assert(FederationInstance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return instance.registerRemotes.apply(instance, args); + return FederationInstance.registerRemotes.apply(FederationInstance, args); } export function registerPlugins( ...args: Parameters ): ReturnType { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); + assert(FederationInstance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return instance.registerPlugins.apply(instance, args); + return FederationInstance.registerPlugins.apply(FederationInstance, args); } export function getInstance(): ModuleFederation | null; @@ -146,7 +144,7 @@ export function getInstance( ): ModuleFederation | null; export function getInstance(finder?: (instance: ModuleFederation) => boolean) { if (!finder) { - return getDefaultFederationInstance(); + return FederationInstance; } return CurrentGlobal.__FEDERATION__.__INSTANCES__.find(finder) || null; @@ -155,10 +153,9 @@ export function getInstance(finder?: (instance: ModuleFederation) => boolean) { export function registerShared( ...args: Parameters ): ReturnType { - const instance = getDefaultFederationInstance(); - assert(instance, RUNTIME_009, runtimeDescMap); + assert(FederationInstance, RUNTIME_009, runtimeDescMap); // eslint-disable-next-line prefer-spread - return instance.registerShared.apply(instance, args); + return FederationInstance.registerShared.apply(FederationInstance, args); } // Inject for debug From d33a2eade2124f8f3a913c50792d6e268a67c188 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:04:52 +0000 Subject: [PATCH 4/9] fix(inject-external-runtime-core-plugin): keep legacy provider behavior Publish the external core again for the same name and version, warn only when a different provider is already published, and stop warning when no runtime image exists. Drop the unread entryLoadingIdentity field. --- .../__tests__/esm-import.spec.ts | 20 ++++++++++++- .../src/index.ts | 29 +++++-------------- 2 files changed, 27 insertions(+), 22 deletions(-) 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 151faf64c6e..f8b5d36a6ad 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 @@ -28,7 +28,6 @@ function expectInjectedRuntime(appName: string, version: string) { expect(globalThis._FEDERATION_RUNTIME_CORE_FROM).toEqual({ name: appName, version, - entryLoadingIdentity: `@module-federation/runtime-core@${version}:1`, }); } @@ -78,6 +77,25 @@ describe('@module-federation/inject-external-runtime-core-plugin', () => { 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 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 d85d66e242e..fd26ac4c8ce 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 @@ -10,7 +10,6 @@ declare global { var _FEDERATION_RUNTIME_CORE_FROM: { version: string; name: string; - entryLoadingIdentity?: string; runtimeImage?: RuntimeImageDescriptorV1; }; } @@ -32,35 +31,23 @@ function injectExternalRuntimeCorePlugin(): ModuleFederationRuntimePlugin { const version = __VERSION__; const runtimeImage = args.userOptions?.runtimeImage ?? args.options.runtimeImage; - if (globalRef._FEDERATION_RUNTIME_CORE) { - const provider = globalRef._FEDERATION_RUNTIME_CORE_FROM; - if (provider) { - runtimeCore.assertRuntimeImageCompatible( - provider.runtimeImage, - runtimeImage, - ); - } - if ( - provider && - !provider.runtimeImage && - (provider.name !== name || provider.version !== version) - ) { + const provider = globalRef._FEDERATION_RUNTIME_CORE_FROM; + if (globalRef._FEDERATION_RUNTIME_CORE && provider) { + runtimeCore.assertRuntimeImageCompatible( + 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; } - return args; - } - if (!runtimeImage) { - console.warn( - 'External runtime-core metadata is missing. Reuse stays in legacy compatibility mode.', - ); } globalRef._FEDERATION_RUNTIME_CORE = runtimeCore; globalRef._FEDERATION_RUNTIME_CORE_FROM = { version, name, - entryLoadingIdentity: `@module-federation/runtime-core@${version}:1`, ...(runtimeImage ? { runtimeImage } : {}), }; return args; From 9f23ffaaccd5279be33c57292b5a23ac8cd4735f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:04:52 +0000 Subject: [PATCH 5/9] docs(changeset): state that runtime image checks are inert without metadata --- .changeset/runtime-image-compatibility.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/runtime-image-compatibility.md b/.changeset/runtime-image-compatibility.md index d462e0cce13..928eb7aee20 100644 --- a/.changeset/runtime-image-compatibility.md +++ b/.changeset/runtime-image-compatibility.md @@ -5,8 +5,12 @@ '@module-federation/inject-external-runtime-core-plugin': patch --- -Carry runtime-image metadata into runtime instances and global external-core -state. Known incompatible families, targets, capabilities, and entry loaders -now fail before shared state or a cached remote entry is reused. +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. -The remote-entry cache key remains unchanged. +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`. From de8653e9232008da956a732a592951fcb7a3bad5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 24 Sep 2026 23:28:54 +0000 Subject: [PATCH 6/9] fix(runtime-core): compare runtime capabilities in both directions Reusing a no-shared runtime for an image that can load shared modules passed, while the reverse order failed. The current image must now provide every capability the next image can use. --- .../__tests__/runtime-image.spec.ts | 18 ++++++++++++++++++ packages/runtime-core/src/runtimeImage.ts | 15 +++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/runtime-core/__tests__/runtime-image.spec.ts b/packages/runtime-core/__tests__/runtime-image.spec.ts index a2d140d6be5..46c12918a45 100644 --- a/packages/runtime-core/__tests__/runtime-image.spec.ts +++ b/packages/runtime-core/__tests__/runtime-image.spec.ts @@ -37,6 +37,24 @@ describe('runtime image compatibility', () => { ).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()); diff --git a/packages/runtime-core/src/runtimeImage.ts b/packages/runtime-core/src/runtimeImage.ts index 9a2f5a389ba..e4764162a82 100644 --- a/packages/runtime-core/src/runtimeImage.ts +++ b/packages/runtime-core/src/runtimeImage.ts @@ -58,19 +58,14 @@ export function assertRuntimeImageCompatible( `Refusing to reuse entry loader ${current.entryLoadingIdentity} with ${next.entryLoadingIdentity}.`, ); } - for (const capability of next.required) { - const provided = - current.available.includes(capability) || - current.required.includes(capability); - if (!provided) { - error(`Runtime image is missing required capability ${capability}.`); + 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) { - const exposed = - current.available.includes(capability) || - current.required.includes(capability); - if (exposed) { + if (provided.has(capability)) { error(`Runtime image exposes forbidden capability ${capability}.`); } } From ee9205312c5c1ad8cec17c15e34375bc19fcafa8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 2 Oct 2026 17:53:35 -0700 Subject: [PATCH 7/9] fix(runtime-core): isolate remote entry evaluators RFC5036 attribution: https://github.com/module-federation/core/pull/5096 --- .../rfc5036-entry-evaluator-isolation.md | 5 + packages/runtime-core/__tests__/load.spec.ts | 341 ++++++++++++++++++ .../__tests__/resources/load/evaluator-a.js | 8 + .../__tests__/resources/load/evaluator-b.js | 8 + packages/runtime-core/src/global.ts | 10 + packages/runtime-core/src/remote/index.ts | 19 +- packages/runtime-core/src/utils/load.ts | 170 ++++++++- 7 files changed, 537 insertions(+), 24 deletions(-) create mode 100644 .changeset/rfc5036-entry-evaluator-isolation.md create mode 100644 packages/runtime-core/__tests__/resources/load/evaluator-a.js create mode 100644 packages/runtime-core/__tests__/resources/load/evaluator-b.js 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/packages/runtime-core/__tests__/load.spec.ts b/packages/runtime-core/__tests__/load.spec.ts index ca6355a6bb1..c703521469d 100644 --- a/packages/runtime-core/__tests__/load.spec.ts +++ b/packages/runtime-core/__tests__/load.spec.ts @@ -565,6 +565,347 @@ describe('getRemoteEntry - globalLoading rejection cache', () => { 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; 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/src/global.ts b/packages/runtime-core/src/global.ts index 1bdf7a97ed3..4b0701b03fb 100644 --- a/packages/runtime-core/src/global.ts +++ b/packages/runtime-core/src/global.ts @@ -33,6 +33,16 @@ export interface RemoteEntryCacheDescriptorV1 { 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 { diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index cae602a6d44..a489a29a76f 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -5,13 +5,7 @@ import { ModuleInfo, } from '@module-federation/sdk'; import { RUNTIME_004, runtimeDescMap } from '@module-federation/error-codes'; -import { - Global, - getInfoWithoutType, - globalLoading, - globalLoadingMeta, - CurrentGlobal, -} from '../global'; +import { Global, getInfoWithoutType, CurrentGlobal } from '../global'; import { Options, UserOptions, @@ -28,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'; @@ -604,14 +598,7 @@ export class RemoteHandler { CurrentGlobal[key] = undefined; } } - const remoteEntryUniqueKey = getRemoteEntryUniqueKey( - loadedModule.remoteInfo, - ); - - if (globalLoading[remoteEntryUniqueKey]) { - delete globalLoading[remoteEntryUniqueKey]; - delete globalLoadingMeta[remoteEntryUniqueKey]; - } + clearRemoteEntryCache(loadedModule.remoteInfo); // delete unloaded shared and instance let remoteInsId = remoteInfo.buildVersion diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 27b9b60a872..9b82d9d4824 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -169,6 +169,7 @@ async function loadEntryScript({ loaderHook, getEntryUrl, resourceContext, + ignoreGlobalExports, }: { name: string; globalName: string; @@ -177,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; } @@ -248,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)) { @@ -272,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( @@ -290,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 = {}) => { @@ -320,7 +341,12 @@ 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; + } return handleRemoteEntryLoaded(name, globalName, entry); }) .catch((e) => { @@ -339,6 +365,7 @@ export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { function getRemoteEntryCacheDescriptor( origin: ModuleFederation, remoteInfo: RemoteInfo, + getEntryUrl?: (url: string) => string, ): RemoteEntryCacheDescriptorV1 | undefined { const image = readRuntimeImage(origin); if (!image) { @@ -351,6 +378,21 @@ function getRemoteEntryCacheDescriptor( 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], + }, }; } @@ -384,6 +426,80 @@ function assertRemoteEntryCacheCompatible( } } +// 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; @@ -400,9 +516,6 @@ export async function getRemoteEntry(params: { resourceContext, _inErrorHandling = false, } = params; - const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); - const cacheDescriptor = getRemoteEntryCacheDescriptor(origin, remoteInfo); - if (remoteEntryExports) { await origin.loaderHook.lifecycle.afterLoadEntry.emit({ origin, @@ -414,6 +527,17 @@ 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, @@ -438,6 +562,29 @@ export async function getRemoteEntry(params: { return res; } 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({ @@ -446,8 +593,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({ From 7c463f713b394c6027923bdc16ce2df05375e94a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 2 Oct 2026 17:43:01 -0700 Subject: [PATCH 8/9] fix(runtime-plugin): require image checker only for tagged runtimes --- .../external-runtime-minimum-contract.md | 5 ++ .../__tests__/esm-import.spec.ts | 71 +++++++++++++++++++ .../src/index.ts | 16 +++-- 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 .changeset/external-runtime-minimum-contract.md 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/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 f8b5d36a6ad..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 @@ -31,6 +31,27 @@ function expectInjectedRuntime(appName: string, version: string) { }); } +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; @@ -134,4 +155,54 @@ describe('@module-federation/inject-external-runtime-core-plugin', () => { 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 fd26ac4c8ce..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 @@ -32,11 +32,19 @@ function injectExternalRuntimeCorePlugin(): ModuleFederationRuntimePlugin { const runtimeImage = args.userOptions?.runtimeImage ?? args.options.runtimeImage; const provider = globalRef._FEDERATION_RUNTIME_CORE_FROM; - if (globalRef._FEDERATION_RUNTIME_CORE && provider) { - runtimeCore.assertRuntimeImageCompatible( - provider.runtimeImage, - runtimeImage, + const assertCompatible = runtimeCore.assertRuntimeImageCompatible; + if ( + typeof assertCompatible !== 'function' && + (provider?.runtimeImage || runtimeImage) + ) { + 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.', ); + } + 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!`, From 89e57fd4a306b27089d812f838f281ff94956812 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 2 Oct 2026 18:42:45 -0700 Subject: [PATCH 9/9] fix(runtime-core): reject malformed image-backed Node entry exports --- .changeset/quiet-node-payload.md | 5 ++ .../__tests__/node-entry-payload.spec.ts | 19 +++++ packages/runtime-core/src/utils/load.ts | 5 ++ tools/scripts/prove-runtime-node-payload.cjs | 70 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 .changeset/quiet-node-payload.md create mode 100644 packages/runtime-core/__tests__/node-entry-payload.spec.ts create mode 100644 tools/scripts/prove-runtime-node-payload.cjs 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/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/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index 9b82d9d4824..3908452aea7 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -347,6 +347,11 @@ async function loadEntryNode({ 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) => { 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; +});