Skip to content

http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests - #46528

Open
Jarred-Sumner wants to merge 33 commits into
mainfrom
claude/http-consolidated
Open

Jarred-Sumner wants to merge 33 commits into
mainfrom
claude/http-consolidated

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

In progress. The lists below describe the branch as it is now. A few more fixes from the final round of verification are still coming, so a number in this description can lag a push behind. CI still has failing tests on the Windows and sanitizer lanes, and they're being fixed.

Consolidates the open pull requests for fetch, Request / Response / Headers, Bun.serve, node:http, node:http2, HTTP/3, node:quic and the undici / node-fetch shims 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 main when the work started. For node:* 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/ and packages/, a net 8,716 fewer. In the diff, req.socket is a plain net.Socket / tls.TLSSocket. node:http has one implementation per kind of connection (uWS for listen(), a port of Node's own connection code for every other stream) and one queue of waiting responses. node:http2 has one stream table and shares its wire types with fetch's HTTP/2 client, fetch has one table of in-flight requests, and Bun.serve, fetch and node:quic share 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.serve

fetch, Request, Response

node:http

node:http2

HTTP/3 and node:quic

undici and node-fetch

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, and req.socket.remoteAddress = x sticks.
  • node:http: a connection that closes under an ended response's last write still emits 'finish'. On listen() connections res.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-chunked Transfer-Encoding is sent with Connection: close.
  • node:http2: without an 'error' listener, a late write() on a response that respond() 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:quic

HTTP/3

node:http2

undici and node-fetch shims

node:http and node:https

Bun.serve

fetch, Request, Response, Headers

Other

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?

  • Each fix has a test that fails on 1.4.4 and passes on the branch. Bugs only a sanitizer sees were checked against a debug + ASAN build of 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.
  • Not done yet: CI isn't green on the final state yet: every static check passes, and the Windows and sanitizer lanes still have failing tests. There's no release benchmark of the final state. The source lints, tsc on the three projects and the check of the published types against the schemas pass.
  • For 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 no node:quic, so Node's answers there come from its source and from the original PRs' runs.
  • Test files, compared by test name on debug + ASAN builds. Before the second pass, about 1,400 files against main. During it, each group of changes against the build before it: the plain req.socket 236 files (227 pass, and the other 9 also fail on main or need Docker, the internet or a non-root user), node:http responses 108 files (6,261 tests pass, 3 failed: one is fixed since, one belongs to #46522, one needs a non-root user), Bun.serve 106 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), fetch and the shims 112 files (6,897 tests, 2 failed and are fixed), node:http2 13 files (1,232 pass).
  • Node's own tests during the second pass: 465 of 467 test-http-* and test-https-* files (the other 2 fail on main too), 257 of 259 http2 files (the other 2 pass when run separately), 305 of 311 test-quic-* and test-dgram-* files (6 failed before it too, 5 of them the same).
  • HTTP client: 2,406 scripted rows differ in 3 from the build before the pass (one connection saved, two races). 1,200 random response streams: no outcome differs. 1,650 random redirect chains: none differs from that build or from Node. Of 90 shapes of Location, 83 equal Node and 7 are as on 1.4.4.
  • Request parsing: the old and the new parser in one ASAN + UBSAN binary, 6,000 request streams split at every byte offset, 1.74 million runs: no difference. 8,000 trailer sections against an independent strict parser, 896,000 runs: no mismatch.
  • Tables: 1,116 raw requests to Bun.serve (header order, letter case and the Content-Encoding change differ, plus 2 rows by timing), 2,870 rows of Bun.serve options (only an internal key differs), 44 failed listens (all 22 node:* rows equal Node, none did on 1.4.4), 1,440 rows of node:http response bytes (450 changed, all to Node's), 798 TLSSocket members of an https req.socket (all equal a tls.Server socket's), 308 proxy shapes (78 lose a false Content-Encoding, none a true one), 7,104 node:http2 teardown cases (2 differ, listed under #43433), 35 ways to end a server under LeakSanitizer (no leak).
  • Random programs: before the second pass, 8,830 programs (24,228 runs on 1.4.4, Node and the branch) found six unintended differences, all fixed. During it, each group reran the families it touches, 240 to 1,300 programs each: no crash, sanitizer report or new hang. What they found is fixed, for example a graceful stop() that waited 16 s for a client.
  • The 1,077 identifiers the pass had deleted when #46522 was merged in were searched for in every tracked file, the exported ones also in the prebuilt WebKit and the vendored libraries. One deletion broke the link and was repaired.
  • Syscalls (strace): a node:http connection costs the same 8 calls as before the pass, and 800 Bun.serve keep-alive requests the same sendto, recvfrom and epoll_pwait2 counts.
  • Speed, on CI's release builds from before the second pass, with main against a copy of itself as the noise control (typically 1 to 3 %). Bun.serve over HTTP/1.1 and node:http hello world were within noise. fetch keep-alive (plain, TLS, and with Keep-Alive: timeout=5) was within 0.7 % of main in cycles per request, with 2 to 4 % fewer user instructions. Slower: a node:http2 server with 100 streams (1 to 2.5 % more cycles) and a node:http2 client downloading 1 MB bodies (4 to 5 %). require("node:http") took 1 % longer and the binary grew by 418 KB.
  • Speed during the pass. On CI's profile build with about a third of the node:http2 work: a server's 300,000 GETs take 4.0 % fewer user instructions than main (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.
  • A known cost, not measured on a release build: a node:http response 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.
  • Same results before and after the pass: http-proxy, socket.io, supertest, koa, got, svelte, vite 6.2's dev server with HMR, hono, express, body-parser, @fastify/websocket. elysia, grpc-js and http2-wrapper fail what they failed before it, plus one elysia test that sleeps 3 ms. Before the pass, fastify, ws, axios, superagent and miniflare matched 1.4.4 too.
  • Windows, macOS and FreeBSD code is compiled and run by CI only: the half-close under kqueue, a socket's hand-off on libuv, the QUIC timer and the send loop without 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.

@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner October 22, 2026 15:47
@Jarred-Sumner

Jarred-Sumner commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

All behavior changes, part 1: Bun.serve, fetch and the shims

Programs that ran without an error on 1.4.4 and behave differently now. Part 2 (node:http, node:http2, HTTP/3, node:quic) is in the next comment.

undici and node-fetch shims

  • 1.4.4 -> now. pool.request() / client.request(): returned undefined -> a promise (or calls the callback). agent.close() / destroy(): TypeError, not a function -> resolve null. new Pool() / new Client() without a valid http(s) origin: constructed -> throw InvalidArgumentError like undici. stream(): threw 'not yet implemented' -> works. request(): a response that fetch decoded no longer reports content-encoding and content-length; trailers is {} instead of undefined; a UrlObject works instead of throwing 'not implemented'; a signal that isn't an AbortSignal is accepted if it's an EventEmitter or EventTarget, else InvalidArgumentError instead of Error; a header whose value is undefined is skipped; a URL object passed with query is no longer modified. getGlobalDispatcher() returns an Agent. new Agent({ connect }) validates the TLS options in connect when it's constructed. request(url, { headers }) still takes an array of [name, value] pairs, as on 1.4.4, next to undici's flat array. (feat(undici): implement Pool, Client, Agent, and stream() #27338, in part)
  • 1.4.4 ignored ProxyAgent and sent requests directly. Now they go through the proxy, and fail if the proxy is down. new ProxyAgent() without a uri, or with both auth and token, throws InvalidArgumentError like undici (1.4.4 constructed a no-op). fetch(url, { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) }) now really skips verification, and ca / cert / key / servername apply (1.4.4 ignored them). undici.fetch is a thin wrapper and no longer === Bun.fetch. An explicit proxy / tls / unix in the fetch() init still wins over the dispatcher's. With an https: proxy, requestTls and proxyTls are both ignored and both hops are verified against the default trust store, so an https proxy or a target with a private CA rejects with a certificate error (1.4.4 ignored the ProxyAgent and connected directly). With an http: proxy requestTls applies to the target. (undici: wire ProxyAgent/setGlobalDispatcher/{dispatcher} to native fetch proxy #35145, in part)
  • 1.4.4 ignored a dispatcher's dispatch(). A dispatcher with its own dispatch() (a subclass of Dispatcher, or of Agent / Pool / Client that overrides it), passed as dispatcher or set with setGlobalDispatcher(), now receives every undici.fetch() / request() / stream() call. The Response of such a fetch() is built in JS: redirected is always false, url is the request URL, and Bun's own init options (proxy, tls, unix, verbose, decompress) aren't forwarded. Dispatchers without an override keep the native path. A BalancedPool or a bare new Dispatcher() as the dispatcher is still ignored, as on 1.4.4. (undici: implement dispatch(), close() and destroy() on the shim dispatchers #39250, in part)
  • 1.4.4: every undici error had name "Error", no code and an empty default message. (fix(node): preserve undici timeout error metadata #42716)
  • 1.4.4 returned "error" for every non-2xx node-fetch Response, now "default" like node-fetch 2 and 3. (node-fetch: report Response.type as default for an HTTP error status #43966)
  • 1.4.4: new FetchError().name was "Error" and it always had an own code. Now "FetchError", and code only with a system error. (node-fetch: name FetchError and AbortError like node-fetch #43967, in part)
  • 1.4.4 returned a plain web Response from these three statics (body was a web ReadableStream), now a node-fetch Response (body is a node Readable). (node-fetch: return a node-fetch Response from Response.json(), redirect() and error() #43968)
  • 1.4.4 rejected node-fetch's fetch() with the native TypeError (message "ECONNREFUSED: Unable to connect..."). Now a FetchError that is not a TypeError, message "request to failed, reason: ", errno is the code string, no path. err.code is unchanged. A default abort rejects with a new AbortError (still a DOMException named AbortError) instead of signal.reason itself. Native fetch(): a refused connection's error has a new syscall: "connect" property, which shows up when the error is printed. (node-fetch: reject fetch() and body reads with AbortError and FetchError #44297, in part)
  • 1.4.4 sent GET for method: true / 123 / {} / "GET POST" / " GET", and sent CONNECT for "CONNECT" and "connect". All of these now reject with InvalidArgumentError (UND_ERR_INVALID_ARG). undici rejects them too, except lower-case "connect", which undici sends as written and the shim would upper-case to CONNECT. (undici.request: reject a method that is not a string, not an HTTP token, or CONNECT #44814, in part)

Bun.serve

fetch, Request, Response, Headers

  • 1.4.4: (await fetch(u)).type was "default", now "basic". response.url kept #frag, now drops it. new Response(null, {status: 101}).type was "error", now "default". data: URLs now go through the URL parser first, as in Node: the fragment is not part of the body, tabs and newlines in the URL are removed (data:text/plain,a\nb is ab, was a\nb), non-ASCII is percent-encoded in response.url, and DATA: works. (fetch: set Response.type and exclude URL fragments per the fetch spec #33499)
  • 1.4.4 dropped a leading BOM: a urlencoded body \uFEFFhello=world gave get('hello') === 'world'. Now the key is \uFEFFhello, and a multipart value \uFEFFbom keeps its BOM. (fetch: preserve a leading UTF-8 BOM in formData() #33631)
  • 1.4.4: data:text/plain,x had Content-Type and blob().type text/plain;charset=utf-8, now text/plain; data:application/json,{} had application/json;charset=utf-8, now application/json; data:,x had no Content-Type header, now text/plain;charset=US-ASCII. data:text/plain;BASE64,WA== returned the text WA==, now decodes to X (the marker also for import of a data: URL and the bundler). The rejection for a bad data: URL is a TypeError with code ERR_INVALID_URL, was a plain Error; same message. fetch() of base64 in the URL-safe alphabet (data:text/plain;base64,_-8=) resolved on 1.4.4 and rejects now, like Node and browsers. import and the bundler decode base64 exactly as on 1.4.4, URL-safe alphabet included. (fetch: data: URL Content-Type and fragment per the Fetch standard #34896)
  • 1.4.4 read 5\r\nhello\r\n0\r\n\r\n after a 204/304 head as a chunked body, threw it away and reused the connection. Now those bytes count as trailing junk, so the connection is closed and the next fetch opens a new one. A lone late 0\r\n\r\n on the idle pooled socket is still ignored. (http: ignore Transfer-Encoding: chunked on 1xx/204/304; remove force_last_modified #34919, in part)
  • 1.4.4: Response.redirect(u, {}) and { headers } had status 200, now 302. Response.redirect(u, "307") was 302, now 307. Nothing that returned a Response on 1.4.4 throws now: "abc", "9999", "404", "", true and false are still a 302, { status: 200 } and new Response(null) as the init are still a 200. (Response.redirect: coerce status via WebIDL ToNumber and fix {status:200} bypass #35222, in part)
  • 1.4.4 derived File.type from the filename extension or the first bytes whenever Content-Type came after Content-Disposition, which is the order every client uses. Now it's the part's Content-Type, as in Node and browsers: a.txt sent as text/plain gives text/plain (was text/plain;charset=utf-8), a.json sent as text/plain gives text/plain (was application/json;charset=utf-8), and a typeless Blob that round-trips through new Response(formData).formData() gives application/octet-stream (was empty). A malformed header line after Content-Disposition now rejects formData(). (Bun.serve, FormData: stop content-sniffing typeless Blob bodies into image/* types #35244, in part)
  • 1.4.4: after const copy = new Request(source, {}) with source built from body: "" or a zero-length view, copy.body === source.body, and reading both rejected with Body already used. Now each has its own empty body. Node moves the body, so there the source is the one that rejects. (webcore: new Request(request) transfers the body; a used or locked body throws in every copy #35858, in part)
  • 1.4.4: Headers.prototype.getSetCookie.call({}) returned undefined. Now it throws TypeError. (Headers: add brand checks to getSetCookie()/getAll()/count #36000, in part)
  • 1.4.4 sent 250 of 300 headers and got a 200. Now all 300 go out, so a server with a field limit can answer 431 (Bun.serve caps at 200 fields, Node at 2000). (fetch: send every request header instead of silently dropping past the 250th #36433)
  • 1.4.4: await fetch(fileUrlOfMissingFile) resolved with a 200 Response and .text() rejected with ENOENT. Now fetch() itself rejects, so a try/catch around only the body read no longer catches it. The rejection has syscall: "stat". The body read on 1.4.4 failed with syscall: "open". (fetch: reject file: URLs whose path cannot be read #37425)
  • Windows only: error.path and the message of a Bun.file() body that can't be opened no longer start with \\?\ (2e7317b3da). 1.4.4 reported \\?\C:\dir\missing.txt, now C:\dir\missing.txt, like .text(), a FormData part and node:fs. (fetch: reject an unreadable Bun.file() request body with a TypeError #37501)
  • 1.4.4 sent a POST/PATCH 307/308 hop on the connection that carried the 3xx, now it closes that connection and dials. GET/HEAD/PUT/DELETE hops still reuse it. The rule is by idempotency, so PROPFIND, REPORT, LOCK and every method outside Bun's table also dial on a hop that keeps the method, and so does a hop that re-sends a Bun.file() body. (fetch: only pool a redirecting connection for idempotent hops; derive the pool key in one place #37522, in part)
  • 1.4.4: Response.redirect("").headers.get("location") was null, now "". The bytes on the wire don't change (an empty Location: was already sent). A static route with Response.redirect("") now sends an empty Location: line. (Response.redirect(""): store an empty Location value instead of a null one #37893)
  • 1.4.4 followed a same-origin redirect from a unix-socket server over TCP, now it stays on the socket. A cross-origin Location still leaves the socket and drops credentials, as before. (fetch: follow same-origin redirects over the unix socket #38394)
  • 1.4.4 followed the redirect with an empty body, now it uploads the file again. A Bun.file() that isn't a regular file but reports 32 KiB or more (in practice only a big directory) takes the read path on Linux too, and still rejects. (fetch: re-send a Bun.file() body when following a redirect #38406)
  • 1.4.4 printed HTTP/1.1 GET http://... and Accept: */* on stderr, now > HTTP/1.1 GET http://... and > Accept: */*. (http: restore the "> " and "[fetch] " prefixes on verbose trace request lines #38691)
  • 1.4.4 sent the first 262,160 bytes of an endless source (Bun.file("/dev/zero"), /dev/urandom, yes | bun upload.js) and resolved. Now the read goes on, on the JS thread, until the JS buffer limit (4 GiB) and the fetch rejects with ENOMEM (5.9 s on the debug build with a 12 GB cap). Under a memory limit below about 4 GiB the process is OOM-killed first (measured with a 3 GB cgroup), same as fs.readFileSync("/dev/zero"). (fetch: stream a Bun.file() body that is a FIFO, socket, or device instead of reading it on the JS thread #41488, in part)
  • On macOS and Linux the curl line's arguments are in '...' instead of "...", and the body is printed raw instead of as a JSON string literal. Windows output is unchanged, because cmd.exe has no single quotes. (fetch, audit: write the verbose curl line and the audit fix --ignore line as shell words #41726, in part)
  • 1.4.4: fetch(url, { method: "POST", body: new URLSearchParams() }) had no Content-Type, now application/x-www-form-urlencoded;charset=UTF-8. A GET with an empty typed body (Node rejects it, Bun accepts it) now carries that Content-Type too. (fetch: send the body's Content-Type for a zero-length typed body #41752)
  • 1.4.4 ignored a non-string unix and connected to the URL's host over TCP. Now it rejects with fetch: 'unix' must be a string (ERR_INVALID_ARG_TYPE). "", null, undefined, false and 0 still mean no unix socket. (fetch: reject a non-string unix option instead of dialing the URL host over TCP #41786)
  • 1.4.4: new Request(put, init) was a GET when init was a Proxy, array, function, Headers, URL, Blob or Response, or when input was not a native Request; now it keeps the input's method. new Request(url, { status: 0 }) threw RangeError, now constructs (status and statusText are no longer read from a RequestInit). new Request(url, new Response(null, { method: "DELETE" })).method was DELETE, now GET. An init.method getter is read once, not twice. (Request: take a method from RequestInit only when it has one #41994)
  • 1.4.4 pooled a connection that sent bytes after the last chunk of a chunked response. Now it closes it and the next fetch dials a new one, which costs a broken server's clients one connection per request. Unchanged from 1.4.4: exactly one extra \r\n, or a repeated 0\r\n\r\n, in the same read as the last chunk still pools, and any byte but a late 0\r\n\r\n on an idle pooled connection still closes it. Bytes after the last chunk of a response now close the connection instead of pooling it. That includes a lone extra CRLF, which 1.4.4 and Node tolerate. The cost is one more dial for the next request. (fetch: never pool an HTTP/1.1 connection that received bytes past the end of a response #42091, in part)
  • 1.4.4 copied the native header list and never looked at Symbol.iterator. A Headers object whose iterator was overridden (subclass, own property, patched Headers.prototype[Symbol.iterator]) now contributes what the iterator yields, with lowercase names on the wire, and throws if it yields something other than pairs. A plain Headers or a subclass that doesn't touch the iterator is still copied, name case included. (HeadersInit: honor an overridden Symbol.iterator on a Headers object #43023)
  • 1.4.4: fetch.preconnect("s3://bucket:9000/key") returned and dialed bucket:9000 as a plain TCP host. Now it throws URL must be HTTP or HTTPS, like --fetch-preconnect. s3://bucket/key threw before (Invalid port) and still throws. (fetch: accept a preconnect URL that has no port #43034)
  • 1.4.4 delivered res.status 99, 20 or 1000 over HTTP/3 for such a :status. Now the request fails with HTTP3ProtocolError. (fetch: treat a :status of 101 as malformed over HTTP/2 and HTTP/3 #43618)
  • 1.4.4 uploaded the first 262,160 bytes of /proc/self/pagemap, /dev/zero or any slice of a device longer than that. Now a slice is uploaded whole, and a whole endless file reads up to 4 GiB on the JS thread and then rejects with ENOMEM, as fs.readFileSync does. (fetch: read a Bun.file() body with st_size 0 to EOF, up to the JS size limit #43964, in part)
  • 1.4.4 resolved a damaged gzip/deflate/br/zstd body read through a CONNECT tunnel (50,080 of 108,000 bytes for a cut gzip stream, 0 bytes for garbage). Now the body read rejects. bun install behind a proxy stops on a manifest whose gzip checksum is wrong instead of using it. (http: fail a tunnelled request with the error its callback found #44534)
  • 1.4.4 sent GET for any method string outside its table, now it's sent as written (or normalized for the six Fetch verbs). So: (1) fetch(url, { method: "LIST", body }) rejected with 'GET/HEAD cannot have body', now it's sent. (2) Bun to Bun: a Bun.serve handler saw GET and answered 200, now the server closes the connection (ECONNRESET), because Bun.serve refuses methods outside its table (Custom HTTP methods via Bun.serve() #6556). (3) fetch("s3://...", { method: "Delete" }) was a read and now deletes, "Put" uploads, and a token rejects with ERR_S3_INVALID_METHOD instead of reading. (4) TRACK, Trace, Connect were GET and are now sent as written. (fetch, Request, server.fetch: stop sending GET for a method outside the method table #44718, in part)
  • 1.4.4: await new Response("\uFEFFabc").text() was "\uFEFFabc", now "abc" (bytes(), arrayBuffer() and blob() still have the BOM). The same for text() / json(), stream.text(), Bun.readableStreamToText() and Bun.readableStreamToJSON() over an async iterable, a node Readable or a direct stream whose bytes start with EF BB BF. A program that reads such a body with text() and serves it again no longer passes the BOM through. (fetch, streams: drop one leading UTF-8 BOM in text() and json() of string and stream bodies #45486, in part)
  • 1.4.4 reused an idle connection for up to 300 s whatever Keep-Alive: timeout=N said. Now it's reused for max(N - 2, N / 2) seconds: 0.5 s for N = 1, 1 s for 2, 1.5 s for 3, 2 s for 4, N - 2 from 5 on. Only timeout=0 is never pooled. A FetchSession with idleTimeout: 1 reused a connection for up to 8 s, now 1 s. Servers see a FIN instead of an RST when Bun's idle timer closes a pooled connection. An idle pooled socket is closed 5 to 6 minutes after it went idle (1.4.4: 4 to 5). Reuse still stops at 300 s. (fetch: stop reusing a pooled keep-alive connection before the origin closes it #45497, in part)
  • 1.4.4: the header was null in all these cases. One Bun.serve shape changes: const r = new Response(Bun.file(p).slice(5, 10)); return new Response(r.body, { headers: r.headers }) was a 206 with Content-Range because r.headers was empty after .body; now r.headers has the type, so it counts as handler-supplied headers and is a 200, as it already was when r.headers was read before r.body. After .body was read or the Response was copied, Bun.serve sends the type from Bun.file(path, { type }) instead of the type of the file extension, and writes the header name as Content-Type instead of content-type. A string body has no type in .headers, before or after it is read, as on 1.4.4. (Keep the body's Content-Type on Response and Request after the body is consumed #45601)
  • 1.4.4 (since Response.clone(): share tee chunk refs instead of StructuredClone per branch #35843, July): both bodies handed script the same Uint8Array object, which was also the producer's object. Now each script read on a cloned body or its clone is a new object over a new buffer of exactly the chunk's length, with the same type (a Buffer stays a Buffer). That costs one allocation and one memcpy per chunk per body read by script. PR's numbers for 64 MB in 1,024 chunks with both bodies read by reader.read(): 2.0 to 3.6x slower (release). .text(), .json(), .arrayBuffer(), .bytes(), .blob(), Bun.serve sending the body and clone chains still copy nothing extra. ReadableStream.prototype.tee() still shares the chunk object. A chunk that script reads from a cloned body loses own properties set on the producer's chunk, and tee() of a clone's body hands out two different objects. (Response.clone(): copy a chunk when a body-clone branch hands it to script #46080, in part)
  • 1.4.4 sent Content-Type: with an empty value for these bodies, so a receiver's headers.get('content-type') was "". Now the header is absent (null). (Blob: treat an empty type as no Content-Type in fetch and Bun.serve routes #46194)
  • 1.4.4: Response.json({ toJSON() {} }), a toJSON that returns a function or symbol, a callable Proxy and a built-in constructor such as Date gave 200, Content-Type: application/json and an empty body. Now they throw a TypeError, so a Bun.serve route that returns one answers 500. Response.json(1n) still throws a TypeError, but the message is JSC's JSON.stringify cannot serialize BigInt. instead of V8's Do not know how to serialize a BigInt, the same text Response.json({ n: 1n }) already had. (Response.json: throw TypeError when JSON.stringify returns undefined #46397)

Other

  • 1.4.4 returned the first member and ignored the rest of the input. Now bytes after a member that start with 1f 8b are decoded as the next member, so the result is longer, and a truncated or corrupt later member throws. Any other trailing bytes (zero padding, junk) are still ignored, where Node throws on non-zero junk. (zlib: decode all members of a concatenated gzip stream, not just the first #31522, in part)

@Jarred-Sumner

Jarred-Sumner commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

All behavior changes, part 2: node:http, node:http2, HTTP/3 and node:quic

Programs that ran without an error on 1.4.4 and behave differently now.

node:quic

HTTP/3

node:http2

node:http and node:https

Not included

Left for a maintainer to decide (features, changed defaults, trust decisions, performance trade-offs):

Already fixed on main: #18410, #18798, #19357, #25758, #30943, #31359, #31488, #31520, #31842, #33061, #35018, #35535, #35538, #35713, #35741, #35763, #37445, #37511, #37543, #37782, #37884, #38082, #38124, #38636, #38673, #39639, #39889, #41337, #41338, #41391, #41874, #42197, #42334, #42579, #42905, #43273, #43449, #43461, #43498, #43614, #43632, #44146, #44457, #44751, #44984, #45026, #45380, #45801.

Duplicates of a PR that is included: #31055, #33578, #37600, #37690, #37951, #38109, #39731, #41560, #42410, #43523, #43866, #44015, #44236, #45822, #45875.

Skipped (refactor only, needs a vendor change, Node disagrees, or no real program hits it): #28342, #31058, #31745, #31983, #32020, #32707, #33619, #33877, #35138, #35411, #35522, #35731, #35732, #35762, #35897, #36110, #36563, #37437, #37446, #37547, #37553, #37554, #37558, #37566, #37568, #37588, #37609, #37649, #37676, #37694, #37703, #37706, #37722, #37805, #37875, #37945, #38078, #38181, #38345, #38644, #39262, #40290, #41322, #41446, #41493, #41494, #41502, #41546, #41564, #41607, #42021, #42190, #42278, #42357, #42391, #42397, #42461, #42485, #42858, #42862, #42901, #42907, #42919, #43017, #43041, #43299, #43337, #43395, #43424, #43441, #43465, #43491, #43501, #43526, #43536, #43550, #43554, #43578, #43583, #43585, #43587, #43609, #43617, #43619, #43677, #43744, #44310, #44323, #44338, #44347, #44510, #44654, #44871, #44881, #44930, #45017, #45058, #45098, #45174, #45398, #45399, #45476, #45532, #45603, #45876, #45913, #46009, #46046, #46056, #46137, #46212, #46261, #46380, #46403.

@robobun

robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 4:58 AM PT - Oct 23rd, 2026

@Jarred-Sumner, your commit d132a01 is building: #131252

Comment thread src/js/thirdparty/undici.js Outdated
Comment thread src/js/thirdparty/undici.js Outdated
Comment thread src/resolver/data_url.rs Outdated
Comment thread packages/bun-uws/src/HttpResponse.h Outdated
Comment thread src/http/h3_client/callbacks.rs
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/http/h3_client/AltSvc.rs
Comment thread packages/bun-uws/src/Http3Context.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/stdin via 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::ISREG leaves the sendfile path and the body is read into memory. In the read path src/runtime/node/node_fs.rs:7289 sets size_unknown for any non-regular file and src/runtime/node/node_fs.rs:7290 clears has_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 safeguard should_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_size false, so the growth arm at 7363-7378 keeps extending the Vec until read returns 0. On base the loop stopped at 262,160 bytes. Lines 7291-7292 set limit_size_for_javascript, so node_fs.rs:6879-6899 rejects with ENOMEM once total > synthetic_allocation_limit().

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread packages/bun-uws/src/HttpParser.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/node/http2.ts Outdated
Comment thread packages/bun-uws/src/HttpContext.h
Comment thread src/js/thirdparty/undici.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/http_types/Method.rs
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/http2.ts Outdated
…pair ends, preconnect with the TLS defaults, and start every Bun.listen socket with its timeout again
@bondz

bondz commented Oct 23, 2026 •

Copy link
Copy Markdown

Does this fix #10507 ?

Still reproduces on Bun 1.4.4 (darwin-arm64, and linux-arm64 in oven/bun:1.4.4-slim): a ReadableStream body
returned from Bun.serve with an explicit content-length goes out Transfer-Encoding: chunked and the header is
dropped. Plain and type: "bytes" streams behave the same, while a Uint8Array body keeps the header.

const body = new TextEncoder().encode("x".repeat(100_000));
const headers = { "content-length": String(body.byteLength) };
const stream = () => new ReadableStream({ start(c) { c.enqueue(body); c.close(); } });
Bun.serve({
  port: 3000,
  routes: {
    "/bytes": () => new Response(body, { headers }),
    "/stream": () => new Response(stream(), { headers }),
  },
});
curl -sD - -o /dev/null localhost:3000/bytes   →  Content-Length: 100000
curl -sD - -o /dev/null localhost:3000/stream  →  Transfer-Encoding: chunked

Node 26.12.1 sends the same streamed body (res.writeHead(200, {"content-length": n}), then
Readable.fromWeb(stream).pipe(res)) with content-length: 100000 and no chunking.

#35682 implemented exactly this and was closed in the stale-PR cleanup rather than on its merits. Could it be
picked up again? Our use: streaming a generated file whose size is known, from a descriptor we then clean up, where
clients rely on Content-Length.

Jarred-Sumner and others added 4 commits October 23, 2026 08:58
… 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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/webcore/Request.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread src/runtime/socket/node_http_socket.rs Outdated
Comment thread src/js/thirdparty/undici.js Outdated
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

A case that the _consuming flag in parserOnMessageComplete (src/js/node/_http_common.ts:245) does not cover. I found it while I worked on the same defect: on emit('connection') and allowHTTP1 connections, the response's 'finish' dumps a request body that the listener is reading.

With the flag, the body is kept when the 'request' listener begins to read synchronously. The body is still lost when the listener begins to read in a microtask or in a process.nextTick. An async listener with one await before it reads is the usual form. That form works in 1.4.2 and is broken since 1.4.3.

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();
});
Runtime Output
Node v26.3.0 4096
Bun 1.4.2 4096
main (9634520) 1024
this PR at 525fdb4, release build, Linux x64 1024
this PR, without the await null 4096

Cause. One parser.execute() call parses the whole read, so the EOF is pushed before the microtask runs. At that time nothing is scheduled on the request, and the flag stays false. Node's parser consumes the handle and calls kOnBody through MakeCallback (src/node_http_parser.cc:523). The nextTick queue and the microtasks run after each body chunk, so _read() runs before the EOF.

Extent. I ran a matrix of 4 framings, 6 ways to read and 4 start times (96 cases) on a net.Socket given to emit('connection'). This PR equals Node in 60 cases (main: 51). 15 cases still lose bytes or 'end':

  • 14 are the microtask and nextTick starts of once('data') then pause(), on('data') and pause(), 'readable' and read(n), and pipe() into a slow Writable. Chunked and Content-Length bodies both.
  • 1 is a synchronous start: once('data') then pause() with a Content-Length body. The bytes arrive, but 'end' fires while the request is paused, so an 'end' listener that is added after the answer never runs.

An HTTP/1.1 client of an http2 server with allowHTTP1 loses the body in the same microtask and nextTick cases.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

Comment thread src/js/node/_http_outgoing.ts
Comment thread src/js/thirdparty/undici.js Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/runtime/webcore/Body.rs
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/thirdparty/undici.js
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

I worked on the same Bun.file() redirect loss (#38406) before I saw this PR. I did not open my branch as a PR, because this PR already has the fix. I ran the tests of that branch against a debug build of a919d6b.

What this PR fixes. A grid of 40 cells (5 sizes, http or https target, 307 or 308, Bun.file() or Buffer) arrives whole in 40 of 40 cells. Bun 1.4.3 loses 12. 21 of my 28 tests pass. They include a hop through a CONNECT tunnel and a hop through a TLS proxy.

What still differs. Each item has a test on the branch.

  1. Keep-alive retry. A PUT of a Bun.file() of 32768 bytes on a reused connection that the server drops rejects with ECONNRESET. The same PUT with 32767 bytes is sent again. retries_on_close admits only Bytes (test).
  2. A file that gets shorter during the upload. SendFile::write and write_copy return Done at the end of the file with bytes still owed, so the request counts as sent. With Bun.serve as the peer, the fetch rejects with ECONNRESET after 12 s, when the server times the request out (first hop, redirected hop).
  3. With BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT, a file body that a plain http origin redirects to an origin that speaks only h2 rejects with EPROTO. A Sendfile body keeps that hop on HTTP/1.1 (test).

Are 1 and 2 deliberate? If they are not, I can send each as a small change on top of this branch.

Branch: robobun/e2e008f6/fetch-file-body-replay. Its diff sends the file again from the descriptor that the caller queued: with sendfile(2) on a plain hop, and as a FileReader stream on every other hop.

…handed-off socket as before when its connection fails, and address review comments
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

@robobun on the Bun.file() bodies: thanks for running your tests against this branch. Neither 1 nor 2 was deliberate, and both are older than this PR. Both are fixed in d132a01.

  1. The retry after a dropped keep-alive connection only knew how to replay an in-memory body, so it depended on whether the file was small enough to be read into memory. Now a file body is rewound and sent again under exactly the same conditions as a Buffer (reused connection, idempotent method, nothing of a response body seen). A redirected hop with a file body also reuses its connection now, like any other hop.
  2. An upload whose file becomes shorter while it's being sent used to count as sent and then wait for the server to give up. It now rejects right away with ERR_HTTP_CONTENT_LENGTH_MISMATCH, on the first hop and on a redirected one, which is also what Node does for a body that ends short of its Content-Length.
  3. That one is a known limit of the experimental flag: a body sent from a file descriptor stays on HTTP/1.1. This PR doesn't change it.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/net.ts
Comment thread src/js/node/_http_common.ts
Comment thread src/http/SendFile.rs
Comment on lines 109 to 114
if errcode != bun_sys::E::SUCCESS || self.remain == 0 || val == 0 {
if errcode == bun_sys::E::SUCCESS {
return Status::Done;
return self.at_end_of_file();
}

return Status::Err(bun_errno::from_errno(errcode as i32).into());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing, nit: on Linux a plain-http fetch() with a Bun.file() body of 32 KiB or more fails with EAGAIN when sendfile(2) finds the socket's send buffer full, instead of waiting for writable. In SendFile::write the Linux branch at src/http/SendFile.rs:109-114 treats every non-SUCCESS errno as fatal, while the FreeBSD branch at :139 excludes EAGAIN. Fix: make the Linux branch return Status::Again for EAGAIN, as the FreeBSD branch and the new write_copy path do, so the caller re-arms mark_needs_more_for_sendfile instead of close_and_fail. The base branch had the same Linux block; this PR reworked the function (write_copy, at_end_of_file) and left the gap.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: fetch(url, { method: "PUT", body: Bun.file(path) }) on Linux over http: with a file of at least 32 KiB (src/runtime/webcore/fetch.rs:1326, is_eligible at src/http/SendFile.rs:20-26), where the raw sendfile(2) call at src/http/SendFile.rs:94-99 is made while the kernel send buffer has no room, so it returns -EAGAIN with zero bytes written. The condition at src/http/SendFile.rs:109 is true because errcode != SUCCESS, errcode is not SUCCESS at :110, so :114 returns Status::Err(EAGAIN). The caller at src/http/lib.rs:3565-3567 matches that arm and calls close_and_fail, so the user's fetch() rejects with an EAGAIN error instead of resuming when the socket becomes writable. The FreeBSD branch at src/http/SendFile.rs:139 explicitly excludes EAGAIN and falls through to Status::Again at :152, so only the Linux kernel-sendfile branch fails this way. The base branch (5c6d4e2 src/http/SendFile.rs) had the identical Linux block, so this is pre-existing; the PR rewrote the surrounding function and left it.

Verification: pre-existing (the Linux condition is byte-identical on the base commit). Trigger: on Linux, plain-http fetch with a regular-file body >= 32 KiB when nonblocking sendfile(2) moves zero bytes and returns -EAGAIN. src/http/SendFile.rs:114 returns an EAGAIN error, and src/http/lib.rs:3565-3567 calls close_and_fail, so the fetch rejects instead of hitting the Status::Again arm at :3571-3574.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

@robobun on the request body that a listener starts to read after an await: confirmed, thanks, and fixed in d132a01. On these connections the parser now runs the tick queue and the microtasks where Node's parser does, and the _consuming flag in parserOnMessageComplete is gone. I built the same kind of matrix (4 framings x 6 ways to read x 4 start times, bytes and events). On a net.Socket given to emit('connection') it now equals Node 26.12.1 in all 96 cases. On allowHTTP1 the bytes are equal in all 96, and 27 cases differ only in when the response's 'finish' fires, because a TLS write completes later in Node. Six of the 96 needed two small changes from Node 26.12's IncomingMessage.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment