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 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..5110c084f22 --- /dev/null +++ b/packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts @@ -0,0 +1,233 @@ +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('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(); + + 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..bc18ea64d07 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( diff --git a/packages/runtime-core/src/plugins/snapshot/index.ts b/packages/runtime-core/src/plugins/snapshot/index.ts index fa09668b674..ed064a89b4a 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; + } + + assignRemoteInfo(remoteInfo, failOver as ModuleInfo); + } + const preloadOptions: PreloadOptions[0] = { remote, preloadConfig: {