feat(snapshot): gate V8 snapshot restore to Node.js >= 24, add docs and cnpmcore e2e#6003
Conversation
…nd cnpmcore e2e Restoring a V8 startup snapshot requires Node.js >= 24: Node.js 22 aborts during deserialization of a non-trivial egg heap (V8 bug `Check failed: current == end_slot_index`). Building still works on Node.js >= 22. - gate(scripts): egg-scripts `start --snapshot-blob` refuses Node.js < 24 before spawning, checking the resolved `--node` target binary's major version; add `allowNo: true` to the sourcemap flag so `--no-sourcemap` is accepted - gate(egg-bundler): defense-in-depth guard in the generated deserialize-main - gate(egg-bin): `snapshot build` notes the Node.js >= 24 restore requirement - docs: enrich advanced/snapshot.md (EN + ZH) with version requirements, CLI workflow, principle, performance, limitations; wire into the sidebar - ci: add a blocking cnpmcore-snapshot ecosystem-ci e2e (build snapshot -> restore via egg-scripts -> curl /-/ping == 200 on Node 24); share the health-check loop via ecosystem-ci/wait-health.sh Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds snapshot CI wiring, a shared health-wait script, Node.js 24 restore gates, bundler/runtime snapshot plumbing, worker entry generation, and expanded English and Chinese snapshot docs with sidebar links. ChangesV8 startup snapshot rollout
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## next #6003 +/- ##
==========================================
+ Coverage 81.99% 82.00% +0.01%
==========================================
Files 676 676
Lines 20522 20534 +12
Branches 4060 4063 +3
==========================================
+ Hits 16826 16839 +13
+ Misses 3189 3187 -2
- Partials 507 508 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces support for V8 Startup Snapshots, including CLI commands, documentation, and a new health check script. It enforces a Node.js version requirement of >= 24 for restoring snapshots. The review feedback highlights a potential TypeError in the test suite when mocking process.versions, suggests simplifying Node.js major version extraction using parseInt, and recommends adding an argument check in the new shell script to prevent crashes caused by unbound variables under set -u.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function pinNodeVersion(version: string): () => void { | ||
| const descriptor = Object.getOwnPropertyDescriptor(process.versions, 'node'); | ||
| Object.defineProperty(process.versions, 'node', { value: version, configurable: true, writable: true }); | ||
| return () => { | ||
| if (descriptor) Object.defineProperty(process.versions, 'node', descriptor); | ||
| }; | ||
| } |
There was a problem hiding this comment.
In standard Node.js, process.versions is a frozen object, meaning that attempting to redefine properties on it directly via Object.defineProperty(process.versions, 'node', ...) will throw a TypeError in many environments. To mock the Node.js version safely and robustly, redefine the versions property on the process object itself instead.
function pinNodeVersion(version: string): () => void {
const originalVersions = process.versions;
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, node: version },
configurable: true,
writable: true,
});
return () => {
Object.defineProperty(process, 'versions', {
value: originalVersions,
configurable: true,
writable: true,
});
};
}There was a problem hiding this comment.
Done in a6417bb — pinNodeVersion now redefines process.versions itself (a fresh object with the pinned node) rather than its node property, which is non-configurable in some runtimes.
| */ | ||
| async #resolveSnapshotNodeMajor(command: string): Promise<number | undefined> { | ||
| if (command === 'node' || command === process.execPath) { | ||
| return Number(process.versions.node.split('.', 1)[0]); |
There was a problem hiding this comment.
There was a problem hiding this comment.
Done in a6417bb — switched to parseInt(process.versions.node, 10).
| // (egg-scripts start --snapshot-blob) already gates this before spawning, | ||
| // so this is a defense-in-depth guard for a direct \`node --snapshot-blob\` | ||
| // launch that managed to deserialize on an unsupported runtime. | ||
| const __nodeMajor = Number(process.versions.node.split('.', 1)[0]); |
There was a problem hiding this comment.
Using parseInt(process.versions.node, 10) is a simpler and more standard way to extract the major version of Node.js, avoiding unnecessary string splitting and array indexing.
| const __nodeMajor = Number(process.versions.node.split('.', 1)[0]); | |
| const __nodeMajor = parseInt(process.versions.node, 10); |
There was a problem hiding this comment.
Done in a6417bb — the generated worker-entry restore guard now uses parseInt(process.versions.node, 10).
| URL="$1" | ||
| RESPONSE_FILE="$2" |
There was a problem hiding this comment.
Since set -u is enabled, running this script with fewer than 2 arguments will cause it to crash immediately with an unbound variable error. Adding an explicit argument count check provides a much friendlier and clearer usage error message.
| URL="$1" | |
| RESPONSE_FILE="$2" | |
| if [ $# -lt 2 ]; then | |
| echo "Usage: $0 <url> <response-file> [timeout-seconds] [sleep-seconds]" >&2 | |
| exit 1 | |
| fi | |
| URL="$1" | |
| RESPONSE_FILE="$2" |
There was a problem hiding this comment.
Done in a6417bb — added an explicit [ "$#" -lt 2 ] usage check with a friendly message before the set -u positional reads.
|
Dependency limit exceeded — report not shown. This pull request scan exceeded the 10,000-dependency limit applied to this scan, so the results are incomplete and may be inaccurate. To avoid reporting false positives, Socket has not posted a report. Upgrade your plan to raise the dependency limit and get complete reports, or view the partial scan in the dashboard. Socket is always free for open source. If this is a non-commercial open source project, contact us to request a free Team account. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tools/scripts/src/commands/start.ts`:
- Around line 129-140: The `#resolveSnapshotNodeMajor` shortcut for bare node is
using process.versions.node, which can disagree with the binary actually
launched by spawn() when PATH is modified. Update this method to resolve the
command using this.env (or prefer the executable path resolution first) so the
version check matches the spawned binary, and keep the execFile fallback for
non-node commands.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5d9c0a4c-2167-4fab-b9cd-5c92e5b61847
⛔ Files ignored due to path filters (1)
tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snapis excluded by!**/*.snap
📒 Files selected for processing (12)
.github/workflows/e2e-test.yml.gitignoreecosystem-ci/patch-project.tsecosystem-ci/repo.jsonecosystem-ci/wait-health.shsite/.vitepress/config.mtssite/docs/advanced/snapshot.mdsite/docs/zh-CN/advanced/snapshot.mdtools/egg-bin/src/commands/snapshot.tstools/egg-bundler/src/lib/EntryGenerator.tstools/scripts/src/commands/start.tstools/scripts/test/snapshot-start.test.ts
| # V8 启动快照 | ||
|
|
||
| Egg 内置了 V8 startup snapshot 的构建与恢复能力。对于需要在启动前预加载框架元数据、插件、Service、Router 和 tegg 模块的应用,这可以减少冷启动阶段的开销。 | ||
| Egg 可以把一个完全加载好的应用固化成 [V8 启动快照](https://nodejs.org/api/cli.html#--build-snapshot), |
There was a problem hiding this comment.
cli 不太好搞,因为 cli 运行的 node 版本是不确定的。除非像 electron 那样把二进制一起打进去。
Strengthen the V8-snapshot lazy-external mechanism so a real-world app
(loading many ESM/CJS deps) builds through the egg loader to V8 serialization:
- PackRunner: single-file externals use ExternalType `commonjs` (a direct
require, surfaced as externalRequire) instead of the UMD form, which inside
the single-file IIFE has no CommonJS module/exports and falls through to
globalThis[name] = undefined, breaking every external.
- prelude: __makeLazyExt is now a member proxy that records the build-time
access path (get/apply/construct + args) and replays it against the real
module on restore, so `class X extends pkg.Klass` and
`DataTypes.INTEGER(11).UNSIGNED` keep working. Web globals become no-op stub
classes (constructable, for `class extends globalThis.Request`); node:buffer
File/Blob are stubbed; http constants are read from the build Node instead of
hand-written tables.
- prelude: the lazy strategy externalizes blacklisted network builtins (now
incl. inspector, which egg core imports) OR any non-builtin package; builtin
tool modules (path/fs/module) stay real.
- EntryGenerator: install __EGG_MODULE_IMPORTER__ for the build phase too, so
the loader routes config/app imports through require() (dynamic import is
unavailable under --build-snapshot) and gracefully skips a manifest-missing
ESM config.
- Bundler: read each external's export names (in the bundler process) and inject
__EXTERNAL_EXPORTS so the member proxy presents a full ESM namespace for
`import { X } from 'pkg'`.
Tests updated for the new prelude shape and build-importer entry; the realbuild
test verifies build-time stub + restore-time real module via the member proxy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…http2 load A plain Object.defineProperty over Node's lazy web globals (specifically `Headers`) makes Node eagerly initialize undici → http/http2 native bindings, which a V8 startup snapshot cannot serialize (CheckGlobalAndEternalHandles fatal). This was the root cause of snapshot build failing on a real-world tegg app (cnpmcore): the prelude's OWN web-global stubbing triggered the very undici load it was meant to prevent — not any application dependency. Fix: two passes — delete each global first (removes Node's lazy getter WITHOUT triggering it), then install the no-op stub class as a plain data property (now nothing to trigger, and still constructable for `class extends globalThis.Request`). With this, cnpmcore's snapshot builds to a ~63MB blob (http2 residual gone) and deserializes on Node 24; no extra force-externals are needed beyond the genuine non-bundlable packages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end fixes found by driving a real tegg + ORM app (cnpmcore) through snapshot build → restore → /-/ping: EntryGenerator — decorator filePath correction: tegg decorators capture a class's source path from the call stack at module eval (StackUtil.getCalleeFromStack at a hardcoded depth). In a bundle every user frame collapses onto worker.js + turbopack wrappers, so a decorator using a deeper index than the norm (notably @advice's depth-5 vs @SingletonProto's depth-4) captures "…/worker.js" instead of its source file. tegg then can't match that proto to its load unit and fails at restore with "Aop Advice(AsyncTimer) not found in loadUnits". Re-stamp the correct path (outputDir + relKey) on every decorated export from the manifest's tegg decoratedFiles, at build, so the snapshot carries correct paths. prelude — member-proxy replay correctness (the lazy-external mechanism): - Reflect.apply/construct instead of v.apply(...): when an earlier step resolves to a callable proxy, typeof is 'function' but v.apply is undefined, throwing "v.apply is not a function" (leoric's createType introspecting a DataType). - object proxy target for call/construct RESULTS so `typeof member` is 'object' (an instance), matching libraries that branch on typeof (leoric: function ⇒ DataType class, object ⇒ instance). - resolve member-proxy ARGS before applying: a captured arg can itself be a lazy export (e.g. `DataTypes.TEXT(LENGTH_VARIANTS.long)`); passing the proxy through makes the callee coerce it to a string and throw. Resolve via the __MR symbol. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive the cnpmcore V8-snapshot regression all the way to a served /-/ping:
- run under EGG_SERVER_ENV=prod (loads only config.default, DB from
CNPMCORE_DATABASE_* env vars) instead of unittest, so the snapshot stays
production-like and does not bake in the @eggjs/mock test plugin.
- set CNPMCORE_FORCE_LOCAL_FS=true so cnpmcore's NFSClientAdapter accepts the
local-fs store under prod (the e2e only needs a working /-/ping, not OSS/S3).
- delete the .ts sources after tsc:prod + overlay so the bundler scans only .js;
otherwise tegg globby picks up .ts AND .js of the same class as two modules
("duplicate proto"), which breaks decorated-class filePath correction on
restore.
- drop the redundant `--force-external leoric` (auto-externalized via its native
optional DB drivers) and keep undici/urllib external alongside
@cnpmjs/packument and koa-onerror.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tools/egg-bundler/src/lib/EntryGenerator.ts`:
- Around line 440-448: The catch block in EntryGenerator’s __buildReq(fp)
wrapper is swallowing all MODULE_NOT_FOUND errors, which hides real
missing-dependency failures from nested imports. Update the try/catch around
__buildReq in EntryGenerator so it only returns undefined for the direct
missing-file case for fp itself, and rethrows MODULE_NOT_FOUND coming from
inside a config/module load. Resolve fp first or otherwise distinguish the
top-level file-not-found path from nested dependency resolution before
suppressing the error.
- Around line 435-437: The snapshot importer setup in EntryGenerator currently
calls process.getBuiltinModule directly, which breaks on Node 22.0–22.2. Update
the __EGG_MODULE_IMPORTER__ setup to use the same Node <22.3 fallback approach
as the restore branch, while keeping the createRequire base rooted at
__outputDir, so the fallback path works consistently with the existing restore
logic.
In `@tools/egg-bundler/src/lib/prelude.ts`:
- Around line 166-185: The snapshot restore path does not re-expose the real web
globals that `prelude.ts` stubs out, so live code can inherit the no-op
`WebGlobalStub` values after restore. Update the deserialize/restore flow that
currently installs `__RUNTIME_REQUIRE` to also restore the original
`globalThis.fetch`/`Headers`/`Request`/`Response`/`File`/`Blob` and
`process.getBuiltinModule('node:buffer').File`/`Blob`, using the existing
prelude restore mechanism or a new explicit restore hook. Add an e2e check
around the restore entrypoint to verify `globalThis.fetch` and
`process.getBuiltinModule('node:buffer').File` are real after restore.
- Around line 381-389: The export collection in collect only uses Object.keys,
so it can miss non-enumerable own properties and function-valued default exports
from external modules. Update collect to inspect own property names on both the
module object and its default export (while keeping the existing Set dedupe),
and make sure the merge logic still covers the CJS-via-ESM case referenced by
the default handling in prelude.ts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 18aa3c9b-9f8f-418a-baa1-6194814fbf91
⛔ Files ignored due to path filters (1)
tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snapis excluded by!**/*.snap
📒 Files selected for processing (9)
.github/workflows/e2e-test.ymltools/egg-bundler/src/lib/Bundler.tstools/egg-bundler/src/lib/EntryGenerator.tstools/egg-bundler/src/lib/PackRunner.tstools/egg-bundler/src/lib/prelude.tstools/egg-bundler/test/PackRunner.test.tstools/egg-bundler/test/snapshot-lazy-bundler.test.tstools/egg-bundler/test/snapshot-lazy-external.test.tstools/egg-bundler/test/snapshot-lazy.realbuild.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- .github/workflows/e2e-test.yml
- start.ts / EntryGenerator worker entry: use parseInt(process.versions.node, 10)
to read the Node major instead of split('.')[0] (gemini-code-assist).
- snapshot-start.test pinNodeVersion: redefine process.versions itself rather
than its `node` property, which is non-configurable in some runtimes and would
throw on a direct defineProperty (gemini-code-assist).
- wait-health.sh: under `set -u`, fail with a clear usage message when called
with fewer than 2 args instead of an unbound-variable crash (gemini-code-assist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tools/scripts/src/commands/start.ts (1)
129-135: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winProbe the same
nodebinary thatspawn()will launch.
spawn(command, ..., { env: this.env })resolvescommandagainst the rewrittenthis.env.PATH, but this helper still readsprocess.versions.nodefor barenodeand probes custom commands with the parent env. If.node/bin/node_modules/.binshadowsnode, or--nodeis a PATH-resolved alias, the gate can validate one binary and launch another, so unsupported Node 22 restores still slip through to the fatal V8 crash this guard is meant to avoid. Limit the shortcut toprocess.execPathand resolve/probe everything else withthis.env.🔧 Minimal fix
async `#resolveSnapshotNodeMajor`(command: string): Promise<number | undefined> { - if (command === 'node' || command === process.execPath) { + if (command === process.execPath) { return parseInt(process.versions.node, 10); } try { - const { stdout } = await execFile(command, ['--version']); + const { stdout } = await execFile(command, ['--version'], { env: this.env }); const match = /^v?(\d+)\./.exec(stdout.toString().trim()); return match ? Number(match[1]) : undefined; } catch {#!/bin/bash set -euo pipefail echo "=== PATH rewrite, version probe, and spawn env ===" sed -n '121,141p;178,188p;264,293p;331,346p' tools/scripts/src/commands/start.ts echo echo "=== Snapshot-start tests touching the gate ===" sed -n '1,220p' tools/scripts/test/snapshot-start.test.ts🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tools/scripts/src/commands/start.ts` around lines 129 - 135, The snapshot version check in resolveSnapshotNodeMajor is probing the wrong Node binary because it shortcuts bare node to process.versions.node and uses the parent environment for execFile. Update this helper so only process.execPath uses the current process version, and resolve/probe all other commands with this.env (matching the spawn(command, ..., { env: this.env }) path) so the gate checks the same binary that will actually launch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@tools/scripts/src/commands/start.ts`:
- Around line 129-135: The snapshot version check in resolveSnapshotNodeMajor is
probing the wrong Node binary because it shortcuts bare node to
process.versions.node and uses the parent environment for execFile. Update this
helper so only process.execPath uses the current process version, and
resolve/probe all other commands with this.env (matching the spawn(command, ...,
{ env: this.env }) path) so the gate checks the same binary that will actually
launch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2d7b7722-3268-476c-84cd-9bc738d6297b
⛔ Files ignored due to path filters (1)
tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snapis excluded by!**/*.snap
📒 Files selected for processing (4)
ecosystem-ci/wait-health.shtools/egg-bundler/src/lib/EntryGenerator.tstools/scripts/src/commands/start.tstools/scripts/test/snapshot-start.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- ecosystem-ci/wait-health.sh
- tools/scripts/test/snapshot-start.test.ts
- tools/egg-bundler/src/lib/EntryGenerator.ts
- build module importer (EntryGenerator): fall back to an eval'd `require` when process.getBuiltinModule is unavailable (Node 22.0–22.2), matching the restore branch; and resolve `fp` first so only a genuine "this file is missing" is skipped — a nested MODULE_NOT_FOUND from a real missing dependency now propagates instead of being silently swallowed (coderabbit). - readExternalExports: collect names with Object.getOwnPropertyNames (not Object.keys) so non-enumerable named exports are included, matching the getOwnPropertyNames(raw) that @utoo/pack's interopEsm enumerates (coderabbit). - docs(snapshot): document that the undici-backed web globals (fetch/Headers/…, Blob/File) remain no-op stubs in the restored process — Node's native lazy getters are not snapshot-serializable, so they can't be reinstated; apps should use a lazy-external HTTP client (urllib/undici) rather than globalThis.fetch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two snapshot-start cases exercising `#resolveSnapshotNodeMajor`'s execFile path (a custom `--node /path`, which the gate version-checks via `<path> --version` rather than process.versions): one where the binary reports an unsupported version (< 24 → blocks before spawn) and one where the query fails (gate fails open and the launch proceeds). Covers the previously-untested execFile/match/catch lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Motivation Building a V8 startup snapshot serializes the whole heap, so any dependency that opens a socket, starts a timer, or initializes a native binding at module-evaluation time can make the blob fail to build — or build and then crash on restore. There was no guide for finding which module is responsible or how to fix it. ## What New dedicated page `advanced/snapshot-troubleshooting.md` (EN + zh-CN): - **The serializability rule** — what cannot survive the round-trip (native bindings / libuv handles / lazy web-global getters) and when a dependency trips it. - **Failure surfaces** — build-time vs restore-time, with the exact error strings each emits (`killed by signal SIGSEGV`, `no blob was written`, `Check failed: current == end_slot_index`, `Aop Advice not found`, `Cannot find module`, …). - **Find the offending module** — `NODE_DEBUG` namespaces, a clean `NODE_OPTIONS`, `--dry-run`, `--skip-bundle` bisecting, `--force-external` confirmation. - **Fixes** — `--force-external`, `egg.snapshot.lazyModules`, the snapshot lifecycle hooks, deferring work out of module scope, avoiding the web globals. - **Failure modes in detail** (tegg `@Advice` filePath, the lazy-external member proxy, runtime-asset `ENOENT`) and a configuration reference table. Also documents the previously-undocumented `egg.snapshot.lazyModules` config in `advanced/snapshot.md`, cross-links the new page from it, and wires the page into the English and Chinese advanced sidebars. Docs-only; no runtime code change. Builds on the snapshot feature already on `next` (#5998 / #5999 / #6001 / #6003). ## Test evidence - `vitepress build site` passes clean — VitePress's dead-link/anchor check is green, so both new pages render and every cross-file and in-page anchor resolves (the two detail headings use explicit ASCII `{#…}` ids because the VitePress slugifier retains CJK + fullwidth `「」`). - Every technical claim was adversarially verified against the `egg-bundler` / `egg-bin` / `egg-scripts` source — exact error strings, flag names, debug namespaces, and the default lazy-module set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a new Snapshot Troubleshooting guide in English and Chinese, covering common build and restore failure symptoms, how to diagnose them, and recommended fixes. * Expanded the Snapshot guide with guidance on configuring additional lazy-loaded modules, plus clearer troubleshooting links. * Updated the sidebar navigation to include the new troubleshooting page in both languages. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
|
Additional cnpmcore startup benchmark data, measured locally with readiness defined as the first HTTP 200 from Setup:
Summary: on cnpmcore, V8 startup snapshot restore reaches healthy-ready in about Node version caveat confirmed locally as well: the blob built with Node |
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>
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>
…ots (#6011) ## Motivation Under `snapshot: true`, the bundler keeps modules **lazy-external** so a V8 startup snapshot stays serializable (member-proxy stub at build, real module forwarded at restore via `globalThis.__RUNTIME_REQUIRE`). `DEFAULT_SNAPSHOT_LAZY_MODULES` currently covers the Node network stack + `inspector`. 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. So every app had to manually add them to `egg.snapshot.lazyModules` to get a working snapshot. This adds `undici` + `urllib` to the default list so they're **forced external by default** — apps get a serializable snapshot for free. ## What changed - `prelude.ts`: add `undici` + `urllib` to `DEFAULT_SNAPSHOT_LAZY_MODULES` (+ JSDoc explaining the rationale and the npm-package-vs-builtin distinction). `Bundler` adds lazy ids to the externals map, so listing them forces them external; the member-proxy from #6003 then stubs them at build and replays the access path against the real module on restore — so egg's `class HttpClient extends urllib.HttpClient` keeps working across the build→restore boundary. ## Tests - Unit: asserts `undici`/`urllib` are in the default list and survive `resolveSnapshotLazyModules`. - Real `@utoo/pack` build: a **forced-external npm package** with `class Sub extends pkg.Base` is a stub at build (no throw, real module never loaded) and a real base instance after `__RUNTIME_REQUIRE` is installed — `super(...)`, the inherited method, and the real instance field all work. Upstream only realbuild-tested the `node:http` builtin, so this fills the forced-external-npm gap. 28 lazy/realbuild tests green; `tsgo` + `oxlint` clean. ## Note on scope (rebase) This PR originally (off the older `next`) shipped a bespoke per-export forwarder inside `__makeLazyExt` to make `class HttpClient extends urllib.HttpClient` survive the build→restore boundary. While it was open, #6003 landed and rewrote `__makeLazyExt` into a general access-path-recording member-proxy (`makeMember`) that already handles `class X extends pkg.Klass` (plus `ownKeys`/`getOwnPropertyDescriptor` via `__EXTERNAL_EXPORTS`, arg resolution, etc.). The PR has been **rebased onto that and reduced to just the default-list addition** — the forwarder is dropped as superseded. The earlier CodeRabbit/Gemini review comments were on that now-removed code; replies posted inline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved snapshot bundling to prevent V8 serialization failures in the HTTP client module chain, ensuring correct restore-time loading. * Expanded the default lazy-external module set to include `undici` and `urllib` automatically (no extra configuration required). * **Tests** * Added coverage for default lazy-external resolution when package metadata is missing. * Added a real-build regression test validating forced-external behavior with `class extends` and correct restore timing. * **Documentation** * Updated bundler docs and coding guidelines to explain lazy-external snapshot behavior and the expanded defaults. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Motivation
Restoring a V8 startup snapshot requires Node.js >= 24: Node.js 22 aborts during deserialization of a non-trivial Egg heap with the native fatal
Check failed: current == end_slot_index(a V8 bug). Building a snapshot still works on Node.js >= 22. Today nothing enforces or documents this, and there is no regression coverage. This PR adds a runtime gate, documentation, and an e2e regression — without changing the snapshot mechanism itself.Builds on the snapshot work already on
next(#5998 entry/prelude +egg-bin snapshotcommand, #5999 lazy-external network stack, #6001 logger reopen, #6002 module-loader hooks).Scope
Runtime gate (restore ≥ 24; build stays ≥ 22)
@eggjs/scripts:egg-scripts start --snapshot-blobrefuses to launch on Node.js < 24 with a clear error before spawning, checking the major version of the resolved--nodetarget binary (not just the egg-scripts runtime). Also addsallowNo: trueto thesourcemapflag so--no-sourcemapis accepted.@eggjs/egg-bundler: a defense-in-depth guard in the generated deserialize-main for directnode --snapshot-bloblaunches that manage to deserialize on an unsupported runtime.@eggjs/bin:snapshot buildprints a note that restoring needs Node.js >= 24.Docs
site/docs/advanced/snapshot.md(EN + ZH): Node version requirements, the CLI workflow (egg-bin snapshot build→egg-scripts start --snapshot-blob), how it works (load module graph → run toconfigWillLoad→ freeze; restore =didReady+ listen), performance (~233ms vs ~942ms, ~4× on cnpmcore), and known limitations.CI
cnpmcore-snapshotecosystem-ci e2e (Node 24): snapshot build → restore viaegg-scripts start --snapshot-blob→curl /-/ping== 200 → stop. Wiresrepo.json,patch-project.ts,.gitignore.ecosystem-ci/wait-health.sh, used by both thecnpmcoreandcnpmcore-snapshotjobs.Test evidence
pnpm --filter=@eggjs/scripts --filter=@eggjs/egg-bundler --filter=@eggjs/bin run typecheck— clean.@eggjs/scriptssnapshot-start.test.ts(4 tests, incl. a Node<24 gate test and a--no-sourcemapparse regression test) +start-unit.test.ts— pass.@eggjs/binsnapshot.test.ts— pass.@eggjs/egg-bundlerEntryGeneratorcanonical snapshot regenerated for the new guard; the rest of the suite matches the pre-change baseline (a few pre-existing macOS/Node-22 path-resolution failures are unrelated).--no-sourcemapis now parseable viaallowNo: true— without it the e2e job would have failed 100%).Notes
cnpmcore-snapshotjob is correct-by-construction but could not be validated on the author's machine (Node 22, no MySQL+cnpmcore build); it relies on the supported path (lazy-external from feat(bundler): lazy-external network stack for V8 snapshots #5999, no manual stubs) and is validated by this PR's CI run.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
--no-sourcemap.Documentation
Tests
Chores