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
9 changes: 9 additions & 0 deletions packages/core/src/egg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export interface EggCoreOptions {
env?: string;
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
metadataOnly?: boolean;
/**
* When true, lifecycle stops after the `configWillLoad` phase.
* `configDidLoad`, `didLoad`, `willReady`, `didReady`, and `serverDidReady`
* are skipped. Used for V8 startup snapshot construction — SDKs typically
* execute during `configDidLoad`, opening connections and starting timers
* which are not serializable. Analogous to `metadataOnly` mode.
Comment on lines +39 to +41
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Snapshot mode skips configDidLoad, which also prevents Boot.beforeClose() hooks from being registered (they’re registered during the configDidLoad loop). Since this option is public API on EggCoreOptions, consider mentioning this in the JSDoc so callers don’t assume app.close() will trigger boot-class beforeClose hooks in snapshot mode.

Suggested change
* are skipped. Used for V8 startup snapshot construction SDKs typically
* execute during `configDidLoad`, opening connections and starting timers
* which are not serializable. Analogous to `metadataOnly` mode.
* are skipped. Because `configDidLoad` is not run, Boot hooks registered
* there (including `beforeClose`) are never set up, and `app.close()` will
* therefore NOT invoke Boot `beforeClose` hooks in snapshot mode.
* Used for V8 startup snapshot construction SDKs typically execute during
* `configDidLoad`, opening connections and starting timers which are not
* serializable. Analogous to `metadataOnly` mode.

Copilot uses AI. Check for mistakes.
*/
snapshot?: boolean;
}

export type EggCoreInitOptions = Partial<EggCoreOptions>;
Expand Down Expand Up @@ -191,6 +199,7 @@ export class EggCore extends KoaApplication {
baseDir: options.baseDir,
app: this,
logger: this.console,
snapshot: options.snapshot,
});
this.lifecycle.on('error', (err) => this.emit('error', err));
this.lifecycle.on('ready_timeout', (id) => this.emit('ready_timeout', id));
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export interface LifecycleOptions {
baseDir: string;
app: EggCore;
logger: EggConsoleLogger;
/**
* When true, the lifecycle stops after configWillLoad phase completes.
* configDidLoad, didLoad, willReady, didReady, and serverDidReady hooks
* are NOT called. Used for V8 startup snapshot construction — SDKs
* typically execute during configDidLoad, opening connections and starting
* timers which are not serializable. The handling is analogous to
* metadataOnly mode: both short-circuit the lifecycle chain early.
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

In snapshot mode configDidLoad is skipped, which also means Boot.beforeClose() hooks are never registered (registration happens during triggerConfigDidLoad). The doc comment lists skipped startup hooks but doesn’t mention this shutdown implication; consider documenting that beforeClose hooks defined on boot classes won’t run automatically in snapshot mode (unless callers register close hooks manually).

Suggested change
* metadataOnly mode: both short-circuit the lifecycle chain early.
* metadataOnly mode: both short-circuit the lifecycle chain early.
*
* Note: because configDidLoad is skipped, Boot.beforeClose() hooks defined
* on boot classes (which are normally registered during triggerConfigDidLoad)
* are not registered and therefore will not run automatically in snapshot
* mode. If you need shutdown logic in snapshot mode, register close hooks
* manually on the application instance.

Copilot uses AI. Check for mistakes.
*/
Comment on lines +72 to +79
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

The PR description says snapshot mode “stops after didLoad”, but the implementation (and this JSDoc) now short-circuits immediately after configWillLoad (before configDidLoad). Please update the PR description (or rename/clarify the feature wording) so reviewers/users don’t get conflicting semantics.

Copilot uses AI. Check for mistakes.
snapshot?: boolean;
}

export type FunWithFullPath = Fun & { fullPath?: string };
Expand Down Expand Up @@ -119,7 +128,7 @@ export class Lifecycle extends EventEmitter {
});

this.ready((err) => {
if (!this.#metadataOnly) {
if (!this.#metadataOnly && !this.options.snapshot) {
void this.triggerDidReady(err);
}
debug('app ready');
Expand Down Expand Up @@ -251,6 +260,14 @@ export class Lifecycle extends EventEmitter {
}
}
debug('trigger configWillLoad end');
if (this.options.snapshot) {
// Snapshot mode: stop AFTER configWillLoad, BEFORE configDidLoad.
// SDKs typically execute during configDidLoad hooks — these open connections
// and start timers which are not serializable in V8 startup snapshots.
debug('snapshot mode: stopping after configWillLoad, skipping configDidLoad and later phases');
this.ready(true);
return;
}
this.triggerConfigDidLoad();
}

Expand Down
246 changes: 246 additions & 0 deletions packages/core/test/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { strict as assert } from 'node:assert';

import { describe, it, afterEach } from 'vitest';

import { EggCore } from '../src/egg.ts';
import { Lifecycle } from '../src/lifecycle.ts';

describe('test/snapshot.test.ts', () => {
let app: EggCore | undefined;

afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
});

describe('Lifecycle snapshot mode', () => {
it('should stop after configWillLoad and skip configDidLoad/didLoad/willReady/didReady/serverDidReady', async () => {
const callOrder: string[] = [];

const lifecycle = new Lifecycle({
baseDir: '.',
app: new EggCore(),
snapshot: true,
});

lifecycle.addBootHook(
class Boot {
configWillLoad(): void {
callOrder.push('configWillLoad');
}

configDidLoad(): void {
callOrder.push('configDidLoad');
}

async didLoad(): Promise<void> {
callOrder.push('didLoad');
}

async willReady(): Promise<void> {
callOrder.push('willReady');
}

async didReady(): Promise<void> {
callOrder.push('didReady');
}

async serverDidReady(): Promise<void> {
callOrder.push('serverDidReady');
}
},
);

lifecycle.init();
lifecycle.triggerConfigWillLoad();
await lifecycle.ready();

// configWillLoad should be called
assert.ok(callOrder.includes('configWillLoad'), 'configWillLoad should be called');

// configDidLoad and all later hooks should NOT be called
assert.ok(!callOrder.includes('configDidLoad'), 'configDidLoad should NOT be called in snapshot mode');
assert.ok(!callOrder.includes('didLoad'), 'didLoad should NOT be called in snapshot mode');
assert.ok(!callOrder.includes('willReady'), 'willReady should NOT be called in snapshot mode');
assert.ok(!callOrder.includes('didReady'), 'didReady should NOT be called in snapshot mode');
assert.ok(!callOrder.includes('serverDidReady'), 'serverDidReady should NOT be called in snapshot mode');

await lifecycle.close();
});

it('should call all lifecycle hooks when snapshot is not set', async () => {
const callOrder: string[] = [];

const lifecycle = new Lifecycle({
baseDir: '.',
app: new EggCore(),
// no snapshot option
});

lifecycle.addBootHook(
class Boot {
configWillLoad(): void {
callOrder.push('configWillLoad');
}

configDidLoad(): void {
callOrder.push('configDidLoad');
}

async didLoad(): Promise<void> {
callOrder.push('didLoad');
}

async willReady(): Promise<void> {
callOrder.push('willReady');
}

async didReady(): Promise<void> {
callOrder.push('didReady');
}
},
);

lifecycle.init();
lifecycle.triggerConfigWillLoad();
await lifecycle.ready();

// All hooks should be called in normal mode
assert.ok(callOrder.includes('configWillLoad'), 'configWillLoad should be called');
assert.ok(callOrder.includes('configDidLoad'), 'configDidLoad should be called');
assert.ok(callOrder.includes('didLoad'), 'didLoad should be called');
assert.ok(callOrder.includes('willReady'), 'willReady should be called in normal mode');
assert.ok(callOrder.includes('didReady'), 'didReady should be called in normal mode');

await lifecycle.close();
});

it('should mark ready immediately after configWillLoad in snapshot mode', async () => {
let configWillLoadCompleted = false;
let configDidLoadCalled = false;
let didLoadCalled = false;

const lifecycle = new Lifecycle({
baseDir: '.',
app: new EggCore(),
snapshot: true,
});

lifecycle.addBootHook(
class Boot {
configWillLoad(): void {
configWillLoadCompleted = true;
}

configDidLoad(): void {
configDidLoadCalled = true;
}

async didLoad(): Promise<void> {
didLoadCalled = true;
}
},
);

lifecycle.init();
lifecycle.triggerConfigWillLoad();
await lifecycle.ready();

assert.ok(configWillLoadCompleted, 'configWillLoad should have completed');
assert.ok(!configDidLoadCalled, 'configDidLoad should NOT be called in snapshot mode');
assert.ok(!didLoadCalled, 'didLoad should NOT be called in snapshot mode');

await lifecycle.close();
});

it('should not register beforeClose hooks in snapshot mode (configDidLoad skipped)', async () => {
let beforeCloseCalled = false;

const lifecycle = new Lifecycle({
baseDir: '.',
app: new EggCore(),
snapshot: true,
});

lifecycle.addBootHook(
class Boot {
configWillLoad(): void {
// configWillLoad runs in snapshot mode
}

configDidLoad(): void {
// configDidLoad is skipped in snapshot mode
}

async beforeClose(): Promise<void> {
beforeCloseCalled = true;
}
},
);

lifecycle.init();
lifecycle.triggerConfigWillLoad();
await lifecycle.ready();
await lifecycle.close();

// beforeClose is registered during configDidLoad iteration, which is skipped
assert.ok(!beforeCloseCalled, 'beforeClose should NOT be called since configDidLoad is skipped');
});
});

describe('EggCore snapshot option', () => {
it('should pass snapshot option to lifecycle', () => {
app = new EggCore({ snapshot: true });
assert.equal(app.options.snapshot, true);
assert.equal(app.lifecycle.options.snapshot, true);
});

it('should not set snapshot by default', () => {
app = new EggCore();
assert.equal(app.options.snapshot, undefined);
assert.equal(app.lifecycle.options.snapshot, undefined);
});

it('should become ready after configWillLoad in snapshot mode (EggCore level)', async () => {
const callOrder: string[] = [];

app = new EggCore({ snapshot: true });

app.lifecycle.addBootHook(
class Boot {
configWillLoad(): void {
callOrder.push('configWillLoad');
}

configDidLoad(): void {
callOrder.push('configDidLoad');
}

async didLoad(): Promise<void> {
callOrder.push('didLoad');
}

async willReady(): Promise<void> {
callOrder.push('willReady');
}

async didReady(): Promise<void> {
callOrder.push('didReady');
}
},
);

app.lifecycle.init();
app.lifecycle.triggerConfigWillLoad();
await app.ready();

assert.ok(callOrder.includes('configWillLoad'), 'configWillLoad should be called');
assert.ok(!callOrder.includes('configDidLoad'), 'configDidLoad should NOT be called');
assert.ok(!callOrder.includes('didLoad'), 'didLoad should NOT be called');
assert.ok(!callOrder.includes('willReady'), 'willReady should NOT be called');
assert.ok(!callOrder.includes('didReady'), 'didReady should NOT be called');
});
});
});
Loading