Skip to content

Commit e5dd6ef

Browse files
authored
feat: enhance error handling for script loading and execution (#4538)
1 parent 3d8b09c commit e5dd6ef

File tree

13 files changed

+1627
-785
lines changed

13 files changed

+1627
-785
lines changed

.changeset/icy-pears-hunt.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@module-federation/runtime-core': patch
3+
'@module-federation/sdk': patch
4+
---
5+
6+
feat(runtime-core): enhance error handling for script loading and execution

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,43 @@ Check whether globalSnapshot contains an object with key `${moduleName}:${module
144144

145145
### Reasons
146146

147-
Runtime resource loading failed. The possible reasons are network instability causing timeout, or an incorrect resource URL.
147+
The remote entry script failed to load. The error message includes a specific failure reason, which falls into one of two categories:
148+
149+
**1. ScriptNetworkError — network-level failure**
150+
151+
The script file could not be downloaded. Common causes:
152+
153+
- Incorrect resource URL (server returns 404)
154+
- Network instability or request timeout
155+
- Cross-origin (CORS) misconfiguration
156+
- CDN node unavailability
157+
158+
**2. ScriptExecutionError — runtime error during script execution**
159+
160+
The script file downloaded successfully, but its IIFE threw a runtime exception during execution. The error message will contain `ScriptExecutionError` along with the original exception (e.g. `TypeError: ...`). Common causes:
161+
162+
- Incompatible syntax in the producer's code (e.g. unsupported browser APIs)
163+
- Producer entry relies on a global variable that was not correctly initialized
164+
- Corrupted or incomplete build output
148165

149166
### Solutions
150167

151-
Check whether the resource URL is correct. If correct, check whether the network is stable. You can add a retry mechanism when the network is unstable, refer to [Runtime retry](/plugin/plugins/retry-plugin.html).
168+
**For ScriptNetworkError:**
169+
170+
1. Open the resource URL from the error message directly in the browser to confirm it is accessible
171+
2. Verify the producer's `publicPath` and `remoteEntry` address configuration
172+
3. Check for CORS issues; if needed, configure CORS on the server or use the [`createScript`](../../plugin/dev/index#createscript) hook to add a `crossOrigin` attribute
173+
4. For flaky networks, add a retry mechanism — refer to [Runtime retry](/plugin/plugins/retry-plugin.html)
174+
175+
**For ScriptExecutionError:**
176+
177+
1. Inspect the original exception in the error message (`TypeError` / `SyntaxError`, etc.) to locate the specific error in the producer's entry file
178+
2. Verify that the producer's build target is compatible with the consumer's browser support range
179+
3. Download the remote entry file from the browser DevTools Network panel and execute it manually to reproduce the error
180+
181+
:::tip
182+
A `ScriptExecutionError` means the script was downloaded successfully — retrying will not fix this type of error. Focus on investigating compatibility or runtime errors in the producer's code first.
183+
:::
152184

153185
## RUNTIME-009
154186

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,43 @@ import Runtime from '@components/zh/runtime/index';
143143

144144
### 原因
145145

146-
Runtime 运行时资源加载失败报错,可能原因为网络不稳定导致资源加载超时失败,或者为资源地址错误导致资源加载失败。
146+
生产者入口脚本(remote entry)加载失败。错误信息中会包含具体失败原因,分为以下两种情况:
147+
148+
**1. ScriptNetworkError — 网络层加载失败**
149+
150+
脚本文件本身无法下载,常见原因:
151+
152+
- 资源地址(URL)错误,服务器返回 404
153+
- 网络不稳定或请求超时
154+
- 跨域(CORS)配置问题
155+
- CDN 节点异常
156+
157+
**2. ScriptExecutionError — 脚本执行时抛错**
158+
159+
脚本文件下载成功,但其中的 IIFE 在执行时抛出了运行时异常。此时错误信息中会包含 `ScriptExecutionError` 及原始异常信息(如 `TypeError: ...`)。常见原因:
160+
161+
- 生产者代码存在兼容性问题(如使用了当前浏览器不支持的语法)
162+
- 生产者入口文件依赖了未正确初始化的全局变量
163+
- 构建产物损坏或不完整
147164

148165
### 解决方法
149166

150-
检查资源地址是否正确,若资源地址正确,检查网络是否稳定。网络不稳定时可增加重试机制,参考 [Runtime 重试机制](/plugin/plugins/retry-plugin.html)
167+
**针对 ScriptNetworkError:**
168+
169+
1. 直接在浏览器地址栏访问错误信息中的资源 URL,确认是否可以正常返回
170+
2. 检查生产者的 `publicPath``remoteEntry` 地址配置是否正确
171+
3. 检查是否存在跨域问题,必要时配置 CORS 或使用 [`createScript`](../../plugin/dev/index#createscript) hook 添加 `crossOrigin` 属性
172+
4. 网络不稳定时,可接入重试机制,参考 [Runtime 重试机制](/plugin/plugins/retry-plugin.html)
173+
174+
**针对 ScriptExecutionError:**
175+
176+
1. 查看错误信息中的原始异常(`TypeError` / `SyntaxError` 等),定位生产者入口文件中的具体报错
177+
2. 检查生产者构建目标(`target`)是否与消费者的浏览器兼容范围匹配
178+
3. 在浏览器 DevTools Network 面板中下载对应的 remote entry 文件,手动执行以复现错误
179+
180+
:::tip
181+
ScriptExecutionError 表示脚本已成功下载,重试不会解决此类问题。需优先排查生产者代码本身的兼容性或运行时错误。
182+
:::
151183

152184
## RUNTIME-009
153185

packages/chrome-devtools/package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"types": "./dist/types/src/App.d.ts"
5959
},
6060
"dependencies": {
61-
"@modern-js/runtime": "2.70.2",
61+
"@modern-js/runtime": "2.70.8",
6262
"@arco-design/web-react": "2.66.7",
6363
"@module-federation/sdk": "workspace:*",
6464
"ahooks": "^3.7.10",
@@ -86,11 +86,11 @@
8686
"react": "^19.2.0",
8787
"react-dom": "^19.2.0",
8888
"@modern-js-app/eslint-config": "2.59.0",
89-
"@modern-js/app-tools": "2.70.2",
89+
"@modern-js/app-tools": "2.70.8",
9090
"@modern-js/eslint-config": "2.59.0",
91-
"@modern-js/module-tools": "2.70.2",
92-
"@modern-js/storybook": "2.70.2",
93-
"@modern-js/tsconfig": "2.70.2",
91+
"@modern-js/module-tools": "2.70.8",
92+
"@modern-js/storybook": "2.70.8",
93+
"@modern-js/tsconfig": "2.70.8",
9494
"@module-federation/runtime": "workspace:*",
9595
"@playwright/test": "1.57.0",
9696
"@types/chrome": "^0.0.272",
@@ -101,6 +101,7 @@
101101
"@types/react-dom": "^19.2.2",
102102
"lint-staged": "~13.1.0",
103103
"prettier": "~3.3.3",
104+
"typescript": "5.9.3",
104105
"rimraf": "~6.0.1",
105106
"vitest": "1.2.2"
106107
}

packages/enhanced/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@ export { default as AsyncBoundaryPlugin } from './wrapper/AsyncBoundaryPlugin';
1414
export { default as HoistContainerReferencesPlugin } from './wrapper/HoistContainerReferencesPlugin';
1515
export { default as TreeShakingSharedPlugin } from './wrapper/TreeShakingSharedPlugin';
1616

17+
const lazyRequire = (id: string): any => module.require(id);
18+
1719
export const dependencies = {
1820
get ContainerEntryDependency() {
19-
return require('./lib/container/ContainerEntryDependency').default;
21+
return lazyRequire('./lib/container/ContainerEntryDependency').default;
2022
},
2123
};
2224

2325
export { parseOptions } from './lib/container/options';
2426

2527
export const container = {
2628
get ContainerEntryModule() {
27-
return require('./lib/container/ContainerEntryModule').default;
29+
return lazyRequire('./lib/container/ContainerEntryModule').default;
2830
},
2931
};
3032

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { getRemoteEntry, getRemoteInfo } from '../src/utils/load';
3+
import { ModuleFederation } from '../src/core';
4+
import { resetFederationGlobalInfo } from '../src/global';
5+
import { RUNTIME_001, RUNTIME_008 } from '@module-federation/error-codes';
6+
import { mockStaticServer, removeScriptTags } from './mock/utils';
7+
8+
// All fixture URLs are served via two complementary mechanisms both pointing to __tests__/:
9+
// 1. mockScriptDomResponse (setup.ts) — patches Element.prototype.appendChild, executes
10+
// matching JS files inline, fires element.onload without a real network request.
11+
// 2. mockStaticServer (below) — mocks window.fetch so jsdom's background script-fetch
12+
// also gets a valid response instead of failing with ECONNREFUSED.
13+
const BASE = 'http://localhost:1111/resources/load';
14+
15+
mockStaticServer({
16+
baseDir: __dirname,
17+
filterKeywords: [],
18+
basename: 'http://localhost:1111/',
19+
});
20+
21+
const createMF = () => new ModuleFederation({ name: 'test-host', remotes: [] });
22+
23+
describe('getRemoteEntry - script load error discrimination', () => {
24+
beforeEach(() => {
25+
resetFederationGlobalInfo();
26+
delete (globalThis as any)['remote'];
27+
removeScriptTags();
28+
});
29+
30+
afterEach(() => {
31+
delete (globalThis as any)['remote'];
32+
removeScriptTags();
33+
});
34+
35+
it('script load failure is reported as RUNTIME_008 with the original error included', async () => {
36+
// "missing.js" does not exist on disk. The mockScriptDomResponse interceptor tries
37+
// to fs.readFileSync it, throws ENOENT, which propagates synchronously through
38+
// document.head.appendChild → loadScript's Promise executor → promise rejects.
39+
// The onRejected handler in loadEntryScript wraps it as RUNTIME_008.
40+
const entry = `${BASE}/missing.js`;
41+
const origin = createMF();
42+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
43+
44+
const err = await getRemoteEntry({ origin, remoteInfo }).catch((e) => e);
45+
46+
expect(err.message).toContain(RUNTIME_008);
47+
// Original ENOENT message is forwarded into the RUNTIME_008 error
48+
expect(err.message).toMatch(/missing\.js|ENOENT/);
49+
});
50+
51+
it('IIFE execution error is reported as RUNTIME_008 with ScriptExecutionError details', async () => {
52+
// exec-error.js dispatches a window ErrorEvent with its own URL as filename.
53+
// dom.ts's executionErrorHandler captures it; when onload fires afterwards,
54+
// onErrorCallback(ScriptExecutionError) is called → loadScript rejects.
55+
const entry = `${BASE}/exec-error.js`;
56+
const origin = createMF();
57+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
58+
59+
const err = await getRemoteEntry({ origin, remoteInfo }).catch((e) => e);
60+
61+
expect(err.message).toContain(RUNTIME_008);
62+
expect(err.message).toContain('ScriptExecutionError');
63+
expect(err.message).toContain('TypeError: exec failed');
64+
});
65+
66+
it('script loaded successfully but global not registered throws RUNTIME_001, not RUNTIME_008', async () => {
67+
// no-global.js executes without side effects — global is never registered.
68+
// loadScript resolves (onload fires), handleRemoteEntryLoaded finds no global → RUNTIME_001.
69+
// The key assertion: RUNTIME_001 is NOT swallowed and replaced with RUNTIME_008.
70+
const entry = `${BASE}/no-global.js`;
71+
const origin = createMF();
72+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
73+
74+
const err = await getRemoteEntry({ origin, remoteInfo }).catch((e) => e);
75+
76+
expect(err.message).toContain(RUNTIME_001);
77+
expect(err.message).not.toContain(RUNTIME_008);
78+
});
79+
80+
it('script loaded and global registered returns the remote entry exports', async () => {
81+
// success.js sets globalThis['remote'] = { get, init } before onload fires.
82+
const entry = `${BASE}/success.js`;
83+
const origin = createMF();
84+
const remoteInfo = getRemoteInfo({ name: 'remote', entry });
85+
86+
const result = await getRemoteEntry({ origin, remoteInfo });
87+
88+
expect(result).toEqual(
89+
expect.objectContaining({
90+
get: expect.any(Function),
91+
init: expect.any(Function),
92+
}),
93+
);
94+
});
95+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Simulates an IIFE that throws during execution.
2+
// In a real browser, such a throw fires window.onerror and then script.onload still fires.
3+
// Here we dispatch the ErrorEvent manually (same mechanism our dom.ts listener uses).
4+
window.dispatchEvent(
5+
new ErrorEvent('error', {
6+
message: 'TypeError: exec failed',
7+
filename: 'http://localhost:1111/resources/load/exec-error.js',
8+
lineno: 1,
9+
colno: 1,
10+
bubbles: true,
11+
}),
12+
);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Script that executes without registering the expected global.
2+
// Used to test RUNTIME_001 (global not found after script load).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Script that registers the expected global — simulates a well-formed remote entry.
2+
globalThis['remote'] = {
3+
get: function (scope) {
4+
return function () {};
5+
},
6+
init: function () {},
7+
};

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,31 @@ async function loadEntryScript({
141141

142142
return;
143143
},
144-
})
145-
.then(() => {
144+
}).then(
145+
() => {
146+
// loadScript resolved: script was fetched, executed without throwing, and
147+
// did not trigger a ScriptExecutionError listener. Now verify the global was registered.
146148
return handleRemoteEntryLoaded(name, globalName, entry);
147-
})
148-
.catch(() => {
149-
error(RUNTIME_008, runtimeDescMap, {
150-
remoteName: name,
151-
resourceUrl: entry,
152-
});
153-
});
149+
},
150+
(loadError: unknown) => {
151+
// loadScript rejected — one of three causes, all with descriptive messages:
152+
// ScriptNetworkError — URL unreachable, 404, CORS, etc.
153+
// ScriptExecutionError — script fetched OK but IIFE threw during execution
154+
// timeout — script took too long to load
155+
// Errors thrown inside handleRemoteEntryLoaded above are NOT caught here.
156+
const originalMsg =
157+
loadError instanceof Error ? loadError.message : String(loadError);
158+
error(
159+
RUNTIME_008,
160+
runtimeDescMap,
161+
{
162+
remoteName: name,
163+
resourceUrl: url,
164+
},
165+
originalMsg,
166+
);
167+
},
168+
);
154169
}
155170
async function loadEntryDom({
156171
remoteInfo,
@@ -280,8 +295,14 @@ export async function getRemoteEntry(params: {
280295
})
281296
.catch(async (err) => {
282297
const uniqueKey = getRemoteEntryUniqueKey(remoteInfo);
298+
// ScriptExecutionError means the script downloaded fine but its IIFE
299+
// threw at runtime — retrying would reproduce the same error, so exclude it.
300+
const isScriptExecutionError =
301+
err instanceof Error && err.message.includes('ScriptExecutionError');
283302
const isScriptLoadError =
284-
err instanceof Error && err.message.includes(RUNTIME_008);
303+
err instanceof Error &&
304+
err.message.includes(RUNTIME_008) &&
305+
!isScriptExecutionError;
285306

286307
if (isScriptLoadError && !_inErrorHandling) {
287308
const wrappedGetRemoteEntry = (

0 commit comments

Comments
 (0)