From 3aa64db20c0c6bd315c4ab43e1c52ec067d52c35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:52:03 +0000 Subject: [PATCH 1/3] node:http: copy the array values of a header list in writeHead() and res.headers= res.writeHead(200, ["Set-Cookie", COMMON, "Set-Cookie", sid]) pushed sid into COMMON, so a server that keeps COMMON in a constant sent each response the cookies of the responses before it. The list of pairs form and the res.headers= setter did the same. _writeHead folds a header list into the header store with appendHeader. The store keeps an array value by reference and appendHeader pushes a later value for the same name into it, as in Node. The four loops of ServerResponse that fold a list now pass a copy of each array value. The pairs arm has no remove pass, so it first replaces the arrays already in the store with copies. Node only folds when a header was set before, and in that case it also appends to the caller's array. Bun appends to a copy there too. --- src/js/node/_http_server.ts | 38 +++- .../node/http/node-http-header-list.test.ts | 192 ++++++++++++++++++ 2 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 test/js/node/http/node-http-header-list.test.ts diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index fae709e723b6..c5816966b7c4 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2223,6 +2223,24 @@ function hasInvalidTrailer(response) { return outHeaders !== null && outHeaders["trailer"] !== undefined; } +// OutgoingMessage keeps an array value by reference and appendHeader pushes into it, as in Node. +// A header list folded below must not reach the caller's array, so an array value goes in as a copy. +function copyHeaderValueArray(values) { + const length = values.length; + const copy = $newArrayWithSize(length); + for (let i = 0; i < length; i++) $putByValDirect(copy, i, values[i]); + return copy; +} + +// For a fold with no remove pass: an array stored earlier can be one that a caller gave to setHeader. +function detachStoredHeaderArrays(response) { + const outHeaders = response[kOutHeaders]; + for (const key in outHeaders) { + const entry = outHeaders[key]; + if ($isArray(entry[1])) entry[1] = copyHeaderValueArray(entry[1]); + } +} + function _writeHead(statusCode, reason, obj, response) { const originalStatusCode = statusCode; statusCode |= 0; @@ -2253,9 +2271,15 @@ function _writeHead(statusCode, reason, obj, response) { const length = obj.length; // Append all the headers provided in the array: if (length && $isArray(obj[0])) { + if (response[kOutHeaders] !== null) detachStoredHeaderArrays(response); for (let i = 0; i < length; i++) { const k = obj[i]; - if (k) response.appendHeader(k[0], k[1]); + if (k) { + const name = k[0]; + let value = k[1]; + if (typeof value === "object" && $isArray(value)) value = copyHeaderValueArray(value); + response.appendHeader(name, value); + } } } else { if (length % 2 !== 0) { @@ -2279,7 +2303,13 @@ function _writeHead(statusCode, reason, obj, response) { for (let n = 0; n < length; n += 2) { k = obj[n]; - if (k) response.appendHeader(k, obj[n + 1]); + if (k) { + let value = obj[n + 1]; + // With a header set before, Node appends to the caller's array here, and the value stays in it: + // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js#L450-L453. Bun appends to a copy. + if (typeof value === "object" && $isArray(value)) value = copyHeaderValueArray(value); + response.appendHeader(k, value); + } } } } else if (obj) { @@ -3172,11 +3202,11 @@ Object.defineProperty(ServerResponse.prototype, "headers", { if ($isArray(value)) { // Array of [name, value] pairs, like the WHATWG Headers sequence init. for (const { 0: key, 1: val } of value) { - this.appendHeader(key, val); + this.appendHeader(key, typeof val === "object" && $isArray(val) ? copyHeaderValueArray(val) : val); } } else if (typeof value.entries === "function") { for (const { 0: key, 1: val } of value.entries()) { - this.appendHeader(key, val); + this.appendHeader(key, typeof val === "object" && $isArray(val) ? copyHeaderValueArray(val) : val); } } else { const keys = ObjectKeys(value); diff --git a/test/js/node/http/node-http-header-list.test.ts b/test/js/node/http/node-http-header-list.test.ts new file mode 100644 index 000000000000..d08909ebf10c --- /dev/null +++ b/test/js/node/http/node-http-header-list.test.ts @@ -0,0 +1,192 @@ +// A header list is the array form of a headers argument: [name, value, name, value] or [[name, value], ...]. +// A value slot can hold an array: one header line per element. +import { describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +describe("a header list does not write into an array that the caller keeps", () => { + // A server can keep the array of a value slot in a constant and name the same header again in the same list. + // The later value must not go into the constant, or every later response carries it. + const THEME = "theme=light; Path=/"; + const LANG = "lang=en; Path=/"; + const users = ["alice", "bob", "carol"]; + type Respond = (res: any, kept: string[], sid: string) => void; + + // Answers one request per user with `respond`. Returns the Set-Cookie lines that each user received, and the + // kept array as the last response left it. + async function cookiesPerUser(respond: Respond, kept: string[] = [THEME, LANG]) { + const errors: string[] = []; + const server = createServer((req, res) => { + try { + respond(res, kept, `sid=${req.headers["x-user"]}`); + } catch (error) { + errors.push(String(error)); + } + res.end(); + }); + try { + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + const received: string[][] = []; + for (const user of users) { + const response = await fetch(`http://127.0.0.1:${port}/`, { headers: { "x-user": user } }); + received.push(response.headers.getSetCookie()); + await response.arrayBuffer(); + } + return { received, kept, errors }; + } finally { + server.closeAllConnections(); + server.close(); + } + } + const ownCookieOnly = { received: users.map(user => [THEME, LANG, `sid=${user}`]), kept: [THEME, LANG], errors: [] }; + + // The `name` lines of the head that a ServerResponse with no socket wrote. + function headLines(res: ServerResponse, name: string) { + const head: string = (res as any).outputData.map((x: any) => String(x.data)).join(""); + return head + .split("\r\n") + .filter(line => line.startsWith(`${name}: `)) + .map(line => line.slice(name.length + 2)); + } + + describe("writeHead(), with the same result on Node.js v26.3.0", () => { + const lists: [string, Respond][] = [ + ["a flat list", (res, kept, sid) => res.writeHead(200, ["Set-Cookie", kept, "Set-Cookie", sid])], + [ + "a list of pairs", + (res, kept, sid) => + res.writeHead(200, [ + ["Set-Cookie", kept], + ["Set-Cookie", sid], + ]), + ], + [ + "a status message, then a flat list", + (res, kept, sid) => res.writeHead(200, "OK", ["Set-Cookie", kept, "Set-Cookie", sid]), + ], + [ + "a name repeated in another case", + (res, kept, sid) => res.writeHead(200, ["Set-Cookie", kept, "set-cookie", sid]), + ], + [ + "an array value after the kept array", + (res, kept, sid) => res.writeHead(200, ["Set-Cookie", kept, "Set-Cookie", [sid]]), + ], + ]; + test.each(lists)("%s", async (_name, respond) => { + expect(await cookiesPerUser(respond)).toEqual(ownCookieOnly); + }); + + test("a frozen array in a flat list", async () => { + const respond: Respond = (res, kept, sid) => res.writeHead(200, ["Set-Cookie", kept, "Set-Cookie", sid]); + expect(await cookiesPerUser(respond, Object.freeze([THEME, LANG]) as string[])).toEqual(ownCookieOnly); + }); + + test.each(["Set-Cookie", "Link"])("a ServerResponse with no socket, %s", name => { + const kept = [THEME, LANG]; + const received = users.map(user => { + const res = new ServerResponse(new IncomingMessage(null as any)); + res.writeHead(200, [name, kept, name, `sid=${user}`]); + res.end(); + return headLines(res, name); + }); + expect({ received, kept, errors: [] }).toEqual(ownCookieOnly); + }); + + // Node renders the head inside writeHead(). A list value is fixed there, so a later push is not sent. + const lateWrites: [string, (res: any, cookies: string[]) => void][] = [ + ["a flat list", (res, cookies) => res.writeHead(200, ["Set-Cookie", cookies])], + ["a list of pairs", (res, cookies) => res.writeHead(200, [["Set-Cookie", cookies]])], + ]; + test.each(lateWrites)("a value pushed after writeHead() with %s is not sent", async (_name, writeHead) => { + const { received } = await cookiesPerUser((res, _kept, sid) => { + const cookies = [THEME, LANG]; + writeHead(res, cookies); + cookies.push(sid); + }); + expect(received).toEqual(users.map(() => [THEME, LANG])); + }); + }); + + // Deliberate divergence from Node v26.3.0. With a header already set, Node merges a flat list into its header + // store and appends to the caller's array there: the second user receives the cookie of the first user. In that + // state Node takes no list of pairs at all (ERR_INVALID_ARG_TYPE, or ERR_INVALID_ARG_VALUE for an odd count). + // Bun takes both forms and appends to a copy. + describe("writeHead() after a header was set", () => { + const lists: [string, Respond][] = [ + [ + "a flat list", + (res, kept, sid) => { + res.setHeader("X-Any", "1"); + res.writeHead(200, ["Set-Cookie", kept, "Set-Cookie", sid]); + }, + ], + [ + "a list of pairs", + (res, kept, sid) => { + res.setHeader("X-Any", "1"); + res.writeHead(200, [ + ["Set-Cookie", kept], + ["Set-Cookie", sid], + ]); + }, + ], + [ + "a list of pairs that names a header given to setHeader() as an array", + (res, kept, sid) => { + res.setHeader("Set-Cookie", kept); + res.writeHead(200, [["Set-Cookie", sid]]); + }, + ], + [ + "a list of pairs that names a header given to appendHeader() as an array", + (res, kept, sid) => { + res.appendHeader("Set-Cookie", kept); + res.writeHead(200, [["Set-Cookie", sid]]); + }, + ], + ]; + test.each(lists)("%s", async (_name, respond) => { + expect(await cookiesPerUser(respond)).toEqual(ownCookieOnly); + }); + + test("a list of pairs that names a header given to setHeader() as an array, Link", () => { + const kept = [THEME, LANG]; + const received = users.map(user => { + const res = new ServerResponse(new IncomingMessage(null as any)); + res.setHeader("Link", kept); + res.writeHead(200, [["Link", `sid=${user}`]]); + res.end(); + return headLines(res, "Link"); + }); + expect({ received, kept, errors: [] }).toEqual(ownCookieOnly); + }); + }); + + // `res.headers = list` is a Bun extension. + describe("res.headers =", () => { + const lists: [string, Respond][] = [ + [ + "a list of pairs", + (res, kept, sid) => + (res.headers = [ + ["Set-Cookie", kept], + ["Set-Cookie", sid], + ]), + ], + [ + "an object with entries()", + (res, kept, sid) => + (res.headers = new Map([ + ["Set-Cookie", kept], + ["set-cookie", sid], + ])), + ], + ]; + test.each(lists)("%s", async (_name, respond) => { + expect(await cookiesPerUser(respond)).toEqual(ownCookieOnly); + }); + }); +}); From 6550faf56d724c306b2f2410ebe28c0df003a240 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:38:57 +0000 Subject: [PATCH 2/3] node:http: the object form of res.headers= also stores a copy of an array value res.headers = { name: ARR } gave ARR to setHeader, so a later res.appendHeader(name, v) pushed into the caller's array. The two list forms of the setter already store a copy. Now every form does. --- src/js/node/_http_server.ts | 4 +++- test/js/node/http/node-http-header-list.test.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c5816966b7c4..d1a02cf5fbe5 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -3199,6 +3199,7 @@ Object.defineProperty(ServerResponse.prototype, "headers", { throwIfServerHeadersSent(this, "set"); this[kOutHeaders] = null; if (!value) return; + // Every form stores a copy of an array value, so that no appendHeader pushes into the caller's array. if ($isArray(value)) { // Array of [name, value] pairs, like the WHATWG Headers sequence init. for (const { 0: key, 1: val } of value) { @@ -3211,7 +3212,8 @@ Object.defineProperty(ServerResponse.prototype, "headers", { } else { const keys = ObjectKeys(value); for (let i = 0; i < keys.length; i++) { - this.setHeader(keys[i], value[keys[i]]); + const val = value[keys[i]]; + this.setHeader(keys[i], typeof val === "object" && $isArray(val) ? copyHeaderValueArray(val) : val); } } }, diff --git a/test/js/node/http/node-http-header-list.test.ts b/test/js/node/http/node-http-header-list.test.ts index d08909ebf10c..fc3ece6a8c3e 100644 --- a/test/js/node/http/node-http-header-list.test.ts +++ b/test/js/node/http/node-http-header-list.test.ts @@ -165,9 +165,9 @@ describe("a header list does not write into an array that the caller keeps", () }); }); - // `res.headers = list` is a Bun extension. + // `res.headers =` is a Bun extension. Each form takes a copy of an array value. describe("res.headers =", () => { - const lists: [string, Respond][] = [ + const forms: [string, Respond][] = [ [ "a list of pairs", (res, kept, sid) => @@ -184,8 +184,15 @@ describe("a header list does not write into an array that the caller keeps", () ["set-cookie", sid], ])), ], + [ + "a plain object, then appendHeader()", + (res, kept, sid) => { + res.headers = { "Set-Cookie": kept }; + res.appendHeader("Set-Cookie", sid); + }, + ], ]; - test.each(lists)("%s", async (_name, respond) => { + test.each(forms)("%s", async (_name, respond) => { expect(await cookiesPerUser(respond)).toEqual(ownCookieOnly); }); }); From ec520468ee932710ddfbfd84e861d4805b3f070a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:48:12 +0000 Subject: [PATCH 3/3] node:http: shorten two comments in _writeHead to one line each --- src/js/node/_http_server.ts | 6 ++---- test/js/node/http/node-http-header-list.test.ts | 7 ++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index d1a02cf5fbe5..dafb1d7c5991 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2223,8 +2223,7 @@ function hasInvalidTrailer(response) { return outHeaders !== null && outHeaders["trailer"] !== undefined; } -// OutgoingMessage keeps an array value by reference and appendHeader pushes into it, as in Node. -// A header list folded below must not reach the caller's array, so an array value goes in as a copy. +// appendHeader pushes into a stored array, as in Node, so a header list folded here stores a copy of each array value. function copyHeaderValueArray(values) { const length = values.length; const copy = $newArrayWithSize(length); @@ -2305,8 +2304,7 @@ function _writeHead(statusCode, reason, obj, response) { k = obj[n]; if (k) { let value = obj[n + 1]; - // With a header set before, Node appends to the caller's array here, and the value stays in it: - // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js#L450-L453. Bun appends to a copy. + // Node pushes into the caller's array here when a header was set before. Bun appends to a copy on purpose. if (typeof value === "object" && $isArray(value)) value = copyHeaderValueArray(value); response.appendHeader(k, value); } diff --git a/test/js/node/http/node-http-header-list.test.ts b/test/js/node/http/node-http-header-list.test.ts index fc3ece6a8c3e..4b802a96e805 100644 --- a/test/js/node/http/node-http-header-list.test.ts +++ b/test/js/node/http/node-http-header-list.test.ts @@ -111,9 +111,10 @@ describe("a header list does not write into an array that the caller keeps", () }); // Deliberate divergence from Node v26.3.0. With a header already set, Node merges a flat list into its header - // store and appends to the caller's array there: the second user receives the cookie of the first user. In that - // state Node takes no list of pairs at all (ERR_INVALID_ARG_TYPE, or ERR_INVALID_ARG_VALUE for an odd count). - // Bun takes both forms and appends to a copy. + // store and appends to the caller's array there: the second user receives the cookie of the first user. + // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_server.js#L450-L453 + // In that state Node takes no list of pairs at all (ERR_INVALID_ARG_TYPE, or ERR_INVALID_ARG_VALUE for an odd + // count). Bun takes both forms and appends to a copy. describe("writeHead() after a header was set", () => { const lists: [string, Respond][] = [ [