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..1720d73f25a7 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,15 @@ 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. `writableEnded` + // rather than `writableFinished` because HTTP/2 marks cancelled streams as finished + response?.once('close', () => { + if (!response.writableEnded) { + 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..aa9f04224afd 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.writableEnded = 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.writableEnded = 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;