Skip to content

Commit 9f099e2

Browse files
killaguclaude
andcommitted
fix(bundler): re-install web globals after a V8 snapshot restore
The snapshot prelude deleted Node's web globals (fetch/Headers/Request/ Response/FormData/WebSocket/.../Blob/File) at build time to keep the heap serializable, but never restored them, so `globalThis.fetch` was a dead stub after restore. The prelude now gates the delete to the snapshot build (EGG_BUNDLE_SNAPSHOT=build, so a plain `node worker.js` keeps native globals) and defines `__installWebGlobalsLazy`, which EntryGenerator's deserialize main calls after installing `__RUNTIME_REQUIRE` (now exposing `.resolve`). It re-installs the globals as lazy accessors: the fetch family from the app's undici (resolved directly, or via urllib under pnpm), Blob/File from node:buffer. The accessor must not cache `undefined`, because undici reads `globalThis.Headers` re-entrantly while its own require() is in flight. Also fixes four build-serializability gaps found bringing a real app (cnpmcore) snapshot up, all build-gated in the prelude: - supply module/exports so the bundle's UMD external wrapper takes the require/externalRequire branch (`node --build-snapshot` runs the worker as a non-CommonJS main, so `typeof module === 'undefined'`). - keep Blob/File (node:buffer-backed, serializable); deleting them only broke build-eval (e.g. undici webidl's MakeTypeAssertion(Blob)). - replace deleted globals with marked, extendable stub classes so a fetch ponyfill doing `class Request extends globalThis.Request` builds without crashing; the restore installer replaces the marked stub with the real value. - expose the hardcoded http constants in the lazy proxy's ownKeys / getOwnPropertyDescriptor so an ESM `import * as http; for (const m of http.METHODS)` works (turbopack interopEsm copies a static namespace). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f9662dc commit 9f099e2

9 files changed

Lines changed: 533 additions & 27 deletions

File tree

packages/typings/src/global.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,24 @@ declare global {
99
* Synchronous require bound to the bundle output directory, installed by the
1010
* snapshot restore main function. The snapshot prelude / lazy mechanism uses it
1111
* to pull in modules through `require()` (Node 22+ can `require()` ESM) because a
12-
* deserialized snapshot process has no dynamic `import()` callback.
12+
* deserialized snapshot process has no dynamic `import()` callback. It also
13+
* carries a `resolve` (the underlying `createRequire(...).resolve`) so the web
14+
* globals re-installer can locate `undici` through the app dependency tree.
1315
*/
1416
// eslint-disable-next-line no-var
15-
var __RUNTIME_REQUIRE: ((id: string) => unknown) | undefined;
17+
var __RUNTIME_REQUIRE:
18+
| (((id: string) => unknown) & { resolve?: (id: string, options?: { paths?: string[] }) => string })
19+
| undefined;
20+
/**
21+
* Re-install the web globals (`fetch`/`Headers`/`Request`/`Response`/`FormData`/
22+
* `WebSocket`/`Blob`/`File`/...) that the snapshot prelude deletes at build time,
23+
* as lazy accessors backed by `undici` (the fetch family) and `node:buffer`
24+
* (`Blob`/`File`). Defined by the snapshot prelude and serialized into the blob;
25+
* the snapshot restore main calls it once `__RUNTIME_REQUIRE` is installed, so an
26+
* app that uses `globalThis.fetch` keeps working after a restore.
27+
*/
28+
// eslint-disable-next-line no-var
29+
var __installWebGlobalsLazy: (() => void) | undefined;
1630
}
1731

1832
export {};

site/docs/advanced/snapshot.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,16 @@ module.exports = AppBootHook;
7373
- `snapshotDidDeserialize()` runs after the process starts from the snapshot.
7474

7575
Use these hooks for resources such as timers, sockets, process listeners, loggers, or other handles that must be recreated in a live runtime.
76+
77+
## Web Globals After Restore (bundled snapshots)
78+
79+
When you build a bundled snapshot (`egg-bin snapshot build`, which uses `@eggjs/egg-bundler`), Node's **undici-backed** web globals — `fetch`, `Headers`, `Request`, `Response`, `FormData`, `WebSocket`, `EventSource`, `MessageEvent`, `CloseEvent` — are deleted at build time. Touching any of them lazily initializes undici, whose llhttp `HTTPParser` / `WebAssembly` instance is a native binding that cannot be written into a V8 startup snapshot. `Blob` and `File` are **not** deleted: they are `node:buffer`-backed, serialize into a snapshot fine, and deleting them would only break a dependency that touches them at load time.
80+
81+
The deleted globals are re-installed automatically when the snapshot is restored, as lazy accessors backed by the application's `undici` (resolved directly, or through `urllib` — egg's direct dependency — under pnpm layouts where undici is not hoisted). `Blob` / `File` survive the snapshot natively and the installer also re-installs them from `node:buffer` as a safety net (skipped when already present).
82+
83+
Each one loads its real implementation on first access, so a restore stays fast when the app never touches them, and `globalThis.fetch(...)` (or a bare `fetch(...)`) and the related constructors all work normally after restore.
84+
85+
Two things to keep in mind:
86+
87+
- The bundle output must be run where `undici` is resolvable. An egg app always satisfies this through its `urllib` dependency; if your deployment strips dependencies, keep `urllib` (or `undici`) installed next to the bundle.
88+
- Reference web globals at call time, not at module top level. During a snapshot build the deleted globals are replaced with extendable stub classes, so a module that does `class MyRequest extends globalThis.Request` at load time builds without crashing — but a binding captured at build time (`const f = fetch`, or a class that `extends` a web global) freezes that stub into the blob and is not upgraded at restore. Call `fetch(...)` / `new Headers(...)` where you use them and the lazy accessors resolve the real implementation after restore.

site/docs/zh-CN/advanced/snapshot.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,16 @@ module.exports = AppBootHook;
7373
- `snapshotDidDeserialize()` 会在进程从快照启动后执行。
7474

7575
这两个钩子适合处理 timer、socket、process listener、logger 等需要在真实运行期重新建立的资源。
76+
77+
## 恢复后的 Web 全局对象(打包快照)
78+
79+
当你构建打包快照时(`egg-bin snapshot build`,底层使用 `@eggjs/egg-bundler`),Node 中 **由 undici 支持**的 Web 全局对象——`fetch``Headers``Request``Response``FormData``WebSocket``EventSource``MessageEvent``CloseEvent`——会在构建阶段被删除。因为一旦触碰它们就会惰性初始化 undici,而其 llhttp `HTTPParser` / `WebAssembly` 实例属于原生绑定,无法写入 V8 启动快照。`Blob``File` **不会**被删除:它们由 `node:buffer` 支持,可以正常序列化到快照中,删除它们只会破坏在加载期触碰它们的依赖。
80+
81+
被删除的全局对象会在恢复快照时自动重新安装为惰性访问器,由应用的 `undici` 提供(直接解析;在 pnpm 等 undici 未被提升的目录布局下,则通过 egg 的直接依赖 `urllib` 解析)。`Blob` / `File` 本身就能存活于快照中,安装器也会从 `node:buffer` 把它们作为安全网重新安装(若已存在则跳过)。
82+
83+
每个全局对象都在首次访问时才加载真实实现,因此当应用从不使用它们时恢复依然很快;而 `globalThis.fetch(...)`(或直接 `fetch(...)`)以及相关构造函数在恢复后都能正常工作。
84+
85+
需要注意两点:
86+
87+
- 打包产物必须运行在能解析到 `undici` 的环境中。egg 应用通过 `urllib` 依赖天然满足这一点;如果你的部署裁剪了依赖,请保留 `urllib`(或 `undici`)安装在产物旁边。
88+
- 请在调用处引用 Web 全局对象,而不是在模块顶层捕获。快照构建期间,被删除的全局对象会被替换为可继承的桩类,因此在模块加载时写 `class MyRequest extends globalThis.Request` 不会再崩溃;但在构建期捕获的绑定(`const f = fetch`,或 `extends` 某个 Web 全局的类)会把该桩固化进 blob,恢复后不会被升级。请在使用处直接调用 `fetch(...)` / `new Headers(...)`,惰性访问器会在恢复后解析出真实实现。

tools/egg-bundler/src/lib/EntryGenerator.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,14 @@ if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') {
367367
}
368368
await app.triggerSnapshotWillSerialize();
369369
370+
// The snapshot prelude set globalThis.module/exports so the bundle's UMD external
371+
// wrapper takes the require branch under \`--build-snapshot\`. Loading is complete
372+
// now (every bundled module — and thus every external factory — has evaluated),
373+
// so remove them before V8 serializes the heap; otherwise a global \`module\` would
374+
// be baked into the blob and could confuse libraries that sniff it after restore.
375+
try { delete (globalThis as Record<string, unknown>).module; } catch {}
376+
try { delete (globalThis as Record<string, unknown>).exports; } catch {}
377+
370378
v8.startupSnapshot.setDeserializeMainFunction(() => {
371379
// ── snapshot restore main ──────────────────────────────────────────
372380
// V8 runs this callback synchronously right after deserialization, before
@@ -392,9 +400,20 @@ if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') {
392400
? process.getBuiltinModule('node:module')
393401
: (0, eval)('require')('node:module');
394402
const __req = createRequire(__outputDir + '/');
395-
globalThis.__RUNTIME_REQUIRE = (id: string) => __req(id);
403+
const __runtimeRequire: any = (id: string) => __req(id);
404+
// Expose resolve so the web-globals re-installer can locate undici through
405+
// the app dependency tree (e.g. via urllib under pnpm).
406+
__runtimeRequire.resolve = (id: string, options?: any) => __req.resolve(id, options);
407+
globalThis.__RUNTIME_REQUIRE = __runtimeRequire;
396408
globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp);
397409
410+
// Re-install the web globals (fetch/Headers/Request/Response/FormData/
411+
// WebSocket/Blob/File/...) the snapshot prelude deleted at build time, so an
412+
// app that uses globalThis.fetch keeps working after a restore. They become
413+
// lazy accessors backed by undici (the fetch family) and node:buffer
414+
// (Blob/File), loaded on first use through __RUNTIME_REQUIRE.
415+
globalThis.__installWebGlobalsLazy?.();
416+
398417
(async () => {
399418
if (app.agent) {
400419
await app.agent.triggerSnapshotDidDeserialize();

0 commit comments

Comments
 (0)