Repository navigation
http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests - #46528
Jarred-Sumner wants to merge 35 commits into
Conversation
…isten and usockets pull requests
…d HTTP/3 pull requests
All behavior changes, part 1:
|
All behavior changes, part 2:
|
|
Updated 9:42 AM PT - Oct 23rd, 2026
✅ @Jarred-Sumner, your commit 52353410b0378b7c3036db33811cef0392eb683b passed in 🧪 To try this PR locally: bunx bun-pr 46528That installs a local version of the PR into your bun-46528 --bun |
There was a problem hiding this comment.
Beyond the inline findings, two other candidates were examined and ruled out: the Keep-Alive: timeout=N handling in src/http/lib.rs (around line 5224) drops pooling only when N minus the 2 s margin reaches zero, matching undici's keepAliveTimeoutThreshold semantics rather than disabling reuse broadly; and the +32 per-field estimate in lshpack_wrapper_encode_block (src/jsc/bindings/c-bindings.cpp:588) is backed by lshpack_enc_encode being bounded by the end pointer, so an undersized buffer surfaces as the SIZE_MAX error return, not an overflow.
Extended reasoning...
The change is a 404-file, roughly 44k-line consolidation across fetch, Bun.serve, node:http, node:http2, HTTP/3, node:quic and the undici/node-fetch shims, including new lsquic/lsqpack patches and uWS/usockets C/C++ changes, which is TLS- and parser-adjacent security-sensitive surface. Ten inline findings are being posted, so a human review is already signaled; this body only records two further candidates checked against the code and found not to be bugs.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/runtime/webcore/fetch.rs:1668— Programs that pass a FIFO, a device or/dev/stdinvia Bun.file() as a fetch() body now buffer the whole source in memory before sending; a long-lived producer grows RSS until the process is killed. The non-regular path breaks out of sendfile at src/runtime/webcore/fetch.rs:1661 into a full read, and src/runtime/node/node_fs.rs:7289-7290 marks the size unknown and drops has_max_size, so the read runs to EOF with no cap. The base stopped at 262,160 bytes; the only brake is the Linux-only ENOMEM check at node_fs.rs:6879, which fires at total system memory. Fix: stream non-regular bodies (chunked transfer) or keep a bounded in-memory cap and reject beyond it with a clear error, on every platform. [also at: src/runtime/node/node_fs.rs:7291 - A fetch() or Response whose body is Bun.file() over a pipe, FIFO or character device now buffers the whole stream in memory before sending; on the base branch the read stopped at 262,160 bytes.]Why this was flagged
The trigger is any fetch() whose body is Bun.file() over a non-regular file:
Bun.file("/dev/stdin")piped from a long producer, a named pipe, or a device, entering through fetch() at src/runtime/webcore/fetch.rs:1661 where!S::ISREGleaves the sendfile path and the body is read into memory. In the read path src/runtime/node/node_fs.rs:7289 setssize_unknownfor any non-regular file and src/runtime/node/node_fs.rs:7290 clearshas_max_size, so the loop reads until EOF with no limit. On the base branch the read stopped after 262,160 bytes (wrong, but bounded); after this change a slow or endless producer makes RSS grow without bound and the process is OOM-killed. The safeguardshould_throw_out_of_memory_early_for_javascript(node_fs.rs:6879) only triggers at the machine's total memory and only on Linux, so on macOS and in containers with cgroup limits the kill arrives first. Remedy: stream such bodies with chunked encoding, or enforce a bounded cap and surface an error.Verification: src/runtime/webcore/fetch.rs:1661-1663 breaks out of sendfile for every non-regular fd. src/runtime/node/node_fs.rs:7289-7290 make
has_max_sizefalse, so the growth arm at 7363-7378 keeps extending the Vec untilreadreturns 0. On base the loop stopped at 262,160 bytes. Lines 7291-7292 setlimit_size_for_javascript, so node_fs.rs:6879-6899 rejects with ENOMEM oncetotal > synthetic_allocation_limit().
…hed stream, and keep 'finish' after a NO_ERROR reset
…n, and three node:http2 fixes from review
… peer's reset under held TLS records as a failed read again
…ction, plain sockets for req.socket, one http2 stream table, one in-flight table for fetch, one QUIC transport layer
|
A case that the With the flag, the body is kept when the const http = require("node:http"), net = require("node:net"), { Writable } = require("node:stream");
const server = http.createServer(async (req, res) => {
await null;
let got = 0;
const slow = new Writable({ highWaterMark: 1, write(c, _e, cb) { got += c.length; setImmediate(cb); } });
slow.on("finish", () => { console.log(got); process.exit(0); });
req.pipe(slow);
res.end();
});
const acceptor = net.createServer(s => server.emit("connection", s)).listen(0, "127.0.0.1", () => {
const parts = [Buffer.from("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n")];
for (const fill of "abcd") parts.push(Buffer.from("400\r\n"), Buffer.alloc(1024, fill), Buffer.from("\r\n"));
parts.push(Buffer.from("0\r\n\r\n"));
const c = net.connect(acceptor.address().port, "127.0.0.1", () => c.write(Buffer.concat(parts)));
c.resume();
});
Cause. One Extent. I ran a matrix of 4 framings, 6 ways to read and 4 start times (96 cases) on a
An HTTP/1.1 client of an |
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (2):
- 🔴
src/js/node/_http_outgoing.ts:379—http.request() callers that cork, write a chunk of 64 KiB (16 KiB on Windows) or more, uncork and then wait for 'drain'… - Also unresolved: 1 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
|
I worked on the same What this PR fixes. A grid of 40 cells (5 sizes, http or https target, 307 or 308, What still differs. Each item has a test on the branch.
Are 1 and 2 deliberate? If they are not, I can send each as a small change on top of this branch. Branch: |
…handed-off socket as before when its connection fails, and address review comments
|
@robobun on the
So there's no need for changes on top. If one of your 28 tests still fails on that commit apart from 3, tell me which. |
|
@robobun on the request body that a listener starts to read after an |
|
I ran the case behind #43433 on this branch at d132a01 (debug build, Linux x64). A client sends a request, reads the status only, and waits for
This PR fixes that case. I found two gaps next to it. 1. No test covers the transport-loss door
The test below goes in that block. Its // A request that looks at the status only, then the server drops the connection. No GOAWAY comes first.
it.each([
["ends", socket => socket.end(), []],
["resets", socket => socket.resetAndDestroy(), ["ECONNRESET"]],
])("is destroyed when the peer %s the connection", async (_, drop, expected) => {
const server = http2.createServer((req, res) => res.end("hello"));
const accepted = once(server, "connection");
await once(server.listen(0, "127.0.0.1"), "listening");
const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
try {
client.on("error", () => {});
const sessionClosed = new Promise(resolve => client.on("close", resolve));
const req = client.request({ ":path": "/", ":method": "POST" });
const events = [];
req.on("error", err => events.push(err.code));
req.on("close", () => events.push("close"));
req.end("fire and forget");
await once(req, "response");
await roundTrip(client);
expect({ closed: req.closed, destroyed: req.destroyed }).toEqual({ closed: true, destroyed: false });
const [socket] = await accepted;
drop(socket);
await sessionClosed;
expect({ destroyed: req.destroyed, events, rstCode: req.rstCode }).toEqual({
destroyed: true,
events: [...expected, "close"],
rstCode: NGHTTP2_NO_ERROR,
});
} finally {
client.destroy();
server.close();
}
});2. In 19 of 77 teardown cells the stream gets no
|
| Runtime | Cells where the stream is destroyed |
|---|---|
| Node v26.3.0 | 77 of 77 |
| main fec4a88 | 0 of 77 |
| This PR | 58 of 77, each with the same stream events as Node |
The other 19 cells come from two lines of settleUnreadClosedStreams. In each of them destroyed stays false and the bytes stay readable. 'close' comes only after the program reads the stream to its end.
| Line | Cells | Node v26.3.0 | This PR |
|---|---|---|---|
http2.ts:4966. The transport goes away with no error (a peer FIN, or socket.destroy() on the same side). The stream has a reader that does not read: pause(), a 'readable' listener, or on('data') then pause(). |
12 (6 client, 6 server) | 'close'. The unread bytes are dropped. |
No event |
http2.ts:4963. session.close(), then session.destroy() in the same tick. Each reader state. |
7 (4 client, 3 server) | 'close'. The unread bytes are dropped. |
No event |
The line at :4966 keeps data that Node drops. I measured that side too. A server consumes a 61,440-byte request body slowly. The client reads the response, then closes its socket.
| Consumer of the server stream | Node v26.3.0 | This PR |
|---|---|---|
pipe(), pipeline(), for await, or 'data' with pause() and resume() |
16,384 bytes, then 'close' with no 'end' |
61,440 bytes, 'end', 'close' |
pipe() into a sink that never drains |
'close' |
No 'close' |
- Without the line at
:4966, its 12 cells match Node (70 of 77). A slow consumer then loses the bytes it has not read at the FIN, as on Node:for awaitgot 49,122 of 61,440 bytes in 5 of 5 runs. - No test covers the line at
:4966.test/js/node/http2/passes with it and without it (1,068 pass, 0 fail). - Without the line at
:4963, its 7 cells match Node, and the test "stays readable after client session.close()" fails. A fix for these cells has to tell adestroy()that the program calls from the end of a graceful close. - The list "Where this PR still differs from Node on purpose" has neither rule. Is each rule intended?
One more difference is outside these counts. In the cells that carry an error, the session's 'error' comes before the stream's 'error' on this PR. On Node it comes after the stream's 'close'.
Script for the first table
Run DROP_MS=2500 <runtime> fire-and-forget.js fin and ... rst. The delay gives a debug build time to finish all 50 exchanges before the server drops the connections.
// 10 sessions x 5 requests. Each request reads the status only and waits for 'close'.
// The server answers in full, then drops each connection DROP_MS after it accepted it.
const http2 = require("node:http2");
const net = require("node:net");
const { once } = require("node:events");
const how = process.argv[2] || "fin";
const h2 = http2.createServer();
h2.on("sessionError", () => {});
h2.on("stream", stream => {
stream.on("error", () => {});
stream.resume();
stream.respond({ ":status": 200 });
stream.end("x".repeat(1000));
});
const listener = net.createServer(socket => {
socket.on("error", () => {});
h2.emit("connection", socket);
setTimeout(() => (how === "rst" ? socket.resetAndDestroy() : socket.destroy()), Number(process.env.DROP_MS || 150));
});
let started = 0;
let settled = 0;
async function fire(session) {
started++;
const req = session.request({ ":method": "POST", ":path": "/" });
req.on("error", () => {});
req.end("hello");
try {
await once(req, "response");
await once(req, "close");
} catch {
} finally {
settled++;
}
}
listener.listen(0, "127.0.0.1", async () => {
const sessions = [];
for (let i = 0; i < 10; i++) {
const session = http2.connect("http://127.0.0.1:" + listener.address().port);
session.on("error", () => {});
sessions.push(new Promise(resolve => session.on("close", resolve)));
for (let j = 0; j < 5; j++) fire(session);
}
await Promise.all(sessions);
await new Promise(resolve => setTimeout(resolve, 300));
console.log(JSON.stringify({ how, started, settled, stuck: started - settled }));
process.exit(0);
});Script for the 77 cells
Run <runtime> teardown.js <side> <door> <reader> for each combination. A server stream with the reader none is not a cell: Node and Bun read and discard its data when it closes on the wire.
// usage: <runtime> teardown.js <side> <door> <reader>
// side: client | server the end that holds the stream under test
// door: destroy | destroy-error | destroy-code | peer-goaway | peer-fin | peer-rst |
// socket-destroy | socket-destroy-error | timeout-destroy | close-destroy | close-destroy-error
// reader: none | pause | readable | data-pause
// The stream under test is complete on the wire (both sides sent END_STREAM) and holds 1000 unread bytes.
// Prints the events of that stream after its session is torn down.
const http2 = require("node:http2");
const net = require("node:net");
const [side = "client", door = "peer-fin", reader = "none"] = process.argv.slice(2);
const events = [];
function watch(stream) {
stream.on("error", err => events.push("error " + err.code));
stream.on("end", () => events.push("end"));
stream.on("close", () => events.push("close"));
if (reader === "pause") stream.pause();
if (reader === "readable") stream.on("readable", () => {});
if (reader === "data-pause") stream.on("data", () => {}).pause();
}
function ignore(stream) {
stream.on("error", () => {});
stream.resume();
}
let serverStream, serverSession, serverSocket, clientSocket;
const server = http2.createServer();
server.on("sessionError", () => {});
server.on("session", session => (serverSession = session).on("error", () => {}));
const gotStream = new Promise(resolve =>
server.on("stream", stream => {
serverStream = stream;
(side === "server" ? watch : ignore)(stream);
stream.respond({ ":status": 200 });
stream.end("x".repeat(1000));
resolve();
}),
);
const listener = net.createServer(socket => {
serverSocket = socket;
socket.on("error", () => {});
server.emit("connection", socket);
});
listener.listen(0, "127.0.0.1", async () => {
const { port } = listener.address();
const client = http2.connect(`http://127.0.0.1:${port}`, {
createConnection: () => (clientSocket = net.connect(port, "127.0.0.1")),
});
client.on("error", () => {});
const req = client.request({ ":method": "POST", ":path": "/" });
const responded = new Promise(resolve => req.on("response", resolve));
(side === "client" ? watch : ignore)(req);
req.end("y".repeat(1000));
await Promise.all([gotStream, responded]);
// Both ends have every frame of the exchange once the second PING is acknowledged.
await new Promise(resolve => client.ping(resolve));
await new Promise(resolve => client.ping(resolve));
const [stream, session, socket, peerSession, peerSocket] =
side === "client"
? [req, client, clientSocket, serverSession, serverSocket]
: [serverStream, serverSession, serverSocket, client, clientSocket];
const before = { closed: stream.closed, destroyed: stream.destroyed, unread: stream.readableLength };
const sessionClosed = new Promise(resolve => session.on("close", resolve));
if (door === "destroy") session.destroy();
if (door === "destroy-error") session.destroy(new Error("boom"));
if (door === "destroy-code") session.destroy(http2.constants.NGHTTP2_CANCEL);
if (door === "peer-goaway") peerSession.goaway(http2.constants.NGHTTP2_ENHANCE_YOUR_CALM);
if (door === "peer-fin") peerSocket.end();
if (door === "peer-rst") peerSocket.resetAndDestroy();
if (door === "socket-destroy") socket.destroy();
if (door === "socket-destroy-error") socket.destroy(new Error("boom"));
if (door === "timeout-destroy") session.setTimeout(50, () => session.destroy());
if (door === "close-destroy" || door === "close-destroy-error") {
session.close();
session.destroy(door === "close-destroy" ? undefined : new Error("boom"));
}
await sessionClosed;
await new Promise(resolve => setTimeout(resolve, 300));
console.log(JSON.stringify({ side, door, reader, before, events, destroyed: stream.destroyed }));
process.exit(0);
});…on, and neutral test names and comments
|
@Jarred-Sumner None apart from 3. On d132a01 23 of the 28 tests pass. Three of the other five fail only on message text, and two are item 3. 6e8e435 has the same result. I ran the set five times on a debug build and five times on a release build of each commit (Linux x64). Every run has the same result.
On the request body that a listener reads after an More checks, outside the 28 tests
Request body. A larger matrix, on the release build of 6e8e435, against Node 26.12.1. It has 12 ways to read and bodies of 4 KiB and 1 MiB, 288 cases on
The script below is that matrix. matrix.js// A request body that the 'request' listener reads while it answers, case by case against a reference runtime.
// All cases: bun matrix.js run <emit|allowHTTP1> <path to node>
// One case: bun matrix.js case <emit|allowHTTP1> <framing> <reader> <start>
// allowHTTP1 reads key.pem and cert.pem beside this file.
const http = require("node:http");
const http2 = require("node:http2");
const net = require("node:net");
const tls = require("node:tls");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { Writable } = require("node:stream");
const consumers = require("node:stream/consumers");
const FRAMINGS = ["chunked-one", "length-one", "chunked-split", "length-split", "chunked-big", "length-big"];
// The acceptor reads the first bytes and puts them back before it hands the socket over.
const SNIFFED = ["sniff-chunked", "sniff-length"];
const READERS = [
"pipeSlow",
"pipeFast",
"flowing",
"dataPause",
"onceDataPause",
"lateEnd",
"pauseThenLate",
"readable",
"readableOnce",
"forAwait",
"forAwaitSlow",
"buffer",
];
const STARTS = {
sync: begin => begin(),
microtask: begin => Promise.resolve().then(begin),
nextTick: begin => process.nextTick(begin),
immediate: begin => setImmediate(begin),
};
function runCase(transport, framing, reader, start) {
// Four pieces. Each byte depends on its offset, so a lost, repeated or reordered piece shows.
const PIECE = framing.endsWith("big") ? 256 * 1024 : 1024;
const BODY = Buffer.alloc(4 * PIECE);
for (let i = 0; i < BODY.length; i++) BODY[i] = (i * 31 + (i >> 8)) & 0xff;
const result = { bytes: 0, end: false, inOrder: true, events: [], timeout: false };
let done = false;
function report(timeout) {
if (done) return;
done = true;
result.timeout = timeout;
// Leave time for the events that follow the end of the read.
setTimeout(() => process.stdout.write(JSON.stringify(result) + "\n", () => process.exit(0)), timeout ? 0 : 150);
}
setTimeout(() => report(true), Number(process.env.WATCHDOG_MS || 2500));
function take(chunk) {
const from = result.bytes;
result.bytes += chunk.length;
if (result.bytes > BODY.length || !BODY.subarray(from, result.bytes).equals(chunk)) result.inOrder = false;
}
function ended(name) {
result.end = true;
result.events.push(name);
report(false);
}
async function iterate(req, turnPerChunk) {
try {
for await (const chunk of req) {
take(chunk);
if (turnPerChunk) await new Promise(resolve => setImmediate(resolve));
}
ended("req:iterated");
} catch (e) {
result.events.push("req:iteration-error:" + (e.code || e.name));
report(false);
}
}
const readers = {
// pipe() into a Writable that takes one chunk per turn of the loop.
pipeSlow(req) {
const write = (chunk, _encoding, callback) => (take(chunk), setImmediate(callback));
req.pipe(new Writable({ highWaterMark: 1, write }).on("finish", () => ended("sink:finish")));
},
pipeFast(req) {
const write = (chunk, _encoding, callback) => (take(chunk), callback());
req.pipe(new Writable({ write }).on("finish", () => ended("sink:finish")));
},
flowing(req) {
req.on("data", take).on("end", () => ended("req:end"));
},
// on('data') that pauses after every chunk and resumes one turn later.
dataPause(req) {
req.on("data", chunk => (take(chunk), req.pause(), setImmediate(() => req.resume())));
req.on("end", () => ended("req:end"));
},
// once('data'), then pause(). The rest is read one turn later, with an 'end' listener added then.
onceDataPause(req) {
req.once("data", chunk => {
take(chunk);
req.pause();
setImmediate(() => req.on("data", take).on("end", () => ended("req:end")).resume());
});
},
// on('data') now, and an 'end' listener one turn later.
lateEnd(req) {
req.on("data", take);
setImmediate(() => req.on("end", () => ended("req:end")));
},
// pause() now. The listeners and resume() come 30 ms later.
pauseThenLate(req) {
req.pause();
setTimeout(() => req.on("data", take).on("end", () => ended("req:end")).resume(), 30);
},
// 'readable' and read(n) until null.
readable(req) {
req.on("readable", () => {
for (let chunk; (chunk = req.read(512)) !== null; ) take(chunk);
});
req.on("end", () => ended("req:end"));
},
// 'readable' and one read() for each event.
readableOnce(req) {
req.on("readable", () => {
const chunk = req.read();
if (chunk !== null) take(chunk);
});
req.on("end", () => ended("req:end"));
},
forAwait: req => void iterate(req, false),
forAwaitSlow: req => void iterate(req, true),
buffer(req) {
consumers.buffer(req).then(
all => (take(all), ended("req:consumed")),
e => (result.events.push("req:consume-error:" + (e.code || e.name)), report(false)),
);
},
};
function listener(req, res) {
req.on("close", () => result.events.push("req:close"));
req.on("error", e => result.events.push("req:error:" + (e.code || e.name)));
res.on("finish", () => result.events.push("res:finish"));
res.on("close", () => result.events.push("res:close"));
STARTS[start](() => {
readers[reader](req);
// The answer goes out while the listener reads the request.
res.end("ok");
});
}
function sendRequest(socket) {
const chunked = framing.includes("chunked");
const framingHeader = chunked ? "Transfer-Encoding: chunked" : "Content-Length: " + BODY.length;
const head = Buffer.from(`POST / HTTP/1.1\r\nHost: x\r\n${framingHeader}\r\n\r\n`);
const pieces = [0, 1, 2, 3].map(i => BODY.subarray(i * PIECE, (i + 1) * PIECE));
let parts = chunked
? [head, ...pieces.map(p => Buffer.concat([Buffer.from(PIECE.toString(16) + "\r\n"), p, Buffer.from("\r\n")])), Buffer.from("0\r\n\r\n")]
: [head, ...pieces];
// "split": the head, then each piece in a write of its own, 25 ms apart. Every other framing: one write.
if (!framing.endsWith("split")) parts = [Buffer.concat(parts)];
const next = () => {
if (parts.length === 0 || socket.destroyed) return;
socket.write(parts.shift());
if (parts.length > 0) setTimeout(next, 25);
};
socket.on("error", () => {});
socket.resume();
next();
}
if (transport === "emit") {
const server = http.createServer(listener);
const acceptor = net.createServer(socket => {
if (!framing.startsWith("sniff")) return void server.emit("connection", socket);
socket.once("data", first => {
socket.pause();
socket.unshift(first);
server.emit("connection", socket);
process.nextTick(() => socket.resume());
});
});
acceptor.listen(0, "127.0.0.1", () => {
const socket = net.connect(acceptor.address().port, "127.0.0.1", () => sendRequest(socket));
});
} else {
const key = fs.readFileSync(path.join(__dirname, "key.pem"));
const cert = fs.readFileSync(path.join(__dirname, "cert.pem"));
const server = http2.createSecureServer({ allowHTTP1: true, key, cert }, listener);
server.listen(0, "127.0.0.1", () => {
const options = { port: server.address().port, host: "127.0.0.1", ALPNProtocols: ["http/1.1"], rejectUnauthorized: false };
const socket = tls.connect(options, () => sendRequest(socket));
});
}
}
function spawnCase(exe, args) {
return new Promise(resolve => {
const child = spawn(exe, [__filename, "case", ...args], { stdio: ["ignore", "pipe", "inherit"] });
let out = "";
child.stdout.on("data", d => (out += d));
child.on("close", code => {
try {
resolve(JSON.parse(out.trim().split("\n").pop()));
} catch {
resolve({ bytes: -1, end: false, inOrder: false, events: ["exit " + code], timeout: false });
}
});
});
}
async function runAll(transport, reference) {
const framings = transport === "emit" ? [...FRAMINGS, ...SNIFFED] : FRAMINGS;
const cases = [];
for (const framing of framings)
for (const reader of READERS) for (const start of Object.keys(STARTS)) cases.push([transport, framing, reader, start]);
const counts = {};
let next = 0;
async function worker() {
while (next < cases.length) {
const args = cases[next++];
const ref = await spawnCase(reference, args);
const own = await spawnCase(process.execPath, args);
let kind = "equal";
if (own.bytes < ref.bytes || (ref.end && !own.end) || !own.inOrder) kind = "fewer bytes or no 'end'";
else if (own.bytes > ref.bytes || (own.end && !ref.end)) kind = "more bytes or an 'end'";
else if (ref.events.join() !== own.events.join()) kind = "other order";
const group = args[1].startsWith("sniff") ? "with the acceptor that reads first" : "plain";
(counts[group] ??= {})[kind] = (counts[group][kind] ?? 0) + 1;
if (kind !== "equal") {
const show = r => `${r.bytes} bytes, end ${r.end}, ${r.events.join(" ")}`;
console.log(`${kind}: ${args.slice(1).join(" / ")}\n reference: ${show(ref)}\n this: ${show(own)}`);
}
}
}
await Promise.all(Array.from({ length: Number(process.env.PARALLEL || 4) }, worker));
console.log(transport, cases.length, "cases", JSON.stringify(counts, null, 1));
}
const [mode, ...args] = process.argv.slice(2);
if (mode === "case") runCase(...args);
else runAll(args[0], args[1]); |
…ork() with nothing corked, and the timeout sweep's timer
|
Thanks, all of your numbers reproduce here. As of 5235341:
The last two go on the list of intended differences. You were right that no test covered the path where the connection ends: the block has 7 new tests, for both sides of that path, and each fails when the line it covers is removed. |
|
Confirmed on 5235341 (debug build, Linux x64, against Node 26.12.1).
|
What does this PR do?
Consolidates the open pull requests for
fetch,Request/Response/Headers,Bun.serve,node:http,node:http2, HTTP/3,node:quicand theundici/node-fetchshims into one.This branch is built on top of #46522 (
node:net,node:tls, usockets) and contains its commits, so #46522 should be merged first. Until then the diff here shows both. Everything below is about this PR's own changes.All 639 open PRs in that area were checked against Bun 1.4.4, which was
mainwhen the work started. Fornode:*behavior the same script ran on Node 26.12.1 first, and Node's answer decided. 145 PRs are included in part, 48 were already fixed, 15 are duplicates, 114 were skipped and 89 are left for a maintainer to decide. All the others are included whole, usually rewritten smaller.After the ports, a second pass over the branch gave each rule one owner. A check that two layers made is now made once, in the layer that owns the data, and code that nothing calls any more is deleted with its tests. So this PR's own diff removes more than it adds: +21,450 -30,166 lines under
src/andpackages/, a net 8,716 fewer. In the diff,req.socketis a plainnet.Socket/tls.TLSSocket.node:httphas one implementation per kind of connection (uWS forlisten(), a port of Node's own connection code for every other stream) and one queue of waiting responses.node:http2has one stream table and shares its wire types with fetch's HTTP/2 client,fetchhas one table of in-flight requests, andBun.serve,fetchandnode:quicshare one QUIC transport layer. Facts about a body (it was a string,fetch()decoded it, its type) travel with the body.The body is at GitHub's size limit, so two lists are in the first two comments below: every behavior change with its 1.4.4 behavior next to it, and the PRs that are not included.
Read these first
These are the changes most likely to be noticed by a program that works today.
Bun.serveBun.file()body whose file is missing answers 404, and 403 if the process may not read it. 1.4.4 answered 500 and printed the error.error()still runs first and its Response wins. (Bun.serve: map Bun.file() open errors to 404/403 instead of 500 #36593)new Response(upstream.body, upstream)no longer forwardsContent-Encodingnext to a bodyfetch()already decoded. A body that went throughpipeThrough()or the program's own code keeps the header. (Bun.serve: drop Content-Encoding when serving a fetch()-decoded Response #35546)stop()and the process don't wait for such a connection. (Bun.serve: hold a pipelined request until the response ahead of it completes instead of closing the connection #38128)await fetch()against such a server resolves before the first body byte. A stream that produces its whole body in a later task isTransfer-Encoding: chunkedwhere 1.4.4 sentContent-Length. (Bun.serve: send the whole HTTP/1 response head when a streamed body is not ready #45187)idleTimeoutfollows progress, not only reads. A download stays open while the client takes at least 16 KB/s, where 1.4.4 cut every response whose write stayed blocked foridleTimeout. The rule is the same after every kind of request. (Bun.serve: do not idle-time-out a response the peer is still reading #41602, Bun.serve: keep idleTimeout armed while an HTTP/1 response write is blocked #44894)idleTimeoutapplies before the first request too. 1.4.4 used about 10 s there, whatever the option said. (Bun.serve: apply server.timeout(req, n) to a Bun.file response body #45788)Connection: closeor HTTP/1.0 saysConnection: close, and responses without a body carryDate. (Bun.serve: send Connection: close in the final response whenever the connection closes after it #43867)process.env.NODE_ENV = "production"set beforeBun.serve()selects production mode, as the docs say. 1.4.4 only looked at the value at process start. (Bun.serve: decide the development default from the live process.env #41926)await server.stop()waits for HTTP/3 and cleartext HTTP/2 connections to drain. The wait for an HTTP/2 client's FIN is capped at 10 to 14 s.stop(true)doesn't wait. (Bun.serve(http3): resolve stop() after the UDP socket and the last QUIC connection are gone #45419, Bun.serve http2: close a connection gracefully with a FIN, not with close(2) #44874)maxRequestBodySizeabortsreq.signal, and a response that already started ends in a reset. (Bun.serve: abort the request when its body exceeds maxRequestBodySize #45999)Bun.serve()orBun.listen()has the kernel'scodeand a negativeerrno(-98, where 1.4.4 had 0 and 98). (Return the error of a failed listen from usockets to Bun.serve and Bun.listen #44651)fetch,Request,ResponseLISTor any other token it did not know. The six Fetch verbs are upper-cased, sofetch("s3://...", { method: "Delete" })was a read and now deletes. (fetch, Request, server.fetch: stop sending GET for a method outside the method table #44718)text()andjson()drop a leading BOM for every kind of body, not only for bytes from the network. (fetch, streams: drop one leading UTF-8 BOM in text() and json() of string and stream bodies #45486)(await req.blob()).typeis theContent-Typeheader, however the body arrived. On 1.4.4 it wastext/plainfor a body that hadn't arrived yet and empty for a stream. AformData()that can't be parsed always rejects with aTypeErrorwhosecodeisERR_FORMDATA_PARSE_ERROR, where 1.4.4 had four texts. (Keep the body's Content-Type on Response and Request after the body is consumed #45601)Keep-Alive: timeout=Nis honored. 1.4.4 reused an idle connection for up to 300 s whatever the server said. (fetch: stop reusing a pooled keep-alive connection before the origin closes it #45497)Locationis resolved by the URL parser only. A few malformed values now go wherenew URL(), browsers and Node say. (http: don't treat relative redirect URLs containing '://' as absolute #30065)response.typeis"basic",response.urlhas no fragment, anddata:URLs go through the URL parser. (fetch: set Response.type and exclude URL fragments per the fetch spec #33499, fetch: data: URL Content-Type and fragment per the Fetch standard #34896)File.typeof a multipart part is the part'sContent-Type, not a guess from the file name. (Bun.serve, FormData: stop content-sniffing typeless Blob bodies into image/* types #35244)fetch()of a missingfile:URL rejects. 1.4.4 resolved with a 200 and the body read rejected. (fetch: reject file: URLs whose path cannot be read #37425)Uint8Array. Reading both withreader.read()is 2.0 to 3.6x slower.text(),json(),bytes()and sending the body copy nothing extra. (Response.clone(): copy a chunk when a body-clone branch hands it to script #46080)/dev/zerois read up to 4 GiB and then rejects withENOMEM. (fetch: stream a Bun.file() body that is a FIFO, socket, or device instead of reading it on the JS thread #41488)node:httpreq.socketis a plainnet.Socketortls.TLSSocket, and'upgrade'and'connect'listeners get that same socket.socket.address()is the local address (1.4.4: the peer's),'close'getshadError,encryptedis undefined on a plain socket,_handle.fdis the descriptor, andunref()works after the hand-off.https.Servernegotiates ALPN. A client that offers onlyh2gets theno_application_protocolalert, as in Node. 1.4.4 served it. A failed handshake emits'clientError'and'tlsClientError'.headersTimeoutdecides how long a server waits for a complete request head: 60 s by default, then a 408, as in Node. 1.4.4 closed the connection after about 12 s. (node:http: apply the server's idleTimeout at accept so headersTimeout > ~13s is honored #35672)listen({ port, path })binds the TCP port, also forport: 0,undefinedornull, as in Node. 1.4.4 bound the unix socket.listen(null)picks a free port instead of 3000 or$PORT.listen({ fd })listens on the descriptor instead of a random port. (Bun.serve: throw when both port and unix are given #41660, node:http: bind an ephemeral port when the listen() port is null #43726, Bun.serve({ fd }) and node:http listen({ fd }): serve on an inherited socket #44013)'drain'fires once, when the bytes have left, so a writer paced by it runs at the client's speed. 1.4.4 fired it twice. (test(node:http): speed up node-http-connect.test.ts and assert exact tunnel bytes #40897, node:http: gate 'drain' on the bytes of the handle, give write callbacks to the stream #46014)node:clusterworkerslisten()asks the primary, soserver.address()is null until'listening',listen(0)gives all workers one port, andworker.disconnect()closes http servers. Not on Windows. (node:http: cluster workers listen through the primary's shared handle, Bun.serve({ fd }) #45186)errno. Text that matchedFailed to start serverno longer matches.codeandsyscallare unchanged. (fix(node): format HTTP listen errors #42742, Return the error of a failed listen from usockets to Bun.serve and Bun.listen #44651)emit('connection'), and HTTP/1.1 on anallowHTTP1server, run a port of Node's own connection code, so they follow Node's rules forHost, backpressure, trailers and the server's timeouts. (node:http: answer a request without Host with 400 on emit('connection') and allowHTTP1 connections #37735, node:http: stop reading a connection served from JS while the request buffer is full #45841, node:http, node:http2: give emit('connection') and allowHTTP1 connections the server's timers and error path #46185)server.close(), as in Node. (node:http: a paused upload behind a sent response does not hold the event loop #46424)node:http2close()anddestroy()put Node's frames on the wire.write(x); close(code)in one tick sendsRST_STREAM(code), not DATA with END_STREAM.destroy()sends onlyRST_STREAMand emits no'finish'. The session's'close'comes after its streams' events. A'frameError'closes the session. (node:http2: send RST_STREAM when a server stream is reset #33380, node:http2: destroy streams before the native sweep in session teardown #42717, node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440, node:http2: destroy the socket on session.destroy() without close(), emit the session 'close' once the socket has closed #38195)close(code)keeps its code when the peer's RST_STREAM crosses it, as in Node. On 1.4.4 it depended on timing. With no'error'listener a peer's CANCEL still wins, so nothing that's silent on 1.4.4 throws. (node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440)destroy()hasrstCode2, as in Node. 1.4.4 had 8, which grpc-js reports as CANCELLED instead of INTERNAL. (node:http2: give pending requests cancelled by session.destroy() node's rstCode #37744)maxSendHeaderBlockLengthis raised, connection-specific headers throw fromrespond()andrequest(), andrespond({ ':status': 102 })throws.res.setHeader()andres.writeHead()drop such headers with a warning instead, so a front end that copies an HTTP/1.1 upstream's headers keeps running (Node throws there too). (node:http2: refuse a header block over the default send limit, like node #43474, node:http2: encode each header block in one call, all fields or none #41520, node:http2: normalize outbound header objects like node #41614, node:http2: validate the server stream :status like node #43453)maxSessionMemory, about what Node counts and more than on 1.4.4. With the default of 10 MB a session refuses the 13,872nd concurrent stream (Node: the 13,867th). (node:http2: do not refuse respond() when the session is over maxSessionMemory #43419)'timeout', and a throw in theconnect()listener is an uncaught exception, both as in Node. (http2: give Http2Stream its own per-stream idle timer #30308, node:http2: register the connect() listener as a one-time 'connect' listener #43492)HTTP/3 and
node:quicmaxConnectionsPerHostsays otherwise, which is Node's default. A load test from one machine hits this. (node:quic: enforce QuicEndpoint maxConnectionsPerHost #35726)ccuse Cubic. 1.4.4 picked Cubic or BBRv1 from the first RTT sample. (node:quic: default the congestion controller to Cubic #37468)patches/, each described inscripts/build/deps/lsquic.tsandlsqpack.ts. They could live in the forks instead.undiciandnode-fetchAgent,Pool,ClientandProxyAgenthaverequest(),stream(),dispatch(),close()anddestroy(), each has its own connection pool, andundici.fetch()applies a dispatcher's proxy andconnectoptions. Sonew Agent({ connect: { rejectUnauthorized: false } })now really skips verification, andnew ProxyAgent()without a URI throws. (feat(undici): implement Pool, Client, Agent, and stream() #27338, undici: wire ProxyAgent/setGlobalDispatcher/{dispatcher} to native fetch proxy #35145, undici: implement dispatch(), close() and destroy() on the shim dispatchers #39250)node-fetchrejects withFetchError, not the nativeTypeError.err.codeis unchanged. (node-fetch: reject fetch() and body reads with AbortError and FetchError #44297)Where this PR still differs from Node on purpose, because 1.4.4 programs rely on it or because Bun's answer is the safer one:
node:http: a socket nobody reads closes when its connection is gone,server.unref()also lets go of sockets that'upgrade'or'connect'got, andreq.socket.remoteAddress = xsticks.node:http: a connection that closes under an ended response's last write still emits'finish'. Onlisten()connectionsres.cork()has no effect and the head is always Latin-1.node:http: a connection that reported a parse error is closed about 10 s later, even if the'clientError'listener leaves it open. A body under a non-chunkedTransfer-Encodingis sent withConnection: close.node:http2: without an'error'listener, a latewrite()on a response thatrespond()ended is dropped, a pushed stream's error isn't thrown, and an early NO_ERROR reset drops later writes. A client session with no'stream'listener cancels pushes.Fixes
node:quicBun.serve,fetchandnode:quic, andnode:quicbuilds its TLS context with the code every other Bun TLS context uses.console.log()orBun.inspect()of aQuicEndpoint,QuicSessionorQuicStreamnow prints each session once. (node:quic: bound [kInspect] recursion through the endpoint<->session cycle #35742)connect()with differenttransportParams,application,cc,handshakeTimeoutorkeepAliveoptions now gets those options, also on an endpoint it shares with the first. (node:quic: key shared-client-endpoint reuse on engine-baked connect() options #35765)QuicEndpointnow appliesudpReceiveBufferSize,udpSendBufferSize,udpTTL,ipv6OnlyandreusePortto its UDP socket. (node:quic: apply the endpoint's UDP socket options when binding #37456, in part)node:quicsession without theccoption now uses Cubic, Node's default. (node:quic: default the congestion controller to Cubic #37468)node:quicstream, writing a body or ending the stream aftersendHeaders()returned false now resets the stream with H3_INTERNAL_ERROR, andclosedrejects on both ends. (node:quic: reset the stream when lsquic_stream_write fails instead of retrying on every tick #42892, in part)Bun.serve({ http3: true }),fetch(..., { protocol: "http3" })andnode:quicin one process no longer breaks QUIC handshakes that are in flight when the second one starts. (quic: run lsquic_global_init once per process #42913) Fixes node:quic: a client handshake fails with ERR_QUIC_TRANSPORT_ERROR when fetch() over HTTP/3 handshakes at the same time #42898.ondatagramstatus(id, status)now reports each datagram under its own id. (node:quic: report a datagram's status under the id kept in its lsquic frame #44680)node:quicclose with code 0 no longer reaches the peer as INTERNAL_ERROR, through the node:quic: send a close with code 0 as code 0, not INTERNAL_ERROR #45702 fix. (node:quic: send a client's coded close before the server's first packet, ignore close options withoutcode#44846, in part)createUnidirectionalStream()before the handshake finishes now opens that stream when the handshake is done. (node:quic: open a uni stream requested before the handshake, keep pending streams paired with lsquic's requests #45342, in part) Fixes node:quic: a unidirectional stream created before the handshake never opens (raw QUIC) #44749.session.close()andsession.destroy()with{ code: 0n, type: "application" }, or with code 0 and areason, now send code 0. (node:quic: send a close with code 0 as code 0, not INTERNAL_ERROR #45702) Fixes node:quic: an application close with error code 0 is sent as transport error INTERNAL_ERROR #45499.node:quicstreams now havestream.opened(fulfilled once a stream created without stream credit gets its ID) andstream.onstopsending(called with the peer's application error). (node:quic: add QuicStream.opened and QuicStream.onstopsending #45712) Fixes node:quic: stream.opened and stream.onstopsending are not implemented #45501.node:quicclient with an IP address asservername("127.0.0.1","::1") now verifies it against the certificate's IP SANs. (node:quic: verify an IP servername against the certificate's IP SANs #45713) Fixes node:quic: an IP-address servername fails hostname verification despite a matching IP subjectAltName #45514.stream.closedreject with the same error assession.closed. (node:quic: give a stream still open at connection close the connection's error #45741) Fixes node:quic: a stream read ends cleanly when the peer closes the connection with an error #45500.resetStream(),setBody()andsetOutbound()on a unidirectional stream that the peer opened no longer send RESET_STREAM or STREAM frames, which made the peer close the whole connection. (node:quic: ignore resetStream() and setBody() on a receive-only stream #45879, in part)stream.stopSending()andstream.destroy()on anode:quicstream whose send side is finished (every headers-only HTTP/3 GET) now send STOP_SENDING right away. (node:quic: make the connection tickable when lsquic queues STOP_SENDING #45931)node:quicstream'spendingTrailerscan't be sent, the stream is now destroyed andstream.closedrejects (with the validation error, orERR_QUIC_STREAM_ABORTED). (node:quic: destroy the stream when its pendingTrailers cannot be sent #45941)node:quicclient now enforces, and returns credit for, the stream limits it advertises. (node:quic: seed the limits for peer-opened streams from the advertised stream counts #46305, in part)transportParams, anode:quicserver can now open a bidirectional stream and send more than 16 KiB on it, and a resumed client can send more than 12 KiB on a unidirectional stream in 0-RTT. (node:quic: use one default stream window per endpoint #46321)node:quicserver opens now respects the right flow-control window on both ends wheninitialMaxStreamDataBidiLocalandinitialMaxStreamDataBidiRemotediffer. (node:quic: pick a bidirectional stream's send limit by who opened it #46322, in part)onnewtoken(token, address)and thequic.session.new.tokendiagnostics channel now get the server'sSocketAddress. (node:quic: give a session both addresses at construction, pass the peer address to onnewtoken #46343, in part)node:quicclient that resumes a session (sessionTicket) and callsstream.resetStream(),stream.stopSending()orstream.destroy()beforeopenednow gets that frame to the server. (node:quic: type a resumed client's pre-handshake application packets as 0-RTT #46425)node:quicsession now reports the transport error code that its own side closed the connection with. (node:quic: report the error lsquic closes a connection with #46487, in part)HTTP/3
fetch(url, { protocol: "http3", tls: { ca, cert, key, serverName } })now works: the server is verified against yourcaandserverName, and the client certificate is presented. (fetch: honor per-request TLS options on the HTTP/3 client #31420)Expect: 100-continuethat is the first request on its connection now gets its response. (Bun.serve(http3): resend the final header block that lsquic refuses behind a pending 100 Continue #42805) Fixes Bun.serve HTTP/3: 100-continue interim response breaks the final response on a fresh QUIC connection #33082.await server.stop()on an HTTP/3 server now resolves after the last QUIC connection is gone and the UDP socket is closed. (Bun.serve(http3): resolve stop() after the UDP socket and the last QUIC connection are gone #45419)--experimental-http3-fetch, a request that Alt-Svc routed to HTTP/3 now falls back to TCP when the QUIC handshake fails or times out, and Bun skips that alternative for 5 minutes. (http(h3): use TCP when the QUIC connection to an Alt-Svc alternative cannot be established #45539, in part)node:http2node:http2session keeps one table of its streams, and shares its frame, settings and header checks with fetch's HTTP/2 client.Http2Stream.setTimeout()(andreq.setTimeout()/res.setTimeout()in the compat API) now arms a timer for that stream only. (http2: give Http2Stream its own per-stream idle timer #30308) Fixes @fastify/http-proxy with HTTP/2 spuriously emits FST_REPLY_FROM_HTTP2_REQUEST_TIMEOUT after idle on Bun #30307.stream.close(code)called right afterwrite()now resets the stream instead of ending it cleanly, so the peer no longer takes an aborted response or upload for a complete one. (node:http2: send RST_STREAM when a server stream is reset #33380, in part)req.end(callback)on a request thatrequest()already ended (every GET, HEAD and DELETE) calls back asynchronously like Node, and passesERR_STREAM_ALREADY_FINISHEDorERR_STREAM_DESTROYEDonce the stream is past that point. (node:http2: defer the callback in Http2Stream#end() on an already-ended stream #33489)internalBinding('http2')exists in the--expose-internalstest shim, so Node's test-http2-binding.js and test-http2-respond-errors.js run and pass. (http2: implement internalBinding('http2') for the vendored node test shim (+2 upstream tests) #34499)stream.write(""),res.write("")and a clientend()with trailers pending no longer put a zero-length DATA frame without END_STREAM on the wire. (node:http2: drop empty DATA frames that carry no END_STREAM, pass the stream id to frameError #37712)endStream(explicit, or implied by GET/HEAD/DELETE) pluswaitForTrailersnow ends on its HEADERS frame like Node, instead of sending HEADERS and then an empty END_STREAM DATA frame. (node:http2: put END_STREAM on the HEADERS frame of an endStream request even with waitForTrailers #37718)session.destroy()runs now reports Node'srstCode: INTERNAL_ERROR (2) instead of CANCEL (8), and a code set earlier byreq.close(code)or a received GOAWAY is kept. (node:http2: give pending requests cancelled by session.destroy() node's rstCode #37744)[name, value, ...]header list passed torespond()orrequest()now goes out as one field per element, like Node. (node:http2: send array values in raw-form header lists as one field per element #37796, in part)respond()(pushStream(headers, { endStream: true })orpush.end()) puts END_STREAM on its response HEADERS. (node:http2: end a pushed response on its HEADERS frame when the stream was ended before respond() #38104)session.destroy()now delivers its GOAWAY (and the initial SETTINGS, if they hadn't been flushed yet) when the session runs over a Duplex instead of a socket. (node:http2: deliver the destroy() GOAWAY on sessions whose socket has no native handle #38158)node:http2session keeps parsing inbound frames when code that continues after an awaited response spins the event loop (await expect(promise).resolvesinbun test,require()of a module with top-level await), on macOS and Linux. On Windows it can still stall, depending on timing, because libuv re-arms a socket only after its callback returns. (node:http2: keep parsing inbound frames when a frame callback spins the event loop #39873) Fixes node:http2 client stops processing inbound frames for ~28s under load (regression 1.3.14 → 1.4.0) #39796.session.setLocalWindowSize(n)withnof 128 KiB or more no longer stalls every request or response body larger than 64 KiB. (node:http2: keep setLocalWindowSize() off SETTINGS_INITIAL_WINDOW_SIZE #40180)http2.connect(url, { maxFrameSize: NaN })throwsERR_HTTP2_INVALID_SETTING_VALUEinstead of spinning inrequest()until the process runs out of memory. (node:http2: read SETTINGS from options.settings and session limits from the top level #41305, in part)setLocalWindowSize()after lowering it no longer grants the peer the same credit twice. (node:http2: withhold connection window credit after setLocalWindowSize() decreases it #41344, in part)node:http2now treats outbound header objects like Node: a property set toundefinedis skipped instead of throwingERR_HTTP2_INVALID_HEADER_VALUE,nulland array items are stringified, andcontent-length: 0on a GET is sent instead of resetting the request. (node:http2: normalize outbound header objects like node #41614)stream.endAfterHeadersno longer throwsInvalid stream idon a request that has no id yet, keeps its value after the stream closes, and is only set by the request HEADERS that opened a server stream, like Node. (node:http2: make the stream lifecycle getters total, like node #41945, in part)session.closedis now true on a client session whose server hung up, like Node. (node:http2: report session.closed when the transport goes away #42180)http2.connect()with an invalid setting still throws, but no longer leaves a connecting socket behind that spins the process at 100% CPU forever. (node:http2: drop the connecting socket when a client session constructor throws #42184)server.stop()no longer waits for a connection that hadn't made a request yet. (Bun.serve http2: close a connection that becomes HTTP/2 after a graceful stop() #42345, in part)node:http2client whose ownsend()is the first to see the peer's reset now gets 'close' on the request and the session. (node:http2: submit a request's frames after request() returns #42353, in part)maxConcurrentStreamslimit no longer sends the next queued request ahead of the RST_STREAM of the request thatdestroy()just cancelled. (node:http2: free a request's slot when its stream closes on the wire, abort request({ signal }) from JS only #42763)stream.sentHeaderson anode:http2server isundefineduntilrespond(), like Node. (node:http2: keep received headers out of sentHeaders #43206)pushStream()takes its default:authorityfrom the request'shostwhen the request has no:authority. (node:http2: default a push's :authority from the request's host #43298)node:http2now honors SETTINGS_HEADER_TABLE_SIZE. (node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK encoder and decoder #43345, in part) Fixes Bun's HTTP/2 server potentially ignores Nginx's HPACK for header compression settingSETTINGS_HEADER_TABLE_SIZE=0#19152.node:http2server no longer drops responses without telling the client once more thanmaxSessionMemory(10 MB by default) of response data is queued. (node:http2: do not refuse respond() when the session is over maxSessionMemory #43419)session.destroy()now destroys a stream that already closed on the wire but whose data was never read, so it emits 'close' (and 'error' when the session was destroyed with one). (node:http2: destroy a closed stream with unread data when its session is torn down #43433)node:http2server whoserespond()is overmaxSendHeaderBlockLengthnow resets the stream with FRAME_SIZE_ERROR and closes the session gracefully like Node, instead of leaving the client hanging. (node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440)respond(),respondWithFile()andrespondWithFD()now throwERR_HTTP2_STATUS_INVALIDfor a 1xx:statuslike Node, andadditionalHeaders()no longer adds:status: 200to a block without one. (node:http2: validate the server stream :status like node #43453, in part) Fixes node:http2: ServerHttp2Stream checks:statusdifferently than Node (no integer conversion, respond() accepts 1xx) #43447.respondWithFile()on a directory with noonErrornow resets the stream without sending response headers first, like Node. (node:http2: do not call respond() when a file response fails before the headers are sent #43463, in part)server.updateSettings()andserver.updateSettings(undefined)are no-ops again instead of throwingERR_INVALID_ARG_TYPE. (node:http2: accept undefined in server.updateSettings() #43472)maxSendHeaderBlockLengthnow defaults to 65536 like Node. (node:http2: refuse a header block over the default send limit, like node #43474)respond(headers, options)only readsendStream,waitForTrailers,sendDateandpaddingStrategynow, like Node. (node:http2: ignore request() options in respond(), like node #43478, in part)http2.connect()is now a one-time 'connect' listener, like Node: it gets(session, socket),session.off('connect', listener)removes it, and if it throws you get an uncaught exception instead of a silently destroyed session. (node:http2: register the connect() listener as a one-time 'connect' listener #43492)http2.connect()withcreateConnectionaccepts any URL protocol like Node,http2.connect({ port }, { protocol: 'http:' })sends:scheme: httpinstead ofhttps, and ERR_HTTP2_UNSUPPORTED_PROTOCOL has Node's message. (node:http2: match Node's unsupported-protocol check in connect() #43505)node:http2server stream that was open beforesession.close()now emits 'trailers' when the client's trailer block arrives after the GOAWAY. (node:http2: emit 'trailers' for a stream delivered before session.close() #43507) Fixes node:http2 server: request trailers that arrive after session.close() do not emit 'trailers' #43488.close()ordestroy()on it, when the header block arrived in the same socket read. (node:http2: emit no header events on a client stream that user code closed #43525)session.close(),stream.pushAllowedis false andpushStream()throwsERR_HTTP2_PUSH_DISABLED, like Node. (node:http2: refuse pushStream() and ping() after session.close() #43531, in part)stream.duplexPair(). (node:http2: send the rest of a body when WINDOW_UPDATE arrives inside the transport write #43562)stream.respondWithFD(fd)sends the whole file every time. (node:http2: port node's respondWithFD() and respondWithFile() response paths #43564, in part)session.request(headers, { parent, exclusive, weight })no longer puts priority fields on the HEADERS frame, like Node. (node:http2: send no priority fields on HEADERS, like node #43599)session.settings(settings, callback)ignores a falsy callback (null,0,"",false) like Node instead of throwingERR_INVALID_ARG_TYPE, and checkssettingsbeforecallback. (node:http2: check settings before callback in session.settings() #43605, in part)node:http2server stream that the handler paused now emits 'end' and 'close' once it has closed and holds no unread data. (node:http2: end a paused server stream that holds no data when it closes #43631)node:http2client no longer throws away response data that arrived before the server's RST_STREAM(NO_ERROR). (node:http2: keep the data that arrived before a peer's RST_STREAM(NO_ERROR) #43754, in part)stream.state,util.inspect(stream)andconsole.log(stream)no longer throwInvalid stream idon a stream that finished on the wire while its data is still unread, or on a pushed client stream. (node:http2: do not throwInvalid stream idfrom stream.state and util.inspect(stream) #43801, in part)stream.close(NGHTTP2_REFUSED_STREAM)no longer gets its whole connection closed with GOAWAY(ENHANCE_YOUR_CALM) after 50 refusals. (node:http2: count a refused stream where the session refuses it, not in rstStream #44248)node:http2server no longer ends the whole session at the 100th stream it refuses for exceedingmaxConcurrentStreams. (node:http2: refuse a stream over the advertised limit before it is opened, on maxSessionInvalidFrames #44337, in part)session.setTimeout()no longer fires 'timeout' in the middle of a download or upload. (node:http2: refresh the session idle timer where node does #44481, in part)respond()andrequest()with a raw[name, value, ...]list no longer push a repeated name's value into an array the caller passed. (node:http2: copy the array values of a raw header list in respond() and request() #44804)session.stateis{}aftersession.destroy(), like Node, instead ofundefined, sosession.state.lastProcStreamIDno longer throws. (node:http2: return {} from session.state without a handle, keep connecting true until the socket is ready #44816, in part)stream.close(code)on a stream that is read throughpipeline()now resets it with the given code. (node:http2: store the code of close(code) before its listeners run #44848, in part)server.stop()orserver.closeIdleConnections()no longer cuts an HTTP/2 response that the kernel hasn't delivered yet. (Bun.serve http2: close a connection gracefully with a FIN, not with close(2) #44874, in part)node:http2no longer emits 'aborted' on a stream whose writable side already ended. (node:http2: emit 'aborted' only while the writable side is open #45056, in part)node:http2client request that is reset withclose(code)while it waits for a stream slot (SETTINGS_MAX_CONCURRENT_STREAMS) is no longer sent. (node:http2: do not send a request that close(code) reset while it waited for a stream slot #45279, in part)node:http2client request that is destroyed or aborted while it waits for the connect or for a stream slot leaves the session's queue right away. (node:http2: take a request out of the client's pending queue when it is destroyed #45448, in part)session.close()andserver.close()no longer cut a large response or upload that was already framed but not yet written to the socket. (node:http2: keep the unsent frames on a graceful close and send the FIN after them #45673, in part)http2.connect()session, a request that waited for a stream slot or for the connect now runs itswrite()andend()callbacks, 'ready' and 'finish' in its own caller'sAsyncLocalStoragecontext. (node:http2: submit a queued client request in the async context of its request() caller #46036, in part)ClientHttp2Session.request()is Bun's own. (node:http2: correct the comment on the one-tick cork in request() #46234)stream.close()andclose(NGHTTP2_CANCEL)no longer emit 'error' ERR_STREAM_PUSH_AFTER_EOF when a DATA frame arrives while close() is still running. (node:http2: drop DATA that arrives after close(code) ended the readable #46428)session.settings(settings, callback)now runs the callback and that frame's'localSettings'event in the caller'sAsyncLocalStoragecontext, like Node. (node:http2: deliver a settings() acknowledgement in the async context of its caller #46463, in part)undici and node-fetch shims
Pool,ClientandAgentfrom the built-inundiciwork:request()andstream()send the request with Bun's fetch,close()waits for pending requests anddestroy()aborts them. (feat(undici): implement Pool, Client, Agent, and stream() #27338, in part) Fixes @elastic/elasticsearch@8.11.0 not working due to missing undici.Pool.request #7920. Fixesundici.Agentis missingasync close()method #14498. Thanks to @igorls.ProxyAgentfrom the built-inundiciworks. (undici: wire ProxyAgent/setGlobalDispatcher/{dispatcher} to native fetch proxy #35145, in part) Fixes SupportundiciProxyAgent #4474.dispatch()works onAgent,Pool,ClientandProxyAgentof the built-inundici, with the handler callbacks of undici 6 and of undici 7, including pause, resume and abort. (undici: implement dispatch(), close() and destroy() on the shim dispatchers #39250, in part) Fixes undici shim:Poollacksdispatch()/close()/destroy(), breaking miniflare + workerd (wrangler dev, @cloudflare/vite-plugin) #39247. Fixes bug: EventEmitter or something different? #8961.undici.request()hasdump()andbytes(). (undici: add dump() and bytes() to the request() response body #40206) Fixes undici shim: response body lacks dump() and bytes(), and the usual destroy(err) recovery kills the process #40205.undicihave undici'sname,UND_ERR_*codeand default message. (fix(node): preserve undici timeout error metadata #42716) Thanks to @steipete.new Request(url, { body })from the built-innode-fetchreads an old-styleStreambody such asform-data. (node-fetch: read an old-style Stream body of a Request #43773, in part)Response.typefrom the built-innode-fetchis"default"for a 404 or 500 instead of"error". (node-fetch: report Response.type as default for an HTTP error status #43966) Thanks to @breken-ai.FetchErrorfrom the built-innode-fetchhas the nameFetchError, setserrnoanderroredSysCall, and has nocodewithout a system error. (node-fetch: name FetchError and AbortError like node-fetch #43967, in part) Thanks to @breken-ai.Response.json(),Response.redirect()andResponse.error()from the built-innode-fetchreturn a node-fetchResponse, withbuffer(),headers.raw()and a Node streambody. (node-fetch: return a node-fetch Response from Response.json(), redirect() and error() #43968) Thanks to @breken-ai.fetch()from the built-innode-fetchrejects with node-fetch'sAbortError(type: "aborted") on abort andFetchErrorwhen the request fails. (node-fetch: reject fetch() and body reads with AbortError and FetchError #44297, in part) Fixes node-fetch: fetch() rejects with native errors, not node-fetch AbortError / FetchError #44282.undici.request()rejects amethodthat isn't a string, isn't an HTTP token, or isCONNECTwith undici'sInvalidArgumentError, instead of sendingGET(orCONNECT). (undici.request: reject a method that is not a string, not an HTTP token, or CONNECT #44814, in part)node:httpandnode:httpsreq.write(chunk, callback)on a client request whose socket is already destroyed now callscallbackwith the socket's error orERR_STREAM_DESTROYEDinstead of never calling it.res.assignSocket(socket)listens withsocket.on()like Node, so the socket stand-ins of serverless-http and @vendia/serverless-express (which have noonce()) work instead of throwingsocket.once is not a function.req.socketon anode:http/node:httpsserver is a plainnet.Socket/tls.TLSSocketfrom accept on, and 'upgrade' and 'connect' listeners get that same socket over the same handle.https.Server#listen()negotiates ALPN (ALPNProtocols,http/1.1by default,ALPNCallback,req.socket.alpnProtocol) and emits 'keylog', like atls.Server.server.emit('connection', socket),http._connectionListenerand http2'sallowHTTP1run a port of Node's own connection code, and every kind of connection has one queue of waiting responses.req.socket.bytesReadon anode:http/node:httpsserver now counts every byte the connection has read (after TLS decryption) instead of staying 0, and keeps its value after 'close'. (Fix req.socket.bytesRead returning 0 in node:http server #28710) Fixesreq.socket.bytesReadreturns 0 when using node:http server #28709.http._connectionListeneris exported (httpolyglot and spdy call it). (node:http: adopt external sockets fed in via server.emit('connection') #35285, in part) Thanks to @steipete.node:http/node:httpsserver's connection socket (server.on('connection'),'secureConnection',req.socket) now gets the raw request bytes, like Node. (node:http: emit 'data' on the server 'connection' socket with the raw request bytes #35661) Fixes Unable to read raw data from http/https requests, using either "node:http" or "node:https" #11924.res.end()now undoes ares.cork()that was never uncorked, so a later CONNECT or Upgrade on the same keep-alive connection can write to its socket instead of buffering forever. (node:http: don't leave the server socket Duplex corked across kept-alive requests #35664, in part)http.Servernow setsserver._handlewhile it listens (null beforelisten()and afterclose()), so a supertest request closes its server and the process exits instead of hanging. (node:http: expose Server._handle so supertest's ephemeral server gets closed #36685) Fixes Bun process hangs when usingSupertestwithExpress— does not exit after test completion #23648.emit('connection')andallowHTTP1connections,res.end(body)followed right away byreq.socket.destroy(),res.destroy()orcloseAllConnections()no longer cuts the response off after its headers. (node:http: deliver a fallback response in full when the connection is torn down right after res.end() #37710, in part)Hostheader now gets400 Bad RequestwithConnection: closeonemit('connection')andallowHTTP1connections, like a listeninghttp.Serverand like Node. (node:http: answer a request without Host with 400 on emit('connection') and allowHTTP1 connections #37735)emit('connection')andallowHTTP1connections, closing a socket that was handed to an 'upgrade' or 'connect' listener no longer emits 'aborted', 'error' and 'close' on the upgrade request. (node:http: abort the in-flight request when an HTTP/1 fallback connection closes #37748, in part)Content-Length: 0request whose head arrives in two TCP reads now counts as complete. (http: classify idle connections by request message state, not response end #37889, in part)node:httpserver'skeepAliveTimeoutis measured on the monotonic clock, sosetSystemTime(), fake timers or a wall-clock step no longer close an idle connection early or keep it open. (node:http: measure the keep-alive idle period on the monotonic clock #37974)res.destroy()orreq.destroy()before the response head is sent now closes the connection without sending anything, like Node. (node:http: send nothing on destroy() before the headers go out, and a 500 (not an empty 200) for a throwing listener #39366, in part)res.statusMessagegoes on the wire as Latin-1 onlisten()connections, like the header values there (OKéwas sent as UTF-8). (node:http statusMessage as Latin-1, spec-compliant Blob.type from headers, UTF-8 property keys #40416, in part) Thanks to @dylan-conway.http.request(),http.get()andhttps.request()accept a URL object from another implementation or one created beforeglobalThis.URLwas replaced, andreq.write()/req.end()accept aUint8Arrayfrom anode:vmcontext. (node:http, node:fs, node:worker_threads: detect URLs structurally, and Uint8Array by cell type #41117, in part)clientErrorreports an empty, non-numeric or oversized Content-Length as HPE_INVALID_CONTENT_LENGTH and two different values as HPE_UNEXPECTED_CONTENT_LENGTH, with Node's message, and every parse error haserr.reason. (node:http: report each bad Content-Length with node's code and reason #41234, in part)server.listen({ port, path })on anode:httpserver now binds the TCP port and ignorespath, andlisten({ path, host })binds the path instead of emitting an 'error', like Node. (Bun.serve: throw when both port and unix are given #41660, in part)server.listen()now reports Node's error:listen EADDRINUSE: address already in use 127.0.0.1:3000, witherrno,syscall,addressandportset. (fix(node): format HTTP listen errors #42742) Thanks to @steipete.server.listen({ port, reusePort: true })now sets SO_REUSEPORT outside a cluster worker, so two processes can share a port as they can withnode:netandBun.serve. (node:http: honour listen({ reusePort: true }) in the primary process #43692) Fixes node:httplisten({ reusePort: true })is silently ignored; node:net honours it #43690.server.listen(null)andserver.listen(null, host)bind a free port, like Node, instead of port 3000 (orPORT). (node:http: bind an ephemeral port when the listen() port is null #43726, in part)node:httprequest with aContent-Lengthbody now emits 'end' after the promise jobs queued by the last chunk's 'data' listeners, like Node. (node:http: run nextTicks and promise jobs where Node's parser does #43736, in part)server.timeoutand thesetTimeout()of a server, request, response or socket no longer cut a response that's still being written:res.write(),res.end(),res.flushHeaders()and a drained write now count as socket activity, like Node. (node:http: count response writes as activity for the socket inactivity timeout #43753, in part)rejectNonStandardBodyWrites: true,res.write("")on a HEAD, 204 or 304 response now throwsERR_HTTP_BODY_NOT_ALLOWEDlike Node, also on a queued pipelined response. (fix(node:http): reject empty writes for bodyless responses #43994) Fixes node:http: res.write("") on a HEAD or 204 response does not throw ERR_HTTP_BODY_NOT_ALLOWED with rejectNonStandardBodyWrites #43535. Thanks to @aryan1306.http.Server.listen({ fd })(and https) serves on the inherited socket. (Bun.serve({ fd }) and node:http listen({ fd }): serve on an inherited socket #44013, in part) Fixes systemd socket activation doesn't work #22559.res.socket.end()on anode:httpserver now sends its FIN once the bytesres.write()left buffered are out, even if the response never ends. 1.4.4 never sent it, so the client waited forever. (node:http: half-close from the drain gate when socket.end() waited for buffered bytes #44244, in part)node:httpserver no longer copies the bytes that follow each request head into a Buffer nobody reads. (node:http: create the 'head' Buffer only for a CONNECT or an Upgrade request without a body #44296) Fixes node:http: every request copies the bytes behind its head into a head Buffer that only 'connect' and 'upgrade' read #44208.res.end(data)sends its body when a replacedres.writeHeadcallsflushHeaders()orwrite(). (node:http: res.end() completes the response when a replaced writeHead() sent the head #44442)res.writeEarlyHints(),writeContinue()andwriteProcessing()send the 1xx response right away instead of when the handler returns, so Early Hints reach the client before synchronous rendering. (node:http: send a 1xx response when it is written #44468, in part)https.Serverkeeps serving TLS afterhttp.Server.call(server)is run on it again, andhttp.createServer({ _pfxExtraCACerts })no longer throws. (Type check src/js under TypeScript 7 with skipLibCheck off, and fix the node:http bugs it found #44609, in part)Bun.serve(),Bun.listen(),http.Server#listen()ornet.Server#listen()now reports the error of the call that actually failed, on every platform. (Return the error of a failed listen from usockets to Bun.serve and Bun.listen #44651) Fixes binding privileged port incorrectly reports EADDRINUSE instead of EACCES on macos #30363.http.request()body written underreq.cork()is no longer sent after the terminating chunk whenreq.end()runs beforereq.uncork(), andreq.uncork()emits 'drain' after a corkedwrite()returned false. (node:http: deliver every chunk that a corked OutgoingMessage accepts #44676, in part)res.writeHead()with a raw[name, value, ...]list no longer changes an array the caller passed as a value. (node:http: copy the array values of a header list in writeHead() and res.headers= #44778)server.close()even if a 'connect' or 'upgrade' socket still has its reads stopped, for example the two-pipe CONNECT proxy from Node's docs whose upstream stalled and whose client gave up. (node:http: a tunnel holds the event loop only while it reads or has bytes left to send #44885, in part)writeHead()fix is in through node:http: copy the array values of a header list in writeHead() and res.headers= #44778. (node:http: walk the headers of writeHead(status, headers) in native code and send them as given #44944, in part)req.socket.setKeepAlive()andhttp.createServer({ keepAlive, keepAliveInitialDelay })now turn on TCP keep-alive for server connections, so a peer that vanishes under an SSE stream or a tunnel gets detected. (node:http: make setKeepAlive() and the keepAlive option act on server sockets #45006)res.writeHead(status, headers)after the call no longer reaches the wire. (node:http: render the response head inside writeHead() #45047, in part)node:clusterworker, http and https servers share a Unix socket path,listen(0)andlisten({ fd })through the primary, like Node. (node:http: cluster workers listen through the primary's shared handle, Bun.serve({ fd }) #45186, in part) Fixes node:http cluster workers fail to share a Unix socket with EADDRINUSE #44794.Expect: 100-continuewith a final status and never callswriteContinue(), the response now saysConnection: closeand the server closes the connection, like Node. (node:http: close the connection after a final response to an unanswered Expect: 100-continue #45189)server.close(callback), the server's 'close' event andawait using serverno longer hang when user code removes the last socket's 'close' listeners or when one of its 'close' listeners throws. (node:http: queue the server 'close' check from the connection's close handler, not from a socket 'close' listener #45257)node:http('connect' / 'upgrade' hand-off) andnode:dgram(Symbol.asyncDispose) no longer call the program'sFunction.prototype.bindon an internal function. (node:dgram, node:http: don't pass promise-settling internals toFunction.prototype.bind#45292)res.writeHead(200); res.write("")now sends the complete response head, so a client (an EventSource, for example) sees the response before the first real chunk. (node:http: an empty write terminates the header section #45986) Fixes node:http:res.write("")sends the response head without the final empty line #45722.bun --hotno longer keeps thehttp.Server(and the whole app behind it) of every earlier reload alive. (node:http: release the earlier http.Server underbun --hot#46039)ConnectionsList#expired()fromprocess.binding('http_parser')no longer wraps timeouts above 4294 ms. (http_parser: multiply ConnectionsList#expired() timeouts in 64 bits #46411)node:httpserver process now exits right afterserver.close()orserver.unref()when the only thing left is a paused upload whose response was already sent, like Node and Bun 1.4.2. (node:http: a paused upload behind a sent response does not hold the event loop #46424)Bun.serveBun.servesends the automatic100 Continuefor any casing ofExpect: 100-continue. (Bun.serve: handle Expect per RFC 9110 (case-insensitive, params/list, auto-417) #30250, in part) Fixes Bun.serve does not auto-respond "100 Continue" when "Expect" header value uses non-lowercase casing (RFC 7231 violation) #30248.new Response(),Response.redirect(), a 204 or 304, an untyped stream) never gets a fallbackContent-Type: application/octet-stream, whatever the build. (server: don't send a fallback content-type on detached responses #30997)error()handler that finishes later without returning a Response now answers 500, like a sync one. (Fix Bun.serve error() returning a deferred Promise<undefined> responding 204 #33140) Fixes Bun.serve: error() returning a deferred Promise<undefined> swallows the original error and responds 204 #33137.Content-Dispositionheader thatBun.serveadds to aBun.file()response now has a disposition type (inline; filename="a.bin"), so RFC 6266 parsers such as npmcontent-dispositionaccept it. (Bun.serve: include disposition-type in auto Content-Disposition header #33655)server.timeout(req, seconds)now say that values above 255 are clamped to 255. (Bun.serve: validate server.timeout() seconds argument #34958, in part)Expect: 100-continueand aContent-LengthovermaxRequestBodySizegets the413alone, over HTTP/1, HTTP/2 and HTTP/3. 1.4.4 sent100 Continuefirst, which started an upload that the server then refused. (Bun.serve: skip 100 Continue when Content-Length exceeds maxRequestBodySize #34960)routesentry ofnew Response(Bun.file(path), { status: 307 })(or 308, or 205) no longer leaves keep-alive clients waiting until the idle timeout. (Bun.serve: stream the body for a 307/308 Bun.file() route instead of hanging keep-alive #35024)development: true, the "Welcome to Bun!" page thatBun.servesends when a handler returns no Response no longer carriesContent-Lengthtwice. (Bun.serve: drop duplicate Content-Length on the dev missing-response page #35294) Fixes Bun.serve default fallback response emits duplicate Content-Length header (RFC 7230 violation) #29714.Bun.serveno longer sends the upstream'sContent-Encodingnext to a body thatfetch()already decoded, however the handler hands that body on:return fetch(upstream),new Response(res.body, res), a clone, atee()branch, a Blob, a static route,HTMLRewriter. (Bun.serve: drop Content-Encoding when serving a fetch()-decoded Response #35546)server.urlno longer throwsInvalid URLfor a unix socket path such asC:\test, and#,?or a space in the path no longer cut it short. (Bun.serve: percent-encode unix socket paths in server.url #35806) Fixes Bun.Server.url can return invalid url in windows #40182.new Response(Bun.file(path))for a path that can't be opened now gets404 Not Found(ENOENT, ENOTDIR, EISDIR, ENAMETOOLONG) or403 Forbidden(EACCES, EPERM) instead of a 500. (Bun.serve: map Bun.file() open errors to 404/403 instead of 500 #36593) Fixes turn new Response(Bun.File(...)) to proper status code, instead of 500 #4292.Bun.serveornode:httpno longer leaks about 800 KB per server type (HTTP, HTTPS, HTTP/2 and HTTP/3) when it exits. (Bun.serve: free a Worker's RequestContext pools when its VM is torn down #37513)Bun.servecloses the connection after it answers aCONNECTrequest. (Bun.serve: close the connection after responding to a CONNECT request #37585)req.paramsis no longer shifted when a route has a:inside a literal segment:/files:batchGet/:idnow gives{ id: "123" }instead of{ batchGet: "123", id: "" }. (Bun.serve: only treat a route segment as a parameter when it starts with ':' #37983, in part)Bun.file()response reaches a client that half-closes right after its request (nc -N, socat, Go'sCloseWrite, some health checkers) in full. (Bun.serve: finish a Bun.file() response after the client half-closes #38107)server.fetch("/path")on a server started withport: 0gives the handler arequest.urlwith the port the server bound, not:0. (Bun.serve: join server.fetch() relative URLs onto the port the listener bound #39027)routesaccepts an in-memoryBlobas a route value, so"/favicon.ico": Bun.file(favicon)withfaviconimportedwith { type: "file" }no longer throws at startup in abun build --compileexecutable. (Bun.serve: accept in-memory Blobs (embedded files, Bun.embeddedFiles) as route values #39304)req.bodyof an upload that arrived completely stays readable after the request ended. (Bun.serve: keep a complete request body readable after the request ends #39660)--defineand the bunfig[define]table now apply to the scripts of HTML imports served byBun.serveandbun ./index.html, in development and production. (Apply CLI and bunfig define to HTML bundles served by Bun.serve #40485) Fixes define does not work withruncommand #40479.routesnow accepts every static value the runtime already accepts there, not just a Response or a handler. (Bun.serve: answer non-GET methods on { dir } routes with 405 #40901, in part)Bun.servefalls back toread()+write()whensendfile(2)refuses a file of 1 MiB or more with EINVAL, ENOSYS, EOPNOTSUPP or EPERM (a filesystem without splice support, a seccomp policy). (Bun.serve: fall back to read+write when sendfile(2) refuses the file #41474, in part)server.reload()with the same HTML import, the/chunk-*.jsand/chunk-*.cssURLs of the page that was already built keep working. (Bun.serve: keep the chunk routes of a built html route across server.reload() #41576)Bun.file()Response from a fetch handler now sends the status and headers GET would: 404 for a missing file or a directory,206/416for a Range request or a slice, plusContent-TypeandContent-Disposition. (Bun.serve: resolve a file body for HEAD the same way as for GET #41585, in part)Bun.serveno longer aborts a response that a slow client is still reading. (Bun.serve: do not idle-time-out a response the peer is still reading #41602)Bun.file()response runs out of bytes before itsContent-Length(the file shrank while it was being sent, orfstatoverstates it as for a sysfs file),Bun.servesends what the file has and closes the connection. (Close the connection when a Bun.file response ends short of its Content-Length #41622)server.fetch()now calls the handler with(request, server), sofetch(req, server) { server.upgrade(req) }no longer throwsundefined is not an objectunderserver.fetch(). (server.fetch(): pass the server as the handler's second argument, accept a URL #41755, in part)Bun.serve()defaults to production mode when the program setprocess.env.NODE_ENV(orBUN_ENV) to"production"before the call, as the docs say (@default process.env.NODE_ENV !== 'production'). (Bun.serve: decide the development default from the live process.env #41926)Responseis sent astext/plain;charset=utf-8after something read.bodyor cloned it. (Bun.serve: keep text/plain for a string body whose .body was read #42064)development: true, an HTML route that arrives throughserver.reload()(or a secondBun.serve()underbun --hot) on a server that started without one now gets the dev server. (Bun.serve: start the dev server when server.reload() adds the first html route #42834) Fixes server.reload doesn't initialize dev server if dev server wasn't previously loaded #23564.Responseroute or aBun.file()route whose headers sayConnection: closenow closes the connection after that response and doesn't answer requests pipelined behind it. (Bun.serve: close the connection after a static or file route sends Connection: close #43107)Bun.file()route ignoresTransfer-EncodingandContent-Lengthheaders on itsResponseand frames the body from the file's size, like static routes and fetch handlers do. (Bun.serve: drop Transfer-Encoding and Content-Length from a Bun.file() route's Response headers #43108)Bun.serveno longer adds the automaticContent-Dispositionheader when the file name isn't safe inside a quoted string. (Bun.serve: omit the automatic content-disposition for a file name with a control byte or a backslash #43742)Bun.servesendsConnection: closein the last response on an HTTP/1 connection: when the request saidConnection: close, when it was HTTP/1.0, and when a gracefulserver.stop()closes the connection after the response in flight. (Bun.serve: send Connection: close in the final response whenever the connection closes after it #43867) Fixes node:http default agent reuses a socket after a Connection: close request in 1.4.2 (every second request ECONNRESET/EPIPE) #43853.Responsebody stream that another reader holds. (Bun.serve: do not end a body stream that a reader holds, and refuse it on HEAD as on GET #43973, in part)%that isn't followed by two hex digits stays a literal%inreq.paramsand in cookie values (req.cookies,Bun.CookieMap). (Bun.serve: keep a%that is not an escape in route params and cookies #44664)Bun.file(path)slice that starts past byte 0 and is as long as the file (slice(5, 21)of 16 bytes) now answers206withContent-Range: bytes 5-15/*, likeslice(5, 20)andslice(5, 22)already did. (Bun.serve: answer 206 for a Bun.file() slice as long as the file that starts past byte 0 #44827)fetch(url, { method: 'TRACE' })now reads the response body instead of resolving with an empty one, over HTTP/1.1, HTTP/2 and HTTP/3. (fetch, Bun.serve: stop dropping the body of a TRACE response #44889) Fixes TRACE body not sent #19615.Responsenow sends its complete HTTP/1 head as soon as the handler's microtasks are done, instead of holding the end of the header section back until the first body chunk. (Bun.serve: send the whole HTTP/1 response head when a streamed body is not ready #45187)new Response(Bun.file(fd))from a fetch handler now sends the file from byte 0 (or from the slice's or Range's offset) wherever the descriptor's position is, and doesn't move the position. (Bun.serve: read a Bun.file(fd) body at its offset, not at the descriptor's position #45266, in part)server.timeout(req, seconds)now works whenBun.servelistens on a unix socket. (Bun.serve: apply server.timeout() on a unix socket server #45360) Fixes Bun.serve:server.timeout(request, seconds)is inert on a unix-socket listener — the same code works on a TCP listener #43816."/*"route that names only some methods, every other method now falls through tofetch(or the 404). (Bun.serve: register the default handler on "/*" for every method #45397, in part)development: false, an HTML route that fails to bundle now prints the bundler's error to stderr once. (Print an html route's bundle error to stderr in production too #45580) Fixes Bun.serve withdevelopment: falsenever reports why an HTML route failed to bundle #45238.server.timeout(req, n)now holds for aBun.file()response body over HTTP/1, TLS and HTTP/2. (Bun.serve: apply server.timeout(req, n) to a Bun.file response body #45788)fetch()Response holds the upstream request open, andfetch()stops after 256 requests #43970. Fixes Bun.serve accepts HTTP/1.1 requests with more than one Host header instead of answering 400 #44232.fetch,Request,Response,Headersfetchkeeps one table of its in-flight requests for HTTP/1.1, HTTP/2 and HTTP/3, and requests with the sametlsoptions share one TLS context through a CONNECT tunnel and over HTTP/3.new Request()reads its arguments by the rulefetch()uses, andtext(),json(),bytes(),arrayBuffer(),blob()andformData()read a body through one function.fetch()follows a relative redirect whoseLocationhas://in its path, query or fragment, like/login?next=https://app.example.com, because everyLocationis now resolved the waynew URL(location, currentURL)does. (http: don't treat relative redirect URLs containing '://' as absolute #30065)type === "basic"and aurlwithout the#fragment, including after a redirect. (fetch: set Response.type and exclude URL fragments per the fetch spec #33499)formData()on Request, Response and Blob keeps a leading UTF-8 BOM in urlencoded names and multipart string values, like Node and browsers. (fetch: preserve a leading UTF-8 BOM in formData() #33631)fetch()of adata:URL follows the Fetch standard: unpadded or line-wrapped base64 decodes,;BASE64is recognized,Content-Typeis the URL's own MIME type, it stays readable after the body is consumed, and a bad URL rejects with a TypeError. (fetch: data: URL Content-Type and fragment per the Fetch standard #34896)fetch()treats a 204 or 304 response as complete at its header block even if it carriesTransfer-Encoding: chunked. (http: ignore Transfer-Encoding: chunked on 1xx/204/304; remove force_last_modified #34919, in part)Response.redirect(url, { headers })is a 302 again instead of a 200 with a Location header, and a string status such as"307"is honored instead of becoming 302. (Response.redirect: coerce status via WebIDL ToNumber and fix {status:200} bypass #35222, in part)request.formData()now gives an uploaded file thetypethe client sent. (Bun.serve, FormData: stop content-sniffing typeless Blob bodies into image/* types #35244, in part) Fixes multipart/form-data: File type is empty for WebP uploads #33012.new Request(request, init)andnew Request(url, response)copy an empty body instead of sharing the input's stream, so both can be read. (webcore: new Request(request) transfers the body; a used or locked body throws in every copy #35858, in part)Headers.prototype.getSetCookieandgetAllthrow a TypeError whenthisis not a Headers, instead of returning undefined. (Headers: add brand checks to getSetCookie()/getAll()/count #36000, in part)fetch()andnode:httprequests send every header. (fetch: send every request header instead of silently dropping past the 250th #36433)fetch("file:///missing")rejects with a TypeError (code: "ENOENT"), and a directory rejects with EISDIR, instead of resolving withstatus: 200, ok: trueand failing when the body is read. (fetch: reject file: URLs whose path cannot be read #37425)fetch()with aBun.file()body that can't be read (missing file, directory, closed descriptor) rejects with a TypeError, like its other failures. (fetch: reject an unreadable Bun.file() request body with a TypeError #37501)fetch(url, { body: Bun.file(fd) })no longer leaks one C runtime file descriptor per upload. (fetch: release the CRT fd behind a Bun.file(fd) request body on Windows #37509)fetch()with a FormData body that holds aBun.file()that can't be read rejects with a TypeError, like a bareBun.file()body (fetch: reject an unreadable Bun.file() request body with a TypeError #37501). (fetch: reject a FormData body whose Bun.file() cannot be read with a TypeError #37514)fetch()no longer fails withECONNRESETwhen a POST or PATCH gets a 307/308 and the server closes the connection right after it withoutConnection: close. (fetch: only pool a redirecting connection for idempotent hops; derive the pool key in one place #37522, in part)Response.redirect("")has a readable emptyLocationheader:headers.get("location")is""and iteration andnew Headers(h)include it. (Response.redirect(""): store an empty Location value instead of a null one #37893)fetch(url, { unix })follows a same-origin redirect (a relativeLocation, or an absolute one on the same host and port) over the unix socket. (fetch: follow same-origin redirects over the unix socket #38394)fetch()re-sends aBun.file()body of 32 KiB or more when it follows a 307 or 308 (or a 301/302 for PUT, PATCH, DELETE). (fetch: re-send a Bun.file() body when following a redirect #38406)verbose: true,BUN_CONFIG_VERBOSE_FETCH,bun install --verbose) prints>in front of request lines again, and the dim[fetch]tag on a color terminal, as the docs show. (http: restore the "> " and "[fetch] " prefixes on verbose trace request lines #38691)fetch()body that is a pipe, FIFO or socket (body: Bun.stdin,Bun.file("/dev/stdin"), or such a file inside a FormData) uploads every byte. (fetch: stream a Bun.file() body that is a FIFO, socket, or device instead of reading it on the JS thread #41488, in part)new Request()with no argument,undefined,null, a number, a boolean, a symbol,""or an object without aurlthrows a TypeError instead of a plain Error, like an invalid URL already did. (Throw TypeError from the Request constructor for a missing or empty url #41551)curlcommand thatverbose: "curl"andBUN_CONFIG_VERBOSE_FETCH=curlprint can be pasted into bash or zsh as is. (fetch, audit: write the verbose curl line and the audit fix --ignore line as shell words #41726, in part)fetch()sends the body's Content-Type for an emptyURLSearchParams, typedBloborFile, also throughfetch(new Request(...)). (fetch: send the body's Content-Type for a zero-length typed body #41752)fetch(url, { unix })rejects with a TypeError whenunixis a Buffer, URL, number,trueor any other truthy non-string, instead of ignoring it and sending the request to the URL's host over TCP. (fetch: reject a non-string unix option instead of dialing the URL host over TCP #41786)Response.redirect(url, {})is a 302), andnew Request(url, { status: 0 })no longer throws since Request: take a method from RequestInit only when it has one #41994. (Response.redirect/json: check status the same way on the number and ResponseInit forms #41841, in part)fetch()no longer uploads aBun.file()body withsendfile(2). (fetch: stop using sendfile(2) for file request bodies on macOS #41905)new Request(input, init)keeps the input's method wheninithas nomethod, whatever kind of objectinputandinitare. (Request: take a method from RequestInit only when it has one #41994) Fixes new Request(input, init) drops the input's method when input is not a native Request #42759.BUN_CONFIG_HTTP_IDLE_TIMEOUT. (fetch: raise the keep-alive pool to 128 idle connections and document the bound #42617, in part)headersvalue that isn't an object (new Headers(1),fetch(url, { headers: "a: b" }),new Response("", { headers: true })) throws Node's ERR_INVALID_ARG_TYPE instead ofTypeError: Type error. (Name the argument or option in HeadersInit conversion errors #42961, in part)Headerssubclass or instance with its ownSymbol.iteratoris converted through that iterator wherever a HeadersInit is accepted, fromnew Headers(h)toserver.upgrade(). (HeadersInit: honor an overridden Symbol.iterator on a Headers object #43023)fetch.preconnect("https://bun.com")andbun --fetch-preconnect https://bun.comwork. (fetch: accept a preconnect URL that has no port #43034) Fixes preconnect URL must have a valid port: #19626.fetch()accepts an HTTP/1.1 response with up to 1000 header fields. (fetch: accept responses with up to 1000 header fields #43181, in part)fetch(undefined),fetch(123)andfetch({ toString() { return "/path" } })reject withfetch() URL is invalidinstead of the misleadingfetch() URL must not be a blank string. (fix(fetch): clearer errors for non-string / host-less URLs #43389, in part) Fixes Misleading error message "URL must not be a blank string" when URL is a non-string value which does not stringify to a fully valid URL with a hostname #21361. Thanks to @TastyHeadphones.fetch(url, { body: Bun.file(path).slice(a, b) })uploads the whole slice of a procfs file, a device or a pipe. (fetch: read a Bun.file() body with st_size 0 to EOF, up to the JS size limit #43964, in part)fetch(),new Request()andserver.fetch()keep a method that isn't in Bun's method table. (fetch, Request, server.fetch: stop sending GET for a method outside the method table #44718, in part)fetch()no longer leaks a file descriptor when the body is an empty window at offset 0 of a file of 32 KiB or more (Bun.file(path).slice(0, 0), or aBun.file()handle whose size was read as 0 before the file was written). (fetch: send an empty Bun.file() slice without sendfile and close its file #44896)text()andjson()drop one leading UTF-8 BOM for every kind of body. (fetch, streams: drop one leading UTF-8 BOM in text() and json() of string and stream bodies #45486, in part)fetch()honors the server'sKeep-Alive: timeout=Nheader: it stops reusing an idle connection 2 seconds before the timeout runs out, or halfway through it when N is under 4, so aPOSTno longer fails withECONNRESETbecause it raced the server's idle close. (fetch: stop reusing a pooled keep-alive connection before the origin closes it #45497, in part)response.headersandrequest.headerskeep theContent-Typeof a typed body (Blob,Bun.file(),URLSearchParams,FormDatawith its boundary) when they are first read after.body, after a reader such as.text(), or afterfetch(request). (Keep the body's Content-Type on Response and Request after the body is consumed #45601) Fixes Implicit Content-Type (e.g. FormData boundary) is null if headers are first read after the body is consumed #45245.Content-Encoding: gzipbody with junk after its last member decodes the same way however the bytes are split across socket reads. (http: judge the byte after a gzip member once #45715)Response.clone()shares chunk buffers between the original and the clone #45782.fetch()and static or file routes no longer send an emptyContent-Type:header for a Blob whosetypeis"", such asfile.slice(0, n)or an S3 file withouttype. (Blob: treat an empty type as no Content-Type in fetch and Bun.serve routes #46194)--experimental-http2-fetch, a request the server refuses withHTTP_1_1_REQUIREDis sent again over HTTP/1.1 instead of rejecting withHTTP2StreamResetorHTTP2GoAway. (http(h2): send the request again over HTTP/1.1 after HTTP_1_1_REQUIRED #46259)Response.json()decides by whatJSON.stringifyreturns, like the spec and Node. (Response.json: throw TypeError when JSON.stringify returns undefined #46397)res.statusof large responses no longer stalls inawait fetch()for about 15 seconds. (fetch: tell the collector about response body bytes that nothing reads #46434, in part)Other
Bun.gunzipSync()decodes every member of a concatenated gzip stream (cat a.gz b.gz, BGZF, WARC), with bothlibrary: "zlib"and"libdeflate". (zlib: decode all members of a concatenated gzip stream, not just the first #31522, in part)response.clone()andrequest.clone()are typed as Bun'sResponseandRequestin a project withoutlib.dom. (types: preserve Bun types on Response.clone() and Request.clone() #42672) Fixes Fetch and Response.clone() type errors #41860. Thanks to @davidhevn.Bun.serve({ unix, idleTimeout })type-checks. (bun-types: accept idleTimeout, http2 and http1 with a unix socket listener in Bun.serve #46341) Fixes Types:idleTimeoutis missing fromUnixServeOptions, though Bun.serve applies it on a unix socket #46138.Tests, docs and types
Deflakes, faster test files, new coverage, docs and type fixes: #24276, #31354, #32755, #33787, #34833, #35419, #35424, #35744, #35921, #35929, #36153, #36154, #36207, #36376, #36444, #36890, #36945, #36946, #37209, #37433, #37439, #37475, #37806, #37809, #37818, #37832, #37910, #37913, #37935, #38470, #38490, #39042, #39068, #39903, #39948, #39950, #39951, #40083, #40088, #40090, #40197, #40297, #40451, #40453, #40539, #40890, #41111, #41336, #41427, #41919, #41951, #42156, #42206, #42255, #42985, #43627, #44069, #44076, #44080, #44113, #44203, #44256, #44308, #44423, #44427, #44927, #44978, #44979, #45428, #45773, #45953, #46027.
How did you verify your code works?
main. A few tests can't fail on Linux (macOS and Windows fixes), and a few pass on both builds because they guard what the second pass could have broken.tscon the three projects and the check of the published types against the schemas pass.node:*changes, the same script prints the same on Node 26.12.1 and on the branch, apart from the differences listed in the behavior changes. The shims were compared with undici 6.29.0 / 7.30.0 and node-fetch 2 / 3 under Node in the same way. The Node build used has nonode:quic, so Node's answers there come from its source and from the original PRs' runs.main. During it, each group of changes against the build before it: the plainreq.socket236 files (227 pass, and the other 9 also fail onmainor need Docker, the internet or a non-root user),node:httpresponses 108 files (6,261 tests pass, 3 failed: one is fixed since, one belongs to #46522, one needs a non-root user),Bun.serve106 files (4,845 pass, 1 needs a non-root user), bodies 121 files (7,553 pass, 2 fail: that one, and a 10 s limit on a loaded machine),fetchand the shims 112 files (6,897 tests, 2 failed and are fixed),node:http213 files (1,232 pass).test-http-*andtest-https-*files (the other 2 fail onmaintoo), 257 of 259 http2 files (the other 2 pass when run separately), 305 of 311test-quic-*andtest-dgram-*files (6 failed before it too, 5 of them the same).Location, 83 equal Node and 7 are as on 1.4.4.Bun.serve(header order, letter case and theContent-Encodingchange differ, plus 2 rows by timing), 2,870 rows ofBun.serveoptions (only an internal key differs), 44 failed listens (all 22node:*rows equal Node, none did on 1.4.4), 1,440 rows ofnode:httpresponse bytes (450 changed, all to Node's), 798TLSSocketmembers of an httpsreq.socket(all equal atls.Serversocket's), 308 proxy shapes (78 lose a falseContent-Encoding, none a true one), 7,104node:http2teardown cases (2 differ, listed under #43433), 35 ways to end a server under LeakSanitizer (no leak).stop()that waited 16 s for a client.node:httpconnection costs the same 8 calls as before the pass, and 800Bun.servekeep-alive requests the samesendto,recvfromandepoll_pwait2counts.mainagainst a copy of itself as the noise control (typically 1 to 3 %).Bun.serveover HTTP/1.1 andnode:httphello world were within noise.fetchkeep-alive (plain, TLS, and withKeep-Alive: timeout=5) was within 0.7 % ofmainin cycles per request, with 2 to 4 % fewer user instructions. Slower: anode:http2server with 100 streams (1 to 2.5 % more cycles) and anode:http2client downloading 1 MB bodies (4 to 5 %).require("node:http")took 1 % longer and the binary grew by 418 KB.node:http2work: a server's 300,000 GETs take 4.0 % fewer user instructions thanmain(it was 3.7 % more), and a client's 1 GB download 0.3 % more cycles (it was 5.0 %). uWS by itself, compiled with release-like flags on stubbed sockets, in instructions per request: hello world 3,537 to 2,901, 16 headers 8,959 to 4,912, a static route 3,996 to 3,134, a file 3,875 to 2,786.node:httpresponse that has to wait for its turn (a pipelined request with an async handler) takes about twice the instructions. Without pipelining, or with a synchronous answer, it's the same or cheaper.sendmmsg, the named pipe's listen error, and #46434's collection on Windows, whose test is skipped there. The HTTP/3 stream reset has no test here, because no lsquic peer can send that stream. It was checked against another QUIC implementation.