diff --git a/package-lock.json b/package-lock.json index 4cdf36f..97023e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", "@gravity-ui/eslint-config": "^3.2.0", - "@gravity-ui/nodekit": "^2.7.0", + "@gravity-ui/nodekit": "^2.10.1", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", "@types/accept-language-parser": "^1.5.6", @@ -1050,9 +1050,9 @@ } }, "node_modules/@gravity-ui/nodekit": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-2.7.0.tgz", - "integrity": "sha512-+mAHg9OXbSkXE2FeEaEwl9FlXhvMP35J2w/PLa4MJWMa5A1VyA70QCNWZXU/vxCVzkir6soEZ1/IzpZyXPPpPg==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-2.10.1.tgz", + "integrity": "sha512-45ZB12pZcsIYFbMYpd0ftuWMf3/35JQOZFDg+K1uxtDaEzaEIsDMUkI8LaUQs8BA5OmAQ9lZTuWonfNz7Wxf0Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ddb9ae7..8c98294 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", "@gravity-ui/eslint-config": "^3.2.0", - "@gravity-ui/nodekit": "^2.7.0", + "@gravity-ui/nodekit": "^2.10.1", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", "@types/accept-language-parser": "^1.5.6", diff --git a/src/router.ts b/src/router.ts index 846bbfe..dc0637d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -23,6 +23,11 @@ function isAllowedMethod(method: string): method is Lowercase | 'mou function wrapMiddleware(fn: AppMiddleware, i?: number): AppMiddleware { const result: AppMiddleware = async (req, res, next) => { const reqCtx = req.ctx; + // Skip creating child context if parent is already ended (e.g. client disconnected). + // Optional chaining for backward compatibility with nodekit < 2.5.0 (no abortSignal). + if (reqCtx.abortSignal?.aborted) { + return next(); + } const ctx = reqCtx.create(`${fn.name || `noname-${i}`} middleware`); let ended = false; @@ -54,6 +59,11 @@ function wrapRouteHandler(fn: AppRouteHandler, handlerName?: string) { const handlerNameLocal = handlerName || fn.name || UNNAMED_CONTROLLER; const handler: AppMiddleware = async (req, res, next) => { + // Skip creating child context if parent is already ended (e.g. client disconnected). + // Optional chaining for backward compatibility with nodekit < 2.5.0 (no abortSignal). + if (req.originalContext.abortSignal?.aborted) { + return; + } req.ctx = req.originalContext.create(handlerNameLocal); if (req.routeInfo.handlerName !== handlerNameLocal) { if (req.routeInfo.handlerName === UNNAMED_CONTROLLER) { diff --git a/src/tests/context-lifecycle.test.ts b/src/tests/context-lifecycle.test.ts index 0e13e00..b2d2b54 100644 --- a/src/tests/context-lifecycle.test.ts +++ b/src/tests/context-lifecycle.test.ts @@ -326,4 +326,76 @@ describe('Context Lifecycle', () => { expect(middlewareOriginalCtx!.abortSignal.aborted).toBe(true); }); }); + + describe('Client Disconnect Handling', () => { + it('should skip next middleware when context is already ended', async () => { + let slowMiddlewareCalled = false; + let nextMiddlewareCalled = false; + + const slowMiddleware = async (req: Request, _res: Response, next: NextFunction) => { + slowMiddlewareCalled = true; + // Simulate client disconnect by destroying the socket + req.socket.destroy(); + // Wait for the close event handler's setImmediate to end the context + await new Promise((resolve) => { + setImmediate(() => setImmediate(() => setImmediate(resolve))); + }); + next(); + }; + + const nextMiddleware = (_req: Request, _res: Response, next: NextFunction) => { + nextMiddlewareCalled = true; + next(); + }; + + const nodekit = new NodeKit(); + const app = new ExpressKit(nodekit, { + 'GET /test': { + beforeAuth: [slowMiddleware, nextMiddleware], + handler: (_req: Request, res: Response) => { + res.json({ok: true}); + }, + }, + }); + + await request + .agent(app.express) + .get('/test') + .catch(() => {}); + + expect(slowMiddlewareCalled).toBe(true); + expect(nextMiddlewareCalled).toBe(false); + }); + + it('should not throw when route handler runs after client disconnect', async () => { + const disconnectMiddleware = async ( + req: Request, + _res: Response, + next: NextFunction, + ) => { + // Simulate client disconnect by destroying the socket + req.socket.destroy(); + // Wait for the close event handler's setImmediate to end the context + await new Promise((resolve) => { + setImmediate(() => setImmediate(() => setImmediate(resolve))); + }); + next(); + }; + + const nodekit = new NodeKit(); + const app = new ExpressKit(nodekit, { + 'GET /test': { + beforeAuth: [disconnectMiddleware], + handler: (_req: Request, res: Response) => { + res.json({ok: true}); + }, + }, + }); + + await request + .agent(app.express) + .get('/test') + .catch(() => {}); + }); + }); });