From 9b00b6879dba4ef6c51fabdad59e4a5711e8b75f Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 12:36:10 +0800 Subject: [PATCH] fix(bundler): re-install web globals after a V8 snapshot restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot prelude replaces Node's undici-backed web globals (fetch/Headers/ Request/Response/FormData/WebSocket/EventSource/MessageEvent/CloseEvent) with constructable stubs at build time so touching them never pulls in undici's non-serializable native bindings — but the restored process kept the stubs, so `globalThis.fetch` was a dead no-op after restore. The generated snapshot-restore entry now calls a new `globalThis.__installWebGlobalsLazy` (defined by the prelude) after installing `__RUNTIME_REQUIRE` (which now carries `.resolve`). It re-installs the globals as lazy accessors: the fetch family from the app's real `undici` (kept external — e.g. `--force-external undici urllib` — so it loads for real on restore; resolved directly, or through `urllib` under pnpm where undici is not hoisted), and `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. `Blob`/`File` are no longer stubbed on `node:buffer`: they are buffer-backed, serialize into a snapshot fine, and stay real (verified the cnpmcore snapshot still builds → restores → serves `/-/ping` with them un-stubbed). So `globalThis.fetch(...)` and the related constructors work normally after a restore. Co-Authored-By: Claude Opus 4.8 --- packages/typings/src/global.ts | 18 ++- site/docs/advanced/snapshot.md | 16 ++- site/docs/zh-CN/advanced/snapshot.md | 12 +- tools/egg-bundler/src/lib/EntryGenerator.ts | 12 +- tools/egg-bundler/src/lib/prelude.ts | 92 ++++++++++++---- tools/egg-bundler/test/EntryGenerator.test.ts | 3 + .../EntryGenerator.worker.canonical.snap | 12 +- .../test/snapshot-lazy-external.test.ts | 103 +++++++++++++++++- .../test/snapshot-lazy.realbuild.test.ts | 74 +++++++++++++ 9 files changed, 296 insertions(+), 46 deletions(-) diff --git a/packages/typings/src/global.ts b/packages/typings/src/global.ts index 2f2dcc9dfc..4878bdd360 100644 --- a/packages/typings/src/global.ts +++ b/packages/typings/src/global.ts @@ -9,10 +9,24 @@ declare global { * Synchronous require bound to the bundle output directory, installed by the * snapshot restore main function. The snapshot prelude / lazy mechanism uses it * to pull in modules through `require()` (Node 22+ can `require()` ESM) because a - * deserialized snapshot process has no dynamic `import()` callback. + * deserialized snapshot process has no dynamic `import()` callback. It also carries + * a `resolve` (the underlying `createRequire(...).resolve`) so the web globals + * re-installer can locate `undici` through the app dependency tree. */ // eslint-disable-next-line no-var - var __RUNTIME_REQUIRE: ((id: string) => unknown) | undefined; + var __RUNTIME_REQUIRE: + | (((id: string) => unknown) & { resolve?: (id: string, options?: { paths?: string[] }) => string }) + | undefined; + /** + * Re-install the web globals (`fetch`/`Headers`/.../`Blob`/`File`) that the snapshot + * prelude replaces with stubs at build time, as lazy accessors backed by `undici` + * (the fetch family) and `node:buffer` (`Blob`/`File`). Defined by the snapshot + * prelude and serialized into the blob; the snapshot restore main calls it once + * `__RUNTIME_REQUIRE` is installed, so an app that uses `globalThis.fetch` keeps + * working after a restore. + */ + // eslint-disable-next-line no-var + var __installWebGlobalsLazy: (() => void) | undefined; } export {}; diff --git a/site/docs/advanced/snapshot.md b/site/docs/advanced/snapshot.md index 5555c21e52..d38a03f939 100644 --- a/site/docs/advanced/snapshot.md +++ b/site/docs/advanced/snapshot.md @@ -203,15 +203,13 @@ which is exactly the cost a snapshot front-loads into build time. be kept external (`--force-external`) or implement the snapshot lifecycle hooks so its state is released before serialization and rebuilt after restore. Not every package is snapshot-safe out of the box. -- **Web globals are no-op stubs after restore**: at build the prelude replaces the - undici-backed globals (`fetch`/`Headers`/`Request`/`Response`/`FormData`/`WebSocket`/ - `EventSource`/`MessageEvent`/`CloseEvent`, plus `Blob`/`File`) with no-op stubs, - because touching them at build time pulls in - Node's built-in undici and its native http/http2 bindings (which a snapshot cannot - serialize). Node's native lazy getters are themselves not snapshot-serializable, so - they cannot be reinstated on restore. **In the restored process those globals stay - stubs** — a snapshotted app should use a lazy-loaded HTTP client (e.g. `urllib`/`undici` - via an external, loaded for real on restore) rather than `globalThis.fetch` directly. +- **Web globals must be referenced at call time**: the undici-backed globals + (`fetch`/`Headers`/`Request`/`Response`/`FormData`/`WebSocket`/...) are stubbed at + build time (touching them pulls in undici's non-serializable native bindings) and + re-installed on restore, so call-time use works. But a reference captured at + module-evaluation time — `const f = fetch`, or `class X extends globalThis.Request` — + freezes the build-time stub into the blob and is not upgraded. Reference web globals + where you use them, not at module top level. The supported surface is still evolving; the full list of known limitations and the design rationale are tracked in the project's V8 snapshot RFC. diff --git a/site/docs/zh-CN/advanced/snapshot.md b/site/docs/zh-CN/advanced/snapshot.md index 804ffbc83f..5ec2187360 100644 --- a/site/docs/zh-CN/advanced/snapshot.md +++ b/site/docs/zh-CN/advanced/snapshot.md @@ -183,13 +183,11 @@ module.exports = AppBootHook; (打开的 socket、原生 HTTP/2 绑定、后台 timer、文件句柄)都必须要么保持 external (`--force-external`),要么实现快照生命周期钩子,在序列化前释放、在恢复后重建。并不是 每个包都开箱即可被快照化。 -- **Web 全局对象在恢复后是惰性桩**:构建期 prelude 会把 undici 支撑的全局对象 - (`fetch`/`Headers`/`Request`/`Response`/`FormData`/`WebSocket`/`EventSource`/`MessageEvent`/ - `CloseEvent`、以及 `Blob`/`File`)替换为空操作的桩,否则在构建期触碰它们会拉起 Node 内建 - undici 的原生 http/http2 绑定(无法被快照 - 序列化)。Node 的原生惰性 getter 本身不可被快照序列化,因此恢复后无法把它们还原。**恢复后的 - 进程里这些全局对象仍是桩**——快照化的应用应使用按需懒加载的 HTTP 客户端(如 `urllib`/`undici`, - 通过 external 在恢复期加载真实模块),而不要直接依赖 `globalThis.fetch`。 +- **Web 全局对象必须在调用处引用**:undici 支撑的全局对象 + (`fetch`/`Headers`/`Request`/`Response`/`FormData`/`WebSocket`/……)会在构建期被替换为桩 + (触碰它们会拉起 undici 不可序列化的原生绑定),并在恢复时重新安装,因此在调用处使用时可正常工作。 + 但在模块求值期捕获的绑定——`const f = fetch`,或 `class X extends globalThis.Request`——会把构建期的 + 桩固化进 blob 且不会被升级。请在使用处引用 Web 全局对象,不要在模块顶层捕获。 支持范围仍在演进中;完整的已知限制与设计取舍记录在项目的 V8 快照 RFC 中。 diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index cad896b875..9c61491f2d 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -506,9 +506,19 @@ if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') { ? process.getBuiltinModule('node:module') : (0, eval)('require')('node:module'); const __req = createRequire(__outputDir + '/'); - globalThis.__RUNTIME_REQUIRE = (id: string) => __req(id); + const __runtimeRequire: any = (id: string) => __req(id); + // Expose resolve so the web-globals re-installer can locate undici through the + // app dependency tree (e.g. via urllib under pnpm). + __runtimeRequire.resolve = (id: string, options?: any) => __req.resolve(id, options); + globalThis.__RUNTIME_REQUIRE = __runtimeRequire; globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp); + // Re-install the web globals (fetch/Headers/.../Blob/File) the snapshot prelude + // replaced with stubs at build time, so an app using globalThis.fetch keeps + // working after a restore. They become lazy accessors backed by undici (the + // fetch family) and node:buffer (Blob/File), loaded on first use. + globalThis.__installWebGlobalsLazy?.(); + (async () => { if (app.agent) { await app.agent.triggerSnapshotDidDeserialize(); diff --git a/tools/egg-bundler/src/lib/prelude.ts b/tools/egg-bundler/src/lib/prelude.ts index d59a699173..81adfd7a0f 100644 --- a/tools/egg-bundler/src/lib/prelude.ts +++ b/tools/egg-bundler/src/lib/prelude.ts @@ -74,7 +74,7 @@ export const DEFAULT_SNAPSHOT_LAZY_MODULES: readonly string[] = [ * stubs (NOT `delete`d — a deleted global throws ReferenceError when a bundled * module references it; a stub is referencable and harmless at build time). */ -const WEB_GLOBALS: readonly string[] = [ +const UNDICI_WEB_GLOBALS: readonly string[] = [ 'fetch', 'Headers', 'Request', @@ -84,17 +84,12 @@ const WEB_GLOBALS: readonly string[] = [ 'EventSource', 'MessageEvent', 'CloseEvent', - 'File', - 'Blob', ]; -/** - * `node:buffer` is a real, serializable builtin (Buffer is needed at build time), - * but reading its `File`/`Blob` getters lazily initializes Node's built-in undici - * (→ http/http2 native bindings). Those two property getters are stubbed at build; - * the live process re-exposes the real ones on restore. - */ -const BUFFER_DANGEROUS_PROPS: readonly string[] = ['File', 'Blob']; +/** Web globals provided by `node:buffer`; re-installed from that builtin on restore. */ +const BUFFER_WEB_GLOBALS: readonly string[] = ['File', 'Blob']; + +const WEB_GLOBALS: readonly string[] = [...UNDICI_WEB_GLOBALS, ...BUFFER_WEB_GLOBALS]; interface AppPackageJson { readonly egg?: { @@ -149,7 +144,8 @@ export function renderSnapshotPrelude( ): string { const lazyJson = JSON.stringify([...lazyModules]); const webJson = JSON.stringify([...WEB_GLOBALS]); - const bufDangerJson = JSON.stringify([...BUFFER_DANGEROUS_PROPS]); + const undiciGlobalsJson = JSON.stringify([...UNDICI_WEB_GLOBALS]); + const bufferGlobalsJson = JSON.stringify([...BUFFER_WEB_GLOBALS]); const externalExportsJson = JSON.stringify(externalExports); const httpConstsJson = JSON.stringify({ METHODS: http.METHODS, @@ -199,18 +195,6 @@ export function renderSnapshotPrelude( // namespace and \`import { X } from 'pkg'\` resolves to a member-proxy (not undefined). globalThis.__EXTERNAL_EXPORTS = ${externalExportsJson}; - // node:buffer is real at build (Buffer is needed) but its File/Blob getters - // trigger Node's built-in undici. Stub just those two; restore re-exposes them. - (function () { - try { - var __buf = process.getBuiltinModule('node:buffer'); - var __bd = ${bufDangerJson}; - for (var __j = 0; __j < __bd.length; __j++) { - try { Object.defineProperty(__buf, __bd[__j], { value: function BufStub(){}, configurable: true, writable: true }); } catch (e) {} - } - } catch (e) {} - })(); - // isBuiltin: tells builtin "tool" modules (path/fs/module — load for real at // build) apart from non-builtin packages (lazy-stubbed at build). globalThis.__isBuiltin = (function () { @@ -318,6 +302,68 @@ export function renderSnapshotPrelude( }); return proxy; }; + + // Restore-time re-installer for the web globals the build replaced with stubs. + // Serialized into the blob and called by the generated snapshot-restore entry AFTER + // globalThis.__RUNTIME_REQUIRE is set. Without it globalThis.fetch (etc.) stays the + // build-time WebGlobalStub in the live process. The fetch family comes from the + // app's real \`undici\` (kept external, so it loads for real at restore — resolved + // directly, or through \`urllib\` for pnpm layouts where undici is not hoisted); + // File/Blob come from \`node:buffer\`. Each becomes a lazy accessor so the real + // module only loads on first access. + var __UNDICI_GLOBALS = ${undiciGlobalsJson}; + var __BUFFER_GLOBALS = ${bufferGlobalsJson}; + globalThis.__installWebGlobalsLazy = function () { + var rt = globalThis.__RUNTIME_REQUIRE; + var getBuiltin = function (id) { + try { return process.getBuiltinModule(id); } catch (e) { return typeof rt === 'function' ? rt(id) : undefined; } + }; + var __undici; + var __undiciTried = false; + var loadUndici = function () { + if (__undiciTried) return __undici; + __undiciTried = true; + if (typeof rt !== 'function') return (__undici = undefined); + try { __undici = rt('undici'); return __undici; } catch (e) {} + try { + var mod = getBuiltin('node:module'); + if (mod && typeof rt.resolve === 'function') __undici = mod.createRequire(rt.resolve('urllib'))('undici'); + } catch (e2) { __undici = undefined; } + return __undici; + }; + var loadBuffer = function () { return getBuiltin('node:buffer'); }; + var install = function (name, getSource) { + // Replace the build stub (a function named WebGlobalStub) or an absent slot; + // never clobber a non-configurable global or a genuine value. + var existing = Object.getOwnPropertyDescriptor(globalThis, name); + if (existing) { + if (existing.configurable === false) return; + if (!('value' in existing)) return; + var cur = existing.value; + if (cur !== undefined && !(cur && cur.name === 'WebGlobalStub')) return; + } + var define = function (value) { + Object.defineProperty(globalThis, name, { value: value, writable: true, enumerable: false, configurable: true }); + return value; + }; + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + get: function () { + var value; + try { var src = getSource(); value = src ? src[name] : undefined; } catch (e) { value = undefined; } + // Do not cache undefined: the source module may still be loading when this + // fires re-entrantly (undici reads globalThis.Headers while its own require() + // is in flight), so leave the accessor in place for a later read to resolve. + if (value !== undefined) return define(value); + return undefined; + }, + set: function (value) { define(value); } + }); + }; + for (var __u = 0; __u < __UNDICI_GLOBALS.length; __u++) install(__UNDICI_GLOBALS[__u], loadUndici); + for (var __b = 0; __b < __BUFFER_GLOBALS.length; __b++) install(__BUFFER_GLOBALS[__b], loadBuffer); + }; })(); `; } diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index b6cf40e26e..2ef91cc112 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -295,6 +295,9 @@ describe('EntryGenerator', () => { expect(worker).toContain("(0, eval)('require')('node:module')"); expect(worker).toContain('globalThis.__RUNTIME_REQUIRE ='); expect(worker).toContain('globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp)'); + // re-install the web globals (fetch/Headers/.../Blob/File) the prelude stubbed + expect(worker).toContain('globalThis.__installWebGlobalsLazy?.()'); + expect(worker).toContain('__runtimeRequire.resolve ='); expect(worker).toContain('app.triggerSnapshotDidDeserialize()'); // daemon readiness over IPC for `egg-scripts start --snapshot-blob` expect(worker).toContain("process.send({ action: 'egg-ready'"); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 32354b4ef7..e93129d279 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -267,9 +267,19 @@ if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') { ? process.getBuiltinModule('node:module') : (0, eval)('require')('node:module'); const __req = createRequire(__outputDir + '/'); - globalThis.__RUNTIME_REQUIRE = (id: string) => __req(id); + const __runtimeRequire: any = (id: string) => __req(id); + // Expose resolve so the web-globals re-installer can locate undici through the + // app dependency tree (e.g. via urllib under pnpm). + __runtimeRequire.resolve = (id: string, options?: any) => __req.resolve(id, options); + globalThis.__RUNTIME_REQUIRE = __runtimeRequire; globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp); + // Re-install the web globals (fetch/Headers/.../Blob/File) the snapshot prelude + // replaced with stubs at build time, so an app using globalThis.fetch keeps + // working after a restore. They become lazy accessors backed by undici (the + // fetch family) and node:buffer (Blob/File), loaded on first use. + globalThis.__installWebGlobalsLazy?.(); + (async () => { if (app.agent) { await app.agent.triggerSnapshotDidDeserialize(); diff --git a/tools/egg-bundler/test/snapshot-lazy-external.test.ts b/tools/egg-bundler/test/snapshot-lazy-external.test.ts index 700a027d18..3f87a145fd 100644 --- a/tools/egg-bundler/test/snapshot-lazy-external.test.ts +++ b/tools/egg-bundler/test/snapshot-lazy-external.test.ts @@ -91,10 +91,15 @@ describe('snapshot lazy-external', () => { } }); - it('stubs node:buffer File/Blob (which would otherwise pull in undici)', () => { + it('defines __installWebGlobalsLazy sourcing the fetch family from undici and File/Blob from node:buffer', () => { const prelude = renderSnapshotPrelude(); - expect(prelude).toContain("process.getBuiltinModule('node:buffer')"); - expect(prelude).toContain('["File","Blob"]'); + expect(prelude).toContain('globalThis.__installWebGlobalsLazy = function'); + expect(prelude).toContain("rt('undici')"); + // pnpm fallback: resolve undici through urllib (egg's direct dependency). + expect(prelude).toContain("rt.resolve('urllib')"); + expect(prelude).toContain("getBuiltin('node:buffer')"); + // node:buffer is NOT stubbed: File/Blob serialize fine and are re-installed from it. + expect(prelude).not.toContain('BufStub'); }); it('installs __LAZY_EXT with every lazy id and the __makeLazyExt factory', () => { @@ -258,4 +263,96 @@ describe('snapshot lazy-external', () => { expect('prototype' in tls).toBe(true); }); }); + + describe('runtime __installWebGlobalsLazy behavior (prelude evaluated in a vm)', () => { + // The vm sandbox has no `process`, so the installer's getBuiltin falls back to + // __RUNTIME_REQUIRE — which the tests supply, standing in for node:buffer/undici. + function makeRestoreContext() { + const sandbox: Record = {}; + vm.createContext(sandbox); + vm.runInContext(renderSnapshotPrelude(['http']), sandbox); + return sandbox; + } + + function makeFakeUndici() { + return { + fetch: () => 'fetched', + Headers: class Headers {}, + Request: class Request {}, + Response: class Response {}, + FormData: class FormData {}, + WebSocket: class WebSocket {}, + EventSource: class EventSource {}, + MessageEvent: class MessageEvent {}, + CloseEvent: class CloseEvent {}, + }; + } + + it('re-installs the fetch family from undici and File/Blob from node:buffer, lazily', () => { + const sandbox = makeRestoreContext(); + const fakeUndici = makeFakeUndici(); + const fakeBuffer = { File: class File {}, Blob: class Blob {} }; + let undiciLoads = 0; + sandbox.__RUNTIME_REQUIRE = (id: string) => { + if (id === 'undici') { + undiciLoads++; + return fakeUndici; + } + if (id === 'node:buffer') return fakeBuffer; + return undefined; + }; + + sandbox.__installWebGlobalsLazy(); + + expect(undiciLoads).toBe(0); // lazy: undici not required until first access + expect(sandbox.fetch).toBe(fakeUndici.fetch); + expect(undiciLoads).toBe(1); + expect(sandbox.Headers).toBe(fakeUndici.Headers); + void sandbox.Request; + expect(undiciLoads).toBe(1); // cached + expect(sandbox.Blob).toBe(fakeBuffer.Blob); + expect(sandbox.File).toBe(fakeBuffer.File); + }); + + it('resolves a web global accessed re-entrantly while undici is still loading', () => { + const sandbox = makeRestoreContext(); + const FakeHeaders = class Headers {}; + sandbox.__RUNTIME_REQUIRE = (id: string) => { + if (id === 'undici') { + void sandbox.Headers; // re-entrant access mid-load -> getSource() is undefined + return { fetch: () => 'F', Headers: FakeHeaders }; + } + return undefined; + }; + + sandbox.__installWebGlobalsLazy(); + + expect(typeof sandbox.fetch).toBe('function'); // triggers the re-entrant load + expect(sandbox.Headers).toBe(FakeHeaders); // resolved once undici finished + }); + + it('replaces the build-time WebGlobalStub but keeps a genuine value', () => { + const sandbox = makeRestoreContext(); + // A genuine value (not a WebGlobalStub) must be preserved. + const genuine = function userHeaders() {}; + sandbox.Headers = genuine; + sandbox.__RUNTIME_REQUIRE = (id: string) => + id === 'undici' ? makeFakeUndici() : id === 'node:buffer' ? { File: class {}, Blob: class {} } : undefined; + + sandbox.__installWebGlobalsLazy(); + + expect(sandbox.Headers).toBe(genuine); // kept + expect(sandbox.fetch()).toBe('fetched'); // the WebGlobalStub was replaced + }); + + it('skips a non-configurable global instead of throwing', () => { + const sandbox = makeRestoreContext(); + const frozen = function frozenFetch() {}; + Object.defineProperty(sandbox, 'fetch', { value: frozen, configurable: false, writable: false }); + sandbox.__RUNTIME_REQUIRE = (id: string) => (id === 'undici' ? makeFakeUndici() : undefined); + + expect(() => sandbox.__installWebGlobalsLazy()).not.toThrow(); + expect(sandbox.fetch).toBe(frozen); + }); + }); }); diff --git a/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts b/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts index 54db919d55..7d1ebae033 100644 --- a/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts +++ b/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts @@ -2,12 +2,17 @@ import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const execFileAsync = promisify(execFile); +// Repo root: tools/egg-bundler/test -> ../../.. . `packages/egg` resolves urllib +// (egg's direct dep), through which the prelude installer reaches undici under pnpm. +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + // REAL @utoo/pack build regression test for the snapshot lazy-external mechanism. // // The mechanism hinges on @utoo/pack emitting a `function externalRequire(id, thunk, @@ -158,4 +163,73 @@ describe('snapshot lazy-external — real @utoo/pack build', () => { expect(restoreProbe.methodsHasGet).toBe(true); // real http.METHODS also has GET expect(restoreProbe.createServerCall).toBe('object'); // real http.createServer() -> Server }, 60_000); + + it('re-installs the web globals (fetch/Headers/Blob) backed by real undici at restore', async () => { + await fs.writeFile(path.join(baseDir, 'package.json'), JSON.stringify({ name: 'snaplazy-rb-wg-app' })); + + const entryDir = path.join(baseDir, '.egg-bundle', 'entries'); + await fs.mkdir(entryDir, { recursive: true }); + const entry = path.join(entryDir, 'worker.entry.ts'); + // The prelude (prepended by bundle()) stubs the web globals at load. This entry + // simulates the snapshot restore-main: it calls __installWebGlobalsLazy (the + // restore-runner installs __RUNTIME_REQUIRE first), then exercises the now-real + // fetch/Headers/Blob against a local server. + await fs.writeFile( + entry, + [ + '// @ts-nocheck', + "if (typeof globalThis.__installWebGlobalsLazy === 'function') globalThis.__installWebGlobalsLazy();", + '(async () => {', + " const http = globalThis.__RUNTIME_REQUIRE('node:http');", + " const server = http.createServer((req, res) => res.end('pong'));", + " await new Promise((r) => server.listen(0, '127.0.0.1', r));", + ' const port = server.address().port;', + ' const out = { installerPresent: typeof globalThis.__installWebGlobalsLazy, fetchType: typeof globalThis.fetch, BlobType: typeof globalThis.Blob };', + ' try {', + " const resp = await fetch('http://127.0.0.1:' + port + '/');", + ' out.body = await resp.text();', + " out.headerOk = new Headers({ x: '1' }).get('x') === '1';", + " out.blobText = await new Blob(['z']).text();", + ' } catch (e) { out.err = String((e && e.message) || e); }', + ' await new Promise((r) => server.close(r));', + " process.stdout.write('WGPROBE:' + JSON.stringify(out), () => process.exit(0));", + '})();', + '', + ].join('\n'), + ); + mocks.workerEntry = entry; + mocks.entryDir = entryDir; + + const outputDir = path.join(baseDir, 'dist'); + await bundle({ baseDir, outputDir, snapshot: true }); + + // Restore-runner installs __RUNTIME_REQUIRE (with resolve) pointing where + // urllib/undici live, exactly as the generated deserialize main would. + const runner = path.join(outputDir, 'wg-restore-runner.cjs'); + await fs.writeFile( + runner, + [ + 'const { createRequire } = require("node:module");', + 'const req = createRequire(process.env.EGG_TEST_REQUIRE_BASE);', + 'const rt = (id) => req(id);', + 'rt.resolve = (id, o) => req.resolve(id, o);', + 'globalThis.__RUNTIME_REQUIRE = rt;', + 'require("./worker.js");', + '', + ].join('\n'), + ); + const ran = await execFileAsync(process.execPath, [runner], { + cwd: outputDir, + env: { ...process.env, EGG_TEST_REQUIRE_BASE: path.join(REPO_ROOT, 'packages/egg', 'package.json') }, + }); + const marker = 'WGPROBE:'; + const probe = JSON.parse(ran.stdout.slice(ran.stdout.indexOf(marker) + marker.length)); + + expect(probe.installerPresent).toBe('function'); // prelude defined it + expect(probe.fetchType).toBe('function'); // re-installed, not the WebGlobalStub + expect(probe.body).toBe('pong'); // a real fetch round-trip works + expect(probe.headerOk).toBe(true); // real undici Headers + expect(probe.BlobType).toBe('function'); + expect(probe.blobText).toBe('z'); // real node:buffer Blob + }, 60_000); });