Skip to content

Commit 855e6d8

Browse files
committed
feat(runtime): add loadEntryTimeout option for the remote entry script timeout
1 parent 5727cf3 commit 855e6d8

11 files changed

Lines changed: 203 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@module-federation/sdk': patch
3+
'@module-federation/runtime-core': patch
4+
---
5+
6+
feat: `loadEntryTimeout` runtime option — how long a remote entry script may take to load before it fails with RUNTIME-008 (default 20000 ms, `Infinity` disables the timer); `createScript` / `loadScript` accept a `timeout` and a `createScript` hook return value still overrides it; when set, the option also bounds the fetch of a manifest entry

apps/website-new/docs/en/guide/runtime/runtime-api.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const mf = createInstance({
4141
mf.loadRemote('sub1/util').then((m) => m.add(1, 2, 3));
4242
```
4343

44+
**Options**
45+
46+
Besides `name`, `remotes`, `shared`, `plugins` and `shareStrategy`, the instance accepts:
47+
48+
- `loadEntryTimeout` — how long (ms) a remote entry script may take to load before the load fails with `RUNTIME-008`. Defaults to `20000`; pass `Infinity` to wait indefinitely. A `createScript` hook that returns `timeout` still takes precedence. When set, the same limit bounds the fetch of a manifest entry (`mf-manifest.json`), which has no timeout by default.
49+
4450
## init <Badge type='warning'>Use with caution</Badge>
4551

4652
Used to initialize or reuse a `ModuleFederation` runtime instance.

apps/website-new/docs/zh/guide/runtime/runtime-api.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const mf = createInstance({
4141
mf.loadRemote('sub1/util').then((m) => m.add(1, 2, 3));
4242
```
4343

44+
**配置项**
45+
46+
除了 `name``remotes``shared``plugins``shareStrategy`,实例还接受:
47+
48+
- `loadEntryTimeout` — 远程入口脚本的加载超时时间(毫秒),超时后以 `RUNTIME-008` 报错。默认 `20000`;传 `Infinity` 表示不限时。`createScript` 钩子返回的 `timeout` 仍然优先。设置后,同一时限也会约束 manifest 入口(`mf-manifest.json`)的请求,默认情况下该请求没有超时。
49+
4450
## init <Badge type='warning'>谨慎使用</Badge>
4551

4652
用于初始化或复用 `ModuleFederation` 运行时实例。

packages/runtime-core/__tests__/load.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,53 @@ describe('getRemoteEntry - script load error discrimination', () => {
126126
);
127127
});
128128

129+
it('passes loadEntryTimeout to the entry script loader', async () => {
130+
const entry = `${BASE}/success.js`;
131+
const origin = new ModuleFederation({
132+
name: 'test-host',
133+
remotes: [],
134+
loadEntryTimeout: 45000,
135+
});
136+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
137+
const setTimeoutSpy = rs.spyOn(globalThis, 'setTimeout');
138+
139+
try {
140+
await getRemoteEntry({ origin, remoteInfo });
141+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 45000);
142+
expect(setTimeoutSpy).not.toHaveBeenCalledWith(
143+
expect.any(Function),
144+
20000,
145+
);
146+
} finally {
147+
setTimeoutSpy.mockRestore();
148+
}
149+
});
150+
151+
it('does not arm the entry timer when loadEntryTimeout is Infinity', async () => {
152+
const entry = `${BASE}/success.js`;
153+
const origin = new ModuleFederation({
154+
name: 'test-host',
155+
remotes: [],
156+
loadEntryTimeout: Infinity,
157+
});
158+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
159+
const setTimeoutSpy = rs.spyOn(globalThis, 'setTimeout');
160+
161+
try {
162+
await getRemoteEntry({ origin, remoteInfo });
163+
expect(setTimeoutSpy).not.toHaveBeenCalledWith(
164+
expect.any(Function),
165+
20000,
166+
);
167+
expect(setTimeoutSpy).not.toHaveBeenCalledWith(
168+
expect.any(Function),
169+
Infinity,
170+
);
171+
} finally {
172+
setTimeoutSpy.mockRestore();
173+
}
174+
});
175+
129176
it('module entry load failure can recover through loadEntryError with getEntryUrl', async () => {
130177
const entry = createDataUrlEntry(
131178
`throw new TypeError('Failed to fetch dynamically imported module: http://localhost:4999/remoteEntry.js');`,

packages/runtime-core/__tests__/snapshot.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { assert, describe, it } from '@rstest/core';
1+
import { assert, describe, it, rs } from '@rstest/core';
22
import { ModuleFederation } from '../src';
33
import { getGlobalSnapshot, resetFederationGlobalInfo } from '../src/global';
44

@@ -46,4 +46,54 @@ describe('snapshot', () => {
4646
},
4747
});
4848
});
49+
describe('manifest fetch and loadEntryTimeout', () => {
50+
const Remote1Entry =
51+
'http://localhost:1111/resources/snapshot/remote1/federation-manifest.json';
52+
53+
const withFetchSpy = async (
54+
loadEntryTimeout: number | undefined,
55+
run: (seen: Array<RequestInit | undefined>) => Promise<void>,
56+
) => {
57+
const originalFetch = global.fetch;
58+
const seen: Array<RequestInit | undefined> = [];
59+
global.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
60+
seen.push(init);
61+
return originalFetch(url, init);
62+
}) as typeof fetch;
63+
try {
64+
const FM = new ModuleFederation({
65+
name: '@snapshot/host-timeout',
66+
remotes: [{ name: '@snapshot/remote1', entry: Remote1Entry }],
67+
...(loadEntryTimeout === undefined ? {} : { loadEntryTimeout }),
68+
});
69+
await FM.loadRemote<() => string>('@snapshot/remote1/say');
70+
await run(seen);
71+
} finally {
72+
global.fetch = originalFetch;
73+
}
74+
};
75+
76+
it('bounds the manifest fetch with loadEntryTimeout', async () => {
77+
const setTimeoutSpy = rs.spyOn(globalThis, 'setTimeout');
78+
try {
79+
await withFetchSpy(4321, async (seen) => {
80+
expect(seen.length).toBeGreaterThan(0);
81+
expect(seen[0]?.signal).toBeInstanceOf(AbortSignal);
82+
expect(setTimeoutSpy).toHaveBeenCalledWith(
83+
expect.any(Function),
84+
4321,
85+
);
86+
});
87+
} finally {
88+
setTimeoutSpy.mockRestore();
89+
}
90+
});
91+
92+
it('leaves the manifest fetch unbounded without loadEntryTimeout', async () => {
93+
await withFetchSpy(undefined, async (seen) => {
94+
expect(seen.length).toBeGreaterThan(0);
95+
expect(seen[0]?.signal).toBeUndefined();
96+
});
97+
});
98+
});
4999
});

packages/runtime-core/src/plugins/snapshot/SnapshotHandler.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,32 @@ export class SnapshotHandler {
356356
let loadError: unknown;
357357
let recovered = false;
358358

359+
// `loadEntryTimeout` bounds the manifest fetch the same way it bounds the entry script:
360+
// a stalled manifest response would otherwise hold `loadRemote` open indefinitely.
361+
const timeout = this.HostInstance.options.loadEntryTimeout;
362+
const controller =
363+
typeof AbortController === 'function' &&
364+
typeof timeout === 'number' &&
365+
Number.isFinite(timeout) &&
366+
timeout > 0
367+
? new AbortController()
368+
: undefined;
369+
const fetchInit: RequestInit = controller
370+
? { signal: controller.signal }
371+
: {};
372+
const timer = controller
373+
? setTimeout(() => controller.abort(), timeout)
374+
: undefined;
375+
const clearTimer = () => {
376+
if (timer !== undefined) {
377+
clearTimeout(timer);
378+
}
379+
};
380+
359381
try {
360382
let res = await this.loaderHook.lifecycle.fetch.emit(
361383
manifestUrl,
362-
{},
384+
fetchInit,
363385
remoteInfo,
364386
resourceOptions
365387
? {
@@ -370,11 +392,13 @@ export class SnapshotHandler {
370392
: undefined,
371393
);
372394
if (!res || !(res instanceof Response)) {
373-
res = await fetch(manifestUrl, {});
395+
res = await fetch(manifestUrl, fetchInit);
374396
}
375397
response = res;
376398
manifestJson = (await res.json()) as Manifest;
399+
clearTimer();
377400
} catch (err) {
401+
clearTimer();
378402
loadError = err;
379403
manifestJson =
380404
(await this.HostInstance.remoteHandler.hooks.lifecycle.errorLoadRemote.emit(

packages/runtime-core/src/type/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ export interface Options {
141141
plugins: Array<ModuleFederationRuntimePlugin>;
142142
inBrowser: boolean;
143143
shareStrategy?: ShareStrategy;
144+
/**
145+
* How long (ms) a remote entry script may take to load before it is reported as RUNTIME-008.
146+
* Defaults to 20000; `Infinity` disables the timer. A `createScript` hook returning `timeout` still wins.
147+
* When set, it also bounds the fetch of a manifest entry (`mf-manifest.json`), which is otherwise unbounded.
148+
*/
149+
loadEntryTimeout?: number;
144150
}
145151

146152
export type UserOptions = Omit<

packages/runtime-core/src/utils/load.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ async function loadEntryScript({
165165
loaderHook,
166166
getEntryUrl,
167167
resourceContext,
168+
timeout,
168169
}: {
169170
name: string;
170171
globalName: string;
@@ -173,6 +174,7 @@ async function loadEntryScript({
173174
loaderHook: ModuleFederation['loaderHook'];
174175
getEntryUrl?: (url: string) => string;
175176
resourceContext?: ResourceLoadContext;
177+
timeout?: number;
176178
}): Promise<RemoteEntryExports> {
177179
const { entryExports: remoteEntryExports } = getRemoteEntryExports(
178180
name,
@@ -187,6 +189,7 @@ async function loadEntryScript({
187189
const url = getEntryUrl ? getEntryUrl(entry) : entry;
188190
return loadScript(url, {
189191
attrs: {},
192+
timeout,
190193
createScriptHook: (url, attrs) => {
191194
const res = loaderHook.lifecycle.createScript.emit({
192195
url,
@@ -244,12 +247,14 @@ async function loadEntryDom({
244247
loaderHook,
245248
getEntryUrl,
246249
resourceContext,
250+
timeout,
247251
}: {
248252
remoteInfo: RemoteInfo;
249253
remoteEntryExports?: RemoteEntryExports;
250254
loaderHook: ModuleFederation['loaderHook'];
251255
getEntryUrl?: (url: string) => string;
252256
resourceContext?: ResourceLoadContext;
257+
timeout?: number;
253258
}) {
254259
const { entry, entryGlobalName: globalName, name, type } = remoteInfo;
255260
if (isEsmRemoteType(type)) {
@@ -268,6 +273,7 @@ async function loadEntryDom({
268273
loaderHook,
269274
getEntryUrl,
270275
resourceContext,
276+
timeout,
271277
});
272278
}
273279

@@ -388,6 +394,7 @@ export async function getRemoteEntry(params: {
388394
loaderHook,
389395
getEntryUrl,
390396
resourceContext,
397+
timeout: origin.options.loadEntryTimeout,
391398
})
392399
: loadEntryNode({ remoteInfo, loaderHook, resourceContext });
393400
})

packages/runtime-core/src/utils/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ function waitForScriptPreload({
233233
);
234234
},
235235
attrs,
236+
timeout: host.options.loadEntryTimeout,
236237
createScriptHook: (hookUrl: string, hookAttrs: any) => {
237238
const res = host.loaderHook.lifecycle.createScript.emit({
238239
url: hookUrl,

packages/sdk/__tests__/dom.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,36 @@ describe('createScript', () => {
8686
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 20000);
8787
});
8888

89+
it('should use the timeout passed to createScript', () => {
90+
const url = 'https://example.com/script.js';
91+
const cb = jest.fn();
92+
createScript({ url, cb, timeout: 45000 });
93+
94+
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 45000);
95+
});
96+
97+
it('should let the createScriptHook timeout override the passed timeout', () => {
98+
const url = 'https://example.com/script.js';
99+
const cb = jest.fn();
100+
createScript({
101+
url,
102+
cb,
103+
attrs: {},
104+
timeout: 45000,
105+
createScriptHook: () => ({ timeout: 5000 }),
106+
});
107+
108+
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
109+
});
110+
111+
it('should not start a timer when the timeout is Infinity', () => {
112+
const url = 'https://example.com/script.js';
113+
const cb = jest.fn();
114+
createScript({ url, cb, timeout: Infinity });
115+
116+
expect(setTimeout).not.toHaveBeenCalled();
117+
});
118+
89119
it('should use the timeout specified in the createScriptHook', () => {
90120
const url = 'https://example.com/script.js';
91121
const cb = jest.fn();

0 commit comments

Comments
 (0)