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
23 changes: 17 additions & 6 deletions packages/koa/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Emitter from 'node:events';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import Stream from 'node:stream';
import util, { debuglog } from 'node:util';
import v8 from 'node:v8';

import { getAsyncLocalStorage } from 'gals';
import { HttpError } from 'http-errors';
Expand Down Expand Up @@ -48,7 +49,7 @@ export class Application extends Emitter {
maxIpsCount: number;
protected _keys?: string[];
middleware: MiddlewareFunc<Context>[];
ctxStorage: AsyncLocalStorage<Context>;
ctxStorage: AsyncLocalStorage<Context> | null;
silent: boolean;
ContextClass: ProtoImplClass<Context>;
context: AnyProto;
Expand Down Expand Up @@ -88,7 +89,14 @@ export class Application extends Emitter {
this._keys = options.keys;
}
this.middleware = [];
this.ctxStorage = getAsyncLocalStorage();
if (v8.startupSnapshot?.isBuildingSnapshot?.()) {
this.ctxStorage = null;
v8.startupSnapshot.addDeserializeCallback((app: Application) => {
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

While v8.startupSnapshot is checked on line 92, it is safer to use optional chaining or verify the existence of addDeserializeCallback before calling it. This API is environment-dependent and may not be fully present in all Node.js versions or environments where the code might run.

Suggested change
v8.startupSnapshot.addDeserializeCallback((app: Application) => {
v8.startupSnapshot.addDeserializeCallback?.((app: Application) => {

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.

Won't fix. Line 92 already guards with v8.startupSnapshot?.isBuildingSnapshot?.() — if this returns true, the full snapshot API (including addDeserializeCallback) is guaranteed to be present per the Node.js docs. Adding ?. here would silently swallow errors if the API were somehow incomplete, which would be a worse failure mode: ctxStorage would stay null forever with no error, causing subtle runtime issues. Failing fast is the safer behavior.

app.ctxStorage = getAsyncLocalStorage();
}, this);
} else {
this.ctxStorage = getAsyncLocalStorage();
}
this.silent = false;
this.ContextClass = class ApplicationContext extends Context {} as ProtoImplClass<Context>;
this.context = this.ContextClass.prototype;
Expand Down Expand Up @@ -184,9 +192,12 @@ export class Application extends Emitter {

const handleRequest = (req: IncomingMessage, res: ServerResponse) => {
const ctx = this.createContext(req, res);
return this.ctxStorage.run(ctx, async () => {
return await this.handleRequest(ctx, fn);
});
if (this.ctxStorage) {
return this.ctxStorage.run(ctx, async () => {
return await this.handleRequest(ctx, fn);
});
}
return this.handleRequest(ctx, fn);
};

return handleRequest;
Expand All @@ -196,7 +207,7 @@ export class Application extends Emitter {
* return current context from async local storage
*/
get currentContext(): Context | undefined {
return this.ctxStorage.getStore();
return this.ctxStorage?.getStore();
}

/**
Expand Down
94 changes: 94 additions & 0 deletions packages/koa/test/application/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
import v8 from 'node:v8';

import { request } from '@eggjs/supertest';
import { afterEach, describe, it } from 'vitest';

import Koa from '../../src/index.ts';

describe('v8 startup snapshot', () => {
const originalStartupSnapshot = v8.startupSnapshot;

afterEach(() => {
(v8 as Record<string, unknown>).startupSnapshot = originalStartupSnapshot;
});

it('should defer AsyncLocalStorage creation when building snapshot', () => {
let deserializeCallback: { cb: (data: unknown) => void; data: unknown } | undefined;
(v8 as Record<string, unknown>).startupSnapshot = {
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.

high

Mutating properties of the v8 module directly (e.g., v8.startupSnapshot = ...) is likely to fail in standard Node.js environments because these properties are typically read-only or defined as getters on the module object. This can lead to TypeError at runtime. It is recommended to use Vitest's mocking utilities like vi.spyOn or vi.mock to simulate the v8.startupSnapshot behavior safely.

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.

Thanks for the suggestion. The direct mutation via (v8 as Record<string, unknown>).startupSnapshot = {...} is intentional here — v8.startupSnapshot is writable at runtime in Node.js (it's not defined as a getter). This pattern is simpler and more explicit than vi.mock('node:v8') which would mock the entire module and require more complex setup. The tests pass on all platforms, confirming the property is writable. Won't change this.

isBuildingSnapshot: () => true,
addDeserializeCallback: (cb: (data: unknown) => void, data: unknown) => {
deserializeCallback = { cb, data };
},
};

const app = new Koa();
assert.strictEqual(app.ctxStorage, null);
assert.ok(deserializeCallback, 'deserialize callback should be registered');

// simulate snapshot deserialization
deserializeCallback.cb(deserializeCallback.data);
assert.ok(app.ctxStorage! instanceof AsyncLocalStorage);
});

it('should return undefined for currentContext when ctxStorage is null', () => {
(v8 as Record<string, unknown>).startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: () => {},
};

const app = new Koa();
assert.strictEqual(app.ctxStorage, null);
assert.strictEqual(app.currentContext, undefined);
});

it('should work normally after deserialization', async () => {
let deserializeCallback: { cb: (data: unknown) => void; data: unknown } | undefined;
(v8 as Record<string, unknown>).startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: (cb: (data: unknown) => void, data: unknown) => {
deserializeCallback = { cb, data };
},
};

const app = new Koa();

// simulate snapshot deserialization
deserializeCallback!.cb(deserializeCallback!.data);

app.use(async (ctx) => {
assert.equal(ctx, app.currentContext);
ctx.body = 'ok';
});

await request(app.callback()).get('/').expect('ok');
assert.strictEqual(app.currentContext, undefined);
});

it('should not defer when not building snapshot', () => {
(v8 as Record<string, unknown>).startupSnapshot = {
isBuildingSnapshot: () => false,
};

const app = new Koa();
assert.ok(app.ctxStorage! instanceof AsyncLocalStorage);
});

it('should handle callback without ctxStorage during snapshot build', async () => {
(v8 as Record<string, unknown>).startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: () => {},
};

const app = new Koa();
assert.strictEqual(app.ctxStorage, null);

app.use(async (ctx) => {
assert.strictEqual(app.currentContext, undefined);
ctx.body = 'ok';
});

await request(app.callback()).get('/').expect('ok');
});
});
Loading