Skip to content
Merged
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
18 changes: 16 additions & 2 deletions packages/typings/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
16 changes: 7 additions & 9 deletions site/docs/advanced/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 5 additions & 7 deletions site/docs/zh-CN/advanced/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 中。

Expand Down
12 changes: 11 additions & 1 deletion tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
92 changes: 69 additions & 23 deletions tools/egg-bundler/src/lib/prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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?: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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);
};
})();
`;
}
Expand Down
3 changes: 3 additions & 0 deletions tools/egg-bundler/test/EntryGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
103 changes: 100 additions & 3 deletions tools/egg-bundler/test/snapshot-lazy-external.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, any> = {};
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);
});
});
});
Loading
Loading