/** * All new tests in this file should also run in Node.js. * * Do not add any tests that only run in Bun. * * A handful of older tests do not run in Node in this file. These tests should be updated to run in Node, or deleted. */ import { bunEnv, bunExe, exampleSite, isASAN, isCI, isDebug, isWindows, randomPort, tls as tlsCert } from "harness"; import { createTest } from "node-harness"; import { EventEmitter, once } from "node:events"; import nodefs from "node:fs"; import http, { Agent, createServer, get, globalAgent, IncomingMessage, OutgoingMessage, request, Server, ServerResponse, validateHeaderName, validateHeaderValue, } from "node:http"; import https, { createServer as createHttpsServer } from "node:https"; import type { AddressInfo } from "node:net"; import { connect, createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { Duplex, duplexPair, PassThrough, Writable } from "node:stream"; import { text } from "node:stream/consumers"; import { connect as tlsConnect } from "node:tls"; import { inspect } from "node:util"; import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; const { describe, expect, it, beforeAll, afterAll, createDoneDotAll, mock, test } = createTest(import.meta.path); function listen(server: Server, protocol: string = "http"): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject("Timed out"), 5000).unref(); server.listen({ port: 0 }, (err?: any, hostname?: string, port?: number) => { clearTimeout(timeout); if (err) { reject(err); } else { resolve(new URL(`${protocol}://${hostname}:${port}`)); } }); }); } describe("node:http", () => { describe("createServer", async () => { it("hello world", async () => { try { var server = createServer((req, res) => { expect(req.url).toBe("/hello?world"); res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello World"); }); const url = await listen(server); const res = await fetch(new URL("/hello?world", url)); expect(await res.text()).toBe("Hello World"); } catch (e) { throw e; } finally { server!.close(); } }); it("is not marked encrypted (#5867)", async () => { try { var server = createServer((req, res) => { expect((req.connection as any).encrypted).toBe(false); res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello World"); }); const url = await listen(server); const res = await fetch(new URL("", url)); expect(await res.text()).toBe("Hello World"); } catch (e) { throw e; } finally { server!.close(); } }); it("request & response body streaming (large)", async () => { const input = Buffer.alloc("hello world, hello world".length * 9000, "hello world, hello world"); try { var server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); req.on("data", chunk => { res.write(chunk); }); req.on("end", () => { res.end(); }); }); const url = await listen(server); const res = await fetch(url, { method: "POST", body: input, }); const out = await res.text(); expect(out).toBe(input.toString()); } finally { server!.close(); } }); it("request & response body streaming (small)", async () => { try { const bodyBlob = new Blob(["hello world", "hello world".repeat(4)]); const input = await bodyBlob.text(); var server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); req.on("data", chunk => { res.write(chunk); }); req.on("end", () => { res.end(); }); }); const url = await listen(server); const res = await fetch(url, { method: "POST", body: bodyBlob, }); const out = await res.text(); expect(out).toBe(input); } finally { server!.close(); } }); it("listen should return server", async () => { const server = createServer(); const listenResponse = server.listen(0); expect(listenResponse instanceof Server).toBe(true); expect(listenResponse).toBe(server); listenResponse.close(); }); it("server.listening follows listen() and close()", async () => { const server = createServer(); expect(server.listening).toBe(false); server.listen(0); try { await once(server, "listening"); expect(server.listening).toBe(true); } finally { server.close(); } expect(server.listening).toBe(false); }); it("listen callback should be bound to server", async () => { const server = createServer(); const { resolve, reject, promise } = Promise.withResolvers(); server.listen(0, function () { try { expect(this === server).toBeTrue(); resolve(); } catch (e) { reject(); } }); await promise; server.close(); }); // Like net.Server, http.Server reports the listen() result from process.nextTick, not a timer. // No host argument: with one, Node resolves it through dns.lookup first, which adds a tick. it("emits 'listening' on the next tick, before the event loop polls", async () => { const server = createServer(); const order: string[] = []; server.on("listening", () => order.push("listening")); server.listen(0); // The socket is bound when listen() returns, so the flag does not wait for the event. const listeningAtOnce = server.listening; process.nextTick(() => order.push("nextTick")); await once(server, "listening"); server.close(); await once(server, "close"); expect({ order, listeningAtOnce }).toEqual({ order: ["listening", "nextTick"], listeningAtOnce: true }); }); it("emits a listen() error on the next tick, before the event loop polls", async () => { const occupant = createServer(); occupant.listen(0); await once(occupant, "listening"); const { port } = occupant.address() as AddressInfo; const server = createServer(); const order: string[] = []; server.on("error", (err: NodeJS.ErrnoException) => order.push("error:" + err.code)); server.listen(port); const listeningAtOnce = server.listening; process.nextTick(() => order.push("nextTick")); await once(server, "error"); occupant.close(); await once(occupant, "close"); expect({ order, listeningAtOnce, listening: server.listening }).toEqual({ order: ["error:EADDRINUSE", "nextTick"], listeningAtOnce: false, listening: false, }); }); // vite's port auto-increment (#27406): the callback of the failed listen() belongs to the // server, not to that attempt, so the retry from the 'error' handler calls it. it("calls the listen() callback after a retry from the EADDRINUSE 'error' handler", async () => { const occupant = createServer(); occupant.listen(0); await once(occupant, "listening"); const { port } = occupant.address() as AddressInfo; const server = createServer(); const events: string[] = []; server.on("error", (err: NodeJS.ErrnoException) => { events.push("error:" + err.code); server.listen(0); }); // Not events.once(): it would reject on the expected 'error'. const { promise: listening, resolve: onListening } = Promise.withResolvers(); server.listen(port, () => events.push("callback")); server.on("listening", () => onListening()); await listening; const address = server.address() as AddressInfo; server.close(); occupant.close(); await Promise.all([once(server, "close"), once(occupant, "close")]); expect({ events, retriedPort: address.port !== port }).toEqual({ events: ["error:EADDRINUSE", "callback"], retriedPort: true, }); }); // Node's server.close() completes the handle's uv_close() on the next loop turn, so a server // listened and closed from a 'beforeExit' handler revives the loop once and 'beforeExit' fires // again (upstream test-process-beforeexit, with net). 'listening' is a nextTick now, so the turn // has to come from close() itself. describe.each([ ["http", {}], ["https", { key: tlsCert.key, cert: tlsCert.cert }], ])("%s: closing a server listened from 'beforeExit'", (mod, options) => { it("re-emits 'beforeExit'", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const { createServer } = require(${JSON.stringify(mod)}); process.once("beforeExit", () => { createServer(${JSON.stringify(options)}) .listen(0) .on("listening", function () { this.close(); process.once("beforeExit", () => console.log("beforeExit again")); }); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr }).toEqual({ stdout: "beforeExit again\n", stderr: "" }); expect(exitCode).toBe(0); }); }); it("listen({ signal }) closes the server when the signal is aborted", async () => { const server = createServer(); try { const controller = new AbortController(); await new Promise(resolve => server.listen({ port: 0, host: "127.0.0.1", signal: controller.signal }, resolve), ); const { port } = server.address() as AddressInfo; const closed = once(server, "close"); controller.abort(); expect(server.listening).toBe(false); expect(server.address()).toBeNull(); await closed; const socket = connect({ port, host: "127.0.0.1" }); const [err] = await once(socket, "error"); expect(err.code).toBe("ECONNREFUSED"); } finally { server.close(); } }); it("listen({ signal }) with an already aborted signal closes the server without emitting 'listening'", async () => { const server = createServer(); try { const onListening = mock(() => {}); server.on("listening", onListening); const closed = once(server, "close"); server.listen({ port: 0, host: "127.0.0.1", signal: AbortSignal.abort() }, onListening); await new Promise(resolve => process.nextTick(resolve)); expect(server.listening).toBe(false); expect(server.address()).toBeNull(); await closed; expect(onListening).not.toHaveBeenCalled(); } finally { server.close(); } }); it("listen({ signal }) rejects a value that is not an AbortSignal", () => { const server = createServer(); try { expect(() => server.listen({ port: 0, host: "127.0.0.1", signal: "INVALID_SIGNAL" as any })).toThrow( expect.objectContaining({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }), ); expect(server.address()).toBeNull(); } finally { server.close(); } }); it("should use the provided port", async () => { while (true) { try { const server = http.createServer(() => {}); const random_port = randomPort(); server.listen(random_port); await once(server, "listening"); const { port } = server.address() as AddressInfo; expect(port).toEqual(random_port); server.close(); break; } catch (err: any) { // Address in use try another port if (err.code === "EADDRINUSE") { continue; } throw err; } } }); it("should assign a random port when undefined", async () => { const server1 = http.createServer(() => {}); const server2 = http.createServer(() => {}); server1.listen(undefined); server2.listen(undefined); const { port: port1 } = server1.address() as AddressInfo; const { port: port2 } = server2.address() as AddressInfo; expect(port1).not.toEqual(port2); expect(port1).toBeWithin(1024, 65535); server1.close(); server2.close(); }); it("option method should be uppercase (#7250)", async () => { try { var server = createServer((req, res) => { expect(req.method).toBe("OPTIONS"); res.writeHead(204, {}); res.end(); }); const url = await listen(server); const res = await fetch(url, { method: "OPTIONS", }); expect(res.status).toBe(204); } finally { server!.close(); } }); }); describe("response", () => { test("set-cookie works with getHeader", () => { const res = new ServerResponse({} as any); res.setHeader("Set-Cookie", ["swag=true", "yolo=true"]); expect(res.getHeader("Set-Cookie")).toEqual(["swag=true", "yolo=true"]); }); test("set-cookie works with getHeaders", () => { const res = new ServerResponse({} as any); res.setHeader("Set-Cookie", ["swag=true", "yolo=true"]); res.setHeader("test", "test"); expect(res.getHeaders()).toEqual({ "set-cookie": ["swag=true", "yolo=true"], "test": "test", }); }); // Node's Writable.prototype.write uses the default encoding for a falsy // encoding argument, so res.write(chunk, "") and res.end(chunk, "") write utf8. test.each(["write", "end"])("res.%s accepts an empty-string encoding (#43370)", async method => { const body = "héllo wörld ✓"; await using server = http.createServer((req, res) => { try { if (method === "write") { res.write(body, "" as any); res.end(); } else { res.end(body, "" as any); } } catch (e: any) { res.statusCode = 500; res.end(`${e.code}: ${e.message}`); } }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const response = await fetch(`http://127.0.0.1:${port}/`); expect(await response.text()).toBe(body); expect(response.status).toBe(200); }); test.each(["write", "end"])("res.%s throws ERR_UNKNOWN_ENCODING for an unknown encoding", async method => { const errors: string[] = []; await using server = http.createServer((req, res) => { for (const encoding of ["bogus", 123, {}]) { try { res[method]("x", encoding as any); } catch (e: any) { errors.push(`${e.code}: ${e.message}`); } } res.end(); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; await fetch(`http://127.0.0.1:${port}/`); expect(errors).toEqual([ "ERR_UNKNOWN_ENCODING: Unknown encoding: bogus", "ERR_UNKNOWN_ENCODING: Unknown encoding: 123", "ERR_UNKNOWN_ENCODING: Unknown encoding: {}", ]); }); }); describe("request", () => { function runTest(done: Function, callback: (server: Server, port: number, done: (err?: Error) => void) => void) { let timer; const server = createServer((req, res) => { if (Object.getPrototypeOf(req.headers) !== Object.prototype) { // Like Node.js, req.headers is a plain object. throw new Error("Headers should have the plain Object prototype"); } const reqUrl = new URL(req.url!, `http://${req.headers.host}`); if (reqUrl.pathname) { if (reqUrl.pathname === "/redirect") { // Temporary redirect res.writeHead(301, { Location: `http://localhost:${(server as any).port}/redirected`, }); res.end("Got redirect!\n"); return; } if (reqUrl.pathname === "/multi-chunk-response") { res.writeHead(200, { "Content-Type": "text/plain" }); const toWrite = "a".repeat(512); for (let i = 0; i < 4; i++) { res.write(toWrite); } res.end(); return; } if (reqUrl.pathname === "/multiple-set-cookie") { expect(req.headers.cookie).toBe("foo=bar; bar=baz"); res.setHeader("Set-Cookie", ["foo=bar", "bar=baz"]); res.end("OK"); return; } if (reqUrl.pathname === "/redirected") { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); return; } if (reqUrl.pathname === "/lowerCaseHeaders") { res.writeHead(200, { "content-type": "text/plain", "X-Custom-Header": "custom_value" }); res.end("Hello World"); return; } if (reqUrl.pathname.includes("timeout")) { if (timer) clearTimeout(timer); req.on("timeout", () => { console.log("req timeout"); }); res.on("timeout", () => { console.log("res timeout"); }); timer = setTimeout(() => { if (res.closed) { return; } res.end("Hello World"); timer = null; }, 3000).unref(); return; } if (reqUrl.pathname === "/pathTest") { res.end("Path correct!\n"); return; } if (reqUrl.pathname === "/customWriteHead") { function createWriteHead(prevWriteHead, listener) { let fired = false; return function writeHead() { if (!fired) { fired = true; listener.call(this); } return prevWriteHead.apply(this, arguments); }; } function addPoweredBy() { if (!this.getHeader("X-Powered-By")) { this.setHeader("X-Powered-By", "Bun"); } } res.writeHead = createWriteHead(res.writeHead, addPoweredBy); res.setHeader("Content-Type", "text/plain"); res.end("Hello World"); return; } if (reqUrl.pathname === "/uploadFile") { let requestData = Buffer.alloc(0); req.on("data", chunk => { requestData = Buffer.concat([requestData, chunk]); }); req.on("end", () => { res.writeHead(200, { "Content-Type": "text/plain" }); res.write(requestData); res.end(); }); return; } } res.writeHead(200, { "Content-Type": "text/plain" }); if (req.headers["x-test"]) { res.write(`x-test: ${req.headers["x-test"]}\n`); } // Check for body if (req.method === "OPTIONS") { req.on("data", chunk => { res.write(chunk); }); req.on("end", () => { res.write("OPTIONS\n"); res.end("Hello World"); }); } else if (req.method === "POST") { req.on("data", chunk => { res.write(chunk); }); req.on("end", () => { res.write("POST\n"); res.end("Hello World"); }); } else { if (req.headers["X-Test"] !== undefined) { res.write(`X-Test: test\n`); } res.write("Maybe GET maybe not\n"); res.end("Hello World"); } }); server.listen({ port: 0 }, (_?: any, __?: any, port?: number) => { var _done = (...args) => { server.close(); done(...args); }; callback(server, port!, _done); }); } // test("check for expected fields", done => { // runTest((server, port) => { // const req = request({ host: "localhost", port, method: "GET" }, res => { // console.log("called"); // res.on("end", () => { // console.log("here"); // server.close(); // done(); // }); // res.on("error", err => { // server.close(); // done(err); // }); // }); // expect(req.path).toEqual("/"); // expect(req.method).toEqual("GET"); // expect(req.host).toEqual("localhost"); // expect(req.protocol).toEqual("http:"); // req.end(); // }); // }); it("should not insert extraneous accept-encoding header", async done => { try { let headers; var server = createServer((req, res) => { headers = req.headers; req.on("data", () => {}); req.on("end", () => { res.end(); }); }); const url = await listen(server); await fetch(url, { decompress: false }); expect(headers["accept-encoding"]).toBeFalsy(); done(); } catch (e) { done(e); } finally { server!.close(); } }); it("multiple Set-Cookie headers works #6810", done => { runTest(done, (server, port, done) => { const req = request(`http://localhost:${port}/multiple-set-cookie`, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(res.headers["set-cookie"]).toEqual(["foo=bar", "bar=baz"]); done(); }); res.on("error", err => done(err)); }); req.setHeader("Cookie", ["foo=bar; bar=baz"]); req.end(); }); }); it("should make a standard GET request when passed string as first arg", done => { runTest(done, (server, port, done) => { const req = request(`http://localhost:${port}`, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Maybe GET maybe not\nHello World"); done(); }); res.on("error", err => done(err)); }); req.end(); }); }); it("should make a https:// GET request when passed string as first arg", _done => { const server = exampleSite(); function done(err?: Error) { server.stop(); _done(err); } const req = https.request(server.url, { ca: server.ca, headers: { "accept-encoding": "identity" } }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toContain("This domain is for use in illustrative examples in documents"); done(); }); res.on("error", err => done(err)); }); req.end(); }); it("should make a POST request when provided POST method, even without a body", done => { runTest(done, (server, serverPort, done) => { const req = request({ host: "localhost", port: serverPort, method: "POST" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("POST\nHello World"); done(); }); res.on("error", err => done(err)); }); req.end(); }); }); it("should correctly handle a POST request with a body", done => { runTest(done, (server, port, done) => { const req = request({ host: "localhost", port, method: "POST" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Posting\nPOST\nHello World"); done(); }); res.on("error", err => done(err)); }); req.write("Posting\n"); req.end(); }); }); it("should noop request.setSocketKeepAlive without error", done => { runTest(done, (server, port, done) => { const req = request(`http://localhost:${port}`); req.setSocketKeepAlive(true, 1000); req.end(); expect(true).toBe(true); // Neglecting to close this will cause a future test to fail. req.on("close", () => done()); }); }); it("should allow us to set timeout with request.setTimeout or `timeout` in options", done => { runTest(done, (server, serverPort, done) => { const createDone = createDoneDotAll(done); const req1Done = createDone(); const req2Done = createDone(); const req1 = request( { host: "localhost", port: serverPort, path: "/timeout", timeout: 500, }, res => { req1Done(new Error("Should not have received response")); }, ); req1.on("timeout", () => req1Done()); const req2 = request( { host: "localhost", port: serverPort, path: "/timeout", }, res => { req2Done(new Error("Should not have received response")); }, ); req2.setTimeout(500, () => { req2Done(); }); req1.end(); req2.end(); }); }); it("should correctly set path when path provided", done => { runTest(done, (server, serverPort, done) => { const createDone = createDoneDotAll(done); const req1Done = createDone(); const req2Done = createDone(); const req1 = request(`http://localhost:${serverPort}/pathTest`, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Path correct!\n"); req1Done(); }); res.on("error", err => req1Done(err)); }); const req2 = request(`http://localhost:${serverPort}`, { path: "/pathTest" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Path correct!\n"); req2Done(); }); res.on("error", err => req2Done(err)); }); req1.end(); req2.end(); expect(req1.path).toBe("/pathTest"); expect(req2.path).toBe("/pathTest"); }); }); it("should emit response when response received", done => { runTest(done, (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}`); req.on("response", res => { expect(res.statusCode).toBe(200); done(); }); req.end(); }); }); // NOTE: Node http.request doesn't follow redirects by default it("should handle redirects properly", done => { runTest(done, (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}/redirect`, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Got redirect!\n"); done(); }); res.on("error", err => done(err)); }); req.end(); }); }); it("should correctly attach headers to request", done => { runTest(done, (server, serverPort, done) => { const req = request({ host: "localhost", port: serverPort, headers: { "X-Test": "test" } }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("x-test: test\nMaybe GET maybe not\nHello World"); done(); }); res.on("error", err => done(err)); }); expect(req.getHeader("X-Test")).toBe("test"); // node returns undefined // Headers returns null expect(req.getHeader("X-Not-Exists")).toBe(undefined); req.end(); }); }); it("should correct casing of method param", done => { runTest(done, (server, serverPort, done) => { const req = request({ host: "localhost", port: serverPort, method: "get" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Maybe GET maybe not\nHello World"); done(); }); res.on("error", err => done(err)); }); req.end(); }); }); it("should allow for port as a string", done => { runTest(done, (server, serverPort, done) => { const req = request({ host: "localhost", port: `${serverPort}`, method: "GET" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Maybe GET maybe not\nHello World"); done(); }); res.on("error", err => done(err)); }); req.end(); }); }); it("should allow us to pass a URL object", done => { runTest(done, (server, serverPort, done) => { const req = request(new URL(`http://localhost:${serverPort}`), { method: "POST" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Hello WorldPOST\nHello World"); done(); }); res.on("error", err => done(err)); }); req.write("Hello World"); req.end(); }); }); it("should ignore body when method is GET/HEAD", done => { runTest(done, (server, serverPort, done) => { const createDone = createDoneDotAll(done); const methods = ["GET", "HEAD"]; const dones = {}; for (const method of methods) { dones[method] = createDone(); } for (const method of methods) { const req = request(`http://localhost:${serverPort}`, { method }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe(method === "GET" ? "Maybe GET maybe not\nHello World" : ""); dones[method](); }); res.on("error", err => dones[method](err)); }); req.write("BODY"); req.end(); } }); }); it("should have a response body when method is OPTIONS", done => { runTest(done, (server, serverPort, done) => { const createDone = createDoneDotAll(done); const methods = ["OPTIONS"]; //keep this logic to add more methods in future const dones = {}; for (const method of methods) { dones[method] = createDone(); } for (const method of methods) { const req = request(`http://localhost:${serverPort}`, { method }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe(method + "\nHello World"); dones[method](); }); res.on("error", err => dones[method](err)); }); req.end(); } }); }); it("should return response with lowercase headers", done => { runTest(done, (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}/lowerCaseHeaders`, res => { expect(res.headers["content-type"]).toBe("text/plain"); expect(res.headers["x-custom-header"]).toBe("custom_value"); done(); }); req.end(); }); }); it("reassign writeHead method, issue#3585", done => { runTest(done, (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}/customWriteHead`, res => { expect(res.headers["content-type"]).toBe("text/plain"); expect(res.headers["x-powered-by"]).toBe("Bun"); done(); }); req.end(); }); }); it("uploading file by 'formdata/multipart', issue#3116", done => { runTest(done, (server, serverPort, done) => { const boundary = "----FormBoundary" + Date.now(); const formDataBegin = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="myfile.txt"\r\nContent-Type: application/octet-stream\r\n\r\n`; const fileData = Buffer.from("80818283", "hex"); const formDataEnd = `\r\n--${boundary}--`; const requestOptions = { hostname: "localhost", port: serverPort, path: "/uploadFile", method: "POST", headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, }, }; const req = request(requestOptions, res => { let responseData = Buffer.alloc(0); res.on("data", chunk => { responseData = Buffer.concat([responseData, chunk]); }); res.on("end", () => { try { expect(responseData).toEqual( Buffer.concat([Buffer.from(formDataBegin), fileData, Buffer.from(formDataEnd)]), ); } catch (e: any) { return done(e); } done(); }); }); req.on("error", err => { done(err); }); req.write(formDataBegin); // string req.write(fileData); // Buffer req.write(formDataEnd); // string req.end(); }); }); it("request via http proxy, issue#4295", async () => { await runHTTPProxyTest(); }); // https://github.com/oven-sh/bun/issues/31795 // A custom agent (the `tunnel` package) establishes an HTTP CONNECT tunnel // by overriding addRequest/createSocket and calling req.onSocket(). Bun // must route through those hooks and emit a CONNECT, not bypass the proxy. it("uses a tunnel.httpsOverHttp() agent and sends CONNECT to the proxy", async () => { const { promise: connectLine, resolve: gotConnect } = Promise.withResolvers(); // Minimal proxy that records the CONNECT request it receives and replies // 502 so the tunnel fails the same way it does in Node. const proxy = createNetServer(socket => { let buf = ""; socket.on("data", chunk => { buf += chunk.toString(); // Buffer to the end of the request-header block — a single TCP // chunk is not guaranteed to carry the full CONNECT line + Host. if (buf.indexOf("\r\n\r\n") === -1) return; gotConnect(buf); socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); }); }); try { await once(proxy.listen(0, "127.0.0.1"), "listening"); const proxyPort = (proxy.address() as AddressInfo).port; const agent = tunnel.httpsOverHttp({ proxy: { host: "127.0.0.1", port: proxyPort } }); const { promise: requestDone, resolve: requestResolve } = Promise.withResolvers<{ status?: number; error?: string; }>(); const req = https.request({ host: "example.com", port: 443, path: "/", method: "GET", agent }, res => { res.resume(); requestResolve({ status: res.statusCode }); }); req.on("error", err => requestResolve({ error: (err as Error).message })); req.end(); // The proxy must receive a CONNECT targeting the requested host:port. const received = await connectLine; expect(received).toContain("CONNECT example.com:443 HTTP/1.1"); expect(received.toLowerCase()).toContain("host: example.com:443"); // And the request must surface the proxy's failure, not a direct 200. const result = await requestDone; expect(result.status).toBeUndefined(); expect(result.error).toContain("statusCode=502"); } finally { await new Promise(r => proxy.close(() => r())); } }); // Full success path: the proxy accepts the CONNECT and pipes bytes through, // so the tunneled request receives the target server's response. it("tunnels a request through an HTTP CONNECT proxy (tunnel.httpOverHttp)", async () => { const target = createServer((req, res) => { res.writeHead(200, { "x-tunneled": "yes" }); res.end("through-the-tunnel"); }); let connectTarget: string | undefined; const proxy = createServer(); proxy.on("connect", (req, clientSocket, head) => { connectTarget = req.url as string; const [host, port] = (req.url as string).split(":"); const serverSocket = connect(Number(port), host, () => { clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head?.length) serverSocket.write(head); serverSocket.pipe(clientSocket); clientSocket.pipe(serverSocket); }); serverSocket.on("error", () => clientSocket.end()); }); try { await once(target.listen(0, "127.0.0.1"), "listening"); const targetPort = (target.address() as AddressInfo).port; await once(proxy.listen(0, "127.0.0.1"), "listening"); const proxyPort = (proxy.address() as AddressInfo).port; const agent = tunnel.httpOverHttp({ proxy: { host: "127.0.0.1", port: proxyPort } }); const { promise, resolve, reject } = Promise.withResolvers<{ status: number; header?: string | string[]; body: string; }>(); const req = http.request({ host: "127.0.0.1", port: targetPort, path: "/", method: "GET", agent }, res => { let body = ""; res.setEncoding("utf8"); res.on("data", c => (body += c)); res.on("end", () => resolve({ status: res.statusCode as number, header: res.headers["x-tunneled"], body })); }); req.on("error", reject); req.end(); const result = await promise; expect(result).toEqual({ status: 200, header: "yes", body: "through-the-tunnel" }); // The request must have gone through the proxy's CONNECT handler, not a // direct connection to the target (which would bypass the agent). expect(connectTarget).toBe(`127.0.0.1:${targetPort}`); } finally { await new Promise(r => proxy.close(() => r())); await new Promise(r => target.close(() => r())); } }); it("should correctly stream a multi-chunk response #5320", async done => { runTest(done, (server, serverPort, done) => { const req = request({ host: "localhost", port: `${serverPort}`, path: "/multi-chunk-response", method: "GET", }); req.on("error", err => done(err)); req.on("response", async res => { const body = res.pipe(new PassThrough({ highWaterMark: 512 })); const response = new Response(body); const text = await response.text(); expect(text.length).toBe(2048); done(); }); req.end(); }); }); it("should emit a socket event when connecting", async done => { runTest(done, async (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}`, {}); // Destroying an in-flight request surfaces a "socket hang up" error, // exactly like Node.js; swallow it so it does not leak into the next // test as an unhandled error. req.on("error", () => {}); req.on("socket", function onRequestSocket(socket) { req.destroy(); done(); }); req.end(); }); }); }); describe("https.request with custom tls options", () => { it("supports custom tls args", async () => { await using httpsServer = exampleSite(); const { promise, resolve, reject } = Promise.withResolvers(); const options: https.RequestOptions & { url: string } = { method: "GET", url: httpsServer.url.href as string, port: httpsServer.url.port, ca: httpsServer.ca, }; const req = https.request(options, res => { res.on("data", () => null); res.on("end", resolve); }); req.on("error", reject); req.end(); await promise; }); }); describe("signal", () => { it("should abort and close the server", done => { const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello World"); }); const interval = setTimeout(() => { server.close(); done(); }, 100); const signal = AbortSignal.timeout(30); signal.addEventListener("abort", () => { clearTimeout(interval); expect(true).toBe(true); done(); }); server.listen({ signal, port: 0 }); }); }); describe("get", () => { it("treats host option containing URL delimiter characters as an unresolvable hostname", async () => { let requestCount = 0; const server = createServer((req, res) => { requestCount++; res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); }); try { const url = await listen(server); // The literal host option string is the DNS/connect target (Node.js semantics). // Characters like "/" and "?" must not allow the value to be re-interpreted as a // URL whose host points at a different server; the only acceptable outcome is a // lookup failure with no request ever being sent. const confusedHost = `127.0.0.1:${url.port}/?.invalid.example`; const { promise, resolve, reject } = Promise.withResolvers(); const req = get({ host: confusedHost, path: "/info", auth: "svc:secret" }, res => { res.resume(); reject(new Error(`request unexpectedly completed with status ${res.statusCode}`)); }); req.on("error", resolve); const err: any = await promise; expect(err.code).toBe("ENOTFOUND"); expect(err.hostname).toBe(confusedHost); expect(requestCount).toBe(0); // A plain host + port still works. const { promise: okPromise, resolve: resolveOk, reject: rejectOk } = Promise.withResolvers(); get({ host: "127.0.0.1", port: url.port, path: "/info" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => (data += chunk)); res.on("end", () => resolveOk({ statusCode: res.statusCode, data })); }).on("error", rejectOk); expect(await okPromise).toEqual({ statusCode: 200, data: "ok" }); expect(requestCount).toBe(1); } finally { server.close(); } }); it("should make a standard GET request, like request", async done => { const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello World"); }); const url = await listen(server); get(url, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => { data += chunk; }); res.on("end", () => { expect(data).toBe("Hello World"); server.close(); done(); }); res.on("error", err => { server.close(); done(err); }); }); }); }); describe("Agent", () => { let dummyAgent; beforeAll(() => { dummyAgent = new Agent(); }); it("should be a class", () => { expect(Agent instanceof Function).toBe(true); }); it("can be constructed with new", () => { expect((new Agent() as any).protocol).toBe("http:"); }); it("can be constructed with apply", () => { expect((Agent as any).apply({}).protocol).toBe("http:"); }); it("should have a default maxSockets of Infinity", () => { expect(dummyAgent.maxSockets).toBe(Infinity); }); it("should have a keepAlive value", () => { expect(dummyAgent.keepAlive).toBe(false); }); it("should noop keepSocketAlive", () => { const agent = new Agent({ keepAlive: true }); // @ts-ignore expect(agent.keepAlive).toBe(true); const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello World"); agent.keepSocketAlive( request({ host: "localhost", port: (server.address() as AddressInfo).port, method: "GET" }) as any, ); (server as any).end(); }); }); it("should provide globalAgent", () => { expect(globalAgent instanceof Agent).toBe(true); }); }); describe("ClientRequest.signal", () => { it("should attempt to make a standard GET request and abort", async () => { let server_port; let server_host; const { resolve: resolveClientAbort, reject: rejectClientAbort, promise: promiseClientAbort, } = Promise.withResolvers(); const server = createServer((req, res) => {}); server.listen({ port: 0 }, (_err?: any, host?: string, port?: number) => { server_port = port; server_host = host; const signal = AbortSignal.timeout(5); // Like Node.js, aborting via an AbortSignal surfaces an AbortError // "error" event on the request (the legacy "abort" event is only // emitted by req.abort()). get(`http://${server_host}:${server_port}`, { signal }, res => { rejectClientAbort(new Error("the server never responds; the request should have been aborted")); }).once("error", err => { try { expect(err.name).toBe("AbortError"); resolveClientAbort(); } catch (e) { rejectClientAbort(e); } }); }); await promiseClientAbort; server.close(); }); }); test("validateHeaderName", () => { validateHeaderName("Foo"); expect(() => validateHeaderName("foo:")).toThrow(); expect(() => validateHeaderName("foo:bar")).toThrow(); }); test("validateHeaderValue", () => { validateHeaderValue("Foo", "Bar"); expect(() => validateHeaderValue("Foo", undefined as any)).toThrow(); expect(() => validateHeaderValue("Foo", "Bar\r")).toThrow(); }); test("req.req = req", done => { const server = createServer((req, res) => { (req as any).req = req; res.write((req as any).req === req ? "ok" : "fail"); res.end(); }); server.listen({ port: 0 }, async (_err?: any, host?: string, port?: number) => { try { const x = await fetch(`http://${host}:${port}`).then(res => res.text()); expect(x).toBe("ok"); done(); } catch (error) { done(error); } finally { server.close(); } }); }); test("test unix socket server", async () => { const { promise, resolve, reject } = Promise.withResolvers(); const socketPath = `${tmpdir()}/bun-server-${Math.random().toString(32)}.sock`; await using server = createServer((req, res) => { expect(req.method).toStrictEqual("GET"); expect(req.url).toStrictEqual("/bun?a=1"); res.writeHead(200, { "Content-Type": "text/plain", "Connection": "close", }); res.write("Bun\n"); res.end(); }); server.listen(socketPath, async () => { try { const response = await fetch(`http://localhost/bun?a=1`, { unix: socketPath, }); const text = await response.text(); expect(text).toBe("Bun\n"); resolve(); } catch (err) { reject(err); } }); await promise; }); test("should not decompress gzip, issue#4397", async () => { using server = Bun.serve({ port: 0, tls: tlsCert, fetch() { const body = Bun.gzipSync(Buffer.from("Hello")); return new Response(body, { headers: { "content-encoding": "gzip" }, }); }, }); const { promise, resolve } = Promise.withResolvers(); https .request(server.url, { ca: tlsCert.cert, headers: { "accept-encoding": "gzip" } }, res => { res.on("data", function cb(chunk) { resolve(chunk); res.off("data", cb); }); }) .end(); const chunk = await promise; expect(chunk.toString()).not.toContain(" { const server = createServer((req, res) => { res.end(); }); server.listen({ port: "0" }, async (_err?: any, host?: string, port?: number) => { try { await fetch(`http://${host}:${port}`).then(res => { expect(res.status).toBe(200); done(); }); } catch (err) { done(err); } finally { server.close(); } }); }); test("error event not fired, issue#4651", async () => { const { promise, resolve } = Promise.withResolvers(); const server = createServer((req, res) => { res.end(); }); server.listen({ port: 0 }, () => { const server2 = createServer((_, res) => { res.end(); }); server2.on("error", err => { resolve(err); }); server2.listen({ port: (server.address() as AddressInfo).port }, () => {}); }); const err = await promise; expect(err.code).toBe("EADDRINUSE"); }); }); describe("node https server", async () => { const httpsOptions = { key: nodefs.readFileSync(path.join(import.meta.dir, "fixtures", "cert.key")), cert: nodefs.readFileSync(path.join(import.meta.dir, "fixtures", "cert.pem")), }; const createServer = onRequest => { return new Promise<{ server: https.Server; done: () => void; url: URL }>(resolve => { const server = createHttpsServer(httpsOptions, (req, res) => { onRequest(req, res); }); listen(server, "https").then(url => { resolve({ server, done: () => server.close(), url, }); }); }); }; it("is marked encrypted (#5867)", async () => { const { server, url, done } = await createServer(async (req, res) => { expect(req.connection.encrypted).toBe(true); res.end(); }); try { await fetch(url, { tls: { rejectUnauthorized: false } }); } catch (e) { throw e; } finally { done(); } }); }); describe("server.address should be valid IP", () => { it("should return null before listening", done => { const server = createServer((req, res) => {}); try { expect(server.address()).toBeNull(); done(); } catch (err) { done(err); } }); it("should return null after close", done => { const server = createServer((req, res) => {}); server.listen(0, async (_err?: any, host?: string, port?: number) => { try { expect(server.address()).not.toBeNull(); server.close(); expect(server.address()).toBeNull(); done(); } catch (err) { done(err); } }); }); it("test default hostname, issue#5850", done => { const server = createServer((req, res) => {}); server.listen(0, async (_err?: any, host?: string, port?: number) => { try { const { address, family, port } = server.address() as AddressInfo; expect(port).toBeInteger(); expect(port).toBeGreaterThan(0); expect(port).toBeLessThan(65536); expect(["::", "0.0.0.0"]).toContain(address); if (address === "0.0.0.0") { expect(family).toStrictEqual("IPv4"); } else { expect(family).toStrictEqual("IPv6"); } done(); } catch (err) { done(err); } finally { server.close(); } }); }); it.each([["localhost"], ["127.0.0.1"]])("test %s", (hostname, done) => { const server = createServer((req, res) => {}); server.listen(0, hostname, async (_err?: any, host?: string, port?: number) => { try { const { address, family } = server.address() as AddressInfo; expect(port).toBeInteger(); expect(port).toBeGreaterThan(0); expect(port).toBeLessThan(65536); expect(["IPv4", "IPv6"]).toContain(family); if (family === "IPv4") { expect(address).toStrictEqual("127.0.0.1"); } else { expect(address).toStrictEqual("::1"); } done(); } catch (err) { done(err); } finally { server.close(); } }); }); it("test unix socket, issue#6413", done => { const socketPath = `${tmpdir()}/bun-server-${Math.random().toString(32)}.sock`; const server = createServer((req, res) => {}); server.listen(socketPath, async (_err?: any, host?: string, port?: number) => { try { expect(server.address()).toStrictEqual(socketPath); done(); } catch (err) { done(err); } finally { server.close(); } }); }); test("ServerResponse init", done => { try { const req: any = {}; const res = new ServerResponse(req); expect(res.req).toBe(req); done(); } catch (err) { done(err); } }); test("ServerResponse instanceof OutgoingMessage", () => { expect(new ServerResponse({} as any) instanceof OutgoingMessage).toBe(true); }); test("ServerResponse assign assignSocket", async done => { const createDone = createDoneDotAll(done); const doneRequest = createDone(); const waitSocket = createDone(); const doneSocket = createDone(); try { const socket: any = new EventEmitter(); const res = new ServerResponse({} as any); res.once("socket", emitted => { expect(emitted).toBe(socket); waitSocket(); }); res.once("close", () => { doneRequest(); }); res.assignSocket(socket); await Bun.sleep(10); expect(res.socket).toBe(socket); expect(socket._httpMessage).toBe(res); expect(() => res.assignSocket(socket)).toThrow("ServerResponse has an already assigned socket"); socket.emit("close"); doneSocket(); } catch (err: any) { doneRequest(err); } }); }); it("should propagate exception in sync data handler", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "run", path.join(import.meta.dir, "node-http-error-in-data-handler-fixture.1.js")], stdout: "pipe", stderr: "inherit", env: bunEnv, }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(stdout).toContain("Test passed"); expect(exitCode).toBe(0); }); it("should propagate exception in async data handler", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "run", path.join(import.meta.dir, "node-http-error-in-data-handler-fixture.2.js")], stdout: "pipe", stderr: "inherit", env: bunEnv, }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(stdout).toContain("Test passed"); expect(exitCode).toBe(0); }); it("request(urlString, cb) does not read options from Object.prototype", async () => { // Pollutes Object.prototype, so it runs in a subprocess. const script = ` const net = require("node:net"); const http = require("node:http"); const heads = []; const server = net.createServer(socket => { socket.once("data", data => { heads.push(String(data).split("\\r\\n\\r\\n")[0]); socket.end("HTTP/1.1 200 OK\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n"); }); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); const url = "http://127.0.0.1:" + server.address().port + "/victim"; const get = () => new Promise((resolve, reject) => { http.get(url, res => { res.resume(); res.on("end", resolve); }).on("error", reject); }); const clean = await get().then(() => heads[0]); Object.prototype.headers = { "x-injected": "1", host: "attacker.example" }; Object.prototype.auth = "attacker:pw"; Object.prototype.method = "DELETE"; await get(); let hijacked = false; const { promise: hijack, resolve: onHijack } = Promise.withResolvers(); Object.prototype.agent = { addRequest(req) { hijacked = true; req.destroy(); onHijack(); }, }; await Promise.race([get(), hijack]); server.close(); console.log(JSON.stringify({ same: heads[1] === clean, hijacked, head: heads[1] })); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], stdout: "pipe", stderr: "pipe", env: bunEnv, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const result = JSON.parse(stdout); expect(result.head).not.toContain("x-injected"); expect(result.head).not.toContain("Authorization"); expect(result.head).toStartWith("GET /victim HTTP/1.1"); expect(result).toMatchObject({ same: true, hijacked: false }); expect(exitCode).toBe(0); }); it("a paused Upgrade request destroyed in its listener does not crash while the TLS close is deferred", async () => { // The request is destroyed inside the read that carried it, and spilled TLS // bytes keep the socket open after that read is parsed. await using proc = Bun.spawn({ cmd: [bunExe(), "run", path.join(import.meta.dir, "node-http-upgrade-paused-destroy-tls-fixture.js")], stdout: "pipe", stderr: "pipe", env: bunEnv, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); }); // This test is disabled because it can OOM the CI it.skip("should be able to stream huge amounts of data", async () => { const buf = Buffer.alloc(1024 * 1024 * 256); const CONTENT_LENGTH = 3 * 1024 * 1024 * 1024; let received = 0; let written = 0; const { promise: listen, resolve: resolveListen } = Promise.withResolvers(); const server = http .createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain", "Content-Length": CONTENT_LENGTH, }); function commit() { if (written < CONTENT_LENGTH) { written += buf.byteLength; res.write(buf, commit); } else { res.end(); } } commit(); }) .listen(0, "localhost", resolveListen); await listen; try { const response = await fetch(`http://localhost:${(server.address() as AddressInfo).port}`); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("text/plain"); const reader = response.body!.getReader(); while (true) { const { done, value } = await reader.read(); received += value ? value.byteLength : 0; if (done) { break; } } expect(written).toBe(CONTENT_LENGTH); expect(received).toBe(CONTENT_LENGTH); } finally { server.close(); } }, 30_000); describe("HTTP Server Security Tests - Advanced", () => { // Setup and teardown utilities let server; let port; beforeEach(async () => { server = new Server(); server.listen(0, () => { port = server.address().port; }); await once(server, "listening"); }); afterEach(async () => { // Close the server if it's still running if (server.listening) { server.closeAllConnections(); server.close(); } }); // Helper that returns a promise with the server response const sendRequest = message => { return new Promise((resolve, reject) => { const client = connect(port, "localhost"); let response = ""; client.setEncoding("utf8"); client.on("data", chunk => { response += chunk; }); client.on("error", reject); client.on("end", () => { resolve((response as any).toString("utf8")); }); client.write(message); }); }; // Mock request handler that simulates security-sensitive operations const createMockHandler = () => { const mockHandler = jest.fn().mockImplementation((req, res) => { // In a real app, this might be a security-sensitive operation res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Request processed successfully"); }); return mockHandler; }; // Like Node, installing a 'clientError' listener makes the listener // responsible for the connection: reply with a raw 400 and close, mirroring // Node's documented handler, so the client still observes the rejection. const replyBadRequest = socket => { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); }; // Test Suites describe("Header Injection Protection", () => { test("rejects requests with CR in header field name", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { expect(err.code).toBe("HPE_INVALID_HEADER_TOKEN"); resolve(); } catch (err) { reject(err); } }); const msg = ["GET / HTTP/1.1", "Host: localhost", "Bad\rHeader: value", "", ""].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; expect(mockHandler).not.toHaveBeenCalled(); }); test("rejects requests with CR in header field value", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { // Node reports a bare CR not followed by LF as HPE_LF_EXPECTED. expect(err.code).toBe("HPE_LF_EXPECTED"); resolve(); } catch (err) { reject(err); } }); const msg = ["GET / HTTP/1.1", "Host: localhost", "X-Custom: bad\rvalue", "", ""].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; expect(mockHandler).not.toHaveBeenCalled(); }); }); describe("Transfer-Encoding Attacks", () => { test("rejects chunked requests with malformed chunk size", async () => { const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { expect(err.code).toBe("HPE_INVALID_CHUNK_SIZE"); resolve(); } catch (err) { reject(err); } }); const msg = [ "POST / HTTP/1.1", "Host: localhost", "Transfer-Encoding: chunked", "", "XYZ\r\n", // Not a valid hex number "data", "0", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; }); test("rejects chunked requests with invalid chunk ending", async () => { const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { expect(err.code).toBe("HPE_STRICT"); expect(err.message).toBe("Parse Error: Expected LF after chunk data"); resolve(); } catch (err) { reject(err); } }); const msg = [ "POST / HTTP/1.1", "Host: localhost", "Transfer-Encoding: chunked", "", "4", "dataXXXX", // Should be "data\r\n" "0", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; }); }); describe("HTTP Request Smuggling", () => { test("rejects requests with both Content-Length and Transfer-Encoding", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { expect(err.code).toBe("HPE_INVALID_TRANSFER_ENCODING"); resolve(); } catch (err) { reject(err); } }); const msg = [ "POST / HTTP/1.1", "Host: localhost", "Content-Length: 10", "Transfer-Encoding: chunked", "", "5", "hello", "0", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; expect(mockHandler).not.toHaveBeenCalled(); }); test("rejects requests with obfuscated Transfer-Encoding header", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { expect(err.code).toBe("HPE_INVALID_HEADER_TOKEN"); resolve(); } catch (err) { reject(err); } }); const msg = [ "POST / HTTP/1.1", "Host: localhost", "Content-Length: 11", "Transfer-Encoding : chunked", // Note the space before colon "", "5", "hello", "0", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("400 Bad Request"); await promise; expect(mockHandler).not.toHaveBeenCalled(); }); test("duplicate request headers follow Node.js precedence rules", async () => { // Expected values verified against Node.js v24: singleton headers keep // the first value, joinable headers are comma-joined, Cookie joins with // "; ", and Set-Cookie becomes an array. const { promise, resolve, reject } = Promise.withResolvers(); server.on("request", (req, res) => { try { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); resolve({ host: req.headers.host, contentType: req.headers["content-type"], authorization: req.headers.authorization, accept: req.headers.accept, xCustom: req.headers["x-custom"], cookie: req.headers.cookie, setCookie: req.headers["set-cookie"], rawHostCount: req.rawHeaders.filter(h => h.toLowerCase() === "host").length, }); } catch (err) { reject(err); } }); const msg = [ "GET / HTTP/1.1", "Host: first.example.com", "Host: second.example.com", "Content-Type: text/plain", "Content-Type: text/html", "Authorization: token1", "Authorization: token2", "Accept: application/json", "Accept: text/html", "X-Custom: one", "X-Custom: two", "Cookie: a=1", "Cookie: b=2", "Set-Cookie: x=1", "Set-Cookie: y=2", "Connection: close", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("200"); const headers: any = await promise; // Singleton headers keep the first value. expect(headers.host).toBe("first.example.com"); expect(headers.contentType).toBe("text/plain"); expect(headers.authorization).toBe("token1"); // Other headers are joined with ", ". expect(headers.accept).toBe("application/json, text/html"); expect(headers.xCustom).toBe("one, two"); // Cookie is joined with "; ". expect(headers.cookie).toBe("a=1; b=2"); // Set-Cookie is collected into an array. expect(headers.setCookie).toEqual(["x=1", "y=2"]); // rawHeaders still reports every received header. expect(headers.rawHostCount).toBe(2); }); test("duplicate request header edge cases follow Node.js precedence rules", async () => { // Expected values verified against Node.js v24. const { promise, resolve, reject } = Promise.withResolvers(); server.on("request", (req, res) => { try { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok"); resolve({ xTriple: req.headers["x-triple"], xMixed: req.headers["x-mixed"], xEmpty: req.headers["x-empty"], server: req.headers.server, retryAfter: req.headers["retry-after"], numeric: req.headers["123"], rawHeaderCount: req.rawHeaders.length, }); } catch (err) { reject(err); } }); const msg = [ "GET / HTTP/1.1", "Host: localhost", "X-Triple: one", "X-Triple: two", "X-Triple: three", "x-MIXED: a", "X-Mixed: b", "X-Empty:", "X-Empty: b", "Server: apache", "Server: nginx", "Retry-After: 10", "Retry-After: 20", "123: a", "123: b", "Connection: close", "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response).toInclude("200"); const headers: any = await promise; expect(headers).toEqual({ // Three or more occurrences are all joined, in order. xTriple: "one, two, three", // Names that differ only by case are the same header. xMixed: "a, b", // An empty first value still participates in the join. xEmpty: ", b", // Singleton headers keep the first value, including the ones WebCore // has no HTTPHeaderName for (server, retry-after). server: "apache", retryAfter: "10", // A header whose name parses as an array index joins like any other. numeric: "a, b", // rawHeaders still reports every received header (15 names + values). rawHeaderCount: 30, }); }); }); describe("HTTP Protocol Violations", () => { test("rejects requests with invalid HTTP version", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const { promise, resolve, reject } = Promise.withResolvers(); server.on("clientError", (err: any, socket) => { replyBadRequest(socket); try { // Node reports an unsupported version as HPE_INVALID_VERSION. expect(err.code).toBe("HPE_INVALID_VERSION"); resolve(); } catch (err) { reject(err); } }); const msg = [ "GET / HTTP/9.9", // Invalid HTTP version "Host: localhost", "", "", ].join("\r\n"); const response = await sendRequest(msg); // Like Node, the parse error is answered with 400 Bad Request (the // 'clientError' handler above owns the reply). expect(response).toInclude("400 Bad Request"); await promise; expect(mockHandler).not.toHaveBeenCalled(); }); test("rejects a request target with a byte that Node's parser rejects", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const codes: string[] = []; server.on("clientError", (err: any, socket) => { codes.push(err.code); replyBadRequest(socket); }); const targets = [ "/\x7f", "/\x80", "/\xff", "/\xc3\xa9", "/\x01", "/a?b=\x7f", "/a?b=\xff", "/a#\xff", "/aaaaaaaaaaaaaaaaaaaa\xffaaaaaaaaaaaaaaaaaaaa", "http://a\xff/", ]; for (const target of targets) { const msg = Buffer.from(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, "latin1"); expect(await sendRequest(msg)).toStartWith("HTTP/1.1 400 Bad Request"); } expect(codes).toEqual(targets.map(() => "HPE_INVALID_URL")); expect(mockHandler).not.toHaveBeenCalled(); }); test("answers 400 to an invalid request target without a 'clientError' listener", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); const msg = Buffer.from("GET /\xff HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", "latin1"); expect(await sendRequest(msg)).toBe("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); expect(mockHandler).not.toHaveBeenCalled(); }); test("rejects DEL in a header or trailer value unless the parser is lenient", async () => { const seen: string[] = []; const onRequest = (req, res) => { req.resume().on("end", () => { seen.push(req.headers["x-a"] ?? req.trailers["x-a"]); res.end(); }); }; const codes: string[] = []; server.on("request", onRequest); server.on("clientError", (err: any, socket) => { codes.push(err.code); replyBadRequest(socket); }); const header = value => Buffer.from(`GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-A: ${value}\r\n\r\n`, "latin1"); const trailer = value => Buffer.from( `POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-A: ${value}\r\n\r\n`, "latin1", ); for (const msg of [header("\x7f"), header("aaaaaaaaaaaaaaaa\x7faaaaaaaaaaaaaaaa"), trailer("a\x7fb")]) { expect(await sendRequest(msg)).toStartWith("HTTP/1.1 400 Bad Request"); } expect(codes).toEqual(["HPE_INVALID_HEADER_TOKEN", "HPE_INVALID_HEADER_TOKEN", "HPE_INVALID_HEADER_TOKEN"]); expect(seen).toEqual([]); // Bytes from 0x80 stay valid and reach the handler as latin1. expect(await sendRequest(header("a\x80\xffb"))).toStartWith("HTTP/1.1 200 OK"); expect(seen).toEqual(["a\x80\xffb"]); for (const options of [ { insecureHTTPParser: true }, { httpValidation: "insecure" }, { httpValidation: "relaxed" }, ]) { server.close(); server = new Server(options as any, onRequest); await once(server.listen(0), "listening"); port = server.address().port; seen.length = 0; for (const msg of [header("a\x7fb"), trailer("c\x7fd")]) { expect(await sendRequest(msg)).toStartWith("HTTP/1.1 200 OK"); } expect({ options, seen }).toEqual({ options, seen: ["a\x7fb", "c\x7fd"] }); // No leniency applies to the request target. for (const target of ["/\x7f", "/\xff"]) { const msg = Buffer.from(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, "latin1"); expect(await sendRequest(msg)).toStartWith("HTTP/1.1 400 Bad Request"); } expect(seen).toHaveLength(2); } }); test("rejects requests with missing Host header in HTTP/1.1", async () => { const mockHandler = createMockHandler(); server.on("request", mockHandler); // Node answers this from parserOnIncoming (res.writeHead(400, ['Connection', // 'close']); res.end()) - no parse error reaches socketOnError, so // 'clientError' never fires. const clientError = jest.fn(); server.on("clientError", clientError); const msg = [ "GET / HTTP/1.1", // Missing Host header "", "", ].join("\r\n"); const response = await sendRequest(msg); expect(response.replace(/Date: [^\r]+/, "Date: ")).toBe( "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nDate: \r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", ); expect(clientError).not.toHaveBeenCalled(); expect(mockHandler).not.toHaveBeenCalled(); }); test("https server fires clientError with HPE_INVALID_EOF_STATE when the client closes mid-request", async () => { // The TLS layer used to force-close on the peer's EOF without ever // dispatching it to the HTTP layer, so a premature EOF that fires // 'clientError' over plain http was silently swallowed over https. // Node reports HPE_INVALID_EOF_STATE on both transports. const httpsServer = createHttpsServer(tlsCert, () => {}); const clientErr = Promise.withResolvers(); httpsServer.on("clientError", (err, socket) => { socket.destroy(); clientErr.resolve(err); }); await new Promise(resolve => httpsServer.listen(0, "127.0.0.1", resolve)); try { const port = (httpsServer.address() as AddressInfo).port; const socket = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { socket.write("POST / HTTP/1.1\r\nHost:"); socket.end(); }); socket.on("error", () => {}); const err = await clientErr.promise; expect(err.code).toBe("HPE_INVALID_EOF_STATE"); } finally { httpsServer.close(); } }); }); describe("Response Splitting Protection", () => { test("rejects CRLF in statusMessage set via property assignment followed by res.end()", async () => { const { promise: errorPromise, resolve: resolveError } = Promise.withResolvers(); server.on("request", (req, res) => { res.statusCode = 200; res.statusMessage = "OK\r\nSet-Cookie: admin=true"; try { res.end("body"); } catch (e: any) { resolveError(e); res.statusMessage = "OK"; res.end("safe"); } }); const response = (await sendRequest("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) as string; const err = await errorPromise; expect((err as any).code).toBe("ERR_INVALID_CHAR"); // The injected Set-Cookie header must NOT appear in the response expect(response).not.toInclude("Set-Cookie: admin=true"); }); test("rejects CRLF in statusMessage set via property assignment followed by res.write()", async () => { const { promise: errorPromise, resolve: resolveError } = Promise.withResolvers(); server.on("request", (req, res) => { res.statusCode = 200; res.statusMessage = "OK\r\nX-Injected: evil"; try { res.write("chunk"); } catch (e: any) { resolveError(e); res.statusMessage = "OK"; res.end("safe"); } }); const response = (await sendRequest("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) as string; const err = await errorPromise; expect((err as any).code).toBe("ERR_INVALID_CHAR"); expect(response).not.toInclude("X-Injected: evil"); }); test("rejects CRLF in statusMessage passed to writeHead()", async () => { server.on("request", (req, res) => { expect(() => { res.writeHead(200, "OK\r\nX-Injected: evil"); }).toThrow(/Invalid character in statusMessage/); res.writeHead(200, "OK"); res.end("safe"); }); const response = (await sendRequest("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) as string; expect(response).not.toInclude("X-Injected"); expect(response).toInclude("safe"); }); test("allows valid statusMessage without control characters", async () => { server.on("request", (req, res) => { res.statusCode = 200; res.statusMessage = "Everything Is Fine"; res.end("ok"); }); const response = (await sendRequest("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) as string; expect(response).toInclude("200 Everything Is Fine"); expect(response).toInclude("ok"); }); }); test("Server should not crash in clientError is emitted when calling destroy", async () => { // A Host-less request is NOT a client error in Node: parserOnIncoming answers // it with 400 itself. An invalid method is what reaches 'clientError'. const invalidRequest = "FOO_BAR / HTTP/1.1\r\nHost: localhost\r\n\r\n"; await using server = http.createServer(async (req, res) => { res.end("Hello World"); }); const codes: string[] = []; const clientErrors: Promise[] = []; server.on("clientError", (err: any, socket) => { codes.push(err.code); clientErrors.push( Bun.sleep(10).then(() => { socket.destroy(); }), ); }); await once(server.listen(), "listening"); const address = server.address() as AddressInfo; async function doRequests(address: AddressInfo) { const client = connect(address.port, address.address, () => { client.write("GET / HTTP/1.1\r\nHost: localhost:3000\r\nContent-Length: 0\r\n\r\n"); }); { const { promise, resolve, reject } = Promise.withResolvers(); client.on("data", resolve); client.on("error", reject); client.on("end", resolve); await promise; } { const { promise, resolve, reject } = Promise.withResolvers(); client.write(invalidRequest); client.on("error", reject); client.on("end", resolve); await promise; } } async function doInvalidRequests(address: AddressInfo) { const client = connect(address.port, address.address, () => { client.write(invalidRequest); }); const { promise, resolve, reject } = Promise.withResolvers(); client.on("error", reject); client.on("close", resolve); await promise; } await doRequests(address); await Promise.all(clientErrors); expect(codes).toEqual(["HPE_INVALID_METHOD"]); clientErrors.length = 0; await doInvalidRequests(address); await Promise.all(clientErrors); expect(codes).toEqual(["HPE_INVALID_METHOD", "HPE_INVALID_METHOD"]); }); test("flushHeaders should send the headers immediately", async () => { let headers_sent_at: number = 0; let server_res: http.ServerResponse | undefined; await using server = http.createServer(async (req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); headers_sent_at = Date.now(); server_res = res; res.flushHeaders(); }); await once(server.listen(0, "127.0.0.1"), "listening"); const address = server.address() as AddressInfo; const response = await fetch(`http://127.0.0.1:${address.port}`); expect(Date.now() - headers_sent_at).toBeLessThan(100); expect(server_res).toBeDefined(); server_res!.write("Hello", () => { server_res!.end(" World"); }); const text = await response.text(); expect(text).toBe("Hello World"); }); }); it("native server socket handle accessors return undefined for non-socket receivers", async () => { // The custom getters/setters on the native server-socket prototype must verify the // receiver type. Reflect.get(proto, name, {}) invokes the native accessor with an // arbitrary object as `this`; it must return undefined instead of reading native // fields out of the foreign object's storage. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); const server = http.createServer((req, res) => { let failure; try { const socket = req.socket; const handleSym = Object.getOwnPropertySymbols(socket).find(s => s.description === "handle"); const handle = handleSym && socket[handleSym]; if (!handle || typeof handle.write !== "function") { throw new Error("could not locate the native socket handle"); } const proto = Object.getPrototypeOf(handle); const getters = [ "closed", "bytesWritten", "secureEstablished", "response", "duplex", "remoteAddress", "localAddress", "onclose", "ondrain", "ondata", ]; for (const name of getters) { // Plain object with populated inline properties as the receiver. const fake = { a: 1.1, b: 2.2, c: 3.3, d: 4.4, e: 5.5, f: 6.6 }; const viaReflect = Reflect.get(proto, name, fake); if (viaReflect !== undefined) { throw new Error(name + " getter returned a value for a plain-object receiver: " + String(viaReflect)); } // The prototype object itself is also not a socket handle. const viaProto = proto[name]; if (viaProto !== undefined) { throw new Error(name + " getter returned a value for the prototype receiver: " + String(viaProto)); } } for (const name of ["duplex", "onclose", "ondrain", "ondata"]) { // Setters must not write through a non-socket receiver. Reflect.set(proto, name, function () {}, { a: 1.1, b: 2.2, c: 3.3 }); } // The real handle still works through the same accessors. if (typeof handle.closed !== "boolean") throw new Error("handle.closed is not a boolean"); if (typeof handle.bytesWritten !== "number") throw new Error("handle.bytesWritten is not a number"); } catch (err) { failure = err; } if (failure) { console.error(failure && (failure.stack || failure.message || failure)); res.end("FAIL"); } else { console.log("OK"); res.end("PASS"); } server.close(); }); server.listen(0, "127.0.0.1", () => { fetch("http://127.0.0.1:" + server.address().port + "/").then(r => r.text()); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toContain("OK"); expect(exitCode).toBe(0); }, 15_000); it("socket handle write keeps buffered data intact when encoding coercion re-enters write", async () => { // Argument conversion for the native socket write can run arbitrary JS (an encoding // object's toString). If that JS calls write() again on the same socket, both the // re-entrant write's data and the outer write's data must survive; nothing may be // dropped or written through a stale buffer. // // The raw handle.write()/streamBuffer path only has its drain machinery wired up for // CONNECT-tunneled sockets (uWS HttpContext::onWritable gates onSocketDrain on // isConnectRequest), so the scenario must be driven from a "connect" handler — on a // plain GET the buffered bytes would never flush and the fixture would hang. const MB = 1024 * 1024; const expectedTotal = 8 * MB + 4 * MB + 4 * MB; await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); const net = require("node:net"); const MB = 1024 * 1024; const A = Buffer.alloc(8 * MB, 0x61); const B = Buffer.alloc(4 * MB, 0x62); const C = Buffer.alloc(4 * MB, 0x63); const server = http.createServer(); server.on("connect", (req, socket) => { const handleSym = Object.getOwnPropertySymbols(socket).find(s => s.description === "handle"); const handle = handleSym && socket[handleSym]; if (!handle || typeof handle.write !== "function") { console.error("could not locate the native socket handle"); process.exit(1); } // The CONNECT path already wired handle.ondrain (kEnableStreaming), so the native // writable handler will flush the stream buffer as the client reads. // The client cannot read while this handler runs synchronously on the same thread, // so most of this 8 MB lands in the native stream buffer. handle.write(A); // The encoding object's toString() re-enters write() on the same socket while the // outer call is still converting its arguments. handle.write(B, { toString() { handle.write(C); return "utf8"; }, }); handle.end(); }); server.listen(0, "127.0.0.1", () => { let received = 0; let aCount = 0, bCount = 0, cCount = 0; const client = net.connect(server.address().port, "127.0.0.1", () => { client.write("CONNECT example.com:443 HTTP/1.1\\r\\nHost: example.com:443\\r\\n\\r\\n"); }); client.on("data", chunk => { received += chunk.length; for (let i = 0; i < chunk.length; i++) { const b = chunk[i]; if (b === 0x61) aCount++; else if (b === 0x62) bCount++; else if (b === 0x63) cCount++; } }); client.on("end", () => { console.log("received=" + received + " a=" + aCount + " b=" + bCount + " c=" + cCount); process.exit(0); }); client.on("error", err => { console.error(err); process.exit(1); }); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toContain("received=" + expectedTotal + " a=" + 8 * MB + " b=" + 4 * MB + " c=" + 4 * MB); expect(exitCode).toBe(0); }, 30_000); it.each([ ["write", `result = res.write(payload, enc); res.end();`, "returned boolean"], ["end", `res.flushHeaders(); result = res.end(payload, enc);`, "returned object"], ])( "ServerResponse.%s() with an encoding whose toPrimitive destroys the response does not crash", async (_method, call, expected) => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); const server = http.createServer((req, res) => { const enc = Object.assign(new String("hex"), { [Symbol.toPrimitive]() { res.destroy(); Bun.gc(true); return "hex"; }, }); const payload = Buffer.alloc(40000, "41").toString(); let result; try { ${call} result = "returned " + typeof result; } catch (e) { result = "threw " + (e.code || e.message); } console.log(result); setImmediate(() => { server.close(); process.exit(0); }); }); server.listen(0, "127.0.0.1", () => { fetch("http://127.0.0.1:" + server.address().port + "/").then(r => r.text()).catch(() => {}); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toBe(expected + "\n"); expect(exitCode).toBe(0); }, ); it("client request path that does not begin with a slash stays on the configured host", async () => { // `options.path` must only ever influence the request target that is written // on the wire; it must never change which server the client connects to, // even when it looks like an absolute URL pointing somewhere else. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); // The server the request is configured to reach. const intended = http.createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("intended " + req.url); }); // A second server that must never receive the request. let decoyRequests = 0; const decoy = http.createServer((req, res) => { decoyRequests++; res.writeHead(200, { "Content-Type": "text/plain" }); res.end("decoy"); }); function get(options) { return new Promise((resolve, reject) => { const req = http.request(options, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => (data += chunk)); res.on("end", () => resolve(data)); }); req.on("error", reject); req.end(); }); } intended.listen(0, "127.0.0.1", () => { decoy.listen(0, "127.0.0.1", async () => { const intendedPort = intended.address().port; const decoyPort = decoy.address().port; try { // An absolute-form request target pointing at another server must // stay on the configured host and be sent verbatim as the // request target (like Node.js). const decoyUrl = "http://127.0.0.1:" + decoyPort + "/"; const answered = await get({ host: "127.0.0.1", port: intendedPort, path: decoyUrl, }); if (!answered.startsWith("intended ")) { throw new Error("request was answered by the wrong server: " + answered); } if (!answered.includes(decoyUrl)) { throw new Error("request path was not preserved: " + answered); } // An ordinary path still reaches the configured host unchanged. const ok = await get({ host: "127.0.0.1", port: intendedPort, path: "/hello?world" }); if (ok !== "intended /hello?world") { throw new Error("ordinary path broke: " + ok); } if (decoyRequests !== 0) { throw new Error("the other server received " + decoyRequests + " request(s)"); } console.log("OK"); } catch (err) { console.error(err && (err.stack || err.message || err)); process.exitCode = 1; } finally { intended.close(); decoy.close(); } }); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toContain("OK"); expect(exitCode).toBe(0); }, 15_000); it("http.request rejects an options.port that is not a valid port number", async () => { // options.port must be an integer in the range 0-65535 (Node.js throws // ERR_SOCKET_BAD_PORT for anything else). Bun builds the request target as // `${protocol}//${host}:${port}${path}`, so an arbitrary string port such as // "80@other-host/" must be rejected up front instead of being parsed as part // of the URL authority, which would change the host the request is sent to. const getError = (fn: () => unknown) => { try { fn(); } catch (err) { return err as NodeJS.ErrnoException; } return undefined; }; for (const badPort of ["80@169.254.169.254/latest/meta-data/?", "1234abc", -1, 65536]) { const err = getError(() => http.request({ host: "127.0.0.1", port: badPort, path: "/" })); expect(err?.code).toBe("ERR_SOCKET_BAD_PORT"); } const typeErr = getError(() => http.request({ host: "127.0.0.1", port: {} as any, path: "/" })); expect(typeErr?.code).toBe("ERR_INVALID_ARG_TYPE"); // A valid port keeps working, including when passed as a numeric string. const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("ok " + req.url); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const body = await new Promise((resolve, reject) => { const req = http.request({ host: "127.0.0.1", port: String(port), path: "/hello" }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => (data += chunk)); res.on("end", () => resolve(data)); }); req.on("error", reject); req.end(); }); expect(body).toBe("ok /hello"); } finally { server.close(); } }); it("ClientRequest.destroy(err) emits 'error' before the terminal 'close'", async () => { // Node: socketErrorListener forwards the error to the request before // socketCloseListener emits 'close'. Code treating 'close' as terminal // (e.g. removing listeners there) must still observe the error. const server = http.createServer((req, res) => { res.write("x"); // keep the response incomplete }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; try { const events = await new Promise(resolve => { const req = http.get({ host: "127.0.0.1", port }, () => { const seen: string[] = []; req.on("error", () => seen.push("error")); req.on("close", () => { seen.push("close"); resolve(seen); }); req.destroy(new Error("boom")); }); req.on("error", () => {}); // swallow pre-response errors }); expect(events).toEqual(["error", "close"]); } finally { server.close(); } }); it("ClientRequest.destroy(err) with no error listener does not throw and still tears down", async () => { // destroy() must never throw synchronously (node routes the error through // the socket's listener and surfaces it async). A listener attached after // destroy() returns, same tick, still catches it. const server = http.createServer((req, res) => { res.write("x"); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; try { const { err, closedAtError } = await new Promise<{ err: Error; closedAtError: boolean }>(resolve => { const req = http.get({ host: "127.0.0.1", port }, () => { let sawClose = false; req.on("close", () => (sawClose = true)); req.destroy(new Error("boom")); // must not throw req.on("error", e => resolve({ err: e, closedAtError: sawClose })); }); }); expect(err.message).toBe("boom"); // 'error' fires before the terminal 'close' (node v26.3.0 verified). expect(closedAtError).toBe(false); } finally { server.close(); } }); it("ClientRequest.destroy(err) with a throwing error listener still tears down; the throw surfaces async", async () => { // node: a throwing 'error' handler becomes an async uncaught exception // after socket.destroy(err) already ran; destroy() itself never throws. const script = ` const http = require("node:http"); const server = http.createServer((req, res) => res.write("x")); server.listen(0, "127.0.0.1", () => { const req = http.get({ host: "127.0.0.1", port: server.address().port }, () => { req.on("error", () => { throw new Error("handler bug"); }); let sawClose = false; req.on("close", () => (sawClose = true)); try { req.destroy(new Error("boom")); console.log("destroy-returned"); } catch (e) { console.log("destroy-threw:" + e.message); } console.log("teardown-ran:" + sawClose); process.on("uncaughtException", e => { console.log("async-uncaught:" + e.message); process.exit(0); }); }); }); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // node v26.3.0 verified: 'close' is async (after destroy() returns). expect(stdout.trim().split("\n")).toEqual(["destroy-returned", "teardown-ran:false", "async-uncaught:handler bug"]); expect(exitCode).toBe(0); }); it("captureRejections: a rejecting async listener on a Server event other than 'request' emits 'error'", async () => { // node: http.Server's and Http2Server's [captureRejectionSymbol] handle // 'request' (and 'stream') themselves and hand every other event to // net.Server's handler, whose default is this.emit('error', err). The // rejection must not be dropped. const script = ` const events = require("node:events"); events.captureRejections = true; const http = require("node:http"); const http2 = require("node:http2"); process.on("unhandledRejection", err => console.log("unhandledRejection: " + err.message)); for (const [name, server] of [["http", http.createServer()], ["http2", http2.createServer()]]) { server.on("error", err => console.log(name + " error event: " + err.message)); server.on("custom", async () => { throw new Error(name + " custom-rejects"); }); server.emit("custom"); } // rejection (microtask) -> nextTick -> 'error'; all settle within one turn. setImmediate(() => setImmediate(() => console.log("done"))); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); // node v26.3.0 verified. expect(stdout.trim().split("\n")).toEqual([ "http error event: http custom-rejects", "http2 error event: http2 custom-rejects", "done", ]); expect(exitCode).toBe(0); }); it("keep-alive socket reused after a 304 response still frames the next response body", async () => { // The native per-request reset must clear the 204/304 no-body flag, or the // 200 that follows a 304 on the same connection is sent with no framing // and no body. That 200 is chunked rather than Content-Length: writeHead() // freezes the framing while _contentLength is still null, which is what Node // sends here too (verified against the v26.3.0 binary). const server = createServer((req, res) => { if (req.url === "/cached") { res.writeHead(304); res.end(); } else { res.writeHead(200); res.end("hello"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET /fresh HTTP/1.1\r\nHost: localhost\r\n\r\n"); } if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("GET /cached HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); expect(out).toContain("HTTP/1.1 304"); const second = out.slice(out.indexOf("HTTP/1.1 200")); expect(second).toContain("HTTP/1.1 200"); expect(second).toContain("Transfer-Encoding: chunked"); expect(second).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } }); it("removing only Content-Length falls back to chunked encoding and keeps the connection alive", async () => { // Node.js's _storeHeader only goes close-delimited when Transfer-Encoding // was removed; removing only Content-Length falls through to chunked. const server = createServer((req, res) => { res.removeHeader("content-length"); res.end("hello"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("0\r\n\r\n")) { // First response completed (terminating chunk seen); the connection // must still be usable for a second request. sentSecond = true; socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n"); } else if (sentSecond && data.match(/0\r\n\r\n[\s\S]*0\r\n\r\n/)) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); expect(out).toContain("Transfer-Encoding: chunked"); expect(out).toContain("Connection: keep-alive"); expect(out).toContain("5\r\nhello\r\n0\r\n\r\n"); // Both requests were answered on the same connection. expect(out.split("HTTP/1.1 200").length).toBe(3); } finally { server.close(); } }); // Node.js's resOnFinish destroySoon()s the socket after a response that ends the // connection: the server sends its FIN and releases the socket right behind it // ('close' on the server-side socket, fd gone) without waiting for the peer to // close its side. A half-close that waits for the client's FIN lets a client // that never closes pin one socket per completed request. describe("a response that closes the connection releases the server-side socket without waiting for the peer", () => { const cases = [ { name: "Connection: close response header", request: "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", prepare: (res: ServerResponse) => res.setHeader("Connection", "close"), expected: /^HTTP\/1\.1 200 OK\r\n[^]*ok$/, }, { name: "Connection: close request header", request: "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", expected: /^HTTP\/1\.1 200 OK\r\n[^]*ok$/, }, { name: "HTTP/1.0 request", request: "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n", expected: /^HTTP\/1\.1 200 OK\r\n[^]*ok$/, }, { // Node answers this itself with res.writeHead(400, ['Connection', 'close']). name: "HTTP/1.1 request without a Host header", request: "GET / HTTP/1.1\r\n\r\n", expected: /^HTTP\/1\.1 400 Bad Request\r\n[^]*\r\n0\r\n\r\n$/, }, ]; for (const { name, request, prepare, expected } of cases) { it.concurrent(name, async () => { const { promise: serverSocketClosed, resolve: onServerSocketClose } = Promise.withResolvers(); const server = createServer((req, res) => { prepare?.(res); res.end("ok"); }); server.on("connection", socket => socket.on("close", onServerSocketClose)); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; // allowHalfOpen: the client keeps its side open after the server's FIN, so // only the server can release the server-side socket. const client = connect({ port, host: "127.0.0.1", allowHalfOpen: true }); try { const out = await new Promise((resolve, reject) => { let data = ""; client.setEncoding("latin1"); client.on("data", chunk => (data += chunk)); client.on("end", () => resolve(data)); client.on("error", reject); client.write(request); }); expect(out).toMatch(expected); expect(out).toContain("\r\nConnection: close\r\n"); // The client has the whole response and the server's FIN, and has not // sent its own. The server-side socket must close on its own now. await serverSocketClosed; expect(client.writableEnded).toBe(false); } finally { client.destroy(); server.close(); } }); } // net.Socket.destroySoon() is what Node's resOnFinish uses: end(), then // destroy() once the write side is done, without waiting for the peer. it.concurrent("socket.destroySoon() from a handler", async () => { const { promise: serverSocketClosed, resolve: onServerSocketClose } = Promise.withResolvers(); const server = createServer((req, res) => { req.socket.write("raw bytes\r\n"); req.socket.destroySoon(); }); server.on("connection", socket => socket.on("close", onServerSocketClose)); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect({ port, host: "127.0.0.1", allowHalfOpen: true }); try { const out = await new Promise((resolve, reject) => { let data = ""; client.setEncoding("latin1"); client.on("data", chunk => (data += chunk)); client.on("end", () => resolve(data)); client.on("error", reject); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); expect(out).toBe("raw bytes\r\n"); await serverSocketClosed; expect(client.writableEnded).toBe(false); } finally { client.destroy(); server.close(); } }); // destroySoon() with response bytes still queued in the transport (the client // is not reading yet) and no res.end(): the queued bytes are delivered first, // then the connection closes, like Node's destroy() on the socket's 'finish'. it.concurrent("socket.destroySoon() behind a backed-up response that never ends", async () => { const CHUNK = Buffer.alloc(16 * 1024, "a"); const CHUNKS = 1024; // 16 MB: more than the loopback socket buffers absorb const { promise: serverSocketClosed, resolve: onServerSocketClose } = Promise.withResolvers(); const { promise: destroyed, resolve: onDestroySoonCalled } = Promise.withResolvers(); const server = createServer((req, res) => { res.on("error", () => {}); for (let i = 0; i < CHUNKS; i++) res.write(CHUNK); req.socket.destroySoon(); onDestroySoonCalled(); }); server.on("connection", socket => socket.on("close", onServerSocketClose)); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect({ port, host: "127.0.0.1", allowHalfOpen: true }); try { let received = 0; const ended = new Promise((resolve, reject) => { client.on("data", chunk => (received += chunk.length)); client.on("end", resolve); client.on("error", reject); }); client.pause(); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); await destroyed; client.resume(); await ended; expect(received).toBeGreaterThan(CHUNK.length * CHUNKS); await serverSocketClosed; expect(client.writableEnded).toBe(false); } finally { client.destroy(); server.close(); } }); }); it("a pipelined request behind Connection: close is never dispatched (clientError HPE_CLOSED_CONNECTION)", async () => { // Node's parser accepts nothing after a message that forbade keep-alive: the // pipelined request must not reach 'request' (request smuggling) and the extra // bytes surface as a clientError. The async handler is the load-bearing case: // the second head is parsed while the first response is still pending. const requests: string[] = []; const { promise: clientErrorPromise, resolve: resolveClientError } = Promise.withResolvers(); const server = createServer((req, res) => { requests.push(req.url!); setTimeout(() => res.end("ok"), 10); }); server.on("clientError", (err, socket) => { resolveClientError(err); socket.destroy(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); socket.write("GET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); const err = await clientErrorPromise; expect(err.code).toBe("HPE_CLOSED_CONNECTION"); expect(requests).toEqual(["/a"]); socket.destroy(); } finally { server.close(); } }); describe("bytes after a message that forbade keep-alive", () => { // Sends `payload` on one connection and reads until the socket closes. async function roundTrip(payload: string) { const requests: string[] = []; const clientErrors: string[] = []; const server = createServer((req, res) => { requests.push(req.url!); req.resume(); // Keep the response pending while the parser walks the rest of the read. // Once it is sent the connection closes and the rest is never parsed. setImmediate(() => res.end("ok")); }); server.on("clientError", (err: any, socket) => { clientErrors.push(err.code); socket.destroy(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const chunks: Buffer[] = []; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); socket.on("data", (c: Buffer) => chunks.push(c)); socket.write(payload); await once(socket, "close"); return { requests, clientErrors, raw: Buffer.concat(chunks).toString("latin1") }; } finally { server.close(); } } // Node's parser skips CR and LF in its closed state and raises // HPE_CLOSED_CONNECTION only on other bytes, so the legacy extra CRLF after // a POST body (RFC 9112 2.2) still gets its response. it.each([ [ "CRLF after a Connection: close POST body", "POST /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 5\r\n\r\nhello\r\n", ], ["blank line after an HTTP/1.0 request", "GET /a HTTP/1.0\r\nHost: x\r\n\r\n\r\n"], ])("%s is not a clientError", async (_label, payload) => { const { requests, clientErrors, raw } = await roundTrip(payload); expect({ requests, clientErrors, status: raw.split("\r\n")[0], body: raw.slice(-2) }).toEqual({ requests: ["/a"], clientErrors: [], status: "HTTP/1.1 200 OK", body: "ok", }); }); // The bytes are never parsed as a request, so they surface as the closed // connection error and not as whatever parse error they would produce. it("other bytes after the CRLF are HPE_CLOSED_CONNECTION", async () => { const { requests, clientErrors } = await roundTrip( "GET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n\r\n@@@ not HTTP\r\n\r\n", ); expect({ requests, clientErrors }).toEqual({ requests: ["/a"], clientErrors: ["HPE_CLOSED_CONNECTION"] }); }); }); it("pipelined responses buffered past the high water mark pause reads on the connection", async () => { // Node's parserOnIncoming stops reading a connection once the bytes queued on // responses that do not own the socket yet (state.outgoingData) reach // socket.writableHighWaterMark, and resumes once the queue drains. const BODY = Buffer.alloc(256 * 1024, "a"); const COUNT = 8; const pausedFlags: boolean[] = []; const { promise: allDispatched, resolve: resolveDispatched } = Promise.withResolvers(); let headRes: any; let serverSocket: any; const server = createServer((req, res) => { serverSocket = req.socket; pausedFlags.push((req.socket as any)._paused === true); if (req.url === "/0") { headRes = res; // held: every request behind it queues its response } else { res.writeHead(200, { "Content-Length": String(BODY.length) }); res.end(BODY); } if (pausedFlags.length === COUNT) resolveDispatched(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); let message = ""; for (let i = 0; i < COUNT; i++) message += `GET /${i} HTTP/1.1\r\nHost: x\r\n\r\n`; socket.write(message); await allDispatched; // Requests already in the read buffer still dispatch (the gate only stops // further reads), so the flags flip once the queue crosses the mark. expect(pausedFlags).toEqual([false, false, true, true, true, true, true, true]); const { promise: allReceived, resolve: resolveReceived, reject: rejectReceived } = Promise.withResolvers(); let received = ""; let bytes = 0; socket.on("data", chunk => { bytes += chunk.length; received += chunk.toString("latin1"); if (received.split("HTTP/1.1 200 OK").length - 1 === COUNT && bytes >= (COUNT - 1) * BODY.length) { resolveReceived(); } }); socket.on("close", () => rejectReceived(new Error("connection closed before every response arrived"))); headRes.end("head"); await allReceived; // The queue drained, so reads resumed (Node's socketOnDrain). expect(serverSocket._paused).toBe(false); socket.destroy(); } finally { server.close(); } }); it("a pipelined response is started when no response is in flight to hand it the socket", async () => { // The dispatcher kicks the pipeline when it queues a response and nothing is in flight (the // previous response finished and detached while it still counts as pending). Clearing // socket._httpMessage by hand reaches that state: only the kick can start /second then. // Bun only: Node.js v26.3.0 fails an internal assertion (resOnFinish) on this use of its internals. const server = createServer((req, res) => { if (req.url === "/first") { (req.socket as any)._httpMessage = null; return; } res.end("second-response"); }); let socket: ReturnType | undefined; try { server.listen(0, "127.0.0.1"); await once(server, "listening"); socket = connect((server.address() as AddressInfo).port, "127.0.0.1"); const { promise: started, resolve: onStarted, reject: onFailure } = Promise.withResolvers(); let received = ""; socket.on("data", chunk => { received += chunk.toString("latin1"); if (received.includes("second-response")) onStarted(); }); socket.on("error", onFailure); socket.on("close", () => onFailure(new Error("closed before the queued response was started"))); socket.write("GET /first HTTP/1.1\r\nHost: x\r\n\r\nGET /second HTTP/1.1\r\nHost: x\r\n\r\n"); await started; // /first never writes, so the stream is the one response to /second and nothing else. const [head, ...bodies] = received.split("\r\n\r\n"); expect({ status: head.split("\r\n")[0], bodies }).toEqual({ status: "HTTP/1.1 200 OK", bodies: ["second-response"], }); } finally { socket?.destroy(); server.close(); } }); // The native dispatch tail answers a throw by ending the connection's current response. For a // pipelined request that was the response ahead of it: the client got "first" of a 10-byte body, // then the close. The throw is an uncaught exception, so each scenario runs in a child process. describe("a dispatch that throws while an earlier response on the connection is pending", () => { async function run(mode: string) { await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "node-http-pipelined-throw-fixture.js")], env: { ...bunEnv, MODE: mode }, stdout: "pipe", stderr: "inherit", }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); return { result: stdout ? JSON.parse(stdout) : undefined, exitCode }; } // node v26.3.0 gives the same result for the tests in these four loops. for (const emitted of ["request", "checkContinue", "checkExpectation"]) { it.concurrent(`'${emitted}' listener: the response ahead still completes`, async () => { expect(await run(emitted)).toEqual({ result: { events: ["request /first", `${emitted} /second`, `uncaught: ${emitted} threw`], bodies: ["first-done"], closed: false, }, exitCode: 0, }); }); } // The response ahead has ended, and most of its 8 MB are still in the send buffer when the // connection is reset at the turn of the response behind it. A destroyed queued response // resets the connection in the same way, with no throw. for (const [mode, events] of [ ["large", ["request /first", "request /second", "uncaught: request threw"]], ["large-destroyed", ["request /first", "request /second"]], ] as const) { it.concurrent(`a large response ahead arrives in full before the connection is reset (${mode})`, async () => { expect(await run(mode)).toEqual({ result: { events, firstBodyBytes: 8 * 1024 * 1024, closed: false }, exitCode: 0, }); }); } for (const mode of ["ended", "ended-later"]) { it.concurrent(`a response that is complete when its turn comes is sent (${mode})`, async () => { expect(await run(mode)).toEqual({ result: { events: ["request /first", "request /second", "uncaught: request threw"], bodies: ["first-done", "second"], closed: false, }, exitCode: 0, }); }); } // The throw comes before node:http has a response to queue. The turn of /second is still held: // the connection closes when it comes, and no later response goes out in its place. Node ends // with the same result, because it answers the next request with a 400 and closes. for (const thrower of ["ServerResponse", "IncomingMessage"]) { it.concurrent(`a throw from the ${thrower} constructor closes the connection at its turn`, async () => { const mode = thrower === "ServerResponse" ? "constructor-response" : "constructor-request"; expect(await run(mode)).toEqual({ result: { events: ["request /first", `${thrower} /second`, `uncaught: ${thrower} threw`], bodies: ["first-done"], closed: true, }, exitCode: 0, }); }); } // The tests below wait for a close. Node never makes it: it answers nothing and keeps the // connection, which then waits forever. Bun answers a throw with a close. For a queued response // that happens when its turn comes, after the responses ahead of it. The requests behind it are // aborted with the connection. it.concurrent("a response that is not complete resets the connection when its turn comes", async () => { expect(await run("unfinished")).toEqual({ result: { events: ["request /first", "request /third", "uncaught: request threw"], bodies: ["first-done", "second"], closed: true, serverSideCloses: ["/fourth", "/fourth", "/second", "/second", "/third", "/third"], }, exitCode: 0, }); }); // Unchanged: with no response ahead, the throwing request is the current one and is answered at once. it.concurrent("a request that is not pipelined is still answered with a close", async () => { expect(await run("not-pipelined")).toEqual({ result: { events: ["request /first", "request /second", "uncaught: request threw"], bodies: ["first-done"], closed: true, }, exitCode: 0, }); }); }); // The same throw with nothing ahead of the request but a raw write of the socket. A 'connection' // listener left it in the buffer, and the client does not read. The first request is then // dispatched like a pipelined one: its response waits in the queue, and the connection has no // current response yet. The throw follows the rule above: nothing of the response goes out, and // the connection closes at its turn, when the raw write has left. // Node v26.3.0 keeps the connection where the modes below expect a close. It also sends the // 100 Continue of "expect-request" before the throw, and it answers "body-to-come" with a 400. describe.concurrent.each(["tcp", "tls"])( "a dispatch that throws while a raw write of the socket is still in its buffer (%s)", transport => { const fixture = path.join(import.meta.dir, "node-http-throw-behind-raw-write-fixture.js"); // CI has its own time for each test. A local debug or ASAN build needs several seconds to start one fixture. const timeout = (isASAN || isDebug) && !isCI ? 90_000 : undefined; const thrown = (thrower: string) => [`${thrower} /first`, `uncaught: ${thrower} threw`]; const nothingSent = { rawBytes: "all", response: "", closed: true }; const expected: Record = { "request": { queued: true, events: thrown("request"), ...nothingSent }, "checkContinue": { queued: true, events: thrown("checkContinue"), ...nothingSent }, "checkExpectation": { queued: true, events: thrown("checkExpectation"), ...nothingSent }, "expect-request": { queued: true, events: thrown("request"), ...nothingSent }, // The constructors throw before a response exists that the server can put in the queue. "constructor-request": { events: ["IncomingMessage", "uncaught: IncomingMessage threw"], ...nothingSent }, "constructor-response": { events: thrown("ServerResponse"), ...nothingSent }, // A response that is complete at its turn is sent, and the connection stays. "ended": { queued: true, events: thrown("request"), rawBytes: "all", response: "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 4\r\n\r\ndone", closed: false, }, // req.destroy() destroys the connection with the rest of the raw write, like in Node.js. "destroyed": { queued: true, events: thrown("request"), response: "", closed: true }, "body": { queued: true, events: thrown("request"), ...nothingSent }, // The body that arrives after the throw ends the connection before the raw write has left. "body-to-come": { queued: true, events: thrown("request"), response: "", closed: true }, }; // Windows takes the whole raw write of a plain TCP socket at once, so the first request is // not queued there. Over TLS it is queued, so that transport covers Windows. test.skipIf(isWindows && transport === "tcp").each(Object.keys(expected))( "%s", async mode => { await using proc = Bun.spawn({ cmd: [bunExe(), fixture, transport, mode], env: bunEnv, stdout: "pipe", stderr: "pipe", stdin: "ignore", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ result: stdout ? JSON.parse(stdout) : undefined, stderr, exitCode, signalCode: proc.signalCode, }).toEqual({ result: { mode, ...expected[mode] }, stderr: "", exitCode: 0, signalCode: null, }); }, timeout, ); }, ); it("requireHostHeader still rejects Upgrade-carrying requests that dispatch as normal requests", async () => { // The native parser exempts Upgrade requests from the Host check so genuine // upgrades can reach the 'upgrade' event, but a request that falls through // to 'request' dispatch (no Connection: upgrade, or no 'upgrade' listener) // must still be rejected with 400 like Node.js. const server = createServer((req, res) => { res.end("handled"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => (data += chunk)); socket.on("end", () => resolve(data)); socket.on("error", reject); // Upgrade header present, no Connection: upgrade, no Host. socket.write("GET / HTTP/1.1\r\nUpgrade: websocket\r\n\r\n"); }); expect(out).toContain("400"); expect(out).not.toContain("handled"); } finally { server.close(); } }); it("close-delimited streaming writes carry raw bytes with no chunk framing artifacts", async () => { // Removing both framing headers makes the response close-delimited; a // streamed write must not inject the chunk-terminating CRLF into the body. const server = createServer((req, res) => { res.removeHeader("transfer-encoding"); res.removeHeader("content-length"); res.write("hello"); res.end(" world"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => (data += chunk)); // Close-delimited: the server closes the connection to end the body. socket.on("end", () => resolve(data)); socket.on("error", reject); socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); const body = out.slice(out.indexOf("\r\n\r\n") + 4); expect(body).toBe("hello world"); expect(out).not.toContain("Transfer-Encoding"); expect(out).not.toContain("Content-Length"); } finally { server.close(); } }); // A bare `new IncomingMessage(null)` has httpVersionMajor/Minor === null; // `null < 1` is true, so the ServerResponse constructor's HTTP/1.0 branch // (matching node v26.3.0 lib/_http_server.js L214) sets shouldKeepAlive=false // and chunked off. The standalone-path tests below that exercise chunked // output construct an HTTP/1.1 req like the vendored // test-http-server-response-standalone.js does. function http11Req() { const req = new IncomingMessage(null as any); req.httpVersionMajor = 1; req.httpVersionMinor = 1; return req; } it("standalone ServerResponse flushes the header block before a non-chunked Buffer body", async () => { // assignSocket() + explicit Content-Length + Buffer body: _send buffers the // rendered header in outputData; _writeRaw must flush it ahead of the body. const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(new IncomingMessage(null as any)); let sawPrefinish = false; res.on("prefinish", () => (sawPrefinish = true)); res.assignSocket(ws as any); res.setHeader("Content-Length", "5"); res.write(Buffer.from("hello")); res.end(); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); expect(out).toStartWith("HTTP/1.1 200 OK\r\n"); expect(out).toContain("Content-Length: 5"); expect(out).toEndWith("\r\n\r\nhello"); // Node emits 'prefinish' when the assigned socket's _httpMessage is the // response (OutgoingMessage.end -> _finish()). expect(sawPrefinish).toBe(true); }); it("standalone ServerResponse buffers writes made before assignSocket and flushes them on assignment", async () => { // Node.js buffers pre-assignSocket output in outputData (OutgoingMessage // _writeRaw with a null socket) and assignSocket() ends with _flush(). const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(http11Req()); res.write("hello"); res.assignSocket(ws as any); res.end(); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); expect(out).toStartWith("HTTP/1.1 200 OK\r\n"); // No Content-Length was known at write() time, so the body is chunked. expect(out).toContain("Transfer-Encoding: chunked"); expect(out).toContain("5\r\nhello\r\n"); expect(out).toEndWith("0\r\n\r\n"); }); it("standalone ServerResponse discards body writes to a no-body response without throwing", async () => { // After writeHead(204) (_hasBody = false), Node.js silently ignores // write('body') and returns true; the no-handle delegation must pass the // original chunk through so write_()'s own discard handles it instead of // its chunk-type validation throwing on a cleared undefined chunk. const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(new IncomingMessage(null as any)); res.assignSocket(ws as any); res.writeHead(204); expect(res.write("body")).toBe(true); res.end(); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); expect(out).toStartWith("HTTP/1.1 204 No Content\r\n"); expect(out).not.toContain("body"); }); it("standalone ServerResponse end() after writeHead() sets writableEnded (#25632)", async () => { // With no native handle and no socket assigned, end() after writeHead() must // still transition to `finished` / `writableEnded` and buffer the rendered // header block into outputData, like Node.js's OutgoingMessage.end(). const res = new ServerResponse(new IncomingMessage(null as any)); res.writeHead(403); res.end("forbidden"); expect({ finished: res.finished, writableEnded: res.writableEnded }).toEqual({ finished: true, writableEnded: true, }); const buffered = (res as any).outputData.map((x: any) => String(x.data)).join(""); expect(buffered).toStartWith("HTTP/1.1 403 Forbidden\r\n"); expect(buffered).toEndWith("\r\n\r\nforbidden"); // And with end() alone (no chunk), the end() callback is registered on // 'finish' (which never fires without a socket), not invoked via nextTick. const res2 = new ServerResponse(new IncomingMessage(null as any)); let cbCalled = false; res2.writeHead(200); res2.end(() => (cbCalled = true)); expect({ finished: res2.finished, writableEnded: res2.writableEnded }).toEqual({ finished: true, writableEnded: true, }); await new Promise(r => process.nextTick(r)); expect(cbCalled).toBe(false); }); it("flushHeaders on a 204 response carries no chunked framing", async () => { // noBodyStatus must suppress the Transfer-Encoding header in flushHeaders() // and the terminating chunk in internalEnd(), like the one-shot end() path. // The /second GET is chunked, not Content-Length: writeHead() freezes the // framing while _contentLength is still null, exactly as Node does. const server = createServer((req, res) => { if (req.url === "/nobody") { res.writeHead(204); res.flushHeaders(); res.end(); } else { res.writeHead(200); res.end("hello"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n"); } if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("GET /nobody HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200")); expect(first).toContain("HTTP/1.1 204"); expect(first).not.toContain("Transfer-Encoding"); expect(first).not.toContain("0\r\n\r\n"); // The keep-alive connection still serves the next request correctly. const second = out.slice(out.indexOf("HTTP/1.1 200")); expect(second).toContain("Transfer-Encoding: chunked"); expect(second).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } }); it("standalone ServerResponse flushHeaders pushes the header block immediately", async () => { const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(http11Req()); res.assignSocket(ws as any); res.flushHeaders(); // The header block reaches the socket before any body is written. const afterFlush = Buffer.concat(chunks).toString(); expect(afterFlush).toStartWith("HTTP/1.1 200 OK\r\n"); expect(afterFlush).toEndWith("\r\n\r\n"); res.end("hi"); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); // The body follows without re-sending the header. expect(out.indexOf("HTTP/1.1 200 OK")).toBe(out.lastIndexOf("HTTP/1.1 200 OK")); expect(out).toContain("2\r\nhi\r\n0\r\n\r\n"); }); it("caches the target's TLS session for proxy-tunneled https requests", async () => { // The 'session' listener must be on the tunneled target socket, not the // proxy connection: a plain HTTP proxy socket never emits 'session', so // pre-fix the cache stayed empty (and an HTTPS proxy cached its own // session under the target's key). const target = createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert }, (req, res) => { res.end("ok"); }); let connectSeen = false; const proxy = createServer(); proxy.on("connect", (req, clientSocket, head) => { connectSeen = true; const [host, port] = (req.url as string).split(":"); const serverSocket = connect(Number(port), host, () => { clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head?.length) serverSocket.write(head); serverSocket.pipe(clientSocket); clientSocket.pipe(serverSocket); }); serverSocket.on("error", () => clientSocket.end()); }); try { target.listen(0, "127.0.0.1"); await once(target, "listening"); const targetPort = (target.address() as AddressInfo).port; proxy.listen(0, "127.0.0.1"); await once(proxy, "listening"); const proxyPort = (proxy.address() as AddressInfo).port; const agent = new https.Agent({ proxyEnv: { HTTPS_PROXY: `http://127.0.0.1:${proxyPort}` }, maxCachedSessions: 10, }); const { promise, resolve, reject } = Promise.withResolvers(); const req = https.request( { host: "127.0.0.1", port: targetPort, path: "/", agent, rejectUnauthorized: false }, res => { res.resume(); res.on("end", resolve); }, ); req.on("error", reject); req.end(); await promise; // The caching listener must sit on the tunneled target socket (pre-fix // it sat on the proxy connection, which never emits 'session' for an // HTTP proxy, so the cache stayed empty forever). The target's TLS 1.3 // ticket arrives asynchronously with the first data flight - await the // cache filling with the real session. while ((agent as any)._sessionCache.list.length === 0) { await Bun.sleep(10); } expect(connectSeen).toBe(true); agent.destroy(); } finally { proxy.close(); target.close(); } }); it("standalone ServerResponse answers 204 + explicit chunked TE with Connection: close", async () => { // _storeHeader's 204/304 handling clears chunkedEncoding and sets // shouldKeepAlive = false; the latter needs real storage on ServerResponse // so the rendered header is Connection: close like Node.js. const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(new IncomingMessage(null as any)); res.assignSocket(ws as any); res.setHeader("Transfer-Encoding", "chunked"); res.writeHead(204); res.end(); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); expect(out).toStartWith("HTTP/1.1 204 No Content\r\n"); expect(out).toContain("Connection: close"); expect(out).not.toContain("keep-alive"); // chunkedEncoding was cleared, so no terminating chunk is emitted. expect(out).toEndWith("\r\n\r\n"); }); it("removing transfer-encoding on a HEAD response keeps the connection alive", async () => { // _hasBody === false means there is no body to close-delimit; Node leaves // the connection open (its _storeHeader checks !_hasBody first). const server = createServer((req, res) => { res.removeHeader("transfer-encoding"); res.end("hello"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; // The connection must still be usable for a normal GET. socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); } if (sentSecond && data.endsWith("hello")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).toContain("Connection: keep-alive"); expect(first).not.toContain("Connection: close"); expect(out).toEndWith("\r\n\r\nhello"); } finally { server.close(); } }); it("clientError after a kept-alive request reuses the connection's socket and untracks it on close", async () => { // The native side returns the existing handle for parser errors on a // connection that already served a request; wrapping it again stranded the // first Duplex in the tracked-connections set and re-emitted 'connection'. let connectionEvents = 0; let connectionSocket: any; let clientErrorSocket: any; const { promise: errored, resolve: onErrored } = Promise.withResolvers(); const server = createServer((req, res) => { res.end("ok"); }); server.on("connection", s => { connectionEvents++; connectionSocket = s; }); server.on("clientError", (err, s) => { clientErrorSocket = s; s.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); onErrored(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); const closed = new Promise(r => socket.on("close", () => r())); let sentGarbage = false; socket.on("data", chunk => { if (!sentGarbage && chunk.toString().includes("ok")) { sentGarbage = true; socket.write("!!!\r\n\r\n"); } }); socket.on("error", () => {}); socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); await errored; expect(connectionEvents).toBe(1); expect(clientErrorSocket).toBe(connectionSocket); await closed; // The lone Duplex untracks itself; getConnections drains to zero. while ((await new Promise(r => server.getConnections((e, c) => r(c)))) !== 0) { await Bun.sleep(10); } } finally { server.close(); } }); describe("malformed request line reaches 'connection' and 'clientError' with a writable socket", () => { it.each([ ["method token followed by CRLF", "BOGUS\r\n\r\n"], ["known method followed by CRLF", "GET\r\n\r\n"], ["CONNECT followed by CRLF", "CONNECT\r\n\r\n"], ["CONNECT-prefixed token followed by CRLF", "CONNECTX\r\n\r\n"], ["single method char followed by CRLF", "G\r\n\r\n"], ["single method char with full line", "G / HTTP/1.1\r\nHost: localhost\r\n\r\n"], ])("%s", async (_name, payload) => { const events: string[] = []; const { promise: errored, resolve: onErrored, reject: onErroredFail } = Promise.withResolvers(); const server = createServer((req, res) => { events.push("request"); res.end("should not reach here"); onErroredFail(new Error("unexpected request")); }); server.on("connection", () => events.push("connection")); server.on("clientError", (err: any, s) => { events.push("clientError " + err.code); try { expect(s.writable).toBe(true); expect(s.destroyed).toBe(false); expect(err.rawPacket).toEqual(Buffer.from(payload)); } catch (e) { onErroredFail(e); return; } s.end("HTTP/1.1 418 I'm a teapot\r\nConnection: close\r\n\r\n"); onErrored(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); let wire = ""; socket.on("data", d => (wire += d)); socket.on("error", () => {}); const closed = new Promise(r => socket.on("close", () => r())); await once(socket, "connect"); socket.write(payload); await errored; await closed; expect(events).toEqual(["connection", "clientError HPE_INVALID_METHOD"]); expect(wire).toContain("HTTP/1.1 418"); } finally { server.closeAllConnections?.(); server.close(); } }); it("a method fragmented across writes still parses", async () => { const events: string[] = []; const { promise: gotRequest, resolve: onRequest, reject: onRequestFail } = Promise.withResolvers(); const server = createServer((req, res) => { events.push("request " + req.method + " " + req.url); res.end("ok"); onRequest(); }); server.on("connection", () => events.push("connection")); server.on("clientError", (err: any, s) => { s.destroy(); onRequestFail(new Error("unexpected clientError " + err.code)); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.setNoDelay(true); socket.on("error", () => {}); await once(socket, "connect"); socket.write("GE"); await new Promise(r => setImmediate(r)); socket.write("T /path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); await gotRequest; socket.end(); expect(events).toEqual(["connection", "request GET /path"]); } finally { server.closeAllConnections?.(); server.close(); } }); // Node accepts some of these (asterisk-form, HTTP/0.9) and fires 'request'; // Bun rejects them and fires 'clientError'. Either is observable. The bug // was that the parser returned short-read on complete input and nothing // surfaced at all while the connection sat idle. it.each([ ["target followed by CRLF with no HTTP version", "GET /\r\nHost: localhost\r\n\r\n"], ["asterisk target followed by CRLF", "GET *\r\n\r\n"], ["asterisk target after a 7-char method", "OPTIONS *\r\n\r\n"], ["short non-origin target followed by CRLF", "POST x\r\n\r\n"], ])("%s surfaces to JS", async (_name, payload) => { const events: string[] = []; const { promise: surfaced, resolve: onSurfaced } = Promise.withResolvers(); const server = createServer((req, res) => { events.push("request"); res.end("ok"); onSurfaced(); }); server.on("connection", () => events.push("connection")); server.on("clientError", (err: any, s) => { events.push("clientError"); s.destroy(); onSurfaced(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); await once(socket, "connect"); socket.write(payload); await surfaced; expect(events[0]).toBe("connection"); expect(["request", "clientError"]).toContain(events[1]); socket.destroy(); } finally { server.closeAllConnections?.(); server.close(); } }); }); it("req.upgrade is true inside shouldUpgradeCallback like Node.js", async () => { // The parser flags the request as an upgrade before the server consults the // callback, so the callback sees true whether it accepts or declines. const seen: [string, string | undefined, unknown][] = []; const server = createServer( { shouldUpgradeCallback: req => { seen.push(["shouldUpgradeCallback", req.url, (req as any).upgrade]); return req.url === "/accept"; }, }, (req, res) => { seen.push(["request", req.url, (req as any).upgrade]); res.writeHead(200, { Connection: "close" }); res.end("ok"); }, ); server.on("upgrade", (req, socket) => { seen.push(["upgrade", req.url, (req as any).upgrade]); socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; for (const target of ["/accept", "/decline"]) { const socket = connect(port, "127.0.0.1"); socket.resume(); socket.write(`GET ${target} HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: upgrade\r\n\r\n`); // The server closes the connection once it has answered. await once(socket, "close"); } expect(seen).toEqual([ ["shouldUpgradeCallback", "/accept", true], ["upgrade", "/accept", true], ["shouldUpgradeCallback", "/decline", true], ["request", "/decline", false], ]); } finally { server.close(); } }); it("req.upgrade reflects the upgrade dispatch decision like Node.js", async () => { // true inside the 'upgrade' listener; false for an Upgrade-carrying request // that falls through to 'request' (no Connection: upgrade token here). let upgradeValue: unknown = "unset"; let requestValue: unknown = "unset"; const { promise: sawUpgrade, resolve: onUpgrade } = Promise.withResolvers(); const { promise: sawRequest, resolve: onRequest } = Promise.withResolvers(); const server = createServer((req, res) => { requestValue = (req as any).upgrade; res.end("ok"); onRequest(); }); server.on("upgrade", (req, socket) => { upgradeValue = (req as any).upgrade; socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); onUpgrade(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const s1 = connect(port, "127.0.0.1"); s1.on("error", () => {}); s1.write("GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: upgrade\r\n\r\n"); await sawUpgrade; s1.destroy(); expect(upgradeValue).toBe(true); const s2 = connect(port, "127.0.0.1"); s2.on("error", () => {}); // Upgrade header without the Connection: upgrade token: normal dispatch. s2.write("GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\n\r\n"); await sawRequest; s2.destroy(); expect(requestValue).toBe(false); } finally { server.close(); } }); it("'upgrade' fires only when llhttp would flag the request as an upgrade", async () => { // Node takes the upgrade bit from llhttp (F_UPGRADE && F_CONNECTION_UPGRADE): // the Upgrade value must be non-empty and "upgrade" must be a whole item of // the Connection list (OWS around the item ignored, llhttp >= 9.4.2, in Node // since the 9.4.2 bump; 9.4.1 rejected a HTAB after the item). The other // rows are verified against Node v26.3.0. const cases: [string, string, "upgrade" | "request"][] = [ ["Connection: Upgrade\r\nUpgrade: x\r\n", "control", "upgrade"], ["Connection: keep-alive, Upgrade \r\nUpgrade: x\r\n", "list item with SP after it", "upgrade"], ["Connection:\tupgrade\r\nUpgrade: x\r\n", "HTAB before the item", "upgrade"], ["Connection: Upgrade\t, close\r\nUpgrade: x\r\n", "HTAB after the item", "upgrade"], ["Connection: Upgrade\r\nUpgrade:\r\n", "empty Upgrade value", "request"], ["Connection: Upgrade\r\nUpgrade: \t\r\n", "whitespace-only Upgrade value", "request"], ["Connection: upgrade-x\r\nUpgrade: x\r\n", "upgrade-x is one token", "request"], ["Connection: foo upgrade\r\nUpgrade: x\r\n", "no comma between the tokens", "request"], ["Connection: foo,upgrade-x,x-upgrade\r\nUpgrade: x\r\n", "no whole item in the list", "request"], ]; const results: Record = {}; const expected: Record = {}; const server = createServer((req, res) => { res.end(`request (req.upgrade=${(req as any).upgrade})`); }); server.on("upgrade", (req, socket) => { socket.end( `HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: x\r\n\r\nupgrade (req.upgrade=${(req as any).upgrade})`, ); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; for (const [headers, name, verdict] of cases) { expected[name] = verdict === "upgrade" ? "upgrade (req.upgrade=true)" : "request (req.upgrade=false)"; const client = connect(port, "127.0.0.1"); client.on("error", () => {}); client.write(`GET / HTTP/1.1\r\nHost: x\r\n${headers}Connection: close\r\n\r\n`); const chunks: Buffer[] = []; client.on("data", (chunk: Buffer) => chunks.push(chunk)); await once(client, "close"); const body = Buffer.concat(chunks).toString(); results[name] = body.slice(body.indexOf("\r\n\r\n") + 4); } expect(results).toEqual(expected); } finally { server.close(); } }); it("Connection: close closes the connection only as a whole list item like llhttp", async () => { // Same grammar as the upgrade bit (F_CONNECTION_CLOSE): "close-x" and // "foo close" keep the connection alive. Verified against Node v26.3.0. const cases: [string, string, "close" | "keep-alive"][] = [ ["Connection: close\r\n", "control", "close"], ["Connection: keep-alive, close\r\n", "list item", "close"], ["Connection: close-x\r\n", "close-x is one token", "keep-alive"], ["Connection: foo close\r\n", "no comma between the tokens", "keep-alive"], ["Connection: x-close,close-x\r\n", "no whole item in the list", "keep-alive"], ]; const results: Record = {}; const expected: Record = {}; const server = createServer((req, res) => { res.end("ok"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; for (const [headers, name, verdict] of cases) { expected[name] = verdict; const client = connect(port, "127.0.0.1"); client.write(`GET / HTTP/1.1\r\nHost: x\r\n${headers}\r\n`); let received = ""; let waiter = Promise.withResolvers(); client.on("data", chunk => { received += chunk.toString(); if (received.endsWith("ok")) waiter.resolve(); }); // A close or error before the body ends fails the case with what was received. client.on("error", err => waiter.reject(new Error(`${name}: ${err.message}; received ${JSON.stringify(received)}`)), ); client.on("close", () => waiter.reject(new Error(`${name}: closed; received ${JSON.stringify(received)}`))); await waiter.promise; const connection = /^connection: (.*)$/im.exec(received)?.[1]; if (connection === "close") { // The server closes after the response. await once(client, "close"); results[name] = "close"; } else { // The server keeps the connection open: a second request gets a response. received = ""; waiter = Promise.withResolvers(); client.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); await waiter.promise; await once(client, "close"); results[name] = connection ?? "(no Connection header)"; } } expect(results).toEqual(expected); } finally { server.close(); } }); it("standalone ServerResponse end() honors rejectNonStandardBodyWrites for no-body responses", async () => { // Mirrors the write() fix: the original chunk must reach write_()'s // !_hasBody handling so the reject option throws like Node.js. const ws = new Writable({ write(c, e, cb) { cb(); }, }); const res = new (ServerResponse as any)({ method: "HEAD" } as any, { rejectNonStandardBodyWrites: true } as any); res.assignSocket(ws); expect(() => res.end("body")).toThrow( expect.objectContaining({ code: "ERR_HTTP_BODY_NOT_ALLOWED", }), ); ws.destroy(); }); it("HEAD response with explicit writeHead(200) carries no body bytes", async () => { // writeHead() must not reset _hasBody for a HEAD request (Node only ever // clears it); pre-fix the body bytes leaked onto the wire. const server = createServer((req, res) => { res.writeHead(200); res.end("hello"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("HEAD / HTTP/1.1\r\nHost: x\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).toStartWith("HTTP/1.1 200"); // No body on the HEAD response; the GET on the same connection has one. expect(first).toEndWith("\r\n\r\n"); expect(out).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } }); it("standalone ServerResponse writeContinue reaches the assigned socket", async () => { // writeContinue must route through _writeRaw on the no-handle path like // its writeProcessing/writeEarlyHints siblings (pre-fix it was a no-op). const chunks: Buffer[] = []; const ws = new Writable({ write(c, e, cb) { chunks.push(Buffer.from(c)); cb(); }, }); const res = new ServerResponse(new IncomingMessage(null as any)); res.assignSocket(ws as any); res.writeContinue(); expect(Buffer.concat(chunks).toString()).toBe("HTTP/1.1 100 Continue\r\n\r\n"); expect((res as any)._sent100).toBe(true); res.end("hello"); await once(res, "finish"); const out = Buffer.concat(chunks).toString(); expect(out).toStartWith("HTTP/1.1 100 Continue\r\n\r\n"); expect(out).toContain("HTTP/1.1 200 OK\r\n"); expect(out.indexOf("HTTP/1.1 200 OK")).toBeGreaterThan(out.indexOf("100 Continue")); }); it("HEAD response with explicit chunked TE carries no terminating chunk", async () => { // RFC 9112 6.3: a HEAD response terminates at the first empty line // whatever framing headers it advertises; a 0\r\n\r\n terminator would be // parsed as the start of the next response on the keep-alive connection. const server = createServer((req, res) => { if (req.method === "HEAD") { res.setHeader("Transfer-Encoding", "chunked"); res.end("hello"); } else { res.end("world"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } if (sentSecond && data.endsWith("world")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("HEAD / HTTP/1.1\r\nHost: x\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).toContain("Transfer-Encoding: chunked"); // Headers only - no terminating chunk after the empty line. expect(first).toEndWith("\r\n\r\n"); expect(first).not.toContain("0\r\n\r\n"); // The keep-alive connection still parses the next response. expect(out).toEndWith("\r\n\r\nworld"); } finally { server.close(); } }); // https://github.com/oven-sh/bun/issues/34158 it("server.close(cb) completes after a raw upgrade once both sockets are destroyed", async () => { // Node v26.3.0 contract (verified): after the 'upgrade' handoff, destroying // both ends of the tunneled connection lets server.close(cb) fire promptly. const server = createServer(); let serverSocket: import("node:net").Socket; server.on("upgrade", (req, socket) => { serverSocket = socket as import("node:net").Socket; socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: WebSocket\r\n\r\n"); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const request = http.get({ host: "127.0.0.1", port, headers: { Connection: "Upgrade", Upgrade: "WebSocket" }, }); request.on("error", () => {}); const [, clientSocket] = (await once(request, "upgrade")) as [unknown, import("node:net").Socket]; clientSocket.destroy(); serverSocket!.destroy(); const { promise: closed, resolve: onClosed } = Promise.withResolvers(); server.close(() => onClosed()); await closed; }); it("req.upgrade is true inside the 'connect' listener", async () => { let upgradeValue: unknown = "unset"; const { promise: sawConnect, resolve: onConnect } = Promise.withResolvers(); const server = createServer((req, res) => res.end("ok")); server.on("connect", (req, socket) => { upgradeValue = (req as any).upgrade; socket.end("HTTP/1.1 200 Connection Established\r\n\r\n"); onConnect(); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); socket.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); await sawConnect; socket.destroy(); expect(upgradeValue).toBe(true); } finally { server.close(); } }); // Reading an earlier request on the connection, or a 'data' listener from the 'connection' event, // leaves the socket flowing. The handoff to 'connect' / 'upgrade' resets that: a listener that // attaches its reader later (a proxy does so once its upstream connects) still gets every byte the // client sent in between. Every expectation below is Node v26.3.0's. describe.each([ ["CONNECT", "connect", "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"], ["Upgrade", "upgrade", "GET /chat HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: custom\r\n\r\n"], ])("%s handoff of a socket that was flowing", (_name, eventName, handoffRequest) => { it.each([ ["after a request in an earlier read", "earlier-read"], ["after a request in the same read", "same-read"], ["after a 'connection' listener read the socket", "connection-listener"], ])("buffers tunnel bytes until the listener reads them (%s)", async (_why, flowedBy) => { await using server = createServer((req, res) => res.end(req.url)); if (flowedBy === "connection-listener") { server.on("connection", socket => socket.on("data", () => {})); } const { promise: handedOff, resolve: resolveHandedOff } = Promise.withResolvers<{ socket: Duplex; head: string; flowing: boolean | null; }>(); server.on(eventName, (req, socket, head) => { resolveHandedOff({ socket, head: head.toString(), flowing: socket.readableFlowing }); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const { promise: firstAnswered, resolve: resolveFirstAnswered } = Promise.withResolvers(); let received = ""; // noDelay: the server never answers the handoff request, so Nagle's algorithm would hold the // next small write until the delayed ACK, long after the barrier below. const client = connect({ port, host: "127.0.0.1", noDelay: true }); client.on("data", data => { received += data; if (received.endsWith("/first")) resolveFirstAnswered(); }); const firstRequest = "GET /first HTTP/1.1\r\nHost: example.com\r\n\r\n"; if (flowedBy === "earlier-read") { client.write(firstRequest); await firstAnswered; client.write(handoffRequest); } else if (flowedBy === "same-read") { client.write(firstRequest + handoffRequest); } else { client.write(handoffRequest); } const { socket, head, flowing: flowingAtHandoff } = await handedOff; client.write("early,"); // Nothing reads the tunnel yet, so the server reports no progress on it. A whole request on a // second connection is the barrier: the server has read "early," by the time it has answered // and closed that connection. const barrier = connect(port, "127.0.0.1"); barrier.resume(); barrier.end("GET /barrier HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); await once(barrier, "close"); const atAttach = { flowing: socket.readableFlowing, buffered: socket.readableLength }; let tunneled = head; socket.on("data", chunk => (tunneled += chunk)); const ended = once(socket, "end"); client.end("late"); await ended; socket.end(); expect({ flowingAtHandoff, atAttach, tunneled }).toEqual({ flowingAtHandoff: null, atAttach: { flowing: null, buffered: 6 }, tunneled: "early,late", }); }); }); it("plain HEAD with flushHeaders carries no auto-chunked framing", async () => { // No explicit framing headers: the native flushHeaders must not enter // chunked mode for a HEAD response, and end() must not write a terminator. const server = createServer((req, res) => { if (req.method === "HEAD") { res.flushHeaders(); res.end(); } else { res.end("world"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } if (sentSecond && data.endsWith("world")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("HEAD / HTTP/1.1\r\nHost: x\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).not.toContain("Transfer-Encoding"); expect(first).toEndWith("\r\n\r\n"); expect(first).not.toContain("0\r\n\r\n"); // The keep-alive connection still parses the next response. expect(out).toEndWith("\r\n\r\nworld"); } finally { server.close(); } }); it("statusCode = 204 with a streamed write carries no body framing", async () => { // Without writeHead(), the implicit-header path must still derive _hasBody // from the status code before the body-discard check, like Node's // _implicitHeader ordering - otherwise the chunk reaches the wire // chunk-framed with no terminator. const server = createServer((req, res) => { if (req.url === "/nobody") { res.statusCode = 204; res.write("hello"); res.end(); } else { res.end("world"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } if (sentSecond && data.endsWith("world")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("GET /nobody HTTP/1.1\r\nHost: x\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).toContain("HTTP/1.1 204"); expect(first).not.toContain("Transfer-Encoding"); expect(first).not.toContain("hello"); expect(first).toEndWith("\r\n\r\n"); expect(out).toEndWith("\r\n\r\nworld"); } finally { server.close(); } }); it("no 'drain' is emitted for accounting flushed after the response finished", async () => { let drains = 0; const { promise: handled, resolve: onHandled } = Promise.withResolvers(); const server = createServer((req, res) => { res.on("drain", () => drains++); const ret = res.write(Buffer.alloc(res.writableHighWaterMark + 100)); res.end(); onHandled(ret); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const res = await fetch(`http://127.0.0.1:${port}/`); await res.arrayBuffer(); const ret = await handled; // The write crossed the high-water mark... expect(ret).toBe(false); // ...but the pending accounting flush must not emit 'drain' on a // response that already finished (matching writableNeedDrain and // Node's socketOnDrain gating). await new Promise(r => process.nextTick(r)); expect(drains).toBe(0); } finally { server.close(); } }); it("registering 'keylog' on an agent with live sockets does not throw", async () => { const server = createServer((req, res) => res.end("ok")); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const agent = new Agent({ keepAlive: true }); const { promise, resolve, reject } = Promise.withResolvers(); const req = http.request({ host: "127.0.0.1", port, path: "/", agent }, res => { // agent.sockets is populated while the request is in flight; the // newListener hook must flatten the socket arrays instead of calling // .on() on them. expect(() => agent.on("keylog", () => {})).not.toThrow(); res.resume(); res.on("end", resolve); }); req.on("error", reject); req.end(); await promise; agent.destroy(); } finally { server.close(); } }); it("statusCode = 204 with an empty first write still discards the body", async () => { // write("") flips the header state without a chunk; the _hasBody // derivation must run unconditionally (like Node's _implicitHeader) so // the later body write is still discarded. const server = createServer((req, res) => { if (req.url === "/nobody") { res.statusCode = 204; res.write(""); res.write("hello"); res.end(); } else { res.end("world"); } }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentSecond = false; socket.on("data", chunk => { data += chunk; if (!sentSecond && data.includes("\r\n\r\n")) { sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } if (sentSecond && data.endsWith("world")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("GET /nobody HTTP/1.1\r\nHost: x\r\n\r\n"); }); const first = out.slice(0, out.indexOf("HTTP/1.1 200", 10)); expect(first).toContain("HTTP/1.1 204"); expect(first).not.toContain("Transfer-Encoding"); expect(first).not.toContain("hello"); expect(first).toEndWith("\r\n\r\n"); expect(out).toEndWith("\r\n\r\nworld"); } finally { server.close(); } }); it("res.shouldKeepAlive = false renders Connection: close and ends the socket", async () => { // Graceful-shutdown helpers (stoppable, http-terminator) clear // shouldKeepAlive on in-flight responses; the rendered header and the // transport must follow it like Node's shouldSendKeepAlive/_last. const server = createServer((req, res) => { res.shouldKeepAlive = false; res.end("ok"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => (data += chunk)); // The server must send FIN on its own; the client never half-closes. socket.on("end", () => resolve(data)); socket.on("error", reject); socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); }); expect(out).toContain("Connection: close"); expect(out).not.toContain("keep-alive"); expect(out).toEndWith("ok"); } finally { server.close(); } }); it("removeHeader('connection') with shouldKeepAlive = false still closes the socket on finish", async () => { // Node's _storeHeader handles _removedConnection before the auto-header // branch: no Connection header is written, but _last = !shouldKeepAlive // still ends the socket. const server = createServer((req, res) => { res.removeHeader("connection"); res.shouldKeepAlive = false; res.end("ok"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => (data += chunk)); // The server must send FIN on its own; the client never half-closes. socket.on("end", () => resolve(data)); socket.on("error", reject); socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); }); expect(out).not.toContain("Connection:"); expect(out).toEndWith("ok"); } finally { server.close(); } }); it("Expect: 100-Continue matches case-insensitively like Node.js", async () => { // RFC 7231 5.1.1: expectation values compare case-insensitively; Node // uses /(?:^|\W)100-continue(?:$|\W)/i, not strict equality. const server = createServer((req, res) => { req.resume(); req.on("end", () => res.end("done")); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; let sentBody = false; socket.on("data", chunk => { data += chunk; if (!sentBody && data.includes("100 Continue")) { sentBody = true; socket.write("hello"); } if (data.endsWith("done")) { socket.end(); resolve(data); } }); socket.on("error", reject); socket.write("POST / HTTP/1.1\r\nHost: x\r\nExpect: 100-Continue\r\nContent-Length: 5\r\n\r\n"); }); expect(out).toContain("HTTP/1.1 100 Continue"); expect(out).not.toContain("417"); expect(out).toEndWith("done"); } finally { server.close(); } }); it("the over-limit 503 advertises Connection: close, not keep-alive", async () => { // Node sets maxRequestsOnConnectionReached unconditionally // (maxRequestsPerSocket <= count), so the dropRequest 503 carries // Connection: close instead of advertising keep-alive right before the // socket is destroyed. const server = createServer((req, res) => res.end("ok")); server.maxRequestsPerSocket = 1; try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const out = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => (data += chunk)); socket.on("close", () => resolve(data)); socket.on("error", reject); // Two pipelined requests: the second exceeds maxRequestsPerSocket. socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n" + "GET / HTTP/1.1\r\nHost: x\r\n\r\n"); }); const second = out.slice(out.indexOf("HTTP/1.1 503")); expect(second).toContain("HTTP/1.1 503"); expect(second).toContain("Connection: close"); expect(second).not.toContain("keep-alive"); } finally { server.close(); } }); const upgradeRequest = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n"; // Uses up a limit of one request with a plain request, then sends `second` on // the same connection once that response has fully arrived. Returns the two // status codes the client saw and the server events each request reached. // Resolves as soon as both responses are in: whether the connection is closed // afterwards differs per case (and Node keeps it open after a 503). async function pastTheLimit( second: string, options: { shouldUpgradeCallback?: (req: IncomingMessage) => boolean; onUpgrade?: boolean } = {}, ): Promise<{ statuses: string[]; events: string[] }> { const events: string[] = []; const server = createServer({ shouldUpgradeCallback: options.shouldUpgradeCallback }, (req, res) => { events.push(`request ${req.url}`); res.end("served"); }); server.maxRequestsPerSocket = 1; server.on("dropRequest", req => events.push(`dropRequest ${req.url}`)); if (options.onUpgrade) { server.on("upgrade", (req, socket) => { events.push(`upgrade ${req.url}`); socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"); }); } try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const { promise, resolve, reject } = Promise.withResolvers(); const socket = connect(port, "127.0.0.1", () => socket.write("GET /first HTTP/1.1\r\nHost: x\r\n\r\n")); let data = ""; let sentSecond = false; socket.setEncoding("utf8"); socket.on("data", chunk => { data += chunk; if (!sentSecond) { if (data.endsWith("served")) { sentSecond = true; socket.write(second); } return; } // Responses are back to back ("...servedHTTP/1.1 503 ..."), so no anchors. const statuses = [...data.matchAll(/HTTP\/1\.1 (\d{3})/g)].map(m => m[1]); if (statuses.length === 2) { socket.destroy(); resolve(statuses); } }); socket.on("error", reject); socket.on("close", () => reject(new Error(`connection closed after receiving: ${JSON.stringify(data)}`))); return { statuses: await promise, events }; } finally { server.close(); } } it.concurrent("an upgrade past maxRequestsPerSocket reaches 'upgrade' instead of being dropped", async () => { // Node's parserOnIncoming hands an accepted upgrade off before it counts the // request or checks the limit (Node v26.3.0 answers 200 then 101 here), so // 'dropRequest' and the 503 never apply to it. expect(await pastTheLimit(upgradeRequest, { onUpgrade: true })).toEqual({ statuses: ["200", "101"], events: ["request /first", "upgrade /ws"], }); }); it.concurrent("an Upgrade request nobody accepts still counts against maxRequestsPerSocket", async () => { // With no 'upgrade' listener the request falls through to normal dispatch, // which Node counts and drops like any other request (200 then 503). expect(await pastTheLimit(upgradeRequest)).toEqual({ statuses: ["200", "503"], events: ["request /first", "dropRequest /ws"], }); }); it.concurrent("shouldUpgradeCallback decides whether the request past maxRequestsPerSocket is counted", async () => { // The exemption follows the callback's verdict, not the headers or the // presence of an 'upgrade' listener (Node v26.3.0: 101 and 503 respectively). const [accepted, declined] = await Promise.all([ pastTheLimit(upgradeRequest, { shouldUpgradeCallback: () => true, onUpgrade: true }), pastTheLimit(upgradeRequest, { shouldUpgradeCallback: () => false, onUpgrade: true }), ]); expect({ accepted, declined }).toEqual({ accepted: { statuses: ["200", "101"], events: ["request /first", "upgrade /ws"] }, declined: { statuses: ["200", "503"], events: ["request /first", "dropRequest /ws"] }, }); }); it.concurrent("a declined upgrade without Host past maxRequestsPerSocket gets the 400, not the 503", async () => { // Node rejects a missing Host before counting the request, so the limit is // never consulted and 'dropRequest' is not emitted (200 then 400). Only // Upgrade-carrying requests can reach this point without a Host header: the // native parser answers every other Host-less request itself. expect(await pastTheLimit("GET /nohost HTTP/1.1\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n")).toEqual({ statuses: ["200", "400"], events: ["request /first"], }); }); it.each([ ["res.end(body)", res => res.end("ok")], ["res.write(body) then res.end()", res => (res.write("ok"), res.end())], ])("Keep-Alive prints a server.keepAliveTimeout assigned after construction as is: %s", async (_, respond) => { // server.keepAliveTimeout is a plain property in Node, so only the constructor option is // validated. _storeHeader prints Math.floor(keepAliveTimeout / 1000) without a range check // (Node v26.3.0 answers with exactly these lines). The first four do not fit the integer the // native header writer takes: -5 used to go out as 4294967291 and 2^31 as 2147483647. const expected: [number, string][] = [ [-5000, "Keep-Alive: timeout=-5"], [-0.5, "Keep-Alive: timeout=-1"], [2 ** 31 * 1000, "Keep-Alive: timeout=2147483648"], [Infinity, "Keep-Alive: timeout=Infinity"], [(2 ** 31 - 1) * 1000, "Keep-Alive: timeout=2147483647"], [5999, "Keep-Alive: timeout=5"], ]; const actual: [number, string | undefined][] = []; for (const [keepAliveTimeout] of expected) { const server = createServer((req, res) => { // The response took its copy when the request arrived. The idle timer reads the server's // value once the response is done, and must not be armed with the numbers above. server.keepAliveTimeout = 1000; respond(res); }); server.keepAliveTimeout = keepAliveTimeout as number; try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const head = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1"); let data = ""; socket.on("data", chunk => { data += chunk; const end = data.indexOf("\r\n\r\n"); if (end === -1) return; socket.destroy(); resolve(data.slice(0, end)); }); socket.on("close", () => reject(new Error(`closed before the end of the response head: ${JSON.stringify(data)}`)), ); socket.on("error", reject); socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); }); actual.push([keepAliveTimeout as number, head.split("\r\n").find(line => line.startsWith("Keep-Alive:"))]); } finally { server.closeAllConnections(); server.close(); } } expect(actual).toEqual(expected); }); it("a non-200 CONNECT through a proxy that holds the connection open is destroyed client-side", async () => { // Node leaves this connection open. https.Agent#createConnection() closes it: the request gets only the error. const proxySockets: import("node:net").Socket[] = []; const proxy = createNetServer(socket => { proxySockets.push(socket); socket.on("error", () => {}); // Reply 407 and HOLD the connection open (no end()). socket.once("data", () => { socket.write("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); }); }); try { proxy.listen(0, "127.0.0.1"); await once(proxy, "listening"); const proxyPort = (proxy.address() as AddressInfo).port; const agent = new https.Agent({ proxyEnv: { HTTPS_PROXY: `http://127.0.0.1:${proxyPort}` } }); const { promise: errored, resolve: onError } = Promise.withResolvers(); const req = https.request({ host: "example.com", port: 443, path: "/", agent }, () => {}); req.on("error", onError); req.end(); const err = await errored; expect(err.code).toBe("ERR_PROXY_TUNNEL"); expect(err.statusCode).toBe(407); // The client must close its end despite the proxy holding the socket. expect(proxySockets.length).toBe(1); await once(proxySockets[0], "close"); agent.destroy(); } finally { for (const s of proxySockets) s.destroy(); proxy.close(); } }); // nodejs/node 3e9954a88b (CVE-2026-48615) it.each([ ["username and password", "user:s3cret", "user:s3cret"], ["username only", "s3cret", "s3cret:"], ["password only", ":s3cret", ":s3cret"], ["percent-encoded", "us%40er:s3cret%3A", "us@er:s3cret:"], ])("ERR_PROXY_TUNNEL does not expose the proxy credentials (%s)", async (_name, userinfo, credentials) => { const { promise: proxyAuthorization, resolve: onProxyAuthorization } = Promise.withResolvers(); const proxy = createServer(); proxy.on("connect", (req, socket) => { onProxyAuthorization(req.headers["proxy-authorization"]); socket.end("HTTP/1.1 407 Proxy Authentication Required\r\nConnection: close\r\n\r\n"); }); try { proxy.listen(0, "127.0.0.1"); await once(proxy, "listening"); const proxyPort = (proxy.address() as AddressInfo).port; const agent = new https.Agent({ proxyEnv: { HTTPS_PROXY: `http://${userinfo}@127.0.0.1:${proxyPort}` } }); try { const { promise: errored, resolve: onError } = Promise.withResolvers(); const req = https.request({ host: "example.com", port: 443, path: "/", agent }, () => {}); req.on("error", onError); req.end(); const err = await errored; expect({ code: err.code, statusCode: err.statusCode, message: err.message }).toEqual({ code: "ERR_PROXY_TUNNEL", statusCode: 407, message: `Failed to establish tunnel to example.com:443 via http://127.0.0.1:${proxyPort}/: HTTP/1.1 407 Proxy Authentication Required`, }); expect(inspect(err)).not.toContain("s3cret"); // The proxy still receives the credentials. expect(await proxyAuthorization).toBe(`Basic ${Buffer.from(credentials).toString("base64")}`); } finally { agent.destroy(); } } finally { proxy.close(); } }); // Node.js v26 removed res.writeHeader (DEP0063 end-of-life, nodejs/node#60635). it("ServerResponse.prototype.writeHeader was removed (DEP0063 EOL)", () => { expect("writeHeader" in ServerResponse.prototype).toBe(false); }); it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { const msg = new OutgoingMessage(); msg.setHeaders(new Map([["set-cookie", []]])); expect(msg.getHeader("set-cookie")).toEqual([]); expect(msg.hasHeader("set-cookie")).toBe(true); expect(msg.getHeaders()["set-cookie"]).toEqual([]); expect(msg.getHeaderNames()).toContain("set-cookie"); expect((msg as any).getRawHeaderNames()).toContain("set-cookie"); msg.removeHeader("set-cookie"); expect(msg.getHeader("set-cookie")).toBeUndefined(); expect(msg.hasHeader("set-cookie")).toBe(false); // Headers without a set-cookie entry never call setHeader("set-cookie", ...) const msg2 = new OutgoingMessage(); msg2.setHeaders(new Map([["x-test", "1"]])); expect(msg2.getHeader("set-cookie")).toBeUndefined(); expect(msg2.getHeader("x-test")).toBe("1"); // getRawHeaderNames preserves the original casing, like Node. const msg3 = new OutgoingMessage(); msg3.setHeader("Set-Cookie", []); expect((msg3 as any).getRawHeaderNames()).toEqual(["Set-Cookie"]); expect(msg3.getHeaderNames()).toEqual(["set-cookie"]); // Appending a cookie supersedes the present-but-empty array (no duplicate // name in getRawHeaderNames, value visible everywhere). msg3.appendHeader("Set-Cookie", "a=1"); expect(msg3.getHeader("set-cookie")).toEqual(["a=1"]); expect((msg3 as any).getRawHeaderNames().filter(n => n.toLowerCase() === "set-cookie")).toHaveLength(1); expect(msg3.getHeaders()["set-cookie"]).toEqual(["a=1"]); }); it("https.Agent applies defaultPort/protocol through options (nodejs/node#58980)", () => { const a = new https.Agent(); try { expect((a as any).defaultPort).toBe(443); expect((a as any).protocol).toBe("https:"); // v26 sets the defaults on the (null-prototype) options object before // calling the base constructor. expect(a.options.defaultPort).toBe(443); expect(a.options.protocol).toBe("https:"); expect(Object.getPrototypeOf(a.options)).toBe(null); } finally { a.destroy(); } const b = new https.Agent({ defaultPort: 8443 }); try { expect((b as any).defaultPort).toBe(8443); expect((b as any).protocol).toBe("https:"); } finally { b.destroy(); } }); it("upgrade request with no 'upgrade' listener falls through to 'request'", async () => { // Mirrors Node.js behavior (see Node's _http_server.js shouldUpgradeCallback // default): when the server has no 'upgrade' listener, an Upgrade request is // handled as a regular request instead of disappearing. const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("regular response"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const result = await new Promise((resolve, reject) => { const socket = connect(port, "127.0.0.1", () => { socket.write("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); }); let data = ""; socket.setEncoding("utf8"); socket.on("data", chunk => { data += chunk; if (data.includes("regular response")) { socket.destroy(); resolve(data); } }); socket.on("error", reject); socket.on("close", () => resolve(data)); }); expect(result).toContain("HTTP/1.1 200"); expect(result).toContain("regular response"); } finally { server.close(); } }); it("ServerResponse does not emit 'drain' after a successful (non-backpressured) write", async () => { // Node.js only emits 'drain' after a write() that returned false. let drains = 0; let writeReturned: boolean | undefined; const server = createServer((req, res) => { res.on("drain", () => drains++); writeReturned = res.write("hello"); // Give a synchronously-emitted 'drain' a chance to fire before ending. process.nextTick(() => { res.end(" world"); }); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const body = await new Promise((resolve, reject) => { const req = http.request({ host: "127.0.0.1", port }, res => { let data = ""; res.setEncoding("utf8"); res.on("data", chunk => (data += chunk)); res.on("end", () => resolve(data)); }); req.on("error", reject); req.end(); }); expect(body).toBe("hello world"); expect(writeReturned).toBe(true); expect(drains).toBe(0); } finally { server.close(); } }); it("https.Agent.prototype.createConnection creates a TLS connection", async () => { expect(typeof https.Agent.prototype.createConnection).toBe("function"); const server = createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert }, (req, res) => { res.end("secure"); }); try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket: any = https.globalAgent.createConnection({ host: "127.0.0.1", port, rejectUnauthorized: false, }); try { await once(socket, "secureConnect"); // It's a TLS socket, not a plain net.Socket. expect(socket.encrypted).toBe(true); } finally { socket.destroy(); } } finally { server.close(); } }); it("http.Agent with proxyEnv does not write to a literal 'undefined' property", () => { // Regression: the kProxyConfig symbol destructured from internal/http was // undefined, so the proxy config was stored as agent["undefined"]. const agent = new Agent({ proxyEnv: { http_proxy: "http://localhost:4873" } } as any); try { expect(Object.hasOwn(agent, "undefined")).toBe(false); } finally { agent.destroy(); } }); it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () => { expect(typeof (OutgoingMessage.prototype as any)._flushOutput).toBe("function"); const a: any = new OutgoingMessage(); const b: any = new OutgoingMessage(); expect(a.outputData).not.toBe(b.outputData); // Buffered writes on one message must not leak into other instances // (outputData used to be a shared array on the prototype). a.outputData.push({ data: "x", encoding: "utf8", callback: null }); expect(a.outputData.length).toBe(1); expect(b.outputData.length).toBe(0); expect((new OutgoingMessage() as any).outputData.length).toBe(0); // Like Node, the prototype has no outputData property at all; reading it off // the prototype must not materialize shared state on the prototype. expect(Object.getOwnPropertyDescriptor(OutgoingMessage.prototype, "outputData")).toBeUndefined(); void (OutgoingMessage.prototype as any).outputData; const c: any = new OutgoingMessage(); const d: any = new OutgoingMessage(); c.outputData.push({ data: "y", encoding: "utf8", callback: null }); expect(d.outputData.length).toBe(0); }); it("connectionListener hands off Upgrade and CONNECT like Node", async () => { // A socket served through server.emit("connection", ...) takes the JS // fallback parser. Its Upgrade/CONNECT dispatch must match Node's // parserOnIncoming: an Upgrade with a listener switches protocols with the // first tunnel bytes as bodyHead; without one it falls through as a normal // request with req.upgrade cleared; CONNECT without a listener destroys. { const unexpectedRequest = Promise.withResolvers(); const server = createServer(() => unexpectedRequest.reject(new Error("request handler must not run for a handled upgrade")), ); server.on("upgrade", (req, socket, head) => { socket.write("HTTP/1.1 101 Switching Protocols\r\n\r\nHEAD:" + head.toString()); socket.on("data", d => socket.write("TUNNEL:" + d)); }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); const out = await Promise.race([ unexpectedRequest.promise, new Promise((resolve, reject) => { let buf = ""; let sentMore = false; clientSide.on("data", d => { buf += d; if (!sentMore && buf.includes("HEAD:early")) { sentMore = true; clientSide.write("more"); } if (buf.includes("TUNNEL:more")) resolve(buf); }); clientSide.on("error", reject); clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\nearly"); }), ]); expect(out).toStartWith("HTTP/1.1 101 Switching Protocols"); expect(out).toContain("HEAD:early"); expect(out).toContain("TUNNEL:more"); clientSide.destroy(); serverSide.destroy(); } { const server = createServer((req, res) => { res.end("normal:" + (req as any).upgrade); }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); const out = await new Promise((resolve, reject) => { let buf = ""; clientSide.on("data", d => { buf += d; if (buf.includes("normal:")) resolve(buf); }); clientSide.on("error", reject); clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); clientSide.write("GET / HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\n"); }); expect(out).toContain("HTTP/1.1 200"); expect(out).toEndWith("normal:false"); clientSide.destroy(); serverSide.destroy(); } { let requestHandlerRan = false; const server = createServer(() => { requestHandlerRan = true; }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); // A duplexPair does not propagate destroy() to the other side; watch the // server half directly. const closed = new Promise(resolve => serverSide.on("close", () => resolve())); clientSide.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"); await closed; expect(requestHandlerRan).toBe(false); expect(serverSide.destroyed).toBe(true); } }); it.each([ ["default", {}, "http/1.1"], ["ALPNProtocols", { ALPNProtocols: ["http/1.1"] }, "http/1.1"], ["ALPNCallback", { ALPNCallback: () => "http/1.1" }, "http/1.1"], ])("https wraps a raw socket injected through the connection event (%s)", async (_name, options, protocol) => { const server = createHttpsServer({ ...tlsCert, ...options }, (req, res) => { expect((req.socket as any).encrypted).toBe(true); expect((req.socket as any).alpnProtocol).toBe(protocol); res.writeHead(200, { Connection: "close" }); res.end("injected-ok"); }); const rawClosed = Promise.withResolvers(); const front = createNetServer(socket => { socket.once("close", () => rawClosed.resolve()); server.emit("connection", socket); }); try { await once(front.listen(0, "127.0.0.1"), "listening"); const response = await new Promise<{ statusCode: number | undefined; body: string }>((resolve, reject) => { const request = https.get( { host: "127.0.0.1", port: (front.address() as AddressInfo).port, rejectUnauthorized: false, agent: false, ALPNProtocols: ["http/1.1"], }, response => { const chunks: Buffer[] = []; response.on("data", chunk => chunks.push(chunk)); response.on("end", () => resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString("utf8") }), ); }, ); request.on("error", reject); }); expect(response).toEqual({ statusCode: 200, body: "injected-ok" }); await rawClosed.promise; } finally { front.close(); } }); it("https wraps a handed-off native CONNECT tunnel", async () => { const server = createHttpsServer(tlsCert, (req, res) => { res.writeHead(200, { Connection: "close" }); res.end(req.socket.encrypted ? "tunnel-ok" : "plaintext"); }); const frontClosed = Promise.withResolvers(); const front = createServer(); front.on("connect", (_req, socket) => { socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); server.emit("connection", socket); front.close(err => (err ? frontClosed.reject(err) : frontClosed.resolve())); }); let client; try { await once(front.listen(0, "127.0.0.1"), "listening"); const result = await new Promise((resolve, reject) => { const tunnel = http.request({ host: "127.0.0.1", port: (front.address() as AddressInfo).port, method: "CONNECT", path: "localhost:443", }); tunnel.on("error", reject); tunnel.on("connect", (_response, socket) => { client = tlsConnect({ socket, rejectUnauthorized: false }, () => { client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); }); const chunks: Buffer[] = []; client.on("error", reject); client.on("data", chunk => chunks.push(chunk)); client.on("end", () => resolve(Buffer.concat(chunks).toString())); }); tunnel.end(); }); expect(result).toContain("HTTP/1.1 200 OK"); expect(result).toContain("tunnel-ok"); await frontClosed.promise; } finally { client?.destroy(); front.closeAllConnections(); if (front.listening) front.close(); } }); async function withStalledInjectedHttpsConnection(observe: (server: https.Server) => Promise) { const server = createHttpsServer({ ...tlsCert, handshakeTimeout: 50 }); const rawClosed = Promise.withResolvers(); const front = createNetServer(socket => { socket.once("close", () => rawClosed.resolve()); server.emit("connection", socket); }); let client; try { const observed = observe(server); await once(front.listen(0, "127.0.0.1"), "listening"); client = connect((front.address() as AddressInfo).port, "127.0.0.1"); client.on("error", () => {}); const result = await observed; await rawClosed.promise; return result; } finally { client?.destroy(); front.close(); } } it("https routes an injected socket handshake timeout through clientError", async () => { const result = await withStalledInjectedHttpsConnection(server => { const clientError = Promise.withResolvers<{ code: string | undefined; destroyed: boolean }>(); server.on("clientError", (err: Error & { code?: string }, socket) => { clientError.resolve({ code: err.code, destroyed: socket.destroyed }); socket.destroy(); }); return clientError.promise; }); expect(result).toEqual({ code: "ERR_TLS_HANDSHAKE_TIMEOUT", destroyed: false }); }); it("https destroys an injected socket after an unhandled handshake timeout", async () => { const result = await withStalledInjectedHttpsConnection(server => { const tlsClientError = Promise.withResolvers<{ code: string | undefined; destroyed: boolean }>(); server.on("tlsClientError", (err: Error & { code?: string }, socket) => { tlsClientError.resolve({ code: err.code, destroyed: socket.destroyed }); }); return tlsClientError.promise; }); expect(result).toEqual({ code: "ERR_TLS_HANDSHAKE_TIMEOUT", destroyed: true }); }); // A TLS client that is mid-handshake when an https server with a 'clientError' listener is closed still // belongs to that server: once its handshake completes, a malformed request from it reaches 'clientError' // (as in Node). The connection used to go uncounted until the handshake finished, so close() considered the // server drained and released its wrapper — the dispatch was dropped, or under GC pressure went through freed // handler slots. test("https 'clientError' for a connection whose handshake completes after close()", async () => { const events: string[] = []; let server: https.Server | null = createHttpsServer(tlsCert, (req, res) => res.end("ok")); server.on("clientError", (err: any, socket) => { events.push("clientError " + (err.code || err.message)); socket.destroy(); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const raw = connect(port, "127.0.0.1"); let client: ReturnType | undefined; try { await once(raw, "connect"); // Client→server bytes flow; server→client bytes are held, so the server has answered the ClientHello and // is waiting mid-handshake when close() runs. let hold = true; const held: Buffer[] = []; const wire = new Duplex({ read() {}, write(chunk, enc, cb) { raw.write(chunk, cb); }, }); raw.on("data", (d: Buffer) => (hold ? held.push(d) : wire.push(d))); raw.on("close", () => wire.push(null)); const c = (client = tlsConnect({ socket: wire, rejectUnauthorized: false })); c.on("error", () => {}); for (const t = Date.now(); held.length === 0 && Date.now() - t < 10_000;) await Bun.sleep(5); expect(held.length).toBeGreaterThan(0); const closed = new Promise(r => server!.close(() => r())); server = null; Bun.gc(true); hold = false; for (const d of held) wire.push(d); c.write("NOT A VALID REQUEST LINE\r\n\r\n"); await new Promise(res => (c.on("data", () => {}), c.on("end", () => res()), c.on("close", () => res()))); expect(events).toEqual([expect.stringMatching(/^clientError HPE_INVALID/)]); await closed; } finally { server?.close(); client?.destroy(); raw.destroy(); } }); // Header values are byte strings: Node writes U+00E9 as the single byte 0xE9, never as // UTF-8 0xC3 0xA9, whatever the engine's internal representation of the string is. A // 16-bit string (TextDecoder, normalize(), JSON.parse output) must produce the same // bytes as the equal 8-bit literal. The body is a Buffer so that Node does not glue the // header block onto a utf8 string chunk. describe("response header values are written as latin-1 bytes", () => { const eightBit = "caf\u00e9-\u0080\u00ff"; const sixteenBit = new TextDecoder("utf-16le").decode(new Uint16Array([0x63, 0x61, 0x66, 0xe9, 0x2d, 0x80, 0xff])); const expectedHex = "63 61 66 e9 2d 80 ff"; async function headerHex(port: number, path: string, name: string): Promise { const socket = connect(port, "127.0.0.1"); try { socket.on("error", () => {}); await once(socket, "connect"); socket.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); let raw = ""; await new Promise(resolve => { socket.on("data", chunk => (raw += chunk.toString("latin1"))); socket.on("close", resolve); }); return raw .split("\r\n\r\n")[0] .split("\r\n") .filter(line => line.toLowerCase().startsWith(name + ":")) .map(line => [...Buffer.from(line.slice(name.length + 2), "latin1")].map(c => c.toString(16).padStart(2, "0")).join(" "), ); } finally { socket.destroy(); } } async function check( write: (res: ServerResponse, value: string) => void, name: string, expected: string[], ): Promise { expect(sixteenBit).toBe(eightBit); const server = createServer((req, res) => { write(res, req.url === "/16" ? sixteenBit : eightBit); res.end(Buffer.from("ok")); }); try { await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; expect(await headerHex(port, "/8", name)).toEqual(expected); expect(await headerHex(port, "/16", name)).toEqual(expected); } finally { server.close(); } } it("setHeader", async () => { await check((res, value) => res.setHeader("x-t", value), "x-t", [expectedHex]); }); it("writeHead with an object", async () => { await check((res, value) => res.writeHead(200, { "x-t": value }), "x-t", [expectedHex]); }); it("writeHead with a flat array", async () => { await check((res, value) => res.writeHead(200, ["x-t", value]), "x-t", [expectedHex]); }); it("setHeader with a Set-Cookie array", async () => { await check((res, value) => res.setHeader("set-cookie", [`a=${value}`, `b=${value}`]), "set-cookie", [ "61 3d " + expectedHex, "62 3d " + expectedHex, ]); }); }); describe("HTTP server transport shutdown", () => { it("binds a successful close callback to the server", async () => { const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); let receiver: unknown; const error = await new Promise(resolve => { server.close(function (error) { receiver = this; resolve(error); }); }); expect({ error, receiver }).toEqual({ error: undefined, receiver: server }); }); it("waits for an active keep-alive connection to close before server.close() completes", async () => { const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const events: string[] = []; const paths: string[] = []; let wire = ""; let dataArrived = Promise.withResolvers(); const server = createServer(async (req, res) => { paths.push(req.url!); entered.resolve(); await release.promise; res.end("done"); }); server.on("connection", socket => socket.once("close", () => events.push("socket close"))); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect(port, "127.0.0.1"); const clientClosed = Promise.withResolvers(); client.on("data", chunk => { wire += chunk.toString("latin1"); dataArrived.resolve(); }); client.on("error", () => {}); client.on("close", clientClosed.resolve); try { await once(client, "connect"); client.write("GET /first HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); await entered.promise; const closed = Promise.withResolvers(); let closeCallbackCalled = false; server.close(error => { closeCallbackCalled = true; events.push("close callback"); closed.resolve(error); }); release.resolve(); while (!wire.endsWith("done")) { await dataArrived.promise; dataArrived = Promise.withResolvers(); } expect(closeCallbackCalled).toBe(false); client.write("GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); while ((wire.match(/done/g)?.length ?? 0) < 2) { await dataArrived.promise; dataArrived = Promise.withResolvers(); } expect(closeCallbackCalled).toBe(false); expect(paths).toEqual(["/first", "/healthz"]); client.destroy(); expect(await closed.promise).toBeUndefined(); await clientClosed.promise; expect(events[0]).toBe("socket close"); expect(events.indexOf("socket close")).toBeLessThan(events.indexOf("close callback")); expect(wire).toContain("Content-Length: 4\r\n"); expect(wire).toEndWith("\r\n\r\ndone"); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) server.close(); } }); it("reports a close() that leaves no connection open, also when listen() follows it at once", async () => { // Node queues 'close' inside close() when the server has no connection. A // listen() in the same tick does not hold it back. const events: string[] = []; const firstClose = Promise.withResolvers(); const finalClose = Promise.withResolvers(); const server = createServer((_req, res) => res.end("done")); server.on("close", () => events.push("server close")); server.listen(0, "127.0.0.1"); await once(server, "listening"); try { server.close(error => { events.push("first close callback"); firstClose.resolve(error); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); expect(await firstClose.promise).toBeUndefined(); expect(events).toEqual(["server close", "first close callback"]); expect(server.listening).toBe(true); const { port } = server.address() as AddressInfo; const body = await new Promise((resolve, reject) => { get({ host: "127.0.0.1", port, headers: { connection: "close" } }, res => { let text = ""; res.setEncoding("utf8"); res.on("data", chunk => (text += chunk)); res.on("end", () => resolve(text)); }).on("error", reject); }); expect(body).toBe("done"); server.close(error => { events.push("final close callback"); finalClose.resolve(error); }); expect(await finalClose.promise).toBeUndefined(); expect(events).toEqual(["server close", "first close callback", "server close", "final close callback"]); } finally { server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it.each(["http", "https"])( "%s: reports a close() that closes its only connection as idle, also when listen() follows it at once", async proto => { // Node counts a connection that close() destroys as gone at once. The // close of an idle TLS connection ends later, with the peer's close_notify. const events: string[] = []; const firstClose = Promise.withResolvers(); const handler = (_req: IncomingMessage, res: ServerResponse) => res.end("done"); const server = proto === "https" ? createHttpsServer(tlsCert, handler) : createServer(handler); server.on("close", () => events.push("server close")); await once(server.listen(0, "127.0.0.1"), "listening"); const client = proto === "https" ? https : http; const agent = new client.Agent({ keepAlive: true }); try { const { port } = server.address() as AddressInfo; const body = await new Promise((resolve, reject) => { client .get({ host: "127.0.0.1", port, agent, rejectUnauthorized: false }, res => { let text = ""; res.setEncoding("utf8"); res.on("data", chunk => (text += chunk)); res.on("end", () => resolve(text)); }) .on("error", reject); }); expect(body).toBe("done"); server.close(error => { events.push("first close callback"); firstClose.resolve(error); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); expect(await firstClose.promise).toBeUndefined(); expect(events).toEqual(["server close", "first close callback"]); expect(server.listening).toBe(true); } finally { agent.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }, ); it("defers a stopped listener's close event while a replacement listener is active", async () => { const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const responseReceived = Promise.withResolvers(); const relistened = Promise.withResolvers(); const firstClose = Promise.withResolvers(); const finalClose = Promise.withResolvers(); const events: string[] = []; const server = createServer(async (req, res) => { if (req.url === "/first") { entered.resolve(); await release.promise; } res.end("done"); }); server.on("close", () => events.push("server close")); server.listen(0, "127.0.0.1"); await once(server, "listening"); const firstPort = (server.address() as AddressInfo).port; const client = connect(firstPort, "127.0.0.1"); let responseWire = ""; client.on("error", () => {}); client.on("data", chunk => { responseWire += chunk.toString("latin1"); if (responseWire.endsWith("done")) responseReceived.resolve(); }); try { await once(client, "connect"); client.write("GET /first HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); await entered.promise; server.close(error => { events.push("first close callback"); firstClose.resolve(error); }); server.listen(0, "127.0.0.1", () => { events.push("relisten"); relistened.resolve(); }); await relistened.promise; release.resolve(); await responseReceived.promise; const clientClosed = once(client, "close"); client.destroy(); await clientClosed; await new Promise(resolve => setImmediate(resolve)); expect(events).toEqual(["relisten"]); expect(server.listening).toBe(true); const secondPort = (server.address() as AddressInfo).port; expect( await new Promise((resolve, reject) => { get({ host: "127.0.0.1", port: secondPort, headers: { connection: "close" } }, res => { let body = ""; res.setEncoding("utf8"); res.on("data", chunk => (body += chunk)); res.on("end", () => resolve(body)); }).on("error", reject); }), ).toBe("done"); server.close(error => { events.push("final close callback"); finalClose.resolve(error); }); expect(await Promise.all([firstClose.promise, finalClose.promise])).toEqual([undefined, undefined]); expect(events).toEqual(["relisten", "server close", "first close callback", "final close callback"]); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it.each(["first", "replacement"] as const)( "waits for both overlapping listener generations when the %s listener drains first", async firstToDrain => { const firstClose = Promise.withResolvers(); const finalClose = Promise.withResolvers(); const events: string[] = []; // The response never ends, so its connection stays open until the client destroys it. const server = createHttpsServer(tlsCert, (_req, res) => void res.write("answer")); server.on("close", () => events.push("server close")); const startHeldHandshake = async (port: number) => { const raw = connect(port, "127.0.0.1"); raw.on("error", () => {}); await once(raw, "connect"); let hold = true; const held: Buffer[] = []; const wire = new Duplex({ read() {}, write(chunk, _encoding, callback) { raw.write(chunk, callback); }, }); raw.on("data", (chunk: Buffer) => (hold ? held.push(chunk) : wire.push(chunk))); raw.on("close", () => wire.push(null)); const client = tlsConnect({ socket: wire, rejectUnauthorized: false }); client.on("error", () => {}); await once(raw, "data"); return { client, raw, release() { hold = false; for (const chunk of held) wire.push(chunk); }, async destroy() { const closed = once(raw, "close"); client.destroy(); raw.destroy(); await closed; }, }; }; server.listen(0, "127.0.0.1"); await once(server, "listening"); const first = await startHeldHandshake((server.address() as AddressInfo).port); server.close(error => { events.push("first close callback"); firstClose.resolve(error); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const final = await startHeldHandshake((server.address() as AddressInfo).port); try { server.close(error => { events.push("final close callback"); finalClose.resolve(error); }); const [early, late] = firstToDrain === "first" ? [first, final] : [final, first]; early.release(); const [earlyServerSocket] = await once(server, "secureConnection"); const earlyServerSocketClosed = once(earlyServerSocket, "close"); await early.destroy(); await earlyServerSocketClosed; // The server knows only the held handshake now. Its answer needs more reads, so a 'close' event for the early listener alone comes before it. late.release(); late.client.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); await once(late.client, "data"); events.push("the other connection is answered"); await late.destroy(); expect(await Promise.all([firstClose.promise, finalClose.promise])).toEqual([undefined, undefined]); expect(events).toEqual([ "the other connection is answered", "server close", "first close callback", "final close callback", ]); } finally { first.client.destroy(); first.raw.destroy(); final.client.destroy(); final.raw.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }, 15_000, ); it("waits for a socket first wrapped by the parser-error path", async () => { const clientError = Promise.withResolvers(); const closed = Promise.withResolvers(); const server = createServer(); server.on("clientError", (_error, socket) => clientError.resolve(socket as import("node:net").Socket)); server.listen(0, "127.0.0.1"); await once(server, "listening"); const client = connect((server.address() as AddressInfo).port, "127.0.0.1"); client.on("error", () => {}); try { await once(client, "connect"); client.write("NOT-HTTP\r\n\r\n"); const serverSocket = await clientError.promise; let closeCalled = false; server.close(error => { closeCalled = true; closed.resolve(error); }); await new Promise(resolve => setImmediate(resolve)); expect(closeCalled).toBe(false); serverSocket.destroy(); expect(await closed.promise).toBeUndefined(); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it.each([ ["destroy", "end", false], ["resetAndDestroy", "ECONNRESET", true], ] as const)("req.socket.%s() uses the matching TCP close", async (method, peerEvent, hadError) => { const called = Promise.withResolvers<{ returnedSameSocket: boolean; destroyed: boolean }>(); const serverSocketClosed = Promise.withResolvers(); const server = createServer(req => { const socket = req.socket; const returned = socket[method](); called.resolve({ returnedSameSocket: returned === socket, destroyed: socket.destroyed }); }); server.on("connection", socket => socket.once("close", () => serverSocketClosed.resolve())); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; let addressCalls = 0; Object.defineProperty(server, "address", { configurable: true, value() { addressCalls++; return { address: "127.0.0.1", family: "IPv4", port }; }, }); const client = connect(port, "127.0.0.1"); const clientEvents: string[] = []; const clientClosed = Promise.withResolvers(); client.on("error", (error: NodeJS.ErrnoException) => clientEvents.push(error.code ?? error.message)); client.on("end", () => clientEvents.push("end")); client.on("close", clientClosed.resolve); try { await once(client, "connect"); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); expect(await called.promise).toEqual({ returnedSameSocket: true, destroyed: true }); expect(await clientClosed.promise).toBe(hadError); await serverSocketClosed.promise; expect(clientEvents).toEqual([peerEvent]); expect(addressCalls).toBe(0); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it.each(["https", "unix"] as const)("req.socket.resetAndDestroy() rejects a %s transport", async kind => { const attempted = Promise.withResolvers(); const handler = (req: IncomingMessage, res: ServerResponse) => { try { req.socket.resetAndDestroy(); attempted.resolve(undefined); } catch (error) { attempted.resolve(error); res.end("caught"); } }; const server = kind === "https" ? createHttpsServer(tlsCert, handler) : createServer(handler); const socketPath = path.join(tmpdir(), `bun-http-reset-${process.pid}.sock`); if (kind === "https") { server.listen(0, "127.0.0.1"); } else { server.listen(socketPath); } await once(server, "listening"); const responseBody = Promise.withResolvers(); void responseBody.promise.catch(() => {}); const onResponse = (res: IncomingMessage) => { let body = ""; res.setEncoding("utf8"); res.on("data", chunk => (body += chunk)); res.on("end", () => responseBody.resolve(body)); res.on("error", responseBody.reject); }; const req = kind === "https" ? https.get( { host: "127.0.0.1", port: (server.address() as AddressInfo).port, rejectUnauthorized: false }, onResponse, ) : get({ socketPath }, onResponse); req.on("error", responseBody.reject); try { expect(await attempted.promise).toMatchObject({ name: "TypeError", code: "ERR_INVALID_HANDLE_TYPE" }); expect(await responseBody.promise).toBe("caught"); } finally { req.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it("flushes response bytes before a successful write callback can destroy the response", async () => { const callback = Promise.withResolvers(); const events: string[] = []; const server = createServer((_req, res) => { res.on("close", () => events.push("response close")); res.writeHead(200, { "content-type": "text/plain" }); res.write("hello", error => { events.push("write callback"); callback.resolve(error ?? undefined); res.destroy(); }); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect(port, "127.0.0.1"); let wire = ""; const closed = Promise.withResolvers(); client.on("data", chunk => (wire += chunk.toString("latin1"))); client.on("error", () => {}); client.on("close", closed.resolve); try { await once(client, "connect"); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); await closed.promise; expect(await callback.promise).toBeUndefined(); expect(events).toEqual(["write callback", "response close"]); expect(wire).toStartWith("HTTP/1.1 200 OK\r\n"); expect(wire).toEndWith("\r\n\r\n5\r\nhello\r\n"); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); // More than a Linux or macOS loopback socket takes in one write(), so the rest of the body waits // for the client to read. Winsock takes a body of any size in one send(). const backedUpBodySize = (isWindows ? 2 : 64) * 1024 * 1024; it("waits for post-flush backpressure before running a write callback", async () => { const body = Buffer.alloc(backedUpBodySize, "x"); const writeReturned = Promise.withResolvers(); const callback = Promise.withResolvers(); let callbackCalled = false; const server = createServer((_req, res) => { res.writeHead(200, { "content-length": body.length }); const accepted = res.write(body, error => { callbackCalled = true; callback.resolve(error ?? undefined); res.destroy(); }); writeReturned.resolve(accepted); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect(port, "127.0.0.1"); const chunks: Buffer[] = []; const closed = Promise.withResolvers(); client.pause(); client.on("data", (chunk: Buffer) => chunks.push(chunk)); client.on("error", () => {}); client.on("close", closed.resolve); try { await once(client, "connect"); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); expect(await writeReturned.promise).toBe(false); if (!isWindows) expect(callbackCalled).toBe(false); client.resume(); await closed.promise; expect(await callback.promise).toBeUndefined(); const wire = Buffer.concat(chunks); const headerEnd = wire.indexOf("\r\n\r\n"); expect(headerEnd).toBeGreaterThan(0); expect(wire.subarray(headerEnd + 4)).toEqual(body); } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); it("fails a buffered write callback when the peer resets before drain", async () => { const body = Buffer.alloc(backedUpBodySize, "x"); const writeReturned = Promise.withResolvers(); const responseClosed = Promise.withResolvers(); const callbackErrors: string[] = []; const server = createServer((_req, res) => { res.on("close", responseClosed.resolve); const accepted = res.write(body, (error: NodeJS.ErrnoException | null | undefined) => { callbackErrors.push(error?.code ?? "success"); }); writeReturned.resolve(accepted); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const client = connect(port, "127.0.0.1"); client.on("error", () => {}); client.pause(); try { await once(client, "connect"); client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); expect(await writeReturned.promise).toBe(false); // Windows 11 takes the whole body at once. The write is then complete before the reset, and its callback has run. const completedBeforeReset = callbackErrors.length > 0; if (!isWindows) expect(completedBeforeReset).toBe(false); client.resetAndDestroy(); await responseClosed.promise; await new Promise(resolve => setImmediate(resolve)); if (completedBeforeReset) { expect(callbackErrors).toEqual(["success"]); } else { expect(callbackErrors).toHaveLength(1); expect(["ERR_STREAM_DESTROYED", "ECONNRESET", "EPIPE"]).toContain(callbackErrors[0]); } } finally { client.destroy(); server.closeAllConnections(); if (server.listening) { await new Promise(resolve => server.close(() => resolve())); } } }); }); it("connectionListener queues pipelined responses like Node", async () => { // Both requests parse in one socket 'data' event, so the second one's // headers complete while the first response is still assigned (its 'finish' // detach is deferred a tick). The second response must be queued, not // assigned - assignSocket throws ERR_HTTP_SOCKET_ASSIGNED - and its output // must be held back until the first response completes. async function exchange(handler: (req: any, res: any) => void, requestBytes: string, done: (buf: string) => boolean) { const server = createServer(handler); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); try { return await new Promise((resolve, reject) => { let buf = ""; clientSide.on("data", d => { buf += d; if (done(buf)) resolve(buf); }); clientSide.on("error", reject); clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); clientSide.write(requestBytes); }); } finally { clientSide.destroy(); serverSide.destroy(); } } // Handler finishes after the current tick: the second request is dispatched // while the first response is still in flight. { const out = await exchange( (req, res) => setImmediate(() => res.end("ok:" + req.url)), "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n", buf => buf.includes("ok:/a") && buf.includes("ok:/b"), ); expect(out.indexOf("ok:/a")).toBeLessThan(out.indexOf("ok:/b")); expect(out.match(/HTTP\/1\.1 200/g)).toHaveLength(2); } // The queued response is written (write + end, chunked) before the first // one finishes: its bytes must still go out second, after the first body. { let releaseFirst!: () => void; const firstGate = new Promise(resolve => (releaseFirst = resolve)); const out = await exchange( (req, res) => { if (req.url === "/a") { firstGate.then(() => res.end("first")); } else { res.write("second-part1"); res.end("second-part2"); releaseFirst(); } }, "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n", buf => buf.includes("second-part2") && buf.endsWith("0\r\n\r\n"), ); expect(out.indexOf("first")).toBeLessThan(out.indexOf("second-part1")); expect(out.indexOf("second-part1")).toBeLessThan(out.indexOf("second-part2")); const second = out.slice(out.indexOf("HTTP/1.1 200", 1)); expect(second).toContain("Transfer-Encoding: chunked"); } // Three pipelined requests answered synchronously: each finish hands the // socket to the next queued response. { const out = await exchange( (req, res) => res.end("r:" + req.url + ";"), "GET /1 HTTP/1.1\r\nHost: x\r\n\r\nGET /2 HTTP/1.1\r\nHost: x\r\n\r\nGET /3 HTTP/1.1\r\nHost: x\r\n\r\n", buf => buf.includes("r:/3;"), ); expect(out.indexOf("r:/1;")).toBeGreaterThanOrEqual(0); expect(out.indexOf("r:/1;")).toBeLessThan(out.indexOf("r:/2;")); expect(out.indexOf("r:/2;")).toBeLessThan(out.indexOf("r:/3;")); expect(out.match(/HTTP\/1\.1 200/g)).toHaveLength(3); } }); describe("a pipelined request whose body continues after the previous response ends", () => { // uws keeps one request body handler per connection. The pipelined request // arms it while the earlier response is still pending; that response ending // must not drop it, or the rest of the body never reaches the request. async function exchange( steps: { write: string; waitFor: string }[], lastUrl: string, describeBody = (body: Buffer) => `${body.length}`, ) { const { promise: lastDispatched, resolve: resolveLastDispatched } = Promise.withResolvers(); const server = createServer((req, res) => { const read = () => { const chunks: Buffer[] = []; req.on("data", c => chunks.push(c)); req.on("end", () => { const trailers = Object.entries(req.trailers) .map(([name, value]) => `,${name}=${value}`) .join(""); res.end(`${req.url}=${describeBody(Buffer.concat(chunks))}${trailers};`); }); }; if (req.url === lastUrl) { resolveLastDispatched(); read(); } else { // Answer only once the last request is dispatched behind this response. lastDispatched.then(read); } }); await once(server.listen(0, "127.0.0.1"), "listening"); const client = connect((server.address() as AddressInfo).port, "127.0.0.1"); client.setNoDelay(true); let out = ""; const { promise: closed, reject: rejectClosed } = Promise.withResolvers(); closed.catch(() => {}); client.on("data", d => (out += d)); client.on("error", rejectClosed); client.on("close", () => rejectClosed(new Error("closed before expected output: " + out))); await once(client, "connect"); try { for (const { write, waitFor } of steps) { client.write(write); while (!out.includes(waitFor)) { await Promise.race([once(client, "data"), closed]); } } return out.match(/\/\w+=[^;]+;/g); } finally { client.destroy(); await once(server.close(), "close"); } } // No byte of the body comes with the head. The dropped body still advanced // the parser to the end of /two, so /three ended it: 'end' with 0 body bytes, // no trailers and no error. it.each([ ["Content-Length", "Content-Length: 10\r\n", "0123456789", "/two=10;"], [ "Content-Length of 1 MiB", `Content-Length: ${1024 * 1024}\r\n`, Buffer.alloc(1024 * 1024, "b").toString(), `/two=${1024 * 1024};`, ], ["Expect: 100-continue", "Content-Length: 10\r\nExpect: 100-continue\r\n", "0123456789", "/two=10;"], [ "chunked with a trailer", "Transfer-Encoding: chunked\r\nTrailer: X-T\r\n", "a\r\n0123456789\r\n0\r\nX-T: v\r\n\r\n", "/two=10,x-t=v;", ], ])("whole body, then the next request: %s", async (_, headers, body, two) => { expect( await exchange( [ { write: "GET /one HTTP/1.1\r\nHost: a\r\n\r\n" + `POST /two HTTP/1.1\r\nHost: a\r\n${headers}\r\n`, waitFor: "/one=0;", }, { write: body + "GET /three HTTP/1.1\r\nHost: a\r\n\r\n", waitFor: "/three=0;" }, ], "/two", ), ).toEqual(["/one=0;", two, "/three=0;"]); }); it("two late bodies in one write each reach their own request", async () => { // /two lost its own body and received the first reads of the body of /three. const size = 1024 * 1024; // Each body is one repeated byte: report the length, then that byte. const lengthAndFill = (body: Buffer) => { if (body.length === 0) return "0"; const uniform = body.equals(Buffer.alloc(body.length, body[0])); return `${body.length}${uniform ? String.fromCharCode(body[0]) : "(mixed)"}`; }; expect( await exchange( [ { write: "GET /one HTTP/1.1\r\nHost: a\r\n\r\n" + `POST /two HTTP/1.1\r\nHost: a\r\nContent-Length: ${size}\r\n\r\n`, waitFor: "/one=0;", }, { write: Buffer.alloc(size, "a").toString() + `POST /three HTTP/1.1\r\nHost: a\r\nContent-Length: ${size}\r\n\r\n` + Buffer.alloc(size, "c").toString() + "GET /four HTTP/1.1\r\nHost: a\r\n\r\n", waitFor: "/four=0;", }, ], "/two", lengthAndFill, ), ).toEqual(["/one=0;", `/two=${size}a;`, `/three=${size}c;`, "/four=0;"]); }); it("Content-Length body", async () => { expect( await exchange( [ { write: "POST /one HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\nhello" + "POST /two HTTP/1.1\r\nHost: a\r\nContent-Length: 10\r\n\r\n01234", waitFor: "/one=5;", }, { write: "56789", waitFor: "/two=10;" }, ], "/two", ), ).toEqual(["/one=5;", "/two=10;"]); }); it("chunked body", async () => { expect( await exchange( [ { write: "POST /one HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\nhello" + "POST /two HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n5\r\n01234\r\n", waitFor: "/one=5;", }, { write: "5\r\n56789\r\n0\r\n\r\n", waitFor: "/two=10;" }, ], "/two", ), ).toEqual(["/one=5;", "/two=10;"]); }); it("third request behind two pending responses, then keep-alive reuse", async () => { expect( await exchange( [ { write: "POST /one HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\nhello" + "POST /two HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\nworld" + "POST /three HTTP/1.1\r\nHost: a\r\nContent-Length: 10\r\n\r\n01234", waitFor: "/two=5;", }, { write: "56789", waitFor: "/three=10;" }, { write: "POST /four HTTP/1.1\r\nHost: a\r\nContent-Length: 3\r\n\r\nabc", waitFor: "/four=3;" }, ], "/three", ), ).toEqual(["/one=5;", "/two=5;", "/three=10;", "/four=3;"]); }); }); it("connectionListener aborts queued pipelined responses when the connection dies", async () => { // Like Node's socketOnClose (abortIncoming) and the native socket's close // path: the in-flight request and a response still queued behind it (with // its request) are destroyed when the socket closes, so they emit 'close' // instead of hanging forever. const closedEvents: string[] = []; const { promise: aborted, resolve: onAborted, reject: onClientFailure } = Promise.withResolvers(); let closesPending = 3; const onQueuedClose = (tag: string) => { closedEvents.push(tag); if (--closesPending === 0) onAborted(); }; const server = createServer((req, res) => { if (req.url === "/a") { // /a never responds, so its response keeps the socket and /b stays queued. req.on("close", () => onQueuedClose("reqA")); return; } req.on("close", () => onQueuedClose("reqB")); res.on("close", () => onQueuedClose("resB")); // Kill the connection while /b is queued behind /a. serverSide.destroy(); }); const [clientSide, serverSide] = duplexPair(); clientSide.on("error", onClientFailure); server.emit("connection", serverSide); clientSide.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); await aborted; expect(closedEvents.sort()).toEqual(["reqA", "reqB", "resB"]); clientSide.destroy(); }); it("connectionListener ends the connection after a Connection: close response instead of advancing", async () => { // Node's resOnFinish _last branch: a response that advertised Connection: // close must be the connection's final response (RFC 9112 9.6); the queued // pipelined response behind it is aborted by the close path, never sent. const closedEvents: string[] = []; const { promise: done, resolve: onDone, reject: onClientFailure } = Promise.withResolvers(); let pending = 3; const tick = (tag: string) => { closedEvents.push(tag); if (--pending === 0) onDone(); }; const server = createServer((req, res) => { if (req.url === "/a") { res.setHeader("Connection", "close"); res.end("closing"); return; } req.on("close", () => tick("reqB")); res.on("close", () => tick("resB")); res.end("should-never-be-sent"); }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); let received = ""; clientSide.on("data", d => (received += d)); clientSide.on("error", onClientFailure); clientSide.on("end", () => { tick("clientEnd"); // A well-behaved client answers the FIN, fully closing the connection. clientSide.end(); }); clientSide.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"); await done; expect(received).toContain("Connection: close"); expect(received).toContain("closing"); expect(received).not.toContain("should-never-be-sent"); expect(closedEvents.sort()).toEqual(["clientEnd", "reqB", "resB"]); clientSide.destroy(); serverSide.destroy(); }); it("connectionListener resets the connection when a queued pipelined response is destroyed", async () => { // Deliberate divergence from Node v26, which assigns the destroyed message // and wedges the connection until requestTimeout: an HTTP/1.1 connection // cannot skip a response slot, so Bun resets it (same as the native path), // aborting the requests queued behind the destroyed slot. const closedEvents: string[] = []; const { promise: cAborted, resolve: onCAborted, reject: onClientFailure } = Promise.withResolvers(); let cClosesPending = 2; let releaseFirst!: () => void; const firstGate = new Promise(resolve => (releaseFirst = resolve)); const server = createServer((req, res) => { if (req.url === "/a") { firstGate.then(() => res.end("first")); } else if (req.url === "/b") { // Queued behind /a: like Node, a queued response has no socket yet. expect(res.socket).toBe(null); res.destroy(); releaseFirst(); } else { req.on("close", () => { closedEvents.push("reqC"); if (--cClosesPending === 0) onCAborted(); }); res.on("close", () => { closedEvents.push("resC"); if (--cClosesPending === 0) onCAborted(); }); } }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); let received = ""; clientSide.on("data", d => (received += d)); clientSide.on("error", onClientFailure); clientSide.write( "GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\nGET /c HTTP/1.1\r\nHost: x\r\n\r\n", ); await cAborted; expect(closedEvents.sort()).toEqual(["reqC", "resC"]); // The in-flight response still reached the client before the reset. expect(received).toContain("first"); expect(serverSide.destroyed).toBe(true); clientSide.destroy(); }); it("connectionListener pauses reads when queued pipelined responses back up", async () => { // Node's parserOnIncoming read gate: once the bytes buffered on queued // responses pass the socket's high water mark, stop reading so a pipelining // client cannot flood the connection, and resume as the pipeline drains. const [clientSide, serverSide] = duplexPair(); const big = Buffer.alloc(Math.ceil(serverSide.writableHighWaterMark / 2), "x").toString(); const N = 10; let dispatched = 0; let releaseFirst!: () => void; const firstGate = new Promise(resolve => (releaseFirst = resolve)); const server = createServer((req, res) => { dispatched++; if (req.url === "/0") { firstGate.then(() => res.end("first")); } else { res.end(big); } }); server.emit("connection", serverSide); const { promise: allServed, resolve: onAllServed, reject: onClientFailure } = Promise.withResolvers(); let received = ""; clientSide.on("data", d => { received += d; if ((received.match(/HTTP\/1\.1 200 /g) || []).length >= N) onAllServed(); }); clientSide.on("error", onClientFailure); clientSide.on("close", () => onClientFailure(new Error("closed before all responses: " + received))); // One write per request so the gate (checked per headers-complete) takes // effect between data events. for (let i = 0; i < N; i++) { clientSide.write(`GET /${i} HTTP/1.1\r\nHost: x\r\n\r\n`); await new Promise(resolve => setImmediate(resolve)); } // Reads paused with requests still unparsed. expect(serverSide.isPaused()).toBe(true); expect(dispatched).toBeLessThan(N); // Draining the pipeline releases the gate and everything is served. releaseFirst(); await allServed; expect(dispatched).toBe(N); clientSide.destroy(); serverSide.destroy(); }); // https://github.com/oven-sh/bun/issues/4733 // https://github.com/oven-sh/bun/issues/18613 // Ending the response inside the request handler must not drop the request // body: like Node.js, the body keeps flowing to req's 'data' listeners and // piped destinations until it has been fully received, and resOnFinish (the // 'finish' listener) decides whether to _dump() based on the state *at* // 'finish', so a consumer attached after res.end() in the same tick still // receives the body. (req.complete / 'end' / 'close' tracking the body, and // connections the response closes, are covered in // node-http-server-abort-events.test.ts.) describe("request body still flows after res.end() was called in the handler", () => { async function run(handler: (req: IncomingMessage, res: ServerResponse, out: Writable) => void) { const events: string[] = []; const chunks: Buffer[] = []; const { promise: finished, resolve: finish, reject } = Promise.withResolvers(); let reqRef: IncomingMessage | undefined; await using server = createServer((req, res) => { reqRef = req; const out = new Writable({ write(chunk, _enc, cb) { chunks.push(Buffer.from(chunk)); cb(); }, }); req.once("end", () => events.push("req-end")); req.once("close", () => events.push("req-close")); req.once("error", reject); out.once("error", reject); out.once("finish", () => { events.push("out-finish"); finish(); }); handler(req, res, out); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const resp = await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "testing-body" }); expect(await resp.text()).toBe("ok"); await finished; expect({ body: Buffer.concat(chunks).toString(), events, dumped: (reqRef as any)._dumped, }).toEqual({ body: "testing-body", events: ["req-end", "out-finish", "req-close"], dumped: false, }); } it("req.pipe(out) before res.end()", async () => { await run((req, res, out) => { req.pipe(out); res.end("ok"); }); }); it("req.on('data') before res.end()", async () => { await run((req, res, out) => { req.on("data", c => out.write(c)); req.on("end", () => out.end()); res.end("ok"); }); }); it("req.pipe(out) after res.end() in the same tick", async () => { await run((req, res, out) => { res.end("ok"); req.pipe(out); }); }); it("req.on('data') after res.end() in the same tick", async () => { await run((req, res, out) => { res.end("ok"); req.on("data", c => out.write(c)); req.on("end", () => out.end()); }); }); it("req.pipe(out) with res.write() + res.end()", async () => { await run((req, res, out) => { req.pipe(out); res.write("o"); res.end("k"); }); }); it("req.pipe(out) with res.end() on nextTick", async () => { await run((req, res, out) => { req.pipe(out); process.nextTick(() => res.end("ok")); }); }); it("with no consumer, req is _dumped on 'finish' like Node's resOnFinish", async () => { let dumpedAtFinish: boolean | undefined; let reqRef: IncomingMessage | undefined; const { promise: closed, resolve, reject } = Promise.withResolvers(); await using server = createServer((req, res) => { reqRef = req; req.once("error", reject); req.once("close", resolve); res.end("ok"); // emitResponseFinish is registered before the 'request' event, so by the // time this listener runs req._dump() has already been called. res.on("finish", () => (dumpedAtFinish = (req as any)._dumped)); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const resp = await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "testing-body" }); expect(await resp.text()).toBe("ok"); await closed; expect({ dumpedAtFinish, dumped: (reqRef as any)._dumped }).toEqual({ dumpedAtFinish: true, dumped: true }); }); it("req.resume() after res.end() in the same tick prevents _dump()", async () => { let dumped: boolean | undefined; const { promise: ended, resolve, reject } = Promise.withResolvers(); await using server = createServer((req, res) => { req.once("error", reject); res.end("ok"); req.resume(); req.once("end", () => { dumped = (req as any)._dumped; resolve(); }); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const resp = await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "testing-body" }); expect(await resp.text()).toBe("ok"); await ended; expect(dumped).toBe(false); }); it("keep-alive connection reused after a consumed body releases the request", async () => { // The body's fin arriving on the re-armed inStream after res.end() must // release the pending-request ref so server.close() resolves and the // process exits. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `const http = require("http"); const { once } = require("events"); (async () => { const bodies = []; const server = http.createServer((req, res) => { let body = ""; req.on("data", c => body += c); req.once("end", () => bodies.push(body)); res.end("ok"); }); await once(server.listen(0, "127.0.0.1"), "listening"); const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); const port = server.address().port; for (let i = 0; i < 3; i++) { await new Promise((resolve, reject) => { const r = http.request({ agent, method: "POST", port }, res => { res.resume(); res.on("end", resolve); res.on("error", reject); }); r.on("error", reject); r.end("body" + i); }); } agent.destroy(); await new Promise(r => server.close(r)); console.log(JSON.stringify(bodies)); })();`, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout.trim()).toBe('["body0","body1","body2"]'); expect(exitCode).toBe(0); }); it("chunked request body split across writes", async () => { let body = ""; const { promise: ended, resolve, reject } = Promise.withResolvers(); await using server = createServer((req, res) => { req.on("data", c => (body += c)); req.once("end", resolve); req.once("error", reject); req.once("close", () => reject(new Error("closed before end"))); res.end("ok"); }); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; const sock = connect(port, "127.0.0.1"); await once(sock, "connect"); sock.write("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n"); // A second TCP segment carrying the remainder (via setNoDelay + event-loop // bounce) so res.end() has already run when it arrives. sock.setNoDelay(true); await new Promise(r => setImmediate(r)); sock.write("6\r\n world\r\n0\r\n\r\n"); await ended; sock.end(); expect(body).toBe("hello world"); }); it("socket closed mid-upload does not strand the event loop", async () => { // Covers the case where the body's fin never arrives after the response // ended: the body-read ref must be released on teardown. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `const http = require("http"); const net = require("net"); const { once } = require("events"); (async () => { const events = []; const server = http.createServer((req, res) => { req.on("data", c => events.push("data(" + c.length + ")")); req.on("end", () => events.push("end")); res.end("ok"); }); await once(server.listen(0, "127.0.0.1"), "listening"); const s = net.connect(server.address().port, "127.0.0.1"); await once(s, "connect"); s.write("POST / HTTP/1.1\\r\\nHost: x\\r\\nContent-Length: 100\\r\\n\\r\\nabc"); s.resume(); await once(s, "data"); s.destroy(); await once(s, "close"); server.close(); process.on("beforeExit", () => console.log(JSON.stringify(events))); })();`, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); // Like Node.js: the partial body is delivered, 'end' is not (incomplete), // and the process reaches beforeExit. expect(stdout.trim()).toBe('["data(3)"]'); expect(exitCode).toBe(0); }); // The body outliving the response must not leave anything holding the event // loop open: the process has to exit on its own both when the body does // arrive later (with the connection then reused, so the early request is no // longer the connection's current one when it closes) and when the client // goes away mid-body instead. it.each(["complete", "abort"])( "process exits on its own after an early response (%s)", async mode => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const { createServer } = require("node:http"); const { connect } = require("node:net"); const server = createServer((req, res) => { if (req.url === "/early") { req.on("close", () => console.log("req close complete=" + req.complete)); req.socket.on("close", () => console.log("socket close complete=" + req.complete)); } res.end("ok:" + req.url); }); server.listen(0, "127.0.0.1", () => { const client = connect(server.address().port, "127.0.0.1"); let received = ""; client.on("data", chunk => { received += chunk; if (received.endsWith("ok:/early")) { received = ""; if (${JSON.stringify(mode)} === "abort") { client.destroy(); server.close(() => console.log("server closed")); } else { client.write("def"); client.write("GET /second HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); } } else if (received.endsWith("ok:/second")) { client.end(); server.close(() => console.log("server closed")); } }); client.write("POST /early HTTP/1.1\\r\\nHost: x\\r\\nContent-Length: 6\\r\\n\\r\\nabc"); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout.trim().split("\n").sort()).toEqual( mode === "abort" ? ["server closed", "socket close complete=false"] : ["req close complete=true", "server closed", "socket close complete=true"], ); expect(exitCode).toBe(0); }, 30_000, ); }); describe("res.strictContentLength", () => { // Runs `handler(res)` inside a request handler and resolves with its return // value. The raw socket client keeps the response from being consumed, so the // only observable effect is what the handler throws. async function inServer(handler: (res: http.ServerResponse) => T): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const server = http.createServer((req, res) => { new Promise(done => done(handler(res))).then(resolve, reject).finally(() => res.destroy()); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as AddressInfo; const socket = connect(port, "127.0.0.1"); socket.on("error", () => {}); socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); try { return await promise; } finally { socket.destroy(); server.closeAllConnections(); server.close(); } } function code(fn: () => unknown): string | undefined { try { fn(); } catch (e: any) { return e.code; } return undefined; } const MISMATCH = "ERR_HTTP_CONTENT_LENGTH_MISMATCH"; test("Content-Length: 0 is checked", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", 0); return code(() => res.end("abc")); }); expect(result).toBe(MISMATCH); }); test("Content-Length: 0 with an empty body does not throw", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", 0); return code(() => res.end()); }); expect(result).toBeUndefined(); }); test("the write that sends the headers is counted but not checked", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", 5); return [code(() => res.write("abcdefghijk")), code(() => res.end("abc"))]; }); expect(result).toEqual([undefined, MISMATCH]); }); test("writes after writeHead are checked", async () => { const result = await inServer(res => { res.strictContentLength = true; res.writeHead(200, { "Content-Length": 10 }); return [code(() => res.write("123456789")), code(() => res.write("123456789")), code(() => res.end("0"))]; }); expect(result).toEqual([undefined, MISMATCH, undefined]); }); test("a Content-Length set after removeHeader is checked", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", 3); res.removeHeader("Content-Length"); res.setHeader("Content-Length", 5); return code(() => res.end("hello world")); }); expect(result).toBe(MISMATCH); }); test("a Content-Length from writeHead's flat array form is checked", async () => { const result = await inServer(res => { res.strictContentLength = true; res.writeHead(200, ["Content-Length", "5"]); return [code(() => res.write("hello")), code(() => res.end("!"))]; }); expect(result).toEqual([undefined, MISMATCH]); }); test("a string header value is parsed like Node (+value)", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", "1e1"); return [code(() => res.end("abcdefghij"))]; }); expect(result).toEqual([undefined]); }); test("a string header value with a mismatch throws", async () => { const result = await inServer(res => { res.strictContentLength = true; res.setHeader("Content-Length", "5"); return [code(() => res.write("hello")), code(() => res.end("!"))]; }); expect(result).toEqual([undefined, MISMATCH]); }); }); describe("connectionListener maxRequestsPerSocket", () => { // Sockets fed in through server.emit("connection", ...) take the JS fallback parser. This // client sends raw keep-alive requests over such a socket one at a time (never pipelined) and // parses each response as it arrives. The servers below only produce Content-Length framed // bodies and the bodiless chunked 503 (a lone terminating chunk), which is all it understands. function keepAliveClientOverEmittedConnection(server: Server) { const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); let received = ""; let waiter: PromiseWithResolvers | undefined; function takeResponse() { const headEnd = received.indexOf("\r\n\r\n"); if (headEnd === -1) return undefined; const head = received.slice(0, headEnd); let bodyEnd = headEnd + 4; const contentLength = /^Content-Length: (\d+)$/m.exec(head); if (contentLength) { bodyEnd += Number(contentLength[1]); } else if (/^Transfer-Encoding: chunked$/m.test(head)) { bodyEnd += "0\r\n\r\n".length; } if (received.length < bodyEnd) return undefined; const response = received.slice(0, bodyEnd); received = received.slice(bodyEnd); return response; } function deliver() { if (waiter === undefined) return; const response = takeResponse(); if (response === undefined) return; const { resolve } = waiter; waiter = undefined; resolve(response); } function fail(reason: string) { waiter?.reject(new Error(`${reason} while waiting for a response; received ${JSON.stringify(received)}`)); waiter = undefined; } clientSide.on("data", chunk => { received += chunk.toString("latin1"); deliver(); }); clientSide.on("end", () => fail("the server ended the connection")); clientSide.on("close", () => fail("the connection closed")); return { serverSide, async request(rawRequest: string) { waiter = Promise.withResolvers(); const { promise } = waiter; clientSide.write(rawRequest); deliver(); const raw = await promise; return { statusCode: Number(raw.slice("HTTP/1.1 ".length, "HTTP/1.1 ".length + 3)), connection: /^Connection: (.*)$/m.exec(raw)?.[1], keepAlive: /^Keep-Alive: (.*)$/m.exec(raw)?.[1], body: raw.slice(raw.indexOf("\r\n\r\n") + 4).replace(/^0\r\n\r\n$/, ""), }; }, [Symbol.dispose]() { clientSide.destroy(); serverSide.destroy(); }, }; } it("advertises Connection: close at the limit and answers every request past it with 503 and 'dropRequest'", async () => { const served: [string, boolean][] = []; const dropped: [string, boolean][] = []; const server = createServer((req, res) => { // Set before 'request' is emitted, like Node; it is what turns the Connection header into "close". served.push([req.url!, (res as any).maxRequestsOnConnectionReached]); res.end("served"); }); server.maxRequestsPerSocket = 2; using client = keepAliveClientOverEmittedConnection(server); server.on("dropRequest", (req, socket) => dropped.push([req.url!, socket === client.serverSide])); const responses: Awaited>[] = []; for (const path of ["/1", "/2", "/3", "/4"]) { responses.push(await client.request(`GET ${path} HTTP/1.1\r\nHost: example.test\r\n\r\n`)); } expect(responses).toEqual([ { statusCode: 200, connection: "keep-alive", keepAlive: "timeout=5, max=2", body: "served" }, { statusCode: 200, connection: "close", keepAlive: undefined, body: "served" }, { statusCode: 503, connection: "close", keepAlive: undefined, body: "" }, { statusCode: 503, connection: "close", keepAlive: undefined, body: "" }, ]); expect(served).toEqual([ ["/1", false], ["/2", true], ]); expect(dropped).toEqual([ ["/3", true], ["/4", true], ]); }); it("counts requests per connection", async () => { const dropped: string[] = []; const server = createServer((req, res) => res.end("served")); server.maxRequestsPerSocket = 1; server.on("dropRequest", req => dropped.push(req.url!)); using first = keepAliveClientOverEmittedConnection(server); using second = keepAliveClientOverEmittedConnection(server); const statusCodes = [ (await first.request("GET /first HTTP/1.1\r\nHost: example.test\r\n\r\n")).statusCode, (await second.request("GET /second HTTP/1.1\r\nHost: example.test\r\n\r\n")).statusCode, (await first.request("GET /first-again HTTP/1.1\r\nHost: example.test\r\n\r\n")).statusCode, (await second.request("GET /second-again HTTP/1.1\r\nHost: example.test\r\n\r\n")).statusCode, ]; expect(statusCodes).toEqual([200, 200, 503, 503]); expect(dropped).toEqual(["/first-again", "/second-again"]); }); it("drops an over-limit request before looking at its Expect header", async () => { // Node's parserOnIncoming checks the limit before the Expect routing, so the dropped // request gets its 503 straight away: no 100 Continue, no 'checkContinue'. const events: string[] = []; const server = createServer((req, res) => { events.push(`request ${req.url}`); res.end("served"); }); server.maxRequestsPerSocket = 1; server.on("checkContinue", (req, res) => { events.push(`checkContinue ${req.url}`); res.writeContinue(); res.end("served"); }); server.on("dropRequest", req => events.push(`dropRequest ${req.url}`)); using client = keepAliveClientOverEmittedConnection(server); expect((await client.request("GET /1 HTTP/1.1\r\nHost: example.test\r\n\r\n")).statusCode).toBe(200); expect( await client.request( "POST /2 HTTP/1.1\r\nHost: example.test\r\nExpect: 100-continue\r\nContent-Length: 0\r\n\r\n", ), ).toEqual({ statusCode: 503, connection: "close", keepAlive: undefined, body: "" }); expect(events).toEqual(["request /1", "dropRequest /2"]); }); it("still hands off an Upgrade request once the limit is reached", async () => { // Node's parserOnIncoming hands upgrades off before it counts requests, so the limit never // turns an upgrade into a 503. const events: string[] = []; const server = createServer((req, res) => { events.push(`request ${req.url}`); res.end("served"); }); server.maxRequestsPerSocket = 1; server.on("upgrade", (req, socket) => { events.push(`upgrade ${req.url}`); socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"); }); server.on("dropRequest", req => events.push(`dropRequest ${req.url}`)); using client = keepAliveClientOverEmittedConnection(server); expect(await client.request("GET /1 HTTP/1.1\r\nHost: example.test\r\n\r\n")).toEqual({ statusCode: 200, connection: "close", keepAlive: undefined, body: "served", }); expect( (await client.request("GET /ws HTTP/1.1\r\nHost: example.test\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n")) .statusCode, ).toBe(101); expect(events).toEqual(["request /1", "upgrade /ws"]); }); }); it("connectionListener applies the server's joinDuplicateHeaders option like the native path and Node", async () => { // Node keeps only the first value of a header it treats as single-valued (Authorization, // Content-Type, ...) unless the server was created with joinDuplicateHeaders; Cookie and unknown // headers are joined either way. The same raw request goes through server.emit("connection", ...) // (the JS parser path, which has to read the option off the server itself) and through the same // server options listening normally (the native path); both must produce Node's req.headers. const rawRequest = "GET / HTTP/1.1\r\nHost: example.test\r\n" + "Authorization: one\r\nAuthorization: two\r\n" + "Content-Type: text/plain\r\nContent-Type: text/html\r\n" + "Cookie: a=1\r\nCookie: b=2\r\n" + "X-Custom: first\r\nX-Custom: second\r\n" + "Connection: close\r\n\r\n"; function serverRecordingHeaders(options: { joinDuplicateHeaders?: boolean }) { const { promise: headers, resolve, reject } = Promise.withResolvers(); const server = createServer(options, (req, res) => { resolve({ ...req.headers }); res.end(); }); // A no-op once the handler has resolved, so the connection closing after the response is fine. const closedEarly = () => reject(new Error("the connection closed before the request was dispatched")); return { server, headers, reject, closedEarly }; } async function headersSeenOverEmittedConnection(options: { joinDuplicateHeaders?: boolean }) { const { server, headers, reject, closedEarly } = serverRecordingHeaders(options); const [clientSide, serverSide] = duplexPair(); clientSide.on("error", reject); serverSide.on("error", reject); serverSide.on("close", closedEarly); clientSide.resume(); server.emit("connection", serverSide); clientSide.write(rawRequest); try { return await headers; } finally { clientSide.destroy(); serverSide.destroy(); } } async function headersSeenOverListeningServer(options: { joinDuplicateHeaders?: boolean }) { const { server, headers, reject, closedEarly } = serverRecordingHeaders(options); server.listen(0, "127.0.0.1"); await once(server, "listening"); const socket = connect((server.address() as AddressInfo).port, "127.0.0.1"); socket.on("error", reject); socket.on("close", closedEarly); socket.resume(); socket.write(rawRequest); try { return await headers; } finally { socket.destroy(); server.close(); } } const joined = { host: "example.test", authorization: "one, two", "content-type": "text/plain, text/html", cookie: "a=1; b=2", "x-custom": "first, second", connection: "close", }; const firstValueWins = { ...joined, authorization: "one", "content-type": "text/plain" }; for (const [options, expected] of [ [{ joinDuplicateHeaders: true }, joined], [{ joinDuplicateHeaders: false }, firstValueWins], [{}, firstValueWins], ] as const) { expect({ options, emitted: await headersSeenOverEmittedConnection(options), listening: await headersSeenOverListeningServer(options), }).toEqual({ options, emitted: expected, listening: expected }); } }); describe("req.headers and req.rawHeaders", () => { type Entries = [string, string | string[]][]; // Sends each request on a connection of its own, one after the other, and gives what `read` made of each. async function seenByServer( requests: string[][], read: (req: IncomingMessage) => T, options: http.ServerOptions = {}, ): Promise { const seen: T[] = []; const server = createServer(options, (req, res) => { try { seen.push(read(req)); } finally { res.end(); } }); try { await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; for (const lines of requests) { const socket = connect(port, "127.0.0.1"); try { socket.write(Buffer.from(`GET / HTTP/1.1\r\n${lines.join("\r\n")}\r\n\r\n`, "latin1")); await new Promise((resolve, reject) => { socket.once("data", () => resolve()); socket.once("error", reject); socket.once("close", () => reject(new Error("the connection closed before the server answered"))); }); } finally { socket.destroy(); } } return seen; } finally { server.close(); } } function bothOf(req: IncomingMessage, rawHeadersFirst: boolean) { const rawHeaders = rawHeadersFirst ? req.rawHeaders : undefined; const headers = req.headers; return { headers: Object.entries(headers) as Entries, rawHeaders: rawHeaders ?? req.rawHeaders, plainObject: Object.getPrototypeOf(headers) === Object.prototype, cached: req.headers === headers, }; } function expectedOf({ lines, headers }: { lines: string[]; headers: Entries }) { const rawHeaders = lines.flatMap(line => { const colon = line.indexOf(":"); return [line.slice(0, colon), line.slice(colon + 1).trim()]; }); return { headers, rawHeaders, plainObject: true, cached: true }; } for (const rawHeadersFirst of [false, true]) { const first = rawHeadersFirst ? "rawHeaders" : "headers"; it(`hold what the request sent when ${first} is read first`, async () => { const cases: { lines: string[]; headers: Entries }[] = [ { lines: ["Host: a", "Accept: */*", "user-agent: x", "X-Custom-Thing: 1", "X-Empty:"], headers: [ ["host", "a"], ["accept", "*/*"], ["user-agent", "x"], ["x-custom-thing", "1"], ["x-empty", ""], ], }, { lines: ["Host: a", "X-Latin1: caf\xe9", "Referer: /caf\xe9"], headers: [ ["host", "a"], ["x-latin1", "caf\xe9"], ["referer", "/caf\xe9"], ], }, { lines: ["Host: a", "Host: b", "X-A: 1", "x-a: 2", "Cookie: a=1", "Cookie: b=2", "Accept: x", "Accept: y"], headers: [ ["host", "a"], ["x-a", "1, 2"], ["cookie", "a=1; b=2"], ["accept", "x, y"], ], }, { lines: ["Host: a", "Set-Cookie: a=1"], headers: [ ["host", "a"], ["set-cookie", ["a=1"]], ], }, { lines: ["Host: a", "Set-Cookie: a=1", "set-cookie: b=2"], headers: [ ["host", "a"], ["set-cookie", ["a=1", "b=2"]], ], }, // Names that Object.prototype has, one that it has in another case, and one that is an index. { lines: ["Host: a", "__proto__: x"], headers: [["host", "a"]], }, { lines: ["Host: a", "Constructor: y"], headers: [ ["host", "a"], ["constructor", "y"], ], }, { lines: ["Host: a", "toString: t"], headers: [ ["host", "a"], ["tostring", "t"], ], }, { lines: ["Host: a", "123: z"], headers: [ ["123", "z"], ["host", "a"], ], }, // A custom name twice, after many other custom names. { lines: ["Host: a", ...Array.from({ length: 12 }, (_, i) => `X-Custom-${i}: ${i}`), "x-custom-11: again"], headers: [ ["host", "a"], ...Array.from({ length: 12 }, (_, i): [string, string] => [ `x-custom-${i}`, i === 11 ? "11, again" : `${i}`, ]), ], }, // More headers than an object holds inline. { lines: ["Host: a", ...Array.from({ length: 70 }, (_, i) => `X-Many-${i}: ${i}`)], headers: [["host", "a"], ...Array.from({ length: 70 }, (_, i): [string, string] => [`x-many-${i}`, `${i}`])], }, ]; expect( await seenByServer( cases.map(({ lines }) => lines), req => bothOf(req, rawHeadersFirst), ), ).toEqual(cases.map(expectedOf)); }); it(`hold each request's own values when ${first} is read first`, async () => { // The same headers from one request to the next, with values that stay, change, and come back. // A value of up to 256 bytes is the longest that one request leaves for the next to reuse. const atLimit = Buffer.alloc(256, "u").toString(); const pastLimit = Buffer.alloc(257, "u").toString(); const longValues: { lines: string[]; headers: Entries } = { lines: ["Host: a", `User-Agent: ${atLimit}`, `Accept: ${pastLimit}`], headers: [ ["host", "a"], ["user-agent", atLimit], ["accept", pastLimit], ], }; const cases: { lines: string[]; headers: Entries }[] = [ { lines: ["Host: a", "User-Agent: one", "Accept: */*", "Content-Type: text/plain"], headers: [ ["host", "a"], ["user-agent", "one"], ["accept", "*/*"], ["content-type", "text/plain"], ], }, { lines: ["Host: b", "User-Agent: two", "Accept: */*", "Content-Type: text/plain"], headers: [ ["host", "b"], ["user-agent", "two"], ["accept", "*/*"], ["content-type", "text/plain"], ], }, { lines: ["Host: a", "User-Agent: one", "Accept: text/html", "Content-Type: text/plaim"], headers: [ ["host", "a"], ["user-agent", "one"], ["accept", "text/html"], ["content-type", "text/plaim"], ], }, longValues, longValues, { lines: ["Host: a", "User-Agent:", "Accept:"], headers: [ ["host", "a"], ["user-agent", ""], ["accept", ""], ], }, { lines: ["Host: a", "User-Agent:", "Accept: */*"], headers: [ ["host", "a"], ["user-agent", ""], ["accept", "*/*"], ], }, ]; expect( await seenByServer( cases.map(({ lines }) => lines), req => bothOf(req, rawHeadersFirst), ), ).toEqual(cases.map(expectedOf)); }); } it("headers goes through the _addHeaderLine of the server's IncomingMessage class", async () => { class UppercasingMessage extends IncomingMessage { _addHeaderLine(field: string, value: string, dest: Record) { dest[field.toLowerCase()] = value.toUpperCase(); } } const seen = await seenByServer([["Host: a", "X-One: b"]], req => Object.entries(req.headers), { IncomingMessage: UppercasingMessage, }); expect(seen).toEqual([ [ ["host", "A"], ["x-one", "B"], ], ]); }); it("headers is the same whichever is read first when the IncomingMessage class has its own rawHeaders", async () => { // Where the class's accessor is in effect, headers folds what it returns. class RedactingMessage extends IncomingMessage {} Object.defineProperty(RedactingMessage.prototype, "rawHeaders", { configurable: true, get(this: IncomingMessage) { const raw: string[] = Reflect.get(IncomingMessage.prototype, "rawHeaders", this) ?? []; return raw.map((entry, i) => (i % 2 === 1 && raw[i - 1] === "X-Secret" ? "redacted" : entry)); }, set(this: IncomingMessage, value: string[]) { Reflect.set(IncomingMessage.prototype, "rawHeaders", value, this); }, }); // One that keeps what it is assigned, as the own property of Node's IncomingMessage does. class StoringMessage extends IncomingMessage {} const stored = new WeakMap(); Object.defineProperty(StoringMessage.prototype, "rawHeaders", { configurable: true, get(this: IncomingMessage) { return stored.get(this) ?? []; }, set(this: IncomingMessage, value: string[]) { stored.set(this, value); }, }); const requests = [["Host: a", "X-Secret: s3cret", "Accept: */*"]]; for (const IncomingMessageClass of [RedactingMessage, StoringMessage]) { const options = { IncomingMessage: IncomingMessageClass }; const headersFirst = await seenByServer(requests, req => bothOf(req, false), options); const rawHeadersFirst = await seenByServer(requests, req => bothOf(req, true), options); expect(headersFirst).toEqual(rawHeadersFirst); } }); it("headers is what _addHeaderLine makes of rawHeaders, for any mix of names", async () => { const names = [ ["Host", "host", "HOST"], ["Accept", "accept"], ["User-Agent", "user-agent"], ["Content-Type", "content-type"], ["Cookie", "cookie"], ["Set-Cookie", "set-cookie"], ["Authorization"], ["Referer"], ["X-Forwarded-For", "x-forwarded-for"], ["X-Custom", "x-custom", "X-CUSTOM"], ["X-Other"], ["Constructor"], ["42"], ]; let seed = 0x2f6e2b1; const random = (below: number) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed % below; }; const requests = Array.from({ length: 48 }, () => { const lines = ["Host: a"]; // Few names per request, so that most requests repeat none and some repeat one. for (let count = random(7), i = 0; i < count; i++) { const spellings = names[random(names.length)]; lines.push(`${spellings[random(spellings.length)]}: v${random(4)}`); } return lines; }); const seen = await seenByServer(requests, req => { const headers = Object.entries(req.headers); const folded = {}; const raw = req.rawHeaders; for (let i = 0; i < raw.length; i += 2) (req as any)._addHeaderLine(raw[i], raw[i + 1], folded); return { raw, headers, folded: Object.entries(folded) }; }); expect(seen.map(({ raw, headers }) => ({ raw, headers }))).toEqual( seen.map(({ raw, folded }) => ({ raw, headers: folded })), ); expect(seen.map(({ raw }) => raw.length / 2)).toEqual(requests.map(lines => lines.length)); }); it("headers joins a value to the one its name inherits from Object.prototype", async () => { Object.defineProperty(Object.prototype, "x-inherited", { value: "inherited", writable: true, configurable: true }); try { const seen = await seenByServer([["Host: a", "X-Inherited: sent"]], req => Object.entries(req.headers)); expect(seen).toEqual([ [ ["host", "a"], ["x-inherited", "inherited, sent"], ], ]); } finally { delete (Object.prototype as any)["x-inherited"]; } }); }); it("setHeader and appendHeader check a name however often names were set before", () => { const message = new OutgoingMessage(); const invalid = (name: unknown) => { const thrown: unknown[] = []; for (const method of ["setHeader", "appendHeader"] as const) { try { message[method](name as string, "value"); thrown.push(undefined); } catch (e) { thrown.push((e as NodeJS.ErrnoException).code); } } return thrown; }; const rejected = ["ERR_INVALID_HTTP_TOKEN", "ERR_INVALID_HTTP_TOKEN"]; expect(invalid("bad name")).toEqual(rejected); // The same names again and again, in more than one spelling, and one longer than most. const long = Buffer.alloc(100, "x").toString(); for (let i = 0; i < 3; i++) { message.setHeader("X-Custom-Name", `${i}`); message.setHeader("x-custom-NAME", `${i}`); message.setHeader("Content-Type", "text/plain"); message.setHeader(long, `${i}`); message.appendHeader("X-Appended", `${i}`); } // More distinct names than an application sets. for (let i = 0; i < 300; i++) message.setHeader(`X-Distinct-${i}`, "value"); for (let i = 0; i < 300; i++) message.removeHeader(`X-Distinct-${i}`); message.setHeader("X-After", "value"); message.setHeader("X-After", "again"); expect(message.getHeaders()).toEqual({ "x-custom-name": "2", "content-type": "text/plain", [long]: "2", "x-appended": ["0", "1", "2"], "x-after": "again", } as any); expect(message.getRawHeaderNames()).toEqual(["x-custom-NAME", "Content-Type", long, "X-Appended", "X-After"]); // A name that is not a string is not one, whatever it converts to; neither is one with a character added. expect([ invalid("bad name"), invalid(["Content-Type"]), invalid(["X-Custom-Name"]), invalid("X-Custom-Name "), invalid("X-After\r\n"), invalid(""), ]).toEqual([rejected, rejected, rejected, rejected, rejected, rejected]); }); it("req.socket.setKeepAlive() and resetAndDestroy() return the socket", async () => { const { promise, resolve, reject } = Promise.withResolvers<{ setKeepAlive: boolean; resetAndDestroy: boolean }>(); const server = createServer((req, res) => { try { const socket = req.socket; resolve({ setKeepAlive: socket.setKeepAlive(true).setNoDelay(true) === socket, resetAndDestroy: socket.resetAndDestroy() === socket, }); } catch (e) { reject(e); } res.end(); }); try { await once(server.listen(0), "listening"); // resetAndDestroy() resets the connection in node, so the request itself may fail. const request = fetch(`http://localhost:${(server.address() as AddressInfo).port}/`).then( response => response.text(), () => {}, ); expect(await promise).toEqual({ setKeepAlive: true, resetAndDestroy: true }); await request; } finally { server.close(); } }); it("the native response's cork() treats a missing callback as undefined", async () => { const { promise, resolve } = Promise.withResolvers(); const server = http.createServer((req, res) => { const handle = res[Object.getOwnPropertySymbols(res).find(s => s.description === "handle")!]; try { resolve(handle.cork()); } catch (error) { resolve(error); } res.end(); }); await once(server.listen(0, "127.0.0.1"), "listening"); try { await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(await promise).toMatchObject({ code: "ERR_INVALID_ARG_TYPE", message: "callback must be a function", }); } finally { server.close(); } }); it.each([NaN, 99, 1000, null, undefined, "abc"])("end() throws when statusCode is %p", async statusCode => { const { promise, resolve } = Promise.withResolvers(); const server = http.createServer((req, res) => { // @ts-expect-error res.statusCode = statusCode; try { resolve(res.end()); } catch (error) { resolve(error); res.statusCode = 500; res.end(); } }); await once(server.listen(0, "127.0.0.1"), "listening"); try { const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(await promise).toMatchObject({ name: "RangeError", code: "ERR_HTTP_INVALID_STATUS_CODE", message: `Invalid status code: ${statusCode}`, }); expect(response.status).toBe(500); } finally { server.close(); } }); it.each([ ["404", 404], [200.5, 200], [2 ** 32 + 200, 200], ])("end() sends statusCode %p as %d", async (statusCode, expected) => { const server = http.createServer((req, res) => { // @ts-expect-error res.statusCode = statusCode; res.end(); }); await once(server.listen(0, "127.0.0.1"), "listening"); try { const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(response.status).toBe(expected); } finally { server.close(); } }); it("end() sends 200 after statusCode is deleted", async () => { const server = http.createServer((req, res) => { // @ts-expect-error delete res.statusCode; try { res.end(); } catch { res.statusCode = 500; res.end(); } }); await once(server.listen(0, "127.0.0.1"), "listening"); try { const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(response.status).toBe(200); } finally { server.close(); } }); it.each(["write", "end"] as const)("%s(body) sends no body when statusCode is the string 304", async method => { const server = http.createServer((req, res) => { // @ts-expect-error res.statusCode = "304"; res[method]("body"); res.end(); }); await once(server.listen(0, "127.0.0.1"), "listening"); try { const socket = connect((server.address() as AddressInfo).port, "127.0.0.1"); socket.end("GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); const [head, body] = (await text(socket)).split("\r\n\r\n"); expect(head).toStartWith("HTTP/1.1 304 Not Modified\r\n"); expect(body).toBe(""); } finally { server.close(); } }); it.each(["write", "end"] as const)("%s() reports a wrong chunk before an unknown encoding", async method => { const { promise, resolve } = Promise.withResolvers(); const server = http.createServer((req, res) => { try { // @ts-expect-error resolve(res[method](5, "nope")); } catch (error) { resolve(error); } res.end(); }); await once(server.listen(0, "127.0.0.1"), "listening"); try { await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(await promise).toMatchObject({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE", message: 'The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received type number (5)', }); } finally { server.close(); } }); it.each([ { invalid: "chunk", status: 201, chunk: { ok: true } }, { invalid: "status code", status: null, chunk: "body" }, ])( "end() that throws for its $invalid sends nothing, and the next end() sends the status and headers", async ({ status, chunk }) => { const { promise, resolve } = Promise.withResolvers(); const server = http.createServer((req, res) => { // @ts-expect-error res.statusCode = status; res.setHeader("x-kept", "yes"); try { // @ts-expect-error res.end(chunk); resolve("did not throw"); } catch { resolve(res.headersSent); res.statusCode = 201; res.end("fallback"); } }); await once(server.listen(0, "127.0.0.1"), "listening"); try { const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); expect(await promise).toBe(false); expect({ status: response.status, kept: response.headers.get("x-kept"), body: await response.text() }).toEqual({ status: 201, kept: "yes", body: "fallback", }); } finally { server.close(); } }, ); it("addTrailers() and end() don't crash on the response of a request that was upgraded to a WebSocket", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); const net = require("node:net"); const { WebSocketServer } = require("ws"); const wss = new WebSocketServer({ noServer: true }); const server = http.createServer((req, res) => { if (req.url === "/first") { res.write("body"); res.addTrailers({ "x-first": Buffer.alloc(200, "a").toString() }); res.end(); return; } wss.handleUpgrade(req, req.socket, Buffer.alloc(0), ws => { try { res.addTrailers({ "x-second": Buffer.alloc(100, "b").toString() }); res.end("late"); } catch {} ws.send("alive"); }); }); server.listen(0, "127.0.0.1", () => { const client = net.connect(server.address().port, "127.0.0.1"); let received = ""; let upgrading = false; client.on("data", data => { received += data.toString("latin1"); if (!upgrading && received.endsWith("\\r\\n\\r\\n") && received.includes("x-first")) { upgrading = true; received = ""; client.write( "GET /second HTTP/1.1\\r\\nHost: x\\r\\nConnection: Upgrade\\r\\nUpgrade: websocket\\r\\n" + "Sec-WebSocket-Version: 13\\r\\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\\r\\n\\r\\n", ); } else if (upgrading && received.endsWith("alive")) { console.log(received.includes("x-second") ? "trailer on the WebSocket" : "alive"); process.exit(0); } }); client.write("GET /first HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr }).toEqual({ stdout: "alive\n", stderr: "" }); expect(exitCode).toBe(0); }); it("a listener can cancel Readable.toWeb(req) and end the response in the same tick", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` const http = require("node:http"); const { Readable } = require("node:stream"); const server = http.createServer((req, res) => { const socket = req.socket; req.on("error", () => {}); res.on("finish", () => console.log("keep-alive timeout", socket.timeout)); Readable.toWeb(req).cancel(); res.end("ok"); }); server.keepAliveTimeout = 4000; server.keepAliveTimeoutBuffer = 0; server.listen(0, "127.0.0.1", async () => { const response = await fetch("http://127.0.0.1:" + server.address().port); console.log(response.status, await response.text()); server.close(); server.closeAllConnections(); }); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr }).toEqual({ stdout: "keep-alive timeout 4000\n200 ok\n", stderr: "" }); expect(exitCode).toBe(0); });