Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website-new/docs/en/blog/error-load-remote.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/website-new/docs/en/guide/runtime/runtime-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/website-new/docs/zh/blog/error-load-remote.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export default fallbackPlugin;
```

- **处理入口文件错误** (`args.lifecycle === 'afterResolve'`)
- 这类错误发生在入口资源 `mf-manifest.json` 加载过程中
- 这类错误发生在入口资源 `mf-manifest.json` 加载过程中,包括网络失败和 manifest 校验错误(例如缺少 `metaData`、`exposes` 或 `shared` 等必填字段)
- 可以通过以下两种方式处理:

a. 尝试加载备用服务:
Expand Down
56 changes: 50 additions & 6 deletions apps/website-new/docs/zh/guide/runtime/runtime-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,29 +181,73 @@ async function errorLoadRemote(args: ErrorLoadRemoteOptions): Promise<void | unk
type ErrorLoadRemoteOptions ={
id: string;
error: unknown;
options?: any;
from: 'build' | 'runtime';
lifecycle: 'beforeRequest' | 'beforeLoadShare' | 'afterResolve' | 'onLoad';
origin: ModuleFederation;
}
```

`lifecycle` 参数标识错误发生的阶段:

- `beforeRequest`:初始请求处理阶段出错
- `afterResolve`:manifest 加载或校验阶段出错(网络失败、manifest 缺少必填字段等)
- `onLoad`:模块加载和执行阶段出错
- `beforeLoadShare`:共享依赖加载阶段出错

* example

```ts
import { createInstance } from '@module-federation/enhanced/runtime'

import { createInstance, loadRemote } from '@module-federation/enhanced/runtime'
import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime';

const fallbackPlugin: () => ModuleFederationRuntimePlugin =
function () {
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: [
Expand All @@ -218,7 +262,7 @@ const mf = createInstance({

mf.loadRemote('app1/un-existed-module').then(mod=>{
expect(mod).toEqual('fallback');
})
});
```

## beforeLoadShare
Expand Down
233 changes: 233 additions & 0 deletions packages/runtime-core/__tests__/error-load-remote-manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}.`,
);
Comment on lines +301 to +304
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate failover manifests after errorLoadRemote

In getManifestJson, moving the required-field assert entirely inside the fetch try means values returned from errorLoadRemote are now accepted without shape checks. If a plugin returns a non-manifest payload (for example an args object), it gets treated as manifestJson and then fails later in generateSnapshotFromManifest, outside this recovery block, which bypasses the intended afterResolve handling and produces a harder-to-recover error path. Re-validate manifestJson after the catch before caching/using it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c5cecf. Added a post-catch assert that re-validates manifestJson after the errorLoadRemote failover, ensuring payloads returned by plugins are checked for required fields (metaData, exposes, shared) before being cached or passed to generateSnapshotFromManifest. Also added a test covering this case.

} catch (err) {
manifestJson =
(await this.HostInstance.remoteHandler.hooks.lifecycle.errorLoadRemote.emit(
Expand Down
21 changes: 19 additions & 2 deletions packages/runtime-core/src/plugins/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down