Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
93 changes: 59 additions & 34 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,44 +76,69 @@ jobs:
CNPMCORE_DATABASE_NAME=cnpmcore bash ./prepare-database-mysql.sh

EGG_TS_ENABLE=false npx eggctl start --port=7001 --env=unittest --daemon
HEALTH_URL="http://127.0.0.1:7001/"
START_TIME=$(date +%s)
TIMEOUT=120
STATUS=""
READY=0

echo "Waiting for application to become healthy at ${HEALTH_URL} (timeout: ${TIMEOUT}s)..."
while true; do
# Capture response body and status code
STATUS=$(curl -s -o /tmp/cnpmcore-health-response -w "%{http_code}" "${HEALTH_URL}" || echo "000")
echo "Health check attempt at $(date): status=${STATUS}"

if [ "${STATUS}" = "200" ]; then
echo "Health check succeeded with status 200"
READY=1
break
fi

NOW=$(date +%s)
ELAPSED=$((NOW - START_TIME))
if [ "${ELAPSED}" -ge "${TIMEOUT}" ]; then
echo "Health check timed out after ${ELAPSED}s with last status ${STATUS}"
break
fi

sleep 5
done

npx eggctl stop

if [ "${READY}" != "1" ]; then
echo "Health check failed; last HTTP status: ${STATUS}"
echo "Last health endpoint response body (if any):"
cat /tmp/cnpmcore-health-response 2>/dev/null || echo "<no response body captured>"

if bash ../wait-health.sh "http://127.0.0.1:7001/" /tmp/cnpmcore-health-response 120 5; then
npx eggctl stop
else
npx eggctl stop || true
echo "Recent error logs (if any):"
cat logs/cnpmcore/common-error.log 2>/dev/null || true
exit 1
fi
- name: cnpmcore-snapshot
node-version: 24
command: |
# V8 startup snapshot regression: build a snapshot of cnpmcore, restore
# it via `egg-scripts start --snapshot-blob`, and assert it serves
# `/-/ping` with HTTP 200. Restoring a V8 snapshot requires Node.js >= 24.
export EGG_TS_ENABLE=false
export EGG_SERVER_ENV=unittest

npm install --legacy-peer-deps

# Compile to JS and overlay onto source so the egg loader and tegg module
# scanner resolve .js at the expected paths (same approach as the cnpmcore
# job); the snapshot is built from the compiled app.
npm run tsc:prod
cp -r dist/* .
rm -rf dist

# The snapshot app runs under EGG_SERVER_ENV=unittest, which resolves
# the DB name to cnpmcore_unittest (config/database.ts), so only that
# database is needed here.
echo "Preparing database..."
mysql -h 127.0.0.1 -u root -e "CREATE DATABASE IF NOT EXISTS cnpmcore_unittest"
CNPMCORE_DATABASE_NAME=cnpmcore_unittest bash ./prepare-database-mysql.sh

# Build the V8 startup snapshot. Building works on Node.js >= 22 (CI runs
# on 24 here). leoric / @cnpmjs/packument / koa-onerror are kept external;
# the network stack stays lazy-external (no manual stubs) so the blob is
# restorable on Node.js >= 24.
npx egg-bin snapshot build \
--output ./dist-bundle \
--force-external leoric \
--force-external @cnpmjs/packument \
--force-external koa-onerror

# Restore the snapshot and verify it serves traffic. --no-sourcemap keeps
# `--import source-map-support` out of the `node --snapshot-blob` process
# (cnpmcore sets egg.typescript, which would otherwise auto-enable it).
npx egg-scripts start \
--snapshot-blob ./dist-bundle/snapshot.blob \
--port 7001 \
--no-sourcemap \
--daemon \
--title=egg-server-cnpmcore-snapshot

if bash ../wait-health.sh "http://127.0.0.1:7001/-/ping" /tmp/cnpmcore-snapshot-health-response 120 3; then
npx egg-scripts stop --title=egg-server-cnpmcore-snapshot
else
npx egg-scripts stop --title=egg-server-cnpmcore-snapshot || true
echo "Recent snapshot error logs (if any):"
cat dist-bundle/logs/cnpmcore/common-error.log 2>/dev/null || true
cat "${HOME}/logs/master-stderr.log" 2>/dev/null || true
exit 1
fi
- name: examples
node-version: 24
command: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ tegg/plugin/tegg/test/fixtures/apps/**/*.js
*.tgz

ecosystem-ci/cnpmcore
ecosystem-ci/cnpmcore-snapshot
ecosystem-ci/examples
pnpm-lock.yaml
.utoo.toml
Expand Down
9 changes: 6 additions & 3 deletions ecosystem-ci/patch-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ async function patchPackageJSON(filePath: string, overrides: Record<string, stri
fs.writeFileSync(filePath, packageJsonString);
}

async function patchCnpmcore(overrides: Record<string, string>): Promise<void> {
const packageJsonPath = join(projectDir, 'cnpmcore', 'package.json');
async function patchProjectRoot(projectName: string, overrides: Record<string, string>): Promise<void> {
const packageJsonPath = join(projectDir, projectName, 'package.json');
await patchPackageJSON(packageJsonPath, overrides);
}

Expand All @@ -111,7 +111,10 @@ async function main(): Promise<void> {

switch (project) {
case 'cnpmcore':
await patchCnpmcore(overrides);
// cnpmcore-snapshot is a second checkout of the same cnpmcore repo used by
// the V8 snapshot e2e job; it patches its own package.json the same way.
case 'cnpmcore-snapshot':
await patchProjectRoot(project, overrides);
break;
case 'examples':
await patchExamples(overrides);
Expand Down
5 changes: 5 additions & 0 deletions ecosystem-ci/repo.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"branch": "master",
"hash": "98463c33188bbd32513a74e6c3a2c4cc559ef5da"
},
"cnpmcore-snapshot": {
"repository": "https://github.com/cnpm/cnpmcore.git",
"branch": "master",
"hash": "98463c33188bbd32513a74e6c3a2c4cc559ef5da"
},
"examples": {
"repository": "https://github.com/eggjs/examples.git",
"branch": "master",
Expand Down
38 changes: 38 additions & 0 deletions ecosystem-ci/wait-health.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Poll an HTTP endpoint until it returns 200, or time out.
#
# Usage: wait-health.sh <url> <response-file> [timeout-seconds] [sleep-seconds]
# Exits 0 on the first HTTP 200; exits 1 on timeout (dumping the last response body).
#
# Shared by the cnpmcore and cnpmcore-snapshot e2e jobs in
# .github/workflows/e2e-test.yml. Deliberately does not `set -e` so the
# curl-poll loop controls flow; callers wrap it in `if wait-health.sh ...; then`.
set -uo pipefail

URL="$1"
RESPONSE_FILE="$2"
Comment on lines +17 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a6417bb — added an explicit [ "$#" -lt 2 ] usage check with a friendly message before the set -u positional reads.

TIMEOUT="${3:-120}"
SLEEP="${4:-5}"

START_TIME=$(date +%s)
echo "Waiting for ${URL} to return HTTP 200 (timeout: ${TIMEOUT}s)..."
while true; do
STATUS=$(curl -s -o "${RESPONSE_FILE}" -w "%{http_code}" "${URL}" || echo "000")
echo "Health check at $(date): status=${STATUS}"

if [ "${STATUS}" = "200" ]; then
echo "Health check succeeded with status 200"
exit 0
fi

NOW=$(date +%s)
ELAPSED=$((NOW - START_TIME))
if [ "${ELAPSED}" -ge "${TIMEOUT}" ]; then
echo "Health check timed out after ${ELAPSED}s with last status ${STATUS}"
echo "Last response body (if any):"
cat "${RESPONSE_FILE}" 2>/dev/null || echo "<no response body captured>"
exit 1
fi

sleep "${SLEEP}"
done
2 changes: 2 additions & 0 deletions site/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ function sidebarAdvanced(): DefaultTheme.SidebarItem[] {
{ text: 'Loader', link: 'loader' },
{ text: 'Plugin Development', link: 'plugin' },
{ text: 'Framework Development', link: 'framework' },
{ text: 'V8 Startup Snapshot', link: 'snapshot' },
],
},
];
Expand Down Expand Up @@ -404,6 +405,7 @@ function sidebarAdvancedZhCN(): DefaultTheme.SidebarItem[] {
{ text: 'View 插件开发', link: 'view-plugin' },
{ text: '升级你的生命周期事件函数', link: 'loader-update' },
{ text: '对象生命周期', link: 'lifecycle' },
{ text: 'V8 启动快照', link: 'snapshot' },
],
},
];
Expand Down
152 changes: 133 additions & 19 deletions site/docs/advanced/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,92 @@ order: 7

# V8 Startup Snapshot

Egg provides built-in helpers for building and restoring a V8 startup snapshot. This can reduce cold-start overhead for applications that need to preload framework metadata, plugins, services, routers, and tegg modules before serving traffic.
Egg can freeze a fully-loaded application into a [V8 startup snapshot](https://nodejs.org/api/cli.html#--build-snapshot)
so that cold start skips most of the boot work. Building a snapshot loads the
entire module graph — framework metadata, plugins, services, routers, and tegg
modules — and runs the lifecycle up to `configWillLoad`, then serializes that
heap into a blob. Restoring the blob resumes the remaining lifecycle (`didReady`)
and starts listening, so the process is ready to serve traffic almost
immediately.

This builds on [Bundle Deployment](../core/bundle.md): the snapshot is produced
from a single self-contained bundle, so a snapshot is essentially a pre-booted
bundle.

## Node.js version requirements

| Phase | Command | Node.js |
| -------------------- | ----------------------------------- | ------- |
| **Build** a snapshot | `egg-bin snapshot build` | >= 22 |
| **Restore** (run) | `egg-scripts start --snapshot-blob` | >= 24 |

::: warning Restoring requires Node.js >= 24
A snapshot can be **built** on Node.js >= 22, but **restoring** a non-trivial Egg
heap on Node.js 22 aborts the process during deserialization with a native fatal
error (`Check failed: current == end_slot_index`, a V8 bug). Always restore on
Node.js **>= 24**.

The supported launcher enforces this: `egg-scripts start --snapshot-blob` refuses
to launch on Node.js < 24 with a clear error before spawning anything. If you
bypass it and run `node --snapshot-blob` directly on Node.js 22, the process still
aborts mid-deserialization with the native fatal above — the snapshot's own
runtime guard can only print a friendly message on a runtime new enough to finish
deserializing but still below 24. So always restore through `egg-scripts` on
Node.js >= 24.
:::

## Build and restore with the CLI

The recommended workflow uses the bundler CLI. There is no `egg-bin snapshot
start`: building is a build-time concern (`egg-bin`), and restoring is a
production-runtime concern (`egg-scripts`).

### Build the blob

## Public APIs
```bash
# bundles the app in snapshot mode (single self-contained worker.js + prelude)
# and runs `node --snapshot-blob <blob> --build-snapshot worker.js` for you
$ egg-bin snapshot build
```

By default this writes the bundle to `./dist-bundle` and the blob to
`./dist-bundle/snapshot.blob`. Useful options:

| Option | Description |
| ------------------ | -------------------------------------------------------------- |
| `--output <dir>` | Bundle output directory (also where `worker.js` lives). |
| `--blob <path>` | Snapshot blob path. Defaults to `<output>/snapshot.blob`. |
| `--force-external` | Package to always keep external, repeatable (see Limitations). |
| `--skip-bundle` | Build the blob from an existing `worker.js` (skip bundling). |

Egg exports two public helpers from `egg`:
### Restore and serve

Start a process directly from the blob with `egg-scripts` (Node.js >= 24):

```bash
$ egg-scripts start --snapshot-blob ./dist-bundle/snapshot.blob --port 7001
```

This launches a single self-contained `node --snapshot-blob <blob>` process (no
egg-cluster, no framework resolution). The snapshot main reads the listen port
from `PORT` (or `--port`), runs `snapshotDidDeserialize` hooks, and calls
`app.listen()`.

## Programmatic API

For custom entry files, Egg also exports two helpers from `egg`:

```ts
import { buildSnapshot, restoreSnapshot } from 'egg';
```

- `buildSnapshot()` starts Egg in snapshot mode, loads metadata, triggers cleanup hooks for non-serializable resources, and registers the application object into the V8 snapshot payload.
- `restoreSnapshot()` restores the application from that payload, recreates runtime-only resources, and resumes the remaining Egg lifecycle.

## Build a Snapshot
- `buildSnapshot()` starts Egg in snapshot mode, loads metadata, triggers cleanup
hooks for non-serializable resources, and registers the application object into
the V8 snapshot payload.
- `restoreSnapshot()` restores the application from that payload, recreates
runtime-only resources, and resumes the remaining Egg lifecycle.

Create a snapshot entry file:
Build entry:

```ts
import { buildSnapshot } from 'egg';
Expand All @@ -30,17 +100,11 @@ await buildSnapshot({
});
```

Then build the blob with Node.js:

```bash
node --snapshot-blob=snapshot.blob --build-snapshot snapshot-entry.mjs
```

During snapshot build, Egg runs with `snapshot: true`. In this mode Egg loads application metadata but stops after `configWillLoad`, so hooks such as `configDidLoad`, `didLoad`, `willReady`, `didReady`, and `serverDidReady` are deferred until restore time.

## Restore a Snapshot

Start a process from the generated blob, then restore the application:
Restore entry (Node.js >= 24):

```ts
import { restoreSnapshot } from 'egg';
Expand All @@ -49,11 +113,30 @@ const app = await restoreSnapshot();
await app.listen(7001);
```

`restoreSnapshot()` resumes the normal startup flow from `configDidLoad` through `didReady`, so the returned app is ready for runtime setup such as creating servers or opening external connections.
`restoreSnapshot()` resumes the normal startup flow from `configDidLoad` through
`didReady`, so the returned app is ready for runtime setup such as creating
servers or opening external connections.

## How it works

## Snapshot Lifecycle Hooks
During snapshot build, Egg runs with `snapshot: true`. In this mode Egg loads
application metadata but **stops after `configWillLoad`**, so hooks such as
`configDidLoad`, `didLoad`, `willReady`, `didReady`, and `serverDidReady` are
deferred until restore time. Before the heap is serialized, Egg runs
`snapshotWillSerialize` hooks to release non-serializable resources (timers,
sockets, native handles, logger streams). The network stack (`node:http`,
`node:https`, TLS/DNS, the HTTP client) is kept **external and lazy** so its
non-serializable native bindings are never captured in the blob; they are
reconnected on first use after restore.

If your `app.js` or `agent.js` boot class manages resources that cannot be serialized into a V8 snapshot, implement these hooks:
At restore time V8 deserializes the heap, then the snapshot main runs
`snapshotDidDeserialize` hooks to recreate those runtime-only resources, finishes
the deferred lifecycle through `didReady`, and starts listening.

## Snapshot lifecycle hooks

If your `app.js` or `agent.js` boot class manages resources that cannot be
serialized into a V8 snapshot, implement these hooks:

```js
class AppBootHook {
Expand All @@ -72,4 +155,35 @@ module.exports = AppBootHook;
- `snapshotWillSerialize()` runs before the snapshot is written.
- `snapshotDidDeserialize()` runs after the process starts from the snapshot.

Use these hooks for resources such as timers, sockets, process listeners, loggers, or other handles that must be recreated in a live runtime.
Use these hooks for resources such as timers, sockets, process listeners,
loggers, or other handles that must be recreated in a live runtime.

## Performance

Because the module graph is already loaded and the app is booted up to
`configWillLoad`, restore only pays for `didReady` and connecting/listening.
Measured on [cnpmcore](https://github.com/cnpm/cnpmcore):

| Boot mode | Restore → listening |
| ------------------ | -------------------- |
| Normal bundle boot | ~942 ms |
| Snapshot restore | ~233 ms (~4x faster) |

The win grows with the size of the module graph (plugins, tegg modules, routers),
which is exactly the cost a snapshot front-loads into build time.

## Known limitations

- **Restore requires Node.js >= 24** (see above).
- **Single process only**: the snapshot runs as one self-contained process
(`mode: 'single'`), like a bundle. Cluster mode is not supported.
- **Native addons are external** and must be present in the deploy target.
- **Third-party dependencies are constrained**: any dependency that opens live
resources or captures non-serializable state at module-evaluation time (open
sockets, native HTTP/2 bindings, background timers, file handles) must either
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.

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.
Loading
Loading