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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

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

issue(blocking): You should up nodekit in peer dependencies because you use isEnded function

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it would be better to add some fallback to not make a major release because of upgrading peer deps

Copy link
Contributor Author

@melikhov-dev melikhov-dev Mar 2, 2026

Choose a reason for hiding this comment

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

issue(blocking): You should up nodekit in peer dependencies because you use isEnded function

Switched to reqCtx.abortSignal.aborted for backward compatibility.

Copy link
Contributor

Choose a reason for hiding this comment

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

issue(blocking): You should up nodekit in peer dependencies because you use isEnded function

Switched to reqCtx.abortSignal.aborted for backward compatibility.

But abortSignal was added in the 2.5.0 version of nodekit, and we support ^1.6.0 || ^2.0.0 in expresskit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But abortSignal was added in the 2.5.0 version of nodekit, and we support ^1.6.0 || ^2.0.0 in expresskit.

Good catch! Added optional chaining (abortSignal?.aborted) so it won't break on nodekit < 2.5.0. The guard simply won't activate, preserving the old behavior.

"@gravity-ui/prettier-config": "^1.1.0",
"@gravity-ui/tsconfig": "^1.0.0",
"@types/accept-language-parser": "^1.5.6",
Expand Down
10 changes: 10 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@
}

function wrapMiddleware(fn: AppMiddleware, i?: number): AppMiddleware {
const result: AppMiddleware = async (req, res, next) => {

Check warning on line 24 in src/router.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Expected to return a value at the end of async arrow function
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;
Expand All @@ -40,7 +45,7 @@
ctx.fail(error);
req.ctx = reqCtx;
next(error);
return;

Check warning on line 48 in src/router.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Async arrow function expected a return value
}
}
};
Expand All @@ -54,6 +59,11 @@
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) {
Expand Down
72 changes: 72 additions & 0 deletions src/tests/context-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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(() => {});
});
});
});
Loading