From 388ffbb7355c58827431577e6193910628928b7e Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:33:50 -0400 Subject: [PATCH 1/2] feat: abort request.signal when the response closes prematurely --- .changeset/pre/get-request-response-close.md | 6 +++ packages/adapter-node/src/handler.js | 1 + packages/kit/src/exports/node/index.js | 11 +++++- packages/kit/src/exports/node/index.spec.js | 37 ++++++++++++++++++- packages/kit/src/exports/vite/dev/index.js | 3 +- .../kit/src/exports/vite/preview/index.js | 3 +- packages/kit/types/index.d.ts | 3 +- 7 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 .changeset/pre/get-request-response-close.md diff --git a/.changeset/pre/get-request-response-close.md b/.changeset/pre/get-request-response-close.md new file mode 100644 index 000000000000..318e3c8aa801 --- /dev/null +++ b/.changeset/pre/get-request-response-close.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/kit': minor +'@sveltejs/adapter-node': patch +--- + +feat: abort `request.signal` when the response closes prematurely, via a new `response` option for `getRequest` diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index ae424dc55fbf..f79534e3dcad 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -134,6 +134,7 @@ const ssr = async (req, res) => { request = getRequest({ base: request_origin, request: req, + response: res, bodySizeLimit: body_size_limit }); } catch { diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index 52814bb3d893..c2b4137b6289 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -116,12 +116,13 @@ function get_raw_body(req, body_size_limit) { /** * @param {{ * request: import('http').IncomingMessage; + * response?: import('http').ServerResponse; * base: string; * bodySizeLimit?: number; * }} options * @returns {Request} */ -export function getRequest({ request, base, bodySizeLimit }) { +export function getRequest({ request, response, base, bodySizeLimit }) { let headers = /** @type {Record} */ (request.headers); if (request.httpVersionMajor >= 2) { // the Request constructor rejects headers with ':' in the name @@ -144,6 +145,14 @@ export function getRequest({ request, base, bodySizeLimit }) { } }); + // `readableAborted` stays false once the request has been fully read (or drained), + // so a client disconnect must also be detected on the response side + response?.once('close', () => { + if (!response.writableFinished) { + controller.abort(); + } + }); + return new Request(base + request.url, { // @ts-expect-error duplex: 'half', diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index 5fa0f52cb20e..8441c59304c5 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -103,8 +103,9 @@ function create_response(req) { /** * @param {Record} [headers] * @param {import('stream').PassThrough} [stream] + * @param {import('http').ServerResponse} [response] */ -function setup_post_request(headers = {}, stream) { +function setup_post_request(headers = {}, stream, response) { const req = stream ?? new PassThrough(); const incoming = /** @type {import('http').IncomingMessage} */ (/** @type {unknown} */ (req)); incoming.headers = { @@ -115,7 +116,7 @@ function setup_post_request(headers = {}, stream) { incoming.url = '/'; incoming.httpVersionMajor = 1; - const request = getRequest({ request: incoming, base: 'http://localhost' }); + const request = getRequest({ request: incoming, response, base: 'http://localhost' }); return { req, incoming, request }; } @@ -213,6 +214,38 @@ test('does not remove unrelated data listeners when draining', async () => { expect(unrelated).toHaveBeenCalled(); }); +// https://github.com/sveltejs/kit/issues/16778 +test('aborts the request signal when the response closes before finishing', async () => { + const res = /** @type {any} */ (new EventEmitter()); + res.writableFinished = false; + + const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res); + + // fully read the body, so the request stream can no longer report the disconnect + req.write(Buffer.from('0123456789')); + req.end(); + await request.text(); + + res.emit('close'); + + expect(request.signal.aborted).toBe(true); +}); + +test('does not abort the request signal when the response finishes normally', async () => { + const res = /** @type {any} */ (new EventEmitter()); + res.writableFinished = true; + + const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res); + + req.write(Buffer.from('0123456789')); + req.end(); + await request.text(); + + res.emit('close'); + + expect(request.signal.aborted).toBe(false); +}); + // Test for fix of CVE-2026-40073 test('requests with no content-length and no transfer-encoding return null body', async () => { const { request, req } = create_request({ diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 6057b70cb612..38b295be9e99 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -593,7 +593,8 @@ export async function dev( const request = getRequest({ base, - request: req + request: req, + response: res }); if (manifest_error) { diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 37524cab3604..c16b7ef2e8cf 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -206,7 +206,8 @@ export async function preview(vite, vite_config, svelte_config) { const request = getRequest({ base: `${protocol}://${host}`, - request: req + request: req, + response: res }); setResponse( diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 2694326106bc..b8a0c2cf11e7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1418,8 +1418,9 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { - export function getRequest({ request, base, bodySizeLimit }: { + export function getRequest({ request, response, base, bodySizeLimit }: { request: import("http").IncomingMessage; + response?: import("http").ServerResponse; base: string; bodySizeLimit?: number; }): Request; From c984d078ba52ece748ffdb3c2953efa2c34efc31 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:10:38 -0400 Subject: [PATCH 2/2] use writableEnded, not writableFinished: h2 marks cancelled streams finished --- packages/kit/src/exports/node/index.js | 5 +++-- packages/kit/src/exports/node/index.spec.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index c2b4137b6289..1720d73f25a7 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -146,9 +146,10 @@ export function getRequest({ request, response, base, bodySizeLimit }) { }); // `readableAborted` stays false once the request has been fully read (or drained), - // so a client disconnect must also be detected on the response side + // so a client disconnect must also be detected on the response side. `writableEnded` + // rather than `writableFinished` because HTTP/2 marks cancelled streams as finished response?.once('close', () => { - if (!response.writableFinished) { + if (!response.writableEnded) { controller.abort(); } }); diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index 8441c59304c5..aa9f04224afd 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -217,7 +217,7 @@ test('does not remove unrelated data listeners when draining', async () => { // https://github.com/sveltejs/kit/issues/16778 test('aborts the request signal when the response closes before finishing', async () => { const res = /** @type {any} */ (new EventEmitter()); - res.writableFinished = false; + res.writableEnded = false; const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res); @@ -233,7 +233,7 @@ test('aborts the request signal when the response closes before finishing', asyn test('does not abort the request signal when the response finishes normally', async () => { const res = /** @type {any} */ (new EventEmitter()); - res.writableFinished = true; + res.writableEnded = true; const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res);