From 2f034b5e950ed711bc71d33f2681ec6d52fe5f85 Mon Sep 17 00:00:00 2001 From: Anthony Sessa Date: Fri, 10 Apr 2026 15:27:40 -0500 Subject: [PATCH 1/3] fix(runtime-core): route manifest validation errors through errorLoadRemote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two error paths in the snapshot plugin bypass `errorLoadRemote`, causing unrecoverable crashes when a remote's manifest is malformed or its remoteEntry URL is missing. Consumers that register an `errorLoadRemote` handler expect all remote-loading failures to flow through the hook so they can provide graceful fallbacks (e.g. rendering a shell without the failed micro-frontend). **Bug 1 — manifest shape validation (SnapshotHandler.getManifestJson):** The `assert(metaData && exposes && shared)` sat outside the `try/catch` that emits `errorLoadRemote`. When a manifest fetched successfully as valid JSON but was missing required fields, the assert threw directly, bypassing the hook entirely. Fix: move the assert inside the `try` block so the `catch` emits `errorLoadRemote` with `lifecycle: 'afterResolve'`, giving plugins a chance to supply a fallback manifest. **Bug 2 — missing remoteEntry URL (snapshotPlugin.afterResolve):** `assignRemoteInfo` calls `error(RUNTIME_011)` which throws when `remoteEntryInfo.url` is empty. This throw was unguarded in the `afterResolve` hook, so it propagated as an uncaught waterfall error instead of routing through `errorLoadRemote`. Fix: wrap `assignRemoteInfo` in a try/catch that emits `errorLoadRemote` with `lifecycle: 'afterResolve'`. If the hook returns a truthy value the error is considered handled and the host continues; otherwise the original error is re-thrown. Made-with: Cursor --- .../error-load-remote-manifest.spec.ts | 180 ++++++++++++++++++ .../src/plugins/snapshot/SnapshotHandler.ts | 9 +- .../src/plugins/snapshot/index.ts | 21 +- 3 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts diff --git a/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts b/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts new file mode 100644 index 00000000000..f0a35ca0030 --- /dev/null +++ b/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts @@ -0,0 +1,180 @@ +import { assert, describe, it, expect, vi } from 'vitest'; +import { ModuleFederation } from '../src/core'; +import type { ModuleFederationRuntimePlugin } from '../src/type/plugin'; +import { mockStaticServer, removeScriptTags } from './mock/utils'; +import { resetFederationGlobalInfo } from '../src/global'; + +mockStaticServer({ + baseDir: __dirname, + filterKeywords: [], + basename: 'http://localhost:1111/', +}); + +describe('errorLoadRemote — manifest validation errors', () => { + beforeEach(() => { + resetFederationGlobalInfo(); + removeScriptTags(); + }); + + it('calls errorLoadRemote when manifest is valid JSON but missing required fields', async () => { + const errorLoadRemoteSpy = vi.fn(); + + const incompleteManifest = { + id: '@test/bad-remote', + name: '@test/bad-remote', + }; + + const fetchPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-fetch-plugin', + fetch(url) { + if (url.includes('bad-manifest')) { + return Promise.resolve( + new Response(JSON.stringify(incompleteManifest), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + }, + }); + + const errorHandlerPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-error-handler', + errorLoadRemote(args) { + errorLoadRemoteSpy(args); + return undefined; + }, + }); + + const FM = new ModuleFederation({ + name: '@test/host', + remotes: [ + { + name: '@test/bad-remote', + entry: 'http://localhost:9999/bad-manifest/mf-manifest.json', + }, + ], + plugins: [fetchPlugin(), errorHandlerPlugin()], + }); + + await expect( + FM.loadRemote('@test/bad-remote/someExpose'), + ).rejects.toThrow(); + + expect(errorLoadRemoteSpy).toHaveBeenCalled(); + const callArgs = errorLoadRemoteSpy.mock.calls[0][0]; + expect(callArgs.lifecycle).toBe('afterResolve'); + expect(callArgs.error).toBeDefined(); + expect(String(callArgs.error)).toContain('Missing required fields'); + }); + + it('recovers via errorLoadRemote when manifest has missing fields and plugin returns fallback', async () => { + const validManifestData = { + id: '@test/bad-remote', + name: '@test/bad-remote', + metaData: { + name: '@test/bad-remote', + publicPath: 'http://localhost:1111/', + type: 'app', + buildInfo: { buildVersion: 'custom' }, + remoteEntry: { + name: 'federation-remote-entry.js', + path: 'resources/hooks/app2/', + }, + types: { name: 'index.d.ts', path: './' }, + globalName: '@loader-hooks/app2', + }, + remotes: [], + shared: [], + exposes: [], + }; + + const incompleteManifest = { + id: '@test/bad-remote', + name: '@test/bad-remote', + }; + + let fetchCallCount = 0; + const fetchPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-fetch-plugin', + fetch(url) { + if (url.includes('bad-manifest')) { + fetchCallCount++; + if (fetchCallCount === 1) { + return Promise.resolve( + new Response(JSON.stringify(incompleteManifest), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + } + }, + }); + + const errorHandlerPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-error-handler', + errorLoadRemote({ lifecycle }) { + if (lifecycle === 'afterResolve') { + return validManifestData; + } + }, + }); + + const FM = new ModuleFederation({ + name: '@test/host-recover', + remotes: [ + { + name: '@test/bad-remote', + entry: 'http://localhost:9999/bad-manifest/mf-manifest.json', + }, + ], + plugins: [fetchPlugin(), errorHandlerPlugin()], + }); + + const module = await FM.loadRemote<() => string>('@test/bad-remote/say'); + assert(module); + expect(module()).toBe('hello app2'); + }); + + it('calls errorLoadRemote when manifest fetch fails (network error)', async () => { + const errorLoadRemoteSpy = vi.fn(); + + const fetchPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-fetch-plugin', + fetch(url) { + if (url.includes('unreachable')) { + return Promise.reject(new TypeError('Failed to fetch')); + } + }, + }); + + const errorHandlerPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-error-handler', + errorLoadRemote(args) { + errorLoadRemoteSpy(args); + return undefined; + }, + }); + + const FM = new ModuleFederation({ + name: '@test/host-network', + remotes: [ + { + name: '@test/unreachable-remote', + entry: 'http://localhost:9999/unreachable/mf-manifest.json', + }, + ], + plugins: [fetchPlugin(), errorHandlerPlugin()], + }); + + await expect( + FM.loadRemote('@test/unreachable-remote/someExpose'), + ).rejects.toThrow(); + + expect(errorLoadRemoteSpy).toHaveBeenCalled(); + const callArgs = errorLoadRemoteSpy.mock.calls[0][0]; + expect(callArgs.lifecycle).toBe('afterResolve'); + expect(callArgs.error).toBeInstanceOf(TypeError); + }); +}); diff --git a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts index a13d804bdaa..7f0d30ea523 100644 --- a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts +++ b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts @@ -297,6 +297,11 @@ export class SnapshotHandler { res = await fetch(manifestUrl, {}); } manifestJson = (await res.json()) as Manifest; + + assert( + manifestJson.metaData && manifestJson.exposes && manifestJson.shared, + `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${[!manifestJson.metaData && 'metaData', !manifestJson.exposes && 'exposes', !manifestJson.shared && 'shared'].filter(Boolean).join(', ')}.`, + ); } catch (err) { manifestJson = (await this.HostInstance.remoteHandler.hooks.lifecycle.errorLoadRemote.emit( @@ -325,10 +330,6 @@ export class SnapshotHandler { } } - assert( - manifestJson.metaData && manifestJson.exposes && manifestJson.shared, - `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${[!manifestJson.metaData && 'metaData', !manifestJson.exposes && 'exposes', !manifestJson.shared && 'shared'].filter(Boolean).join(', ')}.`, - ); this.manifestCache.set(manifestUrl, manifestJson); return manifestJson; }; diff --git a/packages/runtime-core/src/plugins/snapshot/index.ts b/packages/runtime-core/src/plugins/snapshot/index.ts index fa09668b674..aece22d1797 100644 --- a/packages/runtime-core/src/plugins/snapshot/index.ts +++ b/packages/runtime-core/src/plugins/snapshot/index.ts @@ -49,8 +49,25 @@ export function snapshotPlugin(): ModuleFederationRuntimePlugin { id, }); - assignRemoteInfo(remoteInfo, remoteSnapshot); - // preloading assets + try { + assignRemoteInfo(remoteInfo, remoteSnapshot); + } catch (assignError) { + const failOver = + await origin.remoteHandler.hooks.lifecycle.errorLoadRemote.emit({ + id: id || remoteInfo.name, + error: assignError, + from: 'runtime', + lifecycle: 'afterResolve', + origin, + }); + + if (!failOver) { + throw assignError; + } + + return args; + } + const preloadOptions: PreloadOptions[0] = { remote, preloadConfig: { From 1dae71399a73f3455a7e837f0b14b042eb57142e Mon Sep 17 00:00:00 2001 From: Anthony Sessa Date: Fri, 10 Apr 2026 15:38:36 -0500 Subject: [PATCH 2/3] docs: document manifest validation errors in errorLoadRemote Update errorLoadRemote docs to reflect that afterResolve lifecycle now also catches manifest validation errors (missing metaData, exposes, or shared fields), not just network failures. Update ZH runtime-hooks to include the lifecycle field and descriptions matching the EN version. Made-with: Cursor --- .../docs/en/blog/error-load-remote.mdx | 2 +- .../docs/en/guide/runtime/runtime-hooks.mdx | 2 +- .../docs/zh/blog/error-load-remote.mdx | 2 +- .../docs/zh/guide/runtime/runtime-hooks.mdx | 56 +++++++++++++++++-- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/website-new/docs/en/blog/error-load-remote.mdx b/apps/website-new/docs/en/blog/error-load-remote.mdx index b7bb4139ff6..c37adc558ed 100644 --- a/apps/website-new/docs/en/blog/error-load-remote.mdx +++ b/apps/website-new/docs/en/blog/error-load-remote.mdx @@ -427,7 +427,7 @@ export default fallbackPlugin; ``` - **Handle entry file loading errors** (`args.lifecycle === 'afterResolve'`) - - This type of error occurs in the entry resource `mf-manifest.json` loading process + - This type of error occurs in the entry resource `mf-manifest.json` loading process, including network failures and manifest validation errors (e.g. missing required fields like `metaData`, `exposes`, or `shared`) - We can handle it in the following two ways: a. Try to load backup service: diff --git a/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx b/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx index 74ea4010512..974c24b4ec6 100644 --- a/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx +++ b/apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx @@ -189,7 +189,7 @@ type ErrorLoadRemoteOptions ={ The `lifecycle` parameter indicates the stage where the error occurred: - `beforeRequest`: Error during initial request processing -- `afterResolve`: Error during manifest loading (most common for network failures) +- `afterResolve`: Error during manifest loading or validation (network failures, invalid/malformed manifest) - `onLoad`: Error during module loading and execution - `beforeLoadShare`: Error during shared dependency loading diff --git a/apps/website-new/docs/zh/blog/error-load-remote.mdx b/apps/website-new/docs/zh/blog/error-load-remote.mdx index 1a71cf9684e..b3d40ed3963 100644 --- a/apps/website-new/docs/zh/blog/error-load-remote.mdx +++ b/apps/website-new/docs/zh/blog/error-load-remote.mdx @@ -426,7 +426,7 @@ export default fallbackPlugin; ``` - **处理入口文件错误** (`args.lifecycle === 'afterResolve'`) - - 这类错误发生在入口资源 `mf-manifest.json` 加载过程中 + - 这类错误发生在入口资源 `mf-manifest.json` 加载过程中,包括网络失败和 manifest 校验错误(例如缺少 `metaData`、`exposes` 或 `shared` 等必填字段) - 可以通过以下两种方式处理: a. 尝试加载备用服务: diff --git a/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx b/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx index 3e96fbbc741..07e61ea55d9 100644 --- a/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx +++ b/apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx @@ -181,15 +181,24 @@ async function errorLoadRemote(args: ErrorLoadRemoteOptions): Promise ModuleFederationRuntimePlugin = @@ -197,13 +206,48 @@ const fallbackPlugin: () => ModuleFederationRuntimePlugin = return { name: 'fallback-plugin', errorLoadRemote(args) { - const fallback = 'fallback' - return fallback; + const { lifecycle, id, error } = args; + + if (error) { + console.warn(`Failed to load remote ${id} at ${lifecycle}:`, error?.message || error); + } + + switch (lifecycle) { + case 'afterResolve': + return { + id: id || 'fallback', + name: id || 'fallback', + metaData: { /* fallback manifest */ }, + shared: [], + remotes: [], + exposes: [] + }; + + case 'beforeRequest': + console.warn(`Request processing failed for ${id}`); + return void 0; + + case 'onLoad': + return () => ({ + __esModule: true, + default: () => 'Fallback Component' + }); + + case 'beforeLoadShare': + console.warn(`Shared dependency loading failed for ${id}`); + return () => ({ + __esModule: true, + default: {} + }); + + default: + console.warn(`Unknown lifecycle ${lifecycle} for ${id}`); + return void 0; + } }, }; }; - const mf = createInstance({ name: 'mf_host', remotes: [ @@ -218,7 +262,7 @@ const mf = createInstance({ mf.loadRemote('app1/un-existed-module').then(mod=>{ expect(mod).toEqual('fallback'); -}) +}); ``` ## beforeLoadShare From 2c5cecf72d8516edafbf26ad92d401d49c900771 Mon Sep 17 00:00:00 2001 From: Anthony Sessa Date: Fri, 10 Apr 2026 15:44:46 -0500 Subject: [PATCH 3/3] fix(runtime-core): validate failover manifests and apply failover result Address review feedback: - Re-validate manifestJson after errorLoadRemote returns to reject invalid failover payloads before they reach generateSnapshotFromManifest - Apply failover snapshot via assignRemoteInfo instead of discarding it - Add test covering invalid failover rejection Made-with: Cursor --- .../error-load-remote-manifest.spec.ts | 53 +++++++++++++++++++ .../src/plugins/snapshot/SnapshotHandler.ts | 4 ++ .../src/plugins/snapshot/index.ts | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts b/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts index f0a35ca0030..5110c084f22 100644 --- a/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts +++ b/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts @@ -137,6 +137,59 @@ describe('errorLoadRemote — manifest validation errors', () => { expect(module()).toBe('hello app2'); }); + it('rejects when errorLoadRemote returns an invalid failover manifest', async () => { + const errorLoadRemoteSpy = vi.fn(); + const invalidFallback = { + id: '@test/bad-remote', + name: '@test/bad-remote', + }; + + const fetchPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-fetch-plugin', + fetch(url) { + if (url.includes('bad-manifest')) { + return Promise.resolve( + new Response(JSON.stringify({ id: 'x', name: 'x' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + }, + }); + + const errorHandlerPlugin: () => ModuleFederationRuntimePlugin = () => ({ + name: 'test-error-handler', + errorLoadRemote(args) { + errorLoadRemoteSpy(args); + if (args.lifecycle === 'afterResolve') { + return invalidFallback; + } + return undefined; + }, + }); + + const FM = new ModuleFederation({ + name: '@test/host-bad-fallback', + remotes: [ + { + name: '@test/bad-remote', + entry: 'http://localhost:9999/bad-manifest/mf-manifest.json', + }, + ], + plugins: [fetchPlugin(), errorHandlerPlugin()], + }); + + await expect(FM.loadRemote('@test/bad-remote/someExpose')).rejects.toThrow( + /Missing required fields/, + ); + + const lifecycles = errorLoadRemoteSpy.mock.calls.map( + (c: any[]) => c[0].lifecycle, + ); + expect(lifecycles).toContain('afterResolve'); + }); + it('calls errorLoadRemote when manifest fetch fails (network error)', async () => { const errorLoadRemoteSpy = vi.fn(); diff --git a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts index 7f0d30ea523..bc18ea64d07 100644 --- a/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts +++ b/packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts @@ -330,6 +330,10 @@ export class SnapshotHandler { } } + assert( + manifestJson.metaData && manifestJson.exposes && manifestJson.shared, + `"${manifestUrl}" is not a valid federation manifest for remote "${moduleInfo.name}". Missing required fields: ${[!manifestJson.metaData && 'metaData', !manifestJson.exposes && 'exposes', !manifestJson.shared && 'shared'].filter(Boolean).join(', ')}.`, + ); this.manifestCache.set(manifestUrl, manifestJson); return manifestJson; }; diff --git a/packages/runtime-core/src/plugins/snapshot/index.ts b/packages/runtime-core/src/plugins/snapshot/index.ts index aece22d1797..ed064a89b4a 100644 --- a/packages/runtime-core/src/plugins/snapshot/index.ts +++ b/packages/runtime-core/src/plugins/snapshot/index.ts @@ -65,7 +65,7 @@ export function snapshotPlugin(): ModuleFederationRuntimePlugin { throw assignError; } - return args; + assignRemoteInfo(remoteInfo, failOver as ModuleInfo); } const preloadOptions: PreloadOptions[0] = {