diff --git a/packages/core/src/egg.ts b/packages/core/src/egg.ts index 2596923430..c7cbe18e88 100644 --- a/packages/core/src/egg.ts +++ b/packages/core/src/egg.ts @@ -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. + */ + snapshot?: boolean; } export type EggCoreInitOptions = Partial; @@ -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)); diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index e2e4c8bd8d..3facbe3eb5 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -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. + */ + snapshot?: boolean; } export type FunWithFullPath = Fun & { fullPath?: string }; @@ -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'); @@ -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(); } diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts new file mode 100644 index 0000000000..e584046f7a --- /dev/null +++ b/packages/core/test/snapshot.test.ts @@ -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 { + callOrder.push('didLoad'); + } + + async willReady(): Promise { + callOrder.push('willReady'); + } + + async didReady(): Promise { + callOrder.push('didReady'); + } + + async serverDidReady(): Promise { + 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 { + callOrder.push('didLoad'); + } + + async willReady(): Promise { + callOrder.push('willReady'); + } + + async didReady(): Promise { + 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 { + 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 { + 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 { + callOrder.push('didLoad'); + } + + async willReady(): Promise { + callOrder.push('willReady'); + } + + async didReady(): Promise { + 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'); + }); + }); +});