Skip to content

Commit 6f44b93

Browse files
killaguclaude
andcommitted
feat(bundler): lazy-externalize undici + urllib by default for snapshots
Add `undici` and `urllib` to DEFAULT_SNAPSHOT_LAZY_MODULES so an app gets a serializable V8 startup snapshot without listing them in `egg.snapshot.lazyModules`. Egg builds its HttpClient (urllib -> undici) during boot, and undici instantiates an llhttp WebAssembly module (disabled under --build-snapshot) + an HTTPParser that cannot be snapshot-serialized. As npm packages, urllib/undici would otherwise be inlined into the bundle and evaluated at build time; listing them here forces them external so the prelude's member-proxy stub is used at build and the real module is required on restore. The member-proxy (eggjs#6003) already replays the recorded access path, so egg's `class HttpClient extends urllib.HttpClient` keeps working across the build->restore boundary. Adds a unit assertion for the default list and a real @utoo/pack build test that exercises `class Sub extends pkg.Base` for a forced-external npm package across the build-stub / restore-real boundary (upstream only covered the node:http builtin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b39c019 commit 6f44b93

5 files changed

Lines changed: 200 additions & 4 deletions

File tree

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ const debug = debuglog('egg/bundler/snapshot-prelude');
3636
export const SNAPSHOT_PRELUDE_MARKER = '@eggjs/egg-bundler:snapshot-prelude';
3737

3838
/**
39-
* Node built-in modules that produce non-serializable native bindings when loaded
40-
* inside a V8 startup snapshot builder. They are kept as lazy externals: build time
41-
* returns a member-proxy stub; restore time forwards to the real module via
39+
* Modules that produce non-serializable native bindings when loaded inside a V8
40+
* startup snapshot builder. They are kept as lazy externals: build time returns a
41+
* member-proxy stub; restore time forwards to the real module via
4242
* `globalThis.__RUNTIME_REQUIRE`.
4343
*
4444
* - network stack (HTTPParser, nghttp2, SecureContext, ChannelWrap):
@@ -50,6 +50,16 @@ export const SNAPSHOT_PRELUDE_MARKER = '@eggjs/egg-bundler:snapshot-prelude';
5050
* (readline/repl + http2 nghttp2 native), making the heap unserializable. Keep
5151
* it lazy so the build-time stub is used; the live process gets the real module
5252
* on restore.
53+
* - `undici` / `urllib`: egg's HTTP client stack, built during boot
54+
* (`class HttpClient extends urllib.HttpClient`, and urllib's own
55+
* `class BaseAgent extends undici.Agent`). undici instantiates an llhttp
56+
* `WebAssembly` module (disabled under `--build-snapshot`) + an `HTTPParser`, so
57+
* it must not be evaluated at build. Unlike the builtins above these are npm
58+
* packages that would otherwise be **inlined** into the bundle and evaluated at
59+
* build; listing them here forces them external (see {@link Bundler}) so the
60+
* member-proxy stub is used at build and the real module is required on restore.
61+
* Listing them by default means an app gets a serializable snapshot without
62+
* adding them to `egg.snapshot.lazyModules` itself.
5363
*/
5464
export const DEFAULT_SNAPSHOT_LAZY_MODULES: readonly string[] = [
5565
'http',
@@ -64,6 +74,8 @@ export const DEFAULT_SNAPSHOT_LAZY_MODULES: readonly string[] = [
6474
'node:dns',
6575
'inspector',
6676
'node:inspector',
77+
'undici',
78+
'urllib',
6779
];
6880

6981
/**

tools/egg-bundler/test/snapshot-lazy-external.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ describe('snapshot lazy-external', () => {
3131
expect(result).toContain('node:dns');
3232
});
3333

34+
it("lazy-externalizes egg's HTTP client stack (undici + urllib) by default", async () => {
35+
// Egg builds its HttpClient (urllib -> undici) during boot; undici's llhttp
36+
// WebAssembly + HTTPParser cannot be snapshot-serialized. As npm packages they
37+
// would otherwise be inlined, so they must be forced external (this list) to get
38+
// the member-proxy stub at build — without an app listing them itself.
39+
expect(DEFAULT_SNAPSHOT_LAZY_MODULES).toContain('undici');
40+
expect(DEFAULT_SNAPSHOT_LAZY_MODULES).toContain('urllib');
41+
const result = await resolveSnapshotLazyModules(tmp);
42+
expect(result).toContain('undici');
43+
expect(result).toContain('urllib');
44+
});
45+
3446
it('returns the default list when package.json has no egg.snapshot.lazyModules', async () => {
3547
await fs.writeFile(path.join(tmp, 'package.json'), JSON.stringify({ name: 'app', egg: {} }));
3648
expect(await resolveSnapshotLazyModules(tmp)).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]);

tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,141 @@ describe('snapshot lazy-external — real @utoo/pack build', () => {
232232
expect(probe.BlobType).toBe('function');
233233
expect(probe.blobText).toBe('z'); // real node:buffer Blob
234234
}, 60_000);
235+
236+
it('forced-external npm package: class extends pkg.Base is a stub at build, real base after restore', async () => {
237+
// The scenario this PR's undici/urllib defaults enable: a bundled module extends a
238+
// class exported by a forced-external npm package (egg's
239+
// `class HttpClient extends urllib.HttpClient`). The `extends` clause is evaluated
240+
// at BUILD time against the member-proxy stub; at RESTORE the member-proxy replays
241+
// the access path against the real module, so `super(...)` and inherited methods
242+
// resolve to the real base class. A local `lazy-base` package stands in for urllib
243+
// so the test stays hermetic (ExternalsResolver is mocked, so it is forced external
244+
// only via egg.snapshot.lazyModules).
245+
await fs.writeFile(
246+
path.join(baseDir, 'package.json'),
247+
JSON.stringify({ name: 'snaplazy-extends-app', egg: { snapshot: { lazyModules: ['lazy-base'] } } }),
248+
);
249+
const basePkgDir = path.join(baseDir, 'node_modules', 'lazy-base');
250+
await fs.mkdir(basePkgDir, { recursive: true });
251+
await fs.writeFile(path.join(basePkgDir, 'package.json'), JSON.stringify({ name: 'lazy-base', main: 'index.js' }));
252+
await fs.writeFile(
253+
path.join(basePkgDir, 'index.js'),
254+
[
255+
// Module-eval load counter: lets the runners prove WHEN the real package is
256+
// actually loaded (it must be never at build, and only at restore once the
257+
// member-proxy resolves through the lazy hook — not via native resolution).
258+
'globalThis.__LAZY_BASE_LOADS = (globalThis.__LAZY_BASE_LOADS || 0) + 1;',
259+
'class Base {',
260+
' constructor(opt) { this.opt = opt; }',
261+
" greet() { return 'base#' + this.opt; }",
262+
'}',
263+
'module.exports = { Base };',
264+
'',
265+
].join('\n'),
266+
);
267+
268+
const entryDir = path.join(baseDir, '.egg-bundle', 'entries');
269+
await fs.mkdir(entryDir, { recursive: true });
270+
const entry = path.join(entryDir, 'worker.entry.ts');
271+
await fs.writeFile(
272+
entry,
273+
[
274+
'// @ts-nocheck',
275+
// captured at module-eval; the `extends` link freezes against whatever this is
276+
"const { Base } = require('lazy-base');",
277+
'class Sub extends Base {',
278+
' constructor(opt) { super(opt); this.tag = "sub"; }',
279+
' describe() { return this.tag + ":" + this.greet(); }',
280+
'}',
281+
// defer instantiation so the runner controls the build/restore boundary
282+
'globalThis.__makeSub = (n) => new Sub(n);',
283+
'process.stdout.write("ENTRY_OK");',
284+
'',
285+
].join('\n'),
286+
);
287+
mocks.workerEntry = entry;
288+
mocks.entryDir = entryDir;
289+
290+
const outputDir = path.join(baseDir, 'dist');
291+
await bundle({ baseDir, outputDir, snapshot: true });
292+
293+
const workerPath = path.join(outputDir, 'worker.js');
294+
const worker = await fs.readFile(workerPath, 'utf8');
295+
expect(worker).toContain(SNAPSHOT_PRELUDE_MARKER);
296+
expect(worker).toMatch(/function\s+externalRequire\s*\(/);
297+
await execFileAsync(process.execPath, ['--check', workerPath]);
298+
299+
const basePathLiteral = JSON.stringify(basePkgDir);
300+
301+
// BUILD context: define + construct the subclass with no __RUNTIME_REQUIRE. The base
302+
// is the member-proxy stub, so the instance is not a real lazy-base instance, nothing
303+
// throws, and crucially the real lazy-base module is NEVER loaded (realLoads === 0) —
304+
// proving the bundle used the build-time stub rather than resolving the package.
305+
const buildRunner = path.join(outputDir, 'build-runner.cjs');
306+
await fs.writeFile(
307+
buildRunner,
308+
[
309+
'const probe = { phase: "build" };',
310+
'try {',
311+
' require("./worker.js");',
312+
' globalThis.__makeSub(7);',
313+
' probe.restored = !!globalThis.__RUNTIME_REQUIRE;',
314+
' probe.realLoads = globalThis.__LAZY_BASE_LOADS || 0;',
315+
' probe.threw = false;',
316+
'} catch (e) { probe.threw = true; probe.err = e.message; }',
317+
'process.stdout.write("\\n" + JSON.stringify(probe));',
318+
'',
319+
].join('\n'),
320+
);
321+
const built = await execFileAsync(process.execPath, [buildRunner], { cwd: outputDir });
322+
expect(JSON.parse(built.stdout.split('\n').pop() ?? '')).toMatchObject({
323+
restored: false,
324+
threw: false,
325+
realLoads: 0, // real lazy-base never loaded at build
326+
});
327+
328+
// RESTORE context (cross-phase, one process): require worker.js with __RUNTIME_REQUIRE
329+
// still unset (freezing `extends` against the stub), THEN install it, THEN instantiate.
330+
// The load counters prove the real lazy-base is pulled in ONLY when the member-proxy
331+
// resolves through the `__RUNTIME_REQUIRE` hook (loadedBeforeMakeSub === 0,
332+
// loadedAfterMakeSub === 1) — i.e. via the lazy hook, not native pre-resolution.
333+
//
334+
// `instanceof Base` is intentionally NOT asserted: makeMember's `protoProxy` exposes
335+
// only a `get` trap (no `getPrototypeOf`), so inherited methods forward to the real
336+
// prototype but the snapshot-frozen subclass's prototype chain does not literally
337+
// contain the real `Base.prototype` — identity-by-prototype is a known non-goal of the
338+
// upstream member-proxy.
339+
const restoreRunner = path.join(outputDir, 'restore-runner.cjs');
340+
await fs.writeFile(
341+
restoreRunner,
342+
[
343+
'require("./worker.js");',
344+
'const loadedBeforeRT = globalThis.__LAZY_BASE_LOADS || 0;',
345+
`globalThis.__RUNTIME_REQUIRE = (id) => id === "lazy-base" ? require(${basePathLiteral}) : require(id);`,
346+
'const loadedBeforeMakeSub = globalThis.__LAZY_BASE_LOADS || 0;',
347+
'const inst = globalThis.__makeSub(7);',
348+
'const probe = {',
349+
' restored: true,',
350+
' loadedBeforeRT,',
351+
' loadedBeforeMakeSub,',
352+
' loadedAfterMakeSub: globalThis.__LAZY_BASE_LOADS || 0,',
353+
' tag: inst.tag,',
354+
' greet: inst.greet(),',
355+
' describe: inst.describe(),',
356+
'};',
357+
'process.stdout.write("\\n" + JSON.stringify(probe));',
358+
'',
359+
].join('\n'),
360+
);
361+
const restored = await execFileAsync(process.execPath, [restoreRunner], { cwd: outputDir });
362+
expect(JSON.parse(restored.stdout.split('\n').pop() ?? '')).toEqual({
363+
restored: true,
364+
loadedBeforeRT: 0, // building the worker did not load the real module
365+
loadedBeforeMakeSub: 0, // installing __RUNTIME_REQUIRE does not eagerly load it
366+
loadedAfterMakeSub: 1, // loaded exactly once, when the member-proxy resolved via the hook
367+
tag: 'sub', // subclass constructor ran
368+
greet: 'base#7', // inherited real method + real field (opt=7) — real base constructed
369+
describe: 'sub:base#7', // subclass method invoking the inherited one
370+
});
371+
}, 60_000);
235372
});

wiki/log.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
Dates use the workspace-local Asia/Shanghai calendar date.
44

5+
## [2026-06-28] package | snapshot bundler lazy-externalizes undici + urllib by default (PR #6011)
6+
7+
- sources touched: `tools/egg-bundler/src/lib/prelude.ts`, `tools/egg-bundler/test/snapshot-lazy-external.test.ts`, `tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts`
8+
- pages updated: `wiki/packages/egg-bundler.md`, `wiki/log.md`
9+
- branch: `feat/snapshot-default-lazy-undici-urllib` (off `next`)
10+
- change: Added `undici` + `urllib` to `DEFAULT_SNAPSHOT_LAZY_MODULES` so an app gets a serializable V8 snapshot without listing them in `egg.snapshot.lazyModules`. Egg builds its HttpClient (urllib → undici) during boot, and undici's llhttp `WebAssembly` + `HTTPParser` cannot be snapshot-serialized. As npm packages they would be inlined; listing them forces them external (`Bundler` adds lazy ids to the externals map) so the prelude member-proxy stub is used at build and the real module is required on restore.
11+
- history note: the PR originally (off the older `next`) shipped a bespoke per-export forwarder in `__makeLazyExt` to make `class HttpClient extends urllib.HttpClient` survive the build→restore boundary. While the PR was open, #6003 landed on `next` and rewrote `__makeLazyExt` into a general **access-path-recording member-proxy** (`makeMember`) that already handles `class X extends pkg.Klass` / `DataTypes.INTEGER(11).UNSIGNED` plus `ownKeys`/`getOwnPropertyDescriptor` via `__EXTERNAL_EXPORTS`. The PR was rebased onto that and **reduced to just the default-list addition** (forwarder dropped as superseded). Note `makeMember`'s `protoProxy` has only a `get` trap (no `getPrototypeOf`), so `instanceof RealBase` on a snapshot-frozen subclass is `false` — methods/fields/super() work, identity-by-prototype does not.
12+
- verification: unit test asserts undici+urllib in the default list; new real `@utoo/pack` build test exercises a **forced-external npm package** `class Sub extends pkg.Base` across the build-stub / restore-real boundary in one process (upstream only realbuild-tested the `node:http` builtin). 28 lazy/realbuild tests green; tsgo + oxlint clean. Pre-existing macOS `ManifestLoader`/`EntryGenerator` tmpdir-symlink failures unrelated.
13+
514
## [2026-06-27] concept | fix concurrent-import race in multi-app boot (oxc-node PR #5965)
615

716
- sources touched: `packages/utils/src/import.ts`

wiki/packages/egg-bundler.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ source_files:
77
- tools/egg-bundler/src/lib/Bundler.ts
88
- tools/egg-bundler/src/lib/EntryGenerator.ts
99
- tools/egg-bundler/src/lib/ExternalsResolver.ts
10+
- tools/egg-bundler/src/lib/prelude.ts
1011
- tools/egg-bin/src/commands/bundle.ts
1112
- tools/egg-bundler/docs/output-structure.md
12-
updated_at: 2026-05-06
13+
updated_at: 2026-06-28
1314
status: active
1415
---
1516

@@ -66,3 +67,28 @@ CommonJS artifact from an Egg application.
6667
external.
6768
- `BundlerConfig.tegg` is accepted but intentionally not wired into the current
6869
implementation yet.
70+
71+
### Snapshot lazy-external defaults
72+
73+
In `snapshot: true` mode the bundler keeps a set of modules **lazy-external** so a
74+
V8 startup snapshot stays serializable: each is emitted as an `externalRequire`,
75+
left out of the build-time heap (a member-proxy stub from the prelude's
76+
`__makeLazyExt`), and forwarded to the real module at restore via
77+
`globalThis.__RUNTIME_REQUIRE`. The injected hook also lazy-stubs any **non-builtin**
78+
external at build (`!__isBuiltin(id)`); the explicit list mainly exists to (a) cover
79+
builtins the `!isBuiltin` rule skips and (b) **force npm packages external** that
80+
would otherwise be inlined.
81+
82+
- `DEFAULT_SNAPSHOT_LAZY_MODULES` (in `src/lib/prelude.ts`) covers the Node network
83+
stack (`http`/`https`/`http2`/`tls`/`dns`), `inspector`, **and egg's HTTP client
84+
stack `undici` + `urllib`**. Egg builds its `HttpClient` (urllib → undici) during
85+
boot, and undici instantiates an llhttp `WebAssembly` (disabled under
86+
`--build-snapshot`) + `HTTPParser` that cannot be serialized. As npm packages
87+
urllib/undici would be inlined; listing them forces them external (`Bundler` adds
88+
the lazy ids to the externals map) so the member-proxy stub is used at build — an
89+
app gets a serializable snapshot without listing them in `egg.snapshot.lazyModules`.
90+
- The member-proxy records the build-time access path (`get`/`apply`/`construct`) and
91+
replays it against the real module on restore, so `class HttpClient extends
92+
urllib.HttpClient` (and urllib's own `class BaseAgent extends undici.Agent`) keep
93+
working: the `extends` is evaluated against the build stub, then `super(...)` /
94+
inherited methods resolve to the real base class after deserialization.

0 commit comments

Comments
 (0)