From 6ce4a9f9d2a93b216f174287e92bb9b880d0d473 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:58:39 -0400 Subject: [PATCH 01/10] compute content-length in setResponse instead of the json and text helpers --- .changeset/pre/set-response-string-bodies.md | 5 ++ packages/kit/src/exports/index.js | 41 ++++-------- packages/kit/src/exports/node/index.js | 17 +++++ packages/kit/src/exports/node/index.spec.js | 65 ++++++++++++++++++-- packages/kit/types/index.d.ts | 4 +- 5 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 .changeset/pre/set-response-string-bodies.md diff --git a/.changeset/pre/set-response-string-bodies.md b/.changeset/pre/set-response-string-bodies.md new file mode 100644 index 000000000000..d8a955c2def2 --- /dev/null +++ b/.changeset/pre/set-response-string-bodies.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: compute `content-length` in `setResponse` instead of the `json` and `text` helpers diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index e670ee5e8882..740a0d1859f1 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -11,7 +11,9 @@ import { } from '../pathname.js'; import { validate_redirect_location } from './url.js'; -const text_encoder = new TextEncoder(); +// `Symbol.for` because the app's bundled copy of this module must be visible to +// the `@sveltejs/kit/node` copy resolved from node_modules +const string_body = Symbol.for('sveltekit.string_body'); export { VERSION } from '../version.js'; @@ -153,51 +155,30 @@ export function isRedirect(e) { /** * Create a JSON `Response` object from the supplied data. * @param {any} data The value that will be serialized as JSON. - * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. `Content-Type: application/json` and `Content-Length` headers will be added automatically. + * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. A `Content-Type: application/json` header will be added automatically. * @deprecated use `Response.json` */ export function json(data, init) { - const body = JSON.stringify(data); - - // we can't just do `text(JSON.stringify(data), init)` because - // it will set a default `content-type` header. duplicated code - // means less duplicated work const headers = new Headers(init?.headers); - if (!headers.has('content-length')) { - headers.set('content-length', text_encoder.encode(body).byteLength.toString()); - } - if (!headers.has('content-type')) { headers.set('content-type', 'application/json'); } - return new Response(body, { - ...init, - headers - }); + return text(JSON.stringify(data), { ...init, headers }); } /** * Create a `Response` object from the supplied body. * @param {string} body The value that will be used as-is. - * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. A `Content-Length` header will be added automatically. + * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. * @deprecated use `new Response` */ export function text(body, init) { - const headers = new Headers(init?.headers); - if (!headers.has('content-length')) { - const encoded = text_encoder.encode(body); - headers.set('content-length', encoded.byteLength.toString()); - return new Response(encoded, { - ...init, - headers - }); - } - - return new Response(body, { - ...init, - headers - }); + const response = new Response(body, init); + // stash the string so `setResponse` can send it with a content-length + // instead of streaming it + /** @type {any} */ (response)[string_body] = body; + return response; } /** diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index 1720d73f25a7..9fb7443a20ae 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -6,6 +6,11 @@ import { noop } from '../../utils/functions.js'; /** @type {WeakMap void>} */ const body_data_listeners = new WeakMap(); +// set by the `json` and `text` helpers. `Symbol.for` because the helpers live +// in the app's bundled copy of the package while this module is resolved from +// node_modules +const string_body = Symbol.for('sveltekit.string_body'); + /** * @param {import('http').IncomingMessage} req * @param {number} [body_size_limit] @@ -224,6 +229,18 @@ export function setResponse(res, response) { } } + const body = /** @type {any} */ (response)[string_body]; + + if (typeof body === 'string' && !response.body?.locked) { + if (!res.hasHeader('content-length')) { + res.setHeader('content-length', Buffer.byteLength(body)); + } + + res.writeHead(response.status); + res.end(body); + return; + } + res.writeHead(response.status); if (!response.body) { diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index aa9f04224afd..1fa43303fd84 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -2,6 +2,7 @@ import { EventEmitter, once } from 'node:events'; import { PassThrough } from 'node:stream'; import { expect, test, vi } from 'vitest'; import { getRequest, setResponse } from './index.js'; +import { json, text } from '../index.js'; /** * @param {{ @@ -82,18 +83,29 @@ test('rejects request bodies that exceed content-length', async () => { }); /** - * Minimal `ServerResponse` stand-in that emits `finish` when ended. - * @param {import('http').IncomingMessage} req + * Minimal `ServerResponse` stand-in that records headers and body writes and + * emits `finish` when ended. + * @param {import('http').IncomingMessage} [req] */ function create_response(req) { const res = /** @type {any} */ (new EventEmitter()); res.req = req; res.destroyed = false; - res.setHeader = () => {}; - res.getHeaderNames = () => []; + /** @type {Map} */ + res.headers = new Map(); + /** @type {unknown[]} */ + res.chunks = []; + res.setHeader = (/** @type {string} */ name, /** @type {unknown} */ value) => + res.headers.set(name.toLowerCase(), value); + res.hasHeader = (/** @type {string} */ name) => res.headers.has(name.toLowerCase()); + res.getHeaderNames = () => [...res.headers.keys()]; res.writeHead = () => res; - res.write = () => true; - res.end = () => { + res.write = (/** @type {unknown} */ chunk) => { + res.chunks.push(chunk); + return true; + }; + res.end = (/** @type {unknown} */ chunk) => { + if (chunk !== undefined) res.chunks.push(chunk); res.emit('finish'); res.emit('close'); }; @@ -246,6 +258,47 @@ test('does not abort the request signal when the response finishes normally', as expect(request.signal.aborted).toBe(false); }); +test('sends string response bodies in a single write with a content-length', () => { + const res = /** @type {any} */ (create_response()); + + setResponse(res, json({ snowman: '☃' })); + + const body = '{"snowman":"☃"}'; + expect(res.headers.get('content-length')).toBe(Buffer.byteLength(body)); + expect(res.headers.get('content-type')).toBe('application/json'); + expect(res.chunks).toEqual([body]); +}); + +test('does not overwrite an explicit content-length header', () => { + const res = /** @type {any} */ (create_response()); + + setResponse(res, text('hello', { headers: { 'content-length': '999' } })); + + expect(res.headers.get('content-length')).toBe('999'); + expect(res.chunks).toEqual(['hello']); +}); + +test('streams response bodies without a known string body', async () => { + const res = /** @type {any} */ (create_response()); + const finished = once(res, 'finish'); + + setResponse(res, new Response('hello')); + + await finished; + expect(res.headers.has('content-length')).toBe(false); + expect(Buffer.concat(res.chunks).toString()).toBe('hello'); +}); + +test('does not resurrect a string body that was already read', async () => { + const res = /** @type {any} */ (create_response()); + + const response = text('hello'); + await response.text(); + setResponse(res, response); + + expect(String(res.chunks[0])).toMatch(/^Fatal error: Response body is locked/); +}); + // 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/types/index.d.ts b/packages/kit/types/index.d.ts index ae5e59c1868d..f204bc4ded20 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -983,14 +983,14 @@ declare module '@sveltejs/kit' { /** * Create a JSON `Response` object from the supplied data. * @param data The value that will be serialized as JSON. - * @param init Options such as `status` and `headers` that will be added to the response. `Content-Type: application/json` and `Content-Length` headers will be added automatically. + * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Type: application/json` header will be added automatically. * @deprecated use `Response.json` */ export function json(data: any, init?: ResponseInit): Response; /** * Create a `Response` object from the supplied body. * @param body The value that will be used as-is. - * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Length` header will be added automatically. + * @param init Options such as `status` and `headers` that will be added to the response. * @deprecated use `new Response` */ export function text(body: string, init?: ResponseInit): Response; From 6c1aa5e5e6670d0dca0d666d55d2636dfaec28c9 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:11:01 -0400 Subject: [PATCH 02/10] define the string body symbol once in constants --- packages/kit/src/constants.js | 5 +++++ packages/kit/src/exports/index.js | 9 ++------- packages/kit/src/exports/node/index.js | 10 ++-------- packages/kit/src/exports/node/index.spec.js | 2 -- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/constants.js b/packages/kit/src/constants.js index 4f8c3264e448..6a321f952041 100644 --- a/packages/kit/src/constants.js +++ b/packages/kit/src/constants.js @@ -28,3 +28,8 @@ export const SRC_ROOT = import.meta.dirname; // eslint-disable-next-line n/prefer-global/process export const IN_WEBCONTAINER = !!globalThis.process?.versions?.webcontainer; + +// where `json` and `text` stash the raw body string so `setResponse` can write it +// with a content-length. `Symbol.for` because the app's server bundle and +// `@sveltejs/kit/node` are separate copies of the package +export const STRING_BODY = Symbol.for('sveltekit.string_body'); diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 740a0d1859f1..bc1fd76e8d53 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -10,10 +10,7 @@ import { strip_resolution_suffix } from '../pathname.js'; import { validate_redirect_location } from './url.js'; - -// `Symbol.for` because the app's bundled copy of this module must be visible to -// the `@sveltejs/kit/node` copy resolved from node_modules -const string_body = Symbol.for('sveltekit.string_body'); +import { STRING_BODY } from '../constants.js'; export { VERSION } from '../version.js'; @@ -175,9 +172,7 @@ export function json(data, init) { */ export function text(body, init) { const response = new Response(body, init); - // stash the string so `setResponse` can send it with a content-length - // instead of streaming it - /** @type {any} */ (response)[string_body] = body; + /** @type {any} */ (response)[STRING_BODY] = body; return response; } diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index 9fb7443a20ae..7472c87675d3 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -2,15 +2,11 @@ import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { SvelteKitError } from '../internal/shared.js'; import { noop } from '../../utils/functions.js'; +import { STRING_BODY } from '../../constants.js'; /** @type {WeakMap void>} */ const body_data_listeners = new WeakMap(); -// set by the `json` and `text` helpers. `Symbol.for` because the helpers live -// in the app's bundled copy of the package while this module is resolved from -// node_modules -const string_body = Symbol.for('sveltekit.string_body'); - /** * @param {import('http').IncomingMessage} req * @param {number} [body_size_limit] @@ -229,13 +225,11 @@ export function setResponse(res, response) { } } - const body = /** @type {any} */ (response)[string_body]; - + const body = /** @type {any} */ (response)[STRING_BODY]; if (typeof body === 'string' && !response.body?.locked) { if (!res.hasHeader('content-length')) { res.setHeader('content-length', Buffer.byteLength(body)); } - res.writeHead(response.status); res.end(body); return; diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index 1fa43303fd84..c9a8f8eb55b2 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -91,9 +91,7 @@ function create_response(req) { const res = /** @type {any} */ (new EventEmitter()); res.req = req; res.destroyed = false; - /** @type {Map} */ res.headers = new Map(); - /** @type {unknown[]} */ res.chunks = []; res.setHeader = (/** @type {string} */ name, /** @type {unknown} */ value) => res.headers.set(name.toLowerCase(), value); From 32c539effbf408755dff27877c91f2d0ca1c3850 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:27:53 -0400 Subject: [PATCH 03/10] merge the string body write with the bodyless exit --- packages/kit/src/exports/node/index.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index 7472c87675d3..c6738a0719b5 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -225,20 +225,17 @@ export function setResponse(res, response) { } } - const body = /** @type {any} */ (response)[STRING_BODY]; - if (typeof body === 'string' && !response.body?.locked) { - if (!res.hasHeader('content-length')) { - res.setHeader('content-length', Buffer.byteLength(body)); - } - res.writeHead(response.status); - res.end(body); - return; + const stashed = /** @type {any} */ (response)[STRING_BODY]; + const body = typeof stashed === 'string' && !response.body?.locked ? stashed : undefined; + + if (body !== undefined && !res.hasHeader('content-length')) { + res.setHeader('content-length', Buffer.byteLength(body)); } res.writeHead(response.status); - if (!response.body) { - res.end(); + if (body !== undefined || !response.body) { + res.end(body); return; } From 5e961c8c9d9206105e90d4db230edd5d81bdce94 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:39:49 -0400 Subject: [PATCH 04/10] derive the body size span attribute from the stashed string --- packages/kit/src/runtime/server/respond.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 8121051104cb..63fa04991aab 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -28,6 +28,8 @@ import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; import { PageNodes } from '../../utils/page_nodes.js'; import { validate_server_exports } from '../../utils/exports.js'; +import { STRING_BODY } from '../../constants.js'; +import { text_encoder } from '../utils.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; @@ -512,10 +514,15 @@ export async function internal_respond(request, options, manifest, state) { response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); } + const stashed = /** @type {any} */ (response)[STRING_BODY]; resolve_span.setAttributes({ 'http.response.status_code': response.status, 'http.response.body.size': - response.headers.get('content-length') || 'unknown' + response.headers.get('content-length') ?? + // only pay for encoding when the span is recording + (resolve_span.isRecording() && typeof stashed === 'string' + ? text_encoder.encode(stashed).byteLength.toString() + : 'unknown') }); return response; From ada5819170a33a87134830d222220718f87bcc50 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:30:15 -0400 Subject: [PATCH 05/10] derive content-length from fixed response bodies in setResponse --- .changeset/pre/set-response-fixed-bodies.md | 5 ++ .changeset/pre/set-response-string-bodies.md | 5 -- packages/kit/src/constants.js | 5 -- packages/kit/src/exports/index.js | 38 +++++++-- packages/kit/src/exports/node/index.js | 82 ++++++++++++++++---- packages/kit/src/exports/node/index.spec.js | 68 ++++++++++++---- packages/kit/src/runtime/server/respond.js | 9 +-- 7 files changed, 158 insertions(+), 54 deletions(-) create mode 100644 .changeset/pre/set-response-fixed-bodies.md delete mode 100644 .changeset/pre/set-response-string-bodies.md diff --git a/.changeset/pre/set-response-fixed-bodies.md b/.changeset/pre/set-response-fixed-bodies.md new file mode 100644 index 000000000000..9c38b4f96b0f --- /dev/null +++ b/.changeset/pre/set-response-fixed-bodies.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: derive `content-length` from fixed response bodies in `setResponse` diff --git a/.changeset/pre/set-response-string-bodies.md b/.changeset/pre/set-response-string-bodies.md deleted file mode 100644 index d8a955c2def2..000000000000 --- a/.changeset/pre/set-response-string-bodies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@sveltejs/kit': patch ---- - -chore: compute `content-length` in `setResponse` instead of the `json` and `text` helpers diff --git a/packages/kit/src/constants.js b/packages/kit/src/constants.js index 6a321f952041..4f8c3264e448 100644 --- a/packages/kit/src/constants.js +++ b/packages/kit/src/constants.js @@ -28,8 +28,3 @@ export const SRC_ROOT = import.meta.dirname; // eslint-disable-next-line n/prefer-global/process export const IN_WEBCONTAINER = !!globalThis.process?.versions?.webcontainer; - -// where `json` and `text` stash the raw body string so `setResponse` can write it -// with a content-length. `Symbol.for` because the app's server bundle and -// `@sveltejs/kit/node` are separate copies of the package -export const STRING_BODY = Symbol.for('sveltekit.string_body'); diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index bc1fd76e8d53..e670ee5e8882 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -10,7 +10,8 @@ import { strip_resolution_suffix } from '../pathname.js'; import { validate_redirect_location } from './url.js'; -import { STRING_BODY } from '../constants.js'; + +const text_encoder = new TextEncoder(); export { VERSION } from '../version.js'; @@ -152,28 +153,51 @@ export function isRedirect(e) { /** * Create a JSON `Response` object from the supplied data. * @param {any} data The value that will be serialized as JSON. - * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. A `Content-Type: application/json` header will be added automatically. + * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. `Content-Type: application/json` and `Content-Length` headers will be added automatically. * @deprecated use `Response.json` */ export function json(data, init) { + const body = JSON.stringify(data); + + // we can't just do `text(JSON.stringify(data), init)` because + // it will set a default `content-type` header. duplicated code + // means less duplicated work const headers = new Headers(init?.headers); + if (!headers.has('content-length')) { + headers.set('content-length', text_encoder.encode(body).byteLength.toString()); + } + if (!headers.has('content-type')) { headers.set('content-type', 'application/json'); } - return text(JSON.stringify(data), { ...init, headers }); + return new Response(body, { + ...init, + headers + }); } /** * Create a `Response` object from the supplied body. * @param {string} body The value that will be used as-is. - * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. + * @param {ResponseInit} [init] Options such as `status` and `headers` that will be added to the response. A `Content-Length` header will be added automatically. * @deprecated use `new Response` */ export function text(body, init) { - const response = new Response(body, init); - /** @type {any} */ (response)[STRING_BODY] = body; - return response; + const headers = new Headers(init?.headers); + if (!headers.has('content-length')) { + const encoded = text_encoder.encode(body); + headers.set('content-length', encoded.byteLength.toString()); + return new Response(encoded, { + ...init, + headers + }); + } + + return new Response(body, { + ...init, + headers + }); } /** diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index c6738a0719b5..888e81d8f212 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -2,7 +2,6 @@ import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { SvelteKitError } from '../internal/shared.js'; import { noop } from '../../utils/functions.js'; -import { STRING_BODY } from '../../constants.js'; /** @type {WeakMap void>} */ const body_data_listeners = new WeakMap(); @@ -225,21 +224,14 @@ export function setResponse(res, response) { } } - const stashed = /** @type {any} */ (response)[STRING_BODY]; - const body = typeof stashed === 'string' && !response.body?.locked ? stashed : undefined; - - if (body !== undefined && !res.hasHeader('content-length')) { - res.setHeader('content-length', Buffer.byteLength(body)); - } - - res.writeHead(response.status); - - if (body !== undefined || !response.body) { - res.end(body); + if (!response.body) { + res.writeHead(response.status); + res.end(); return; } if (response.body.locked) { + res.writeHead(response.status); res.end( 'Fatal error: Response body is locked. ' + "This can happen when the response was already read (for example through 'response.json()' or 'response.text()')." @@ -267,11 +259,73 @@ export function setResponse(res, response) { res.on('close', cancel); res.on('error', cancel); - void next(); + /** @type {Uint8Array[]} */ + const buffered = []; + + /** @type {ReturnType | null} */ + let pending = null; + + void probe(); + + // a fixed body (a string, buffer or blob, however constructed) settles all its + // reads before the next macrotask, so it can be measured and sent with a + // `content-length`; a genuine stream leaves a read pending and only has its + // headers delayed by a single tick + async function probe() { + try { + /** @type {Promise} */ + const deadline = new Promise((fulfil) => setImmediate(() => fulfil(undefined))); + + while (buffered.length < 2) { + pending = reader.read(); + const result = await Promise.race([pending, deadline]); + + if (!result) break; // deadline hit — treat the body as a stream + + pending = null; + + if (result.done) { + // a `content-length` next to a `transfer-encoding` would be invalid + if (!res.hasHeader('content-length') && !res.hasHeader('transfer-encoding')) { + res.setHeader( + 'content-length', + buffered.reduce((total, chunk) => total + chunk.byteLength, 0) + ); + } + break; + } + + buffered.push(result.value); + } + + if (res.destroyed) return; + + res.writeHead(response.status); + await next(); + } catch (error) { + if (!res.headersSent) res.writeHead(response.status); + cancel(error instanceof Error ? error : new Error(String(error))); + } + } + async function next() { try { for (;;) { - const { done, value } = await reader.read(); + /** @type {Awaited>} */ + let result; + if (buffered.length > 0) { + result = { + done: false, + value: /** @type {Uint8Array} */ (buffered.shift()) + }; + } else if (pending) { + result = await pending; + pending = null; + } else { + result = await reader.read(); + } + + const { done, value } = result; if (done) break; diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index c9a8f8eb55b2..0ae910f5577a 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -2,7 +2,6 @@ import { EventEmitter, once } from 'node:events'; import { PassThrough } from 'node:stream'; import { expect, test, vi } from 'vitest'; import { getRequest, setResponse } from './index.js'; -import { json, text } from '../index.js'; /** * @param {{ @@ -256,41 +255,80 @@ test('does not abort the request signal when the response finishes normally', as expect(request.signal.aborted).toBe(false); }); -test('sends string response bodies in a single write with a content-length', () => { +test('sends fixed response bodies with a content-length', async () => { const res = /** @type {any} */ (create_response()); + const finished = once(res, 'finish'); - setResponse(res, json({ snowman: '☃' })); + setResponse(res, Response.json({ snowman: '☃' })); - const body = '{"snowman":"☃"}'; - expect(res.headers.get('content-length')).toBe(Buffer.byteLength(body)); - expect(res.headers.get('content-type')).toBe('application/json'); - expect(res.chunks).toEqual([body]); + await finished; + expect(res.headers.get('content-length')).toBe(Buffer.byteLength('{"snowman":"☃"}')); + expect(Buffer.concat(res.chunks).toString()).toBe('{"snowman":"☃"}'); }); -test('does not overwrite an explicit content-length header', () => { +test('sends empty fixed bodies with a zero content-length', async () => { const res = /** @type {any} */ (create_response()); + const finished = once(res, 'finish'); - setResponse(res, text('hello', { headers: { 'content-length': '999' } })); + setResponse(res, new Response('')); - expect(res.headers.get('content-length')).toBe('999'); - expect(res.chunks).toEqual(['hello']); + await finished; + expect(res.headers.get('content-length')).toBe(0); + expect(res.chunks).toEqual([]); }); -test('streams response bodies without a known string body', async () => { +// proxied responses can carry a transfer-encoding header copied from the +// upstream hop; adding a content-length next to it would be invalid +test('does not add a content-length to responses with a transfer-encoding', async () => { const res = /** @type {any} */ (create_response()); const finished = once(res, 'finish'); - setResponse(res, new Response('hello')); + setResponse(res, new Response('hello', { headers: { 'transfer-encoding': 'chunked' } })); await finished; expect(res.headers.has('content-length')).toBe(false); expect(Buffer.concat(res.chunks).toString()).toBe('hello'); }); -test('does not resurrect a string body that was already read', async () => { +test('does not overwrite an explicit content-length header', async () => { + const res = /** @type {any} */ (create_response()); + const finished = once(res, 'finish'); + + setResponse(res, new Response('hello', { headers: { 'content-length': '999' } })); + + await finished; + expect(res.headers.get('content-length')).toBe('999'); + expect(Buffer.concat(res.chunks).toString()).toBe('hello'); +}); + +test('streams bodies that do not settle within a tick, without a content-length', async () => { + const res = /** @type {any} */ (create_response()); + + let controller = /** @type {ReadableStreamDefaultController} */ (/** @type {any} */ (null)); + const body = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(new TextEncoder().encode('first')); + } + }); + + setResponse(res, new Response(body)); + + // headers and the first chunk must go out while the stream is still open + await vi.waitFor(() => expect(res.chunks.length).toBe(1)); + expect(res.headers.has('content-length')).toBe(false); + + const finished = once(res, 'finish'); + controller.enqueue(new TextEncoder().encode(' second')); + controller.close(); + await finished; + expect(Buffer.concat(res.chunks).toString()).toBe('first second'); +}); + +test('does not send a body that was already read', async () => { const res = /** @type {any} */ (create_response()); - const response = text('hello'); + const response = new Response('hello'); await response.text(); setResponse(res, response); diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 63fa04991aab..8121051104cb 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -28,8 +28,6 @@ import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; import { PageNodes } from '../../utils/page_nodes.js'; import { validate_server_exports } from '../../utils/exports.js'; -import { STRING_BODY } from '../../constants.js'; -import { text_encoder } from '../utils.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; @@ -514,15 +512,10 @@ export async function internal_respond(request, options, manifest, state) { response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); } - const stashed = /** @type {any} */ (response)[STRING_BODY]; resolve_span.setAttributes({ 'http.response.status_code': response.status, 'http.response.body.size': - response.headers.get('content-length') ?? - // only pay for encoding when the span is recording - (resolve_span.isRecording() && typeof stashed === 'string' - ? text_encoder.encode(stashed).byteLength.toString() - : 'unknown') + response.headers.get('content-length') || 'unknown' }); return response; From e4189016d2883339eb793e7614241c0a681d0976 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:30:43 -0400 Subject: [PATCH 06/10] regenerate types --- packages/kit/types/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f204bc4ded20..ae5e59c1868d 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -983,14 +983,14 @@ declare module '@sveltejs/kit' { /** * Create a JSON `Response` object from the supplied data. * @param data The value that will be serialized as JSON. - * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Type: application/json` header will be added automatically. + * @param init Options such as `status` and `headers` that will be added to the response. `Content-Type: application/json` and `Content-Length` headers will be added automatically. * @deprecated use `Response.json` */ export function json(data: any, init?: ResponseInit): Response; /** * Create a `Response` object from the supplied body. * @param body The value that will be used as-is. - * @param init Options such as `status` and `headers` that will be added to the response. + * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Length` header will be added automatically. * @deprecated use `new Response` */ export function text(body: string, init?: ResponseInit): Response; From 44c0e5f63073bf257f37c9c4b540be6bff19ab66 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:39:17 -0400 Subject: [PATCH 07/10] drop a test for behavior this PR does not own --- packages/kit/src/exports/node/index.spec.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index 0ae910f5577a..6d8c751b588f 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -325,16 +325,6 @@ test('streams bodies that do not settle within a tick, without a content-length' expect(Buffer.concat(res.chunks).toString()).toBe('first second'); }); -test('does not send a body that was already read', async () => { - const res = /** @type {any} */ (create_response()); - - const response = new Response('hello'); - await response.text(); - setResponse(res, response); - - expect(String(res.chunks[0])).toMatch(/^Fatal error: Response body is locked/); -}); - // 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({ From 1cd2dc29c51e3bd1929ee105528943646a801b3f Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:18:05 -0400 Subject: [PATCH 08/10] chore: use Response.json instead of the deprecated json helper --- .changeset/pre/runtime-response-json.md | 5 +++++ packages/kit/src/runtime/server/errors.js | 4 ++-- packages/kit/src/runtime/server/page/actions.js | 3 +-- packages/kit/src/runtime/server/remote-functions.js | 10 +++++----- packages/kit/src/runtime/server/respond.js | 6 +++--- 5 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 .changeset/pre/runtime-response-json.md diff --git a/.changeset/pre/runtime-response-json.md b/.changeset/pre/runtime-response-json.md new file mode 100644 index 000000000000..46f56be3ff6c --- /dev/null +++ b/.changeset/pre/runtime-response-json.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: use `Response.json` instead of the deprecated `json` helper in runtime responses diff --git a/packages/kit/src/runtime/server/errors.js b/packages/kit/src/runtime/server/errors.js index 377627a4add0..dcdd153e2ca4 100644 --- a/packages/kit/src/runtime/server/errors.js +++ b/packages/kit/src/runtime/server/errors.js @@ -1,4 +1,4 @@ -import { json, text } from '@sveltejs/kit'; +import { text } from '@sveltejs/kit'; import { HandledHttpError, HttpError, @@ -28,7 +28,7 @@ export async function handle_fatal_error(event, state, options, error) { ]); if (event.isDataRequest || type === 'application/json') { - return json(body, { + return Response.json(body, { status }); } diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 00e3a6dc0e64..708143d24c2d 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -2,7 +2,6 @@ /** @import { ActionResult } from '$app/forms' */ /** @import { SSROptions, SSRNode, ServerNode } from 'types' */ import { DEV } from 'esm-env'; -import { json } from '@sveltejs/kit'; import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/kit/internal'; import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server'; import { normalize_error } from '../../../utils/error.js'; @@ -164,7 +163,7 @@ export function action_json_redirect(redirect) { * @param {ResponseInit} [init] */ function action_json(data, init) { - return with_version_header(json(data, init)); + return with_version_header(Response.json(data, init)); } /** diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js index 43674bc0abf9..32c35234df4a 100644 --- a/packages/kit/src/runtime/server/remote-functions.js +++ b/packages/kit/src/runtime/server/remote-functions.js @@ -3,7 +3,7 @@ /** @import { ActionResult } from '$app/forms' */ /** @import { RemoteFormInternals, RemoteFunctionData, RemoteFunctionResponse, RemoteInternals, RequestState, SSROptions } from 'types' */ -import { json, error } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import { Redirect, SvelteKitError } from '@sveltejs/kit/internal'; import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server'; import { app_dir, base } from '#app/paths'; @@ -262,7 +262,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) if (data._.issues) { // special case — don't serialize refreshes/reconnects - return json( + return Response.json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data) @@ -310,7 +310,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) await collect_remote_data(data, event, state, options); - return json( + return Response.json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data) @@ -321,7 +321,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) if (error instanceof Redirect) { const data = await collect_remote_data({ redirect: error.location }, event, state, options); - return json( + return Response.json( /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data) @@ -332,7 +332,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const transformed = await handle_error_and_jsonify(event, state, options, error); - return json( + return Response.json( /** @type {RemoteFunctionResponse} */ ({ type: 'error', error: transformed diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 8121051104cb..8f94df47b812 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -1,6 +1,6 @@ /** @import { SSRNode } from 'types' */ import { DEV } from 'esm-env'; -import { json, text } from '@sveltejs/kit'; +import { text } from '@sveltejs/kit'; import { Redirect, SvelteKitError } from '@sveltejs/kit/internal'; import { merge_tracing, @@ -111,7 +111,7 @@ export async function internal_respond(request, options, manifest, state) { }) ) { const message = 'Cross-site remote requests are forbidden'; - return json({ message }, { status: 403 }); + return Response.json({ message }, { status: 403 }); } } else if (options.csrf_check_origin) { const forbidden = is_csrf_forbidden({ @@ -126,7 +126,7 @@ export async function internal_respond(request, options, manifest, state) { const opts = { status: 403 }; if (request.headers.get('accept') === 'application/json') { - return json({ message }, opts); + return Response.json({ message }, opts); } return text(message, opts); From 2a809847777a193669fae27ea878ed16be457dc0 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:59:56 -0400 Subject: [PATCH 09/10] migrate the remote prerender response as well --- packages/kit/src/runtime/app/server/remote/prerender.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index a7c0a8081742..b7aa91f10ad3 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,7 +1,6 @@ /** @import { RemoteResource, RemotePrerenderFunction } from '$app/server' */ /** @import { RemoteFunctionResponse, RemotePrerenderInputsGenerator, RemotePrerenderInternals, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { json } from '@sveltejs/kit'; import { HandledHttpError } from '@sveltejs/kit/internal'; import { get_request_store } from '@sveltejs/kit/internal/server'; import { stringify_remote_arg } from '../../../shared.js'; @@ -145,7 +144,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { const body = { type: 'result', data: stringify({ _: result }) }; state.prerendering.dependencies.set(url, { body: JSON.stringify(body), - response: json(body) + response: Response.json(body) }); } From ad23cfcf4fb16cd9f232345ba8d89e793222dd9c Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:05:50 -0400 Subject: [PATCH 10/10] docs: use Response.json in examples --- .../docs/10-getting-started/40-web-standards.md | 10 +++------- documentation/docs/20-core-concepts/10-routing.md | 12 ++++-------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/documentation/docs/10-getting-started/40-web-standards.md b/documentation/docs/10-getting-started/40-web-standards.md index 81fc10d8b25a..fb3b03332f64 100644 --- a/documentation/docs/10-getting-started/40-web-standards.md +++ b/documentation/docs/10-getting-started/40-web-standards.md @@ -24,20 +24,18 @@ An instance of [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Res ### Headers -The [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) interface allows you to read incoming `request.headers` and set outgoing `response.headers`. For example, you can get the `request.headers` as shown below, and use the [`json` convenience function](@sveltejs-kit#json) to send modified `response.headers`: +The [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) interface allows you to read incoming `request.headers` and set outgoing `response.headers`. For example, you can get the `request.headers` as shown below, and use [`Response.json`](https://developer.mozilla.org/en-US/docs/Web/API/Response/json_static) to send modified `response.headers`: ```js // @errors: 2461 /// file: src/routes/what-is-my-user-agent/+server.js -import { json } from '@sveltejs/kit'; - /** @type {import('./$types').RequestHandler} */ export function GET({ request }) { // log all headers console.log(...request.headers); // create a JSON Response using a header we received - return json({ + return Response.json({ // retrieve a specific header userAgent: request.headers.get('user-agent') }, { @@ -54,8 +52,6 @@ When dealing with HTML native form submissions you'll be working with [`FormData ```js // @errors: 2461 /// file: src/routes/hello/+server.js -import { json } from '@sveltejs/kit'; - /** @type {import('./$types').RequestHandler} */ export async function POST(event) { const body = await event.request.formData(); @@ -63,7 +59,7 @@ export async function POST(event) { // log all fields console.log([...body]); - return json({ + return Response.json({ // get a specific field's value name: body.get('name') ?? 'world' }); diff --git a/documentation/docs/20-core-concepts/10-routing.md b/documentation/docs/20-core-concepts/10-routing.md index 2202076c28a0..4ce47d352804 100644 --- a/documentation/docs/20-core-concepts/10-routing.md +++ b/documentation/docs/20-core-concepts/10-routing.md @@ -320,7 +320,7 @@ export function GET({ url }) { The first argument to `Response` can be a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), making it possible to stream large amounts of data or create server-sent events (unless deploying to platforms that buffer responses, like AWS Lambda). -You can use the [`error`](@sveltejs-kit#error), [`redirect`](@sveltejs-kit#redirect) and [`json`](@sveltejs-kit#json) methods from `@sveltejs/kit` for convenience (but you don't have to). +You can use the [`error`](@sveltejs-kit#error) and [`redirect`](@sveltejs-kit#redirect) methods from `@sveltejs/kit` for convenience (but you don't have to). If an error is thrown (either `error(...)` or an unexpected error), the response will be a JSON representation of the error or a fallback error page — which can be customised via `src/error.html` — depending on the `Accept` header. The [`+error.svelte`](#error) component will _not_ be rendered in this case. You can read more about error handling [here](errors). @@ -361,12 +361,10 @@ By exporting `POST`/`PUT`/`PATCH`/`DELETE`/`OPTIONS`/`HEAD`/`QUERY` handlers, `+ ```js /// file: src/routes/api/add/+server.js -import { json } from '@sveltejs/kit'; - /** @type {import('./$types').RequestHandler} */ export async function POST({ request }) { const { a, b } = await request.json(); - return json(a + b); + return Response.json(a + b); } ``` @@ -380,18 +378,16 @@ Exporting the `fallback` handler will match any unhandled request methods, inclu ```js /// file: src/routes/api/add/+server.js -import { json, text } from '@sveltejs/kit'; - /** @type {import('./$types').RequestHandler} */ export async function POST({ request }) { const { a, b } = await request.json(); - return json(a + b); + return Response.json(a + b); } // This handler will respond to PUT, PATCH, DELETE, etc. /** @type {import('./$types').RequestHandler} */ export async function fallback({ request }) { - return text(`I caught your ${request.method} request!`); + return new Response(`I caught your ${request.method} request!`); } ```