From bfa40020109241ffaf136bcb423a9abc4d4c1660 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Oct 2026 15:50:50 +0000 Subject: [PATCH] fix(node): don't expose derived content-length on materialized FastResponse headers --- src/adapters/_node/response.ts | 34 ++++------ test/node-adapters.test.ts | 112 +++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 41 deletions(-) diff --git a/src/adapters/_node/response.ts b/src/adapters/_node/response.ts index 4a0fff4b..336799ba 100644 --- a/src/adapters/_node/response.ts +++ b/src/adapters/_node/response.ts @@ -40,6 +40,7 @@ export const NodeResponse: { #init?: ResponseInit; #headers?: Headers; #response?: globalThis.Response; + #contentLength?: number; constructor(body?: ResponseBody | null, init?: ResponseInit) { this.#body = body; @@ -132,36 +133,23 @@ export const NodeResponse: { body = stream as unknown as ResponseBody; } - // Carry the content-type / content-length that `_toNodeResponse()` would - // derive, so materializing (reading `.body`, `.clone()`, wrapping the - // body in a proxy layer, …) doesn't silently switch to chunked framing. - let contentType: string | undefined; - let contentLength: number | undefined; + // Derived length for `_toNodeResponse()`, kept out of `headers` to match native `Response`. if (typeof body === "string") { - contentType = "text/plain; charset=UTF-8"; - contentLength = Buffer.byteLength(body); + this.#contentLength = Buffer.byteLength(body); } else if ( body instanceof ArrayBuffer || body instanceof Uint8Array || body instanceof DataView ) { - contentLength = body.byteLength; + this.#contentLength = body.byteLength; } else if (body instanceof Blob) { - contentType = body.type; - contentLength = body.size; - } - let headers = this.#headers; - if (contentLength !== undefined) { - headers ??= new Headers(this.#init?.headers); - if (contentType && !headers.has("content-type")) { - headers.set("content-type", contentType); - } - if (!headers.has("content-length")) { - headers.set("content-length", String(contentLength)); - } + this.#contentLength = body.size; } - this.#response = new NativeResponse(body, headers ? { ...this.#init, headers } : this.#init); + this.#response = new NativeResponse( + body, + this.#headers ? { ...this.#init, headers: this.#headers } : this.#init, + ); this.#init = undefined; this.#headers = undefined; this.#body = undefined; @@ -180,6 +168,9 @@ export const NodeResponse: { let contentLength: string | number | undefined | null; if (this.#response) { body = this.#response.body; + if (!this.#response.bodyUsed) { + contentLength = this.#contentLength; + } } else if (this.#body != null) { // `!= null` (not a truthy check): an empty-string body is falsy but must // still receive the implicit `text/plain` content-type and a @@ -261,6 +252,7 @@ export const NodeResponse: { this.#init = undefined; this.#headers = undefined; this.#response = undefined; + this.#contentLength = undefined; this.#body = undefined; return { diff --git a/test/node-adapters.test.ts b/test/node-adapters.test.ts index 42334c8e..6a98602f 100644 --- a/test/node-adapters.test.ts +++ b/test/node-adapters.test.ts @@ -1278,20 +1278,40 @@ describe("FastResponse header dedup", () => { describe("FastResponse keeps derived content-length once materialized", () => { const view = new DataView(new Uint8Array([0, 1, 2, 3, 4, 5]).buffer, 1, 3); const cases: [string, () => BodyInit, string, string | null][] = [ - ["string", () => "héllo", "6", "text/plain; charset=UTF-8"], - ["empty string", () => "", "0", "text/plain; charset=UTF-8"], + ["string", () => "héllo", "6", "text/plain;charset=UTF-8"], + ["empty string", () => "", "0", "text/plain;charset=UTF-8"], ["ArrayBuffer", () => new ArrayBuffer(4), "4", null], ["Uint8Array", () => new Uint8Array(7), "7", null], ["DataView", () => view, "3", null], ["Blob", () => new Blob(["abc"], { type: "text/css" }), "3", "text/css"], ]; - test.skipIf(noBunCompat).each(cases)("%s body", (_, body, length, type) => { - const res = new FastResponse(body()); - void res.body; - expect(res.headers.get("content-length")).toBe(length); - expect(res.headers.get("content-type")).toBe(type); - }); + test.skipIf(noBunCompat).each(cases)( + "%s body is sent content-length framed after reading .body", + async (_, body, length, type) => { + const server = serve({ + port: 0, + fetch() { + const res = new FastResponse(body()); + void res.body; + return res; + }, + }); + await server.ready(); + try { + const res = await fetch(server.url!); + expect(res.headers.get("content-length")).toBe(length); + expect(res.headers.get("content-type")).toBe(type); + expect(res.headers.get("transfer-encoding")).toBeNull(); + expect((await res.arrayBuffer()).byteLength).toBe(Number(length)); + + const headRes = await fetch(server.url!, { method: "HEAD" }); + expect(headRes.headers.get("content-length")).toBe(length); + } finally { + await server.close(true); + } + }, + ); test.skipIf(noBunCompat)("user headers take precedence", () => { const res = new FastResponse("hello", { @@ -1307,7 +1327,6 @@ describe("FastResponse keeps derived content-length once materialized", () => { res.headers.set("x-custom", "1"); void res.body; expect(res.headers.get("x-custom")).toBe("1"); - expect(res.headers.get("content-length")).toBe("5"); }); test.skipIf(noBunCompat)("does not mutate a shared Headers init", () => { @@ -1322,24 +1341,77 @@ describe("FastResponse keeps derived content-length once materialized", () => { expect(res.headers.get("content-length")).toBeNull(); }); - test("is sent content-length framed after reading .body", async () => { + test.skipIf(noBunCompat)( + "a consumed body is sent without the derived content-length", + async () => { + const server = serve({ + port: 0, + async fetch() { + const res = new FastResponse("hello"); + const reader = res.body!.getReader(); + while (!(await reader.read()).done); + reader.releaseLock(); + return res; + }, + }); + await server.ready(); + try { + const res = await fetch(server.url!, { signal: AbortSignal.timeout(2000) }); + expect(res.headers.get("content-length")).toBeNull(); + expect(await res.text()).toBe(""); + } finally { + await server.close(true); + } + }, + ); +}); + +describe("FastResponse headers match native Response after reading the body", () => { + const NativeResponse = globalThis.Response; + const bodies: [string, () => BodyInit][] = [ + ["string", () => "héllo"], + ["empty string", () => ""], + ["ArrayBuffer", () => new ArrayBuffer(4)], + ["Uint8Array", () => new Uint8Array(7)], + ["DataView", () => new DataView(new ArrayBuffer(6), 1, 3)], + ["Blob", () => new Blob(["abc"], { type: "text/css" })], + ["untyped Blob", () => new Blob(["abc"])], + ["ReadableStream", () => new Blob(["abc"]).stream()], + ]; + const reads: [string, (res: Response) => unknown][] = [ + ["text()", (res) => res.text()], + ["arrayBuffer()", (res) => res.arrayBuffer()], + ["clone()", (res) => res.clone()], + [".body", (res) => res.body], + ]; + + describe.each(reads)("%s", (_, read) => { + test.each(bodies)("%s body", async (_, body) => { + const init = { headers: { "x-custom": "1" } }; + const native = new NativeResponse(body(), init); + const fast = new FastResponse(body(), init); + await read(native); + await read(fast); + expect([...fast.headers]).toEqual([...native.headers]); + }); + }); + + test("a response rebuilt from read headers is sent with its own length", async () => { const server = serve({ port: 0, - fetch() { - const res = new FastResponse("hello"); - void res.body; - return res; + async fetch() { + const res = new FastResponse("abc", { + headers: { "content-type": "text/html" }, + }); + const html = await res.text(); + return new FastResponse(html + "", { headers: res.headers }); }, }); await server.ready(); try { const res = await fetch(server.url!); - expect(res.headers.get("content-length")).toBe("5"); - expect(res.headers.get("transfer-encoding")).toBeNull(); - expect(await res.text()).toBe("hello"); - - const headRes = await fetch(server.url!, { method: "HEAD" }); - expect(headRes.headers.get("content-length")).toBe("5"); + expect(res.headers.get("content-length")).toBe("32"); + expect(await res.text()).toBe("abc"); } finally { await server.close(true); }