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

JA
12h ago
+99629-62874

What does this PR do?

In progress. This text can lag a push behind.

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 642 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. 146 PRs are included in part, 48 were already fixed, 15 are duplicates, 113 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 these lists are in comments below: every behavior change with its 1.4.4 behavior next to it (it continues in a second comment), after it the PRs that are not included, and what was added since.

Read these first

These are the changes most likely to be noticed by a program that works today.

Bun.serve

  • A Bun.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. (#36593)
  • HEAD has GET's status and headers for file bodies, so a HEAD health check on a missing file turns from 200 into 404. (#41585)
  • A proxy written as new Response(upstream.body, upstream) no longer forwards Content-Encoding next to a body fetch() already decoded. A body that went through pipeThrough() or the program's own code keeps the header. (#35546)
  • A request that arrives while the previous response is pending now waits and is answered. 1.4.4 closed the connection. (#38128)
  • After a response that closes the connection, the server sends its FIN and waits for the client's, for 10 to 14 s at most. A client that's still uploading gets the response instead of a reset. 1.4.4 closed right away. stop() and the process don't wait for such a connection. (#38128)
  • The head of a streamed response goes out right away, so await fetch() against such a server resolves before the first body byte. A stream that produces its whole body in a later task is Transfer-Encoding: chunked where 1.4.4 sent Content-Length. (#45187)
  • idleTimeout follows 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 for idleTimeout. The rule is the same after every kind of request. (#41602, #44894)
  • idleTimeout applies before the first request too. 1.4.4 used about 10 s there, whatever the option said. (#45788)
  • A response to Connection: close or HTTP/1.0 says Connection: close, and responses without a body carry Date. (#43867)
  • An unknown method gets a 501 instead of a closed socket, and the request head is validated more strictly. (#45396, #44241)
  • A chunked request body can end with a trailer section. 1.4.4 answered 400. (#32887)
  • process.env.NODE_ENV = "production" set before Bun.serve() selects production mode, as the docs say. 1.4.4 only looked at the value at process start. (#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. (#45419, #44874)
  • A body over maxRequestBodySize aborts req.signal, and a response that already started ends in a reset. (#45999)
  • A failed Bun.serve() or Bun.listen() has the kernel's code and a negative errno (-98, where 1.4.4 had 0 and 98). (#44651)

fetch, Request, Response

  • A method outside Bun's table is sent as written. 1.4.4 sent GET for LIST or any other token it did not know. The six Fetch verbs are upper-cased, so fetch("s3://...", { method: "Delete" }) was a read and now deletes. (#44718)
  • text() and json() drop a leading BOM for every kind of body, not only for bytes from the network. (#45486)
  • (await req.blob()).type is the Content-Type header, however the body arrived. On 1.4.4 it was text/plain for a body that hadn't arrived yet and empty for a stream. A formData() that can't be parsed always rejects with a TypeError whose code is ERR_FORMDATA_PARSE_ERROR, where 1.4.4 had four texts. (#45601)
  • Keep-Alive: timeout=N is honored. 1.4.4 reused an idle connection for up to 300 s whatever the server said. (#45497)
  • A response that's complete on the wire gives its connection back before the body is read, and an unread compressed body is decoded only as fast as it's read, on every protocol. (#45612)
  • A redirect's Location is resolved by the URL parser only. A few malformed values now go where new URL(), browsers and Node say. (#30065)
  • response.type is "basic", response.url has no fragment, and data: URLs go through the URL parser. (#33499, #34896)
  • File.type of a multipart part is the part's Content-Type, not a guess from the file name. (#35244)
  • fetch() of a missing file: URL rejects. 1.4.4 resolved with a 200 and the body read rejected. (#37425)
  • Script reads on a cloned body get their own copy of each chunk. Both bodies used to hand out the same Uint8Array. Reading both with reader.read() is 2.0 to 3.6x slower. text(), json(), bytes() and sending the body copy nothing extra. (#46080)
  • A pipe or device as a request body is read to EOF. 1.4.4 stopped after 262,160 bytes. An endless source such as /dev/zero is read up to 4 GiB and then rejects with ENOMEM. (#41488)

node:http

  • req.socket is a plain net.Socket or tls.TLSSocket, and 'upgrade' and 'connect' listeners get that same socket. socket.address() is the local address (1.4.4: the peer's), 'close' gets hadError, encrypted is undefined on a plain socket, _handle.fd is the descriptor, and unref() works after the hand-off.
  • https.Server extends tls.Server, as in Node, so an https server is no instanceof http.Server. OpenTelemetry sees plain http.Server requests again. 1.4.4 lost them. (#46589) Fixes #46567.
  • An https.Server negotiates ALPN. A client that offers only h2 gets the no_application_protocol alert, as in Node. 1.4.4 served it. A failed handshake emits 'clientError' and 'tlsClientError'.
  • headersTimeout decides 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. (#35672)
  • listen({ port, path }) binds the TCP port, also for port: 0, undefined or null, 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. (#41660, #43726, #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. (#40897, #46014)
  • In node:cluster workers listen() asks the primary, so server.address() is null until 'listening', listen(0) gives all workers one port, and worker.disconnect() closes http servers. Not on Windows. (#45186)
  • Listen errors have Node's message and a negative errno. Text that matched Failed to start server no longer matches. code and syscall are unchanged. (#42742, #44651)
  • Connections passed in with emit('connection'), and HTTP/1.1 on an allowHTTP1 server, run a port of Node's own connection code, so they follow Node's rules for Host, backpressure, trailers and the server's timeouts. (#37735, #45841, #46185)
  • A response destroyed before it was sent resets the connection. 1.4.4 sent an empty 200. (#39366)
  • A paused upload whose response was already sent no longer keeps the process alive after server.close(), as in Node. (#46424)

node:http2

  • close() and destroy() put Node's frames on the wire. write(x); close(code) in one tick sends RST_STREAM(code), not DATA with END_STREAM. destroy() sends only RST_STREAM and emits no 'finish'. The session's 'close' comes after its streams' events. A 'frameError' closes the session. (#33380, #42717, #43440, #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. (#43440)
  • A pending request cancelled by destroy() has rstCode 2, as in Node. 1.4.4 had 8, which grpc-js reports as CANCELLED instead of INTERNAL. (#37744)
  • Send limits match Node's: a header block over 64 KiB is refused unless maxSendHeaderBlockLength is raised, connection-specific headers throw from respond() and request(), and respond({ ':status': 102 }) throws. res.setHeader() and res.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). (#43474, #41520, #41614, #43453)
  • An open stream counts 756 bytes toward 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 that its peer opens (Node: the 13,867th). A client's own requests aren't held back, as in Node. (#43419)
  • A session no longer re-emits its socket's 'timeout', and a throw in the connect() listener is an uncaught exception, both as in Node. (#30308, #43492)

HTTP/3 and node:quic

  • An endpoint accepts 100 sessions per IP address unless maxConnectionsPerHost says otherwise, which is Node's default. A load test from one machine hits this. (#35726)
  • Default stream windows are 1 MiB on a server and 6 MiB on a client for every kind of stream. With a 1.4.4 peer on the other end, a server-opened bidirectional stream carries no data unless that peer sets its window. (#46321, #46322)
  • Sessions without cc use Cubic. 1.4.4 picked Cubic or BBRv1 from the first RTT sample. (#37468)
  • 18 new patches to lsquic and lsqpack are in patches/, each described in scripts/build/deps/lsquic.ts and lsqpack.ts. They could live in the forks instead.
  • An unread HTTP/3 body is taken out of lsquic and waits in memory, undecoded, up to one stream window. 1.4.4 left it in lsquic, where it used up the connection's window and was lost when the connection went away. (#45612)

undici and node-fetch

  • Dispatchers work: Agent, Pool, Client and ProxyAgent have request(), stream(), dispatch(), close() and destroy(), each has its own connection pool, and undici.fetch() applies a dispatcher's proxy and connect options. So new Agent({ connect: { rejectUnauthorized: false } }) now really skips verification, and new ProxyAgent() without a URI throws. (#27338, #35145, #39250)
  • node-fetch rejects with FetchError, not the native TypeError. err.code is unchanged. (#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, 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

  • A UDP socket's reads are bounded per event loop turn, as in libuv.
  • One QUIC transport layer in usockets drives lsquic for Bun.serve, fetch and node:quic, and node:quic builds its TLS context with the code every other Bun TLS context uses.
  • console.log() or Bun.inspect() of a QuicEndpoint, QuicSession or QuicStream now prints each session once. (#35742)
  • A second connect() with different transportParams, application, cc, handshakeTimeout or keepAlive options now gets those options, also on an endpoint it shares with the first. (#35765)
  • QuicEndpoint now applies udpReceiveBufferSize, udpSendBufferSize, udpTTL, ipv6Only and reusePort to its UDP socket. (#37456, in part)
  • A node:quic session without the cc option now uses Cubic, Node's default. (#37468)
  • On an HTTP/3 node:quic stream, writing a body or ending the stream after sendHeaders() returned false now resets the stream with H3_INTERNAL_ERROR, and closed rejects on both ends. (#42892, in part)
  • Using two of Bun.serve({ http3: true }), fetch(..., { protocol: "http3" }) and node:quic in one process no longer breaks QUIC handshakes that are in flight when the second one starts. (#42913) Fixes #42898.
  • ondatagramstatus(id, status) now reports each datagram under its own id. (#44680)
  • A node:quic close with code 0 no longer reaches the peer as INTERNAL_ERROR, through the #45702 fix. (#44846, in part)
  • createUnidirectionalStream() before the handshake finishes now opens that stream when the handshake is done. (#45342, in part) Fixes #44749.
  • session.close() and session.destroy() with { code: 0n, type: "application" }, or with code 0 and a reason, now send code 0. (#45702) Fixes #45499.
  • node:quic streams now have stream.opened (fulfilled once a stream created without stream credit gets its ID) and stream.onstopsending (called with the peer's application error). (#45712) Fixes #45501.
  • A node:quic client with an IP address as servername ("127.0.0.1", "::1") now verifies it against the certificate's IP SANs. (#45713) Fixes #45514.
  • When the peer closes a QUIC connection with an error, a stream that was still open now fails with that error: a pending read and stream.closed reject with the same error as session.closed. (#45741) Fixes #45500.
  • resetStream(), setBody() and setOutbound() on a unidirectional stream that the peer opened no longer send RESET_STREAM or STREAM frames, which made the peer close the whole connection. (#45879, in part)
  • stream.stopSending() and stream.destroy() on a node:quic stream whose send side is finished (every headers-only HTTP/3 GET) now send STOP_SENDING right away. (#45931)
  • When a node:quic stream's pendingTrailers can't be sent, the stream is now destroyed and stream.closed rejects (with the validation error, or ERR_QUIC_STREAM_ABORTED). (#45941)
  • A QUIC connection no longer declares a packet lost that the peer acknowledged. (#46055)
  • A non-HTTP/3 node:quic client now enforces, and returns credit for, the stream limits it advertises. (#46305, in part)
  • With default transportParams, a node:quic server 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. (#46321)
  • A bidirectional stream that a node:quic server opens now respects the right flow-control window on both ends when initialMaxStreamDataBidiLocal and initialMaxStreamDataBidiRemote differ. (#46322, in part)
  • onnewtoken(token, address) and the quic.session.new.token diagnostics channel now get the server's SocketAddress. (#46343, in part)
  • A node:quic client that resumes a session (sessionTicket) and calls stream.resetStream(), stream.stopSending() or stream.destroy() before opened now gets that frame to the server. (#46425)
  • Ending a raw QUIC stream that has no flow-control credit (the peer's window is 0, or the body filled it exactly) now sends the FIN right away. (#46446)
  • A node:quic session now reports the transport error code that its own side closed the connection with. (#46487, in part)
  • Hardening against misbehaving peers: #35726, #37491, #44800, #45537, #46226, #46453.

HTTP/3

  • fetch(url, { protocol: "http3", tls: { ca, cert, key, serverName } }) now works: the server is verified against your ca and serverName, and the client certificate is presented. (#31420)
  • On Windows, Cubic's pacing rate wrapped for every window, so a Cubic QUIC connection was paced at a more or less random rate. (#37455, in part)
  • Over HTTP/3, a request with Expect: 100-continue that is the first request on its connection now gets its response. (#42805) Fixes #33082.
  • await server.stop() on an HTTP/3 server now resolves after the last QUIC connection is gone and the UDP socket is closed. (#45419)
  • With --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. (#45539, in part)
  • Over HTTP/3, what one connection's handler writes to another connection's stream (a held response, an SSE or long-poll chunk) now goes out right away instead of waiting up to seconds for that connection's next packet or timer. (#46103)
  • Hardening against misbehaving peers: #40598, #42895, #45357, #45612, #46012.

node:http2

  • A node:http2 session keeps one table of its streams, and shares its frame, settings and header checks with fetch's HTTP/2 client.
  • Http2Stream.setTimeout() (and req.setTimeout() / res.setTimeout() in the compat API) now arms a timer for that stream only. (#30308) Fixes #30307.
  • stream.close(code) called right after write() now resets the stream instead of ending it cleanly, so the peer no longer takes an aborted response or upload for a complete one. (#33380, in part)
  • req.end(callback) on a request that request() already ended (every GET, HEAD and DELETE) calls back asynchronously like Node, and passes ERR_STREAM_ALREADY_FINISHED or ERR_STREAM_DESTROYED once the stream is past that point. (#33489)
  • internalBinding('http2') exists in the --expose-internals test shim, so Node's test-http2-binding.js and test-http2-respond-errors.js run and pass. (#34499)
  • stream.write(""), res.write("") and a client end() with trailers pending no longer put a zero-length DATA frame without END_STREAM on the wire. (#37712)
  • A client request with endStream (explicit, or implied by GET/HEAD/DELETE) plus waitForTrailers now ends on its HEADERS frame like Node, instead of sending HEADERS and then an empty END_STREAM DATA frame. (#37718)
  • A client request that is still pending when session.destroy() runs now reports Node's rstCode: INTERNAL_ERROR (2) instead of CANCEL (8), and a code set earlier by req.close(code) or a received GOAWAY is kept. (#37744)
  • An array value in a raw [name, value, ...] header list passed to respond() or request() now goes out as one field per element, like Node. (#37796, in part)
  • A pushed stream that's ended before respond() (pushStream(headers, { endStream: true }) or push.end()) puts END_STREAM on its response HEADERS. (#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. (#38158)
  • A node:http2 session keeps parsing inbound frames when code that continues after an awaited response spins the event loop (await expect(promise).resolves in bun 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. (#39873) Fixes #39796.
  • session.setLocalWindowSize(n) with n of 128 KiB or more no longer stalls every request or response body larger than 64 KiB. (#40180)
  • http2.connect(url, { maxFrameSize: NaN }) throws ERR_HTTP2_INVALID_SETTING_VALUE instead of spinning in request() until the process runs out of memory. (#41305, in part)
  • Open HTTP/2 streams now follow a SETTINGS_INITIAL_WINDOW_SIZE change in both directions. (#41329) Fixes #43893.
  • Raising setLocalWindowSize() after lowering it no longer grants the peer the same credit twice. (#41344, in part)
  • Node's test-cluster-bind-twice.js passes and is added. The rest of this PR is in through #33191 and #34499. (#41400, in part)
  • node:http2 now treats outbound header objects like Node: a property set to undefined is skipped instead of throwing ERR_HTTP2_INVALID_HEADER_VALUE, null and array items are stringified, and content-length: 0 on a GET is sent instead of resetting the request. (#41614)
  • stream.endAfterHeaders no longer throws Invalid stream id on 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. (#41945, in part)
  • A client session now destroys its transport when the transport emits 'error' without closing itself, instead of leaking the connection. (#42175)
  • session.closed is now true on a client session whose server hung up, like Node. (#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. (#42184)
  • A graceful server.stop() no longer waits for a connection that hadn't made a request yet. (#42345, in part)
  • An h2c node:http2 client whose own send() is the first to see the peer's reset now gets 'close' on the request and the session. (#42353, in part)
  • A client at the server's maxConcurrentStreams limit no longer sends the next queued request ahead of the RST_STREAM of the request that destroy() just cancelled. (#42763)
  • stream.sentHeaders on a node:http2 server is undefined until respond(), like Node. (#43206)
  • pushStream() takes its default :authority from the request's host when the request has no :authority. (#43298)
  • node:http2 now honors SETTINGS_HEADER_TABLE_SIZE. (#43345, in part) Fixes #19152.
  • A node:http2 server no longer drops responses without telling the client once more than maxSessionMemory (10 MB by default) of response data is queued. (#43419)
  • A write on one HTTP/2 stream no longer stalls while another stream on the same session has data waiting for its own flow-control window. (#43422)
  • 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). (#43433)
  • A node:http2 server whose respond() is over maxSendHeaderBlockLength now resets the stream with FRAME_SIZE_ERROR and closes the session gracefully like Node, instead of leaving the client hanging. (#43440)
  • respond(), respondWithFile() and respondWithFD() now throw ERR_HTTP2_STATUS_INVALID for a 1xx :status like Node, and additionalHeaders() no longer adds :status: 200 to a block without one. (#43453, in part) Fixes #43447.
  • respondWithFile() on a directory with no onError now resets the stream without sending response headers first, like Node. (#43463, in part)
  • server.updateSettings() and server.updateSettings(undefined) are no-ops again instead of throwing ERR_INVALID_ARG_TYPE. (#43472)
  • maxSendHeaderBlockLength now defaults to 65536 like Node. (#43474)
  • respond(headers, options) only reads endStream, waitForTrailers, sendDate and paddingStrategy now, like Node. (#43478, in part)
  • The listener passed to 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. (#43492)
  • http2.connect() with createConnection accepts any URL protocol like Node, http2.connect({ port }, { protocol: 'http:' }) sends :scheme: http instead of https, and ERR_HTTP2_UNSUPPORTED_PROTOCOL has Node's message. (#43505)
  • A node:http2 server stream that was open before session.close() now emits 'trailers' when the client's trailer block arrives after the GOAWAY. (#43507) Fixes #43488.
  • A client stream no longer emits 'response', 'headers', 'continue', 'push' or 'trailers' after your handler called close() or destroy() on it, when the header block arrived in the same socket read. (#43525)
  • After session.close(), stream.pushAllowed is false and pushStream() throws ERR_HTTP2_PUSH_DISABLED, like Node. (#43531, in part)
  • A stream sends at most one RST_STREAM, and none once it has closed. (#43539, in part)
  • A request or response body larger than the flow-control window no longer stalls over a synchronous transport such as stream.duplexPair(). (#43562)
  • stream.respondWithFD(fd) sends the whole file every time. (#43564, in part)
  • session.request(headers, { parent, exclusive, weight }) no longer puts priority fields on the HEADERS frame, like Node. (#43599)
  • session.settings(settings, callback) ignores a falsy callback (null, 0, "", false) like Node instead of throwing ERR_INVALID_ARG_TYPE, and checks settings before callback. (#43605, in part)
  • A node:http2 server stream that the handler paused now emits 'end' and 'close' once it has closed and holds no unread data. (#43631)
  • A node:http2 client no longer throws away response data that arrived before the server's RST_STREAM(NO_ERROR). (#43754, in part)
  • stream.state, util.inspect(stream) and console.log(stream) no longer throw Invalid stream id on a stream that finished on the wire while its data is still unread, or on a pushed client stream. (#43801, in part)
  • A server handler that calls stream.close(NGHTTP2_REFUSED_STREAM) no longer gets its whole connection closed with GOAWAY(ENHANCE_YOUR_CALM) after 50 refusals. (#44248)
  • A node:http2 server no longer ends the whole session at the 100th stream it refuses for exceeding maxConcurrentStreams. (#44337, in part)
  • session.setTimeout() no longer fires 'timeout' in the middle of a download or upload. (#44481, in part)
  • respond() and request() with a raw [name, value, ...] list no longer push a repeated name's value into an array the caller passed. (#44804)
  • session.state is {} after session.destroy(), like Node, instead of undefined, so session.state.lastProcStreamID no longer throws. (#44816, in part)
  • stream.close(code) on a stream that is read through pipeline() now resets it with the given code. (#44848, in part)
  • A graceful server.stop() or server.closeIdleConnections() no longer cuts an HTTP/2 response that the kernel hasn't delivered yet. (#44874, in part)
  • node:http2 no longer emits 'aborted' on a stream whose writable side already ended. (#45056, in part)
  • A node:http2 client request that is reset with close(code) while it waits for a stream slot (SETTINGS_MAX_CONCURRENT_STREAMS) is no longer sent. (#45279, in part)
  • A node:http2 client request that is destroyed or aborted while it waits for the connect or for a stream slot leaves the session's queue right away. (#45448, in part)
  • session.close() and server.close() no longer cut a large response or upload that was already framed but not yet written to the socket. (#45673, in part)
  • A request made before the session connects is no longer sent if a 'connect' or 'secureConnect' listener destroys or closes the session first. (#45992, in part)
  • On a shared http2.connect() session, a request that waited for a stream slot or for the connect now runs its write() and end() callbacks, 'ready' and 'finish' in its own caller's AsyncLocalStorage context. (#46036, in part)
  • Comment only: the one-tick cork in ClientHttp2Session.request() is Bun's own. (#46234)
  • stream.close() and close(NGHTTP2_CANCEL) no longer emit 'error' ERR_STREAM_PUSH_AFTER_EOF when a DATA frame arrives while close() is still running. (#46428)
  • session.settings(settings, callback) now runs the callback and that frame's 'localSettings' event in the caller's AsyncLocalStorage context, like Node. (#46463, in part)
  • Hardening against misbehaving peers: #33191, #36410, #37563, #37985, #38195, #41520, #42211, #42369, #42411, #42519, #42522, #42717, #43475, #43494, #43534, #43553, #43558, #43575, #43586, #43612, #44383, #45315, #46058, #46121, #46185, #46451. Fixes #45728. Fixes #43493. Fixes #43509.

undici and node-fetch shims

  • Pool, Client and Agent from the built-in undici work: request() and stream() send the request with Bun's fetch, close() waits for pending requests and destroy() aborts them. (#27338, in part) Fixes #7920. Fixes #14498. Thanks to @igorls.
  • ProxyAgent from the built-in undici works. (#35145, in part) Fixes #4474.
  • dispatch() works on Agent, Pool, Client and ProxyAgent of the built-in undici, with the handler callbacks of undici 6 and of undici 7, including pause, resume and abort. (#39250, in part) Fixes #39247. Fixes #8961.
  • The body returned by the built-in undici.request() has dump() and bytes(). (#40206) Fixes #40205.
  • The error classes in the built-in undici have undici's name, UND_ERR_* code and default message. (#42716) Thanks to @steipete.
  • new Request(url, { body }) from the built-in node-fetch reads an old-style Stream body such as form-data. (#43773, in part)
  • Response.type from the built-in node-fetch is "default" for a 404 or 500 instead of "error". (#43966) Thanks to @breken-ai.
  • FetchError from the built-in node-fetch has the name FetchError, sets errno and erroredSysCall, and has no code without a system error. (#43967, in part) Thanks to @breken-ai.
  • Response.json(), Response.redirect() and Response.error() from the built-in node-fetch return a node-fetch Response, with buffer(), headers.raw() and a Node stream body. (#43968) Thanks to @breken-ai.
  • fetch() from the built-in node-fetch rejects with node-fetch's AbortError (type: "aborted") on abort and FetchError when the request fails. (#44297, in part) Fixes #44282.
  • The built-in undici.request() rejects a method that isn't a string, isn't an HTTP token, or is CONNECT with undici's InvalidArgumentError, instead of sending GET (or CONNECT). (#44814, in part)

node:http and node:https

  • req.write(chunk, callback) on a client request whose socket is already destroyed now calls callback with the socket's error or ERR_STREAM_DESTROYED instead of never calling it.
  • res.assignSocket(socket) listens with socket.on() like Node, so the socket stand-ins of serverless-http and @vendia/serverless-express (which have no once()) work instead of throwing socket.once is not a function.
  • The callback of res.write() is always called when the connection has failed, and the callback of res.end() wherever Node or 1.4.4 calls it.
  • node:http does less work for a pipelined response that waits for its turn and for each chunk of a request body, and a lone string written under socket.cork() is no longer copied twice.
  • http and https servers honor listen({ ipv6Only: true }), like net.Server and Node.
  • req.socket on a node:http / node:https server is a plain net.Socket / tls.TLSSocket from accept on, and 'upgrade' and 'connect' listeners get that same socket over the same handle.
  • https.Server#listen() negotiates ALPN (ALPNProtocols, http/1.1 by default, ALPNCallback, req.socket.alpnProtocol) and emits 'keylog', like a tls.Server.
  • server.emit('connection', socket), http._connectionListener and http2's allowHTTP1 run a port of Node's own connection code, and every kind of connection has one queue of waiting responses.
  • req.socket.bytesRead on a node:http / node:https server now counts every byte the connection has read (after TLS decryption) instead of staying 0, and keeps its value after 'close'. (#28710) Fixes #28709.
  • http._connectionListener is exported (httpolyglot and spdy call it). (#35285, in part) Thanks to @steipete.
  • A 'data' or 'readable' listener on a node:http / node:https server's connection socket (server.on('connection'), 'secureConnection', req.socket) now gets the raw request bytes, like Node. (#35661) Fixes #11924.
  • res.end() now undoes a res.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. (#35664, in part)
  • http.Server now sets server._handle while it listens (null before listen() and after close()), so a supertest request closes its server and the process exits instead of hanging. (#36685) Fixes #23648.
  • On emit('connection') and allowHTTP1 connections, res.end(body) followed right away by req.socket.destroy(), res.destroy() or closeAllConnections() no longer cuts the response off after its headers. (#37710, in part)
  • An HTTP/1.1 request without a Host header now gets 400 Bad Request with Connection: close on emit('connection') and allowHTTP1 connections, like a listening http.Server and like Node. (#37735)
  • On emit('connection') and allowHTTP1 connections, closing a socket that was handed to an 'upgrade' or 'connect' listener no longer emits 'aborted', 'error' and 'close' on the upgrade request. (#37748, in part)
  • A Content-Length: 0 request whose head arrives in two TCP reads now counts as complete. (#37889, in part)
  • A node:http server's keepAliveTimeout is measured on the clock its timer runs on, so setSystemTime() or a wall-clock step no longer closes an idle connection early or keeps it open, and under jest.useFakeTimers() it closes when the fake clock says so. (#37974)
  • res.destroy() or req.destroy() before the response head is sent now closes the connection without sending anything, like Node. (#39366, in part)
  • res.statusMessage goes on the wire as Latin-1 on listen() connections, like the header values there (OKé was sent as UTF-8). (#40416, in part) Thanks to @dylan-conway.
  • The socket handed to a 'connect' or 'upgrade' listener emits 'drain' once per backed-up write instead of twice. (#40897)
  • http.request(), http.get() and https.request() accept a URL object from another implementation or one created before globalThis.URL was replaced, and req.write() / req.end() accept a Uint8Array from a node:vm context. (#41117, in part)
  • clientError reports 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 has err.reason. (#41234, in part)
  • server.listen({ port, path }) on a node:http server now binds the TCP port and ignores path, and listen({ path, host }) binds the path instead of emitting an 'error', like Node. (#41660, in part)
  • A failed server.listen() now reports Node's error: listen EADDRINUSE: address already in use 127.0.0.1:3000, with errno, syscall, address and port set. (#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 with node:net and Bun.serve. (#43692) Fixes #43690.
  • server.listen(null) and server.listen(null, host) bind a free port, like Node, instead of port 3000 (or PORT). (#43726, in part)
  • A node:http request with a Content-Length body now emits 'end' after the promise jobs queued by the last chunk's 'data' listeners, like Node. (#43736, in part)
  • server.timeout and the setTimeout() of a server, request, response or socket no longer cut a response that's still being written: res.write(), res.end(), res.flushHeaders(), a 1xx response and a drained write now count as socket activity, and the timer waits while the peer keeps taking bytes of a queued write, like Node. (#43753, in part)
  • With rejectNonStandardBodyWrites: true, res.write("") on a HEAD, 204 or 304 response now throws ERR_HTTP_BODY_NOT_ALLOWED like Node, also on a queued pipelined response. (#43994) Fixes #43535. Thanks to @aryan1306.
  • http.Server.listen({ fd }) (and https) serves on the inherited socket. (#44013, in part) Fixes #22559.
  • res.socket.end() on a node:http server now sends its FIN once the bytes res.write() left buffered are out, even if the response never ends. 1.4.4 never sent it, so the client waited forever. (#44244, in part)
  • A node:http server no longer copies the bytes that follow each request head into a Buffer nobody reads. (#44296) Fixes #44208.
  • res.end(data) sends its body when a replaced res.writeHead calls flushHeaders() or write(). (#44442)
  • res.writeEarlyHints(), writeContinue() and writeProcessing() send the 1xx response right away instead of when the handler returns, so Early Hints reach the client before synchronous rendering. (#44468, in part)
  • http.Server.call(httpsServer) leaves that server alone, as in Node, and http.createServer({ _pfxExtraCACerts }) no longer throws. (#44609, in part)
  • A failed Bun.serve(), Bun.listen(), http.Server#listen() or net.Server#listen() now reports the error of the call that actually failed, on every platform. (#44651) Fixes #30363.
  • A chunked http.request() body written under req.cork() is no longer sent after the terminating chunk when req.end() runs before req.uncork(), req.uncork() emits 'drain' after a corked write() returned false, and an uncork() with no matching cork() no longer holds the next chunks of a message back until end(). (#44676)
  • res.writeHead() with a raw [name, value, ...] list no longer changes an array the caller passed as a value. (#44778)
  • A process now exits after 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. (#44885, in part)
  • Its writeHead() fix is in through #44778. (#44944, in part)
  • req.socket.setKeepAlive() and http.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. (#45006)
  • A change to an array passed to res.writeHead(status, headers) after the call no longer reaches the wire. (#45047, in part)
  • In a node:cluster worker, http and https servers share a Unix socket path, listen(0) and listen({ fd }) through the primary, like Node. (#45186, in part) Fixes #44794.
  • When a 'checkContinue' listener answers Expect: 100-continue with a final status and never calls writeContinue(), the response now says Connection: close and the server closes the connection, like Node. (#45189)
  • server.close(callback), the server's 'close' event and await using server no longer hang when user code removes the last socket's 'close' listeners or when one of its 'close' listeners throws. (#45257)
  • node:http ('connect' / 'upgrade' hand-off) and node:dgram (Symbol.asyncDispose) no longer call the program's Function.prototype.bind on an internal function. (#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. (#45986) Fixes #45722.
  • bun --hot no longer keeps the http.Server (and the whole app behind it) of every earlier reload alive. (#46039)
  • ConnectionsList#expired() from process.binding('http_parser') no longer wraps timeouts above 4294 ms. (#46411)
  • A node:http server process now exits right after server.close() or server.unref() when the only thing left is a paused upload whose response was already sent, like Node and Bun 1.4.2. (#46424)
  • Hardening against misbehaving peers: #33871, #35672, #44454, #44505, #45841, #46014, #46421.

Bun.serve

  • HTTP/1, HTTP/2 and HTTP/3 requests and WebSocket handshakes take one path from the parser to the handler, and a server's state (listening, stopping, closed, drained) is computed in one place.
  • Bun.serve sends the automatic 100 Continue for any casing of Expect: 100-continue. (#30250, in part) Fixes #30248.
  • A response without a body (new Response(), Response.redirect(), a 204 or 304, an untyped stream) never gets a fallback Content-Type: application/octet-stream, whatever the build. (#30997)
  • An async error() handler that finishes later without returning a Response now answers 500, like a sync one. (#33140) Fixes #33137.
  • The Content-Disposition header that Bun.serve adds to a Bun.file() response now has a disposition type (inline; filename="a.bin"), so RFC 6266 parsers such as npm content-disposition accept it. (#33655)
  • The docs and types for server.timeout(req, seconds) now say that values above 255 are clamped to 255. (#34958, in part)
  • A request with Expect: 100-continue and a Content-Length over maxRequestBodySize gets the 413 alone, over HTTP/1, HTTP/2 and HTTP/3. 1.4.4 sent 100 Continue first, which started an upload that the server then refused. (#34960)
  • A routes entry of new Response(Bun.file(path), { status: 307 }) (or 308, or 205) no longer leaves keep-alive clients waiting until the idle timeout. (#35024)
  • With development: true, the "Welcome to Bun!" page that Bun.serve sends when a handler returns no Response no longer carries Content-Length twice. (#35294) Fixes #29714.
  • Bun.serve no longer sends the upstream's Content-Encoding next to a body that fetch() already decoded, however the handler hands that body on: return fetch(upstream), new Response(res.body, res), a clone, a tee() branch, a Blob, a static route, HTMLRewriter. (#35546)
  • server.url no longer throws Invalid URL for a unix socket path such as C:\test, and #, ? or a space in the path no longer cut it short. (#35806) Fixes #40182.
  • A fetch handler that returns new Response(Bun.file(path)) for a path that can't be opened now gets 404 Not Found (ENOENT, ENOTDIR, EISDIR, ENAMETOOLONG) or 403 Forbidden (EACCES, EPERM) instead of a 500. (#36593) Fixes #4292.
  • A Worker that runs Bun.serve or node:http no longer leaks about 800 KB per server type (HTTP, HTTPS, HTTP/2 and HTTP/3) when it exits. (#37513)
  • Bun.serve closes the connection after it answers a CONNECT request. (#37585)
  • An HTML route whose bundle or plugin load failed now answers every request with 500. (#37916)
  • req.params is no longer shifted when a route has a : inside a literal segment: /files:batchGet/:id now gives { id: "123" } instead of { batchGet: "123", id: "" }. (#37983, in part)
  • A Bun.file() response reaches a client that half-closes right after its request (nc -N, socat, Go's CloseWrite, some health checkers) in full. (#38107)
  • server.fetch("/path") on a server started with port: 0 gives the handler a request.url with the port the server bound, not :0. (#39027)
  • routes accepts an in-memory Blob as a route value, so "/favicon.ico": Bun.file(favicon) with favicon imported with { type: "file" } no longer throws at startup in a bun build --compile executable. (#39304)
  • req.body of an upload that arrived completely stays readable after the request ended. (#39660)
  • --define and the bunfig [define] table now apply to the scripts of HTML imports served by Bun.serve and bun ./index.html, in development and production. (#40485) Fixes #40479.
  • Types only: a method key in routes now accepts every static value the runtime already accepts there, not just a Response or a handler. (#40901, in part)
  • Bun.serve falls back to read() + write() when sendfile(2) refuses a file of 1 MiB or more with EINVAL, ENOSYS, EOPNOTSUPP or EPERM (a filesystem without splice support, a seccomp policy). (#41474, in part)
  • The development error page cuts each source line at 1024 bytes, like the terminal error printer. (#41537)
  • After server.reload() with the same HTML import, the /chunk-*.js and /chunk-*.css URLs of the page that was already built keep working. (#41576)
  • HEAD for a Bun.file() Response from a fetch handler now sends the status and headers GET would: 404 for a missing file or a directory, 206 / 416 for a Range request or a slice, plus Content-Type and Content-Disposition. (#41585, in part)
  • Bun.serve no longer aborts a response that a slow client is still reading. (#41602)
  • When a Bun.file() response runs out of bytes before its Content-Length (the file shrank while it was being sent, or fstat overstates it as for a sysfs file), Bun.serve sends what the file has and closes the connection. (#41622)
  • server.fetch() now calls the handler with (request, server), so fetch(req, server) { server.upgrade(req) } no longer throws undefined is not an object under server.fetch(). (#41755, in part)
  • Bun.serve() defaults to production mode when the program set process.env.NODE_ENV (or BUN_ENV) to "production" before the call, as the docs say (@default process.env.NODE_ENV !== 'production'). (#41926)
  • A string Response is sent as text/plain;charset=utf-8 after something read .body or cloned it. (#42064)
  • With development: true, an HTML route that arrives through server.reload() (or a second Bun.serve() under bun --hot) on a server that started without one now gets the dev server. (#42834) Fixes #23564.
  • A static Response route or a Bun.file() route whose headers say Connection: close now closes the connection after that response and doesn't answer requests pipelined behind it. (#43107)
  • A Bun.file() route ignores Transfer-Encoding and Content-Length headers on its Response and frames the body from the file's size, like static routes and fetch handlers do. (#43108)
  • Bun.serve no longer adds the automatic Content-Disposition header when the file name isn't safe inside a quoted string. (#43742)
  • Bun.serve sends Connection: close in the last response on an HTTP/1 connection: when the request said Connection: close, when it was HTTP/1.0, and when a graceful server.stop() closes the connection after the response in flight. (#43867) Fixes #43853.
  • The server no longer cancels or closes a Response body stream that another reader holds. (#43973, in part)
  • A % that isn't followed by two hex digits stays a literal % in req.params and in cookie values (req.cookies, Bun.CookieMap). (#44664)
  • A Bun.file(path) slice that starts past byte 0 and is as long as the file (slice(5, 21) of 16 bytes) now answers 206 with Content-Range: bytes 5-15/*, like slice(5, 20) and slice(5, 22) already did. (#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. (#44889) Fixes #19615.
  • A streamed Response now 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. (#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. (#45266, in part)
  • server.timeout(req, seconds) now works when Bun.serve listens on a unix socket. (#45360) Fixes #43816.
  • With a "/*" route that names only some methods, every other method now falls through to fetch (or the 404). (#45397, in part)
  • With development: false, an HTML route that fails to bundle now prints the bundler's error to stderr once. (#45580) Fixes #45238.
  • server.timeout(req, n) now holds for a Bun.file() response body over HTTP/1, TLS and HTTP/2. (#45788)
  • Hardening against misbehaving peers: #32887, #37500, #38128, #44227, #44241, #44753, #44826, #44894, #45396, #45990, #45999. Fixes #43970. Fixes #44232.

fetch, Request, Response, Headers

  • A response body that the program cancelled is dropped instead of being decoded.
  • fetch keeps one table of its in-flight requests for HTTP/1.1, HTTP/2 and HTTP/3, and requests with the same tls options share one TLS context through a CONNECT tunnel and over HTTP/3.
  • new Request() reads its arguments by the rule fetch() uses, and text(), json(), bytes(), arrayBuffer(), blob() and formData() read a body through one function.
  • fetch() follows a relative redirect whose Location has :// in its path, query or fragment, like /login?next=https://app.example.com, because every Location is now resolved the way new URL(location, currentURL) does. (#30065)
  • Acquiring a reader on response.body or request.body no longer uses the body. (#33461, in part)
  • A fetched Response has type === "basic" and a url without the #fragment, including after a redirect. (#33499)
  • formData() on Request, Response and Blob keeps a leading UTF-8 BOM in urlencoded names and multipart string values, like Node and browsers. (#33631)
  • fetch() of a data: URL follows the Fetch standard: unpadded or line-wrapped base64 decodes, ;BASE64 is recognized, Content-Type is the URL's own MIME type, it stays readable after the body is consumed, and a bad URL rejects with a TypeError. (#34896)
  • fetch() treats a 204 or 304 response as complete at its header block even if it carries Transfer-Encoding: chunked. (#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. (#35222, in part)
  • request.formData() now gives an uploaded file the type the client sent. (#35244, in part) Fixes #33012.
  • new Request(request, init) and new Request(url, response) copy an empty body instead of sharing the input's stream, so both can be read. (#35858, in part)
  • Headers.prototype.getSetCookie and getAll throw a TypeError when this is not a Headers, instead of returning undefined. (#36000, in part)
  • fetch() and node:http requests send every header. (#36433)
  • fetch("file:///missing") rejects with a TypeError (code: "ENOENT"), and a directory rejects with EISDIR, instead of resolving with status: 200, ok: true and failing when the body is read. (#37425)
  • fetch() with a Bun.file() body that can't be read (missing file, directory, closed descriptor) rejects with a TypeError, like its other failures. (#37501)
  • Windows: fetch(url, { body: Bun.file(fd) }) no longer leaks one C runtime file descriptor per upload. (#37509)
  • fetch() with a FormData body that holds a Bun.file() that can't be read rejects with a TypeError, like a bare Bun.file() body (#37501). (#37514)
  • fetch() no longer fails with ECONNRESET when a POST or PATCH gets a 307/308 and the server closes the connection right after it without Connection: close. (#37522, in part)
  • Response.redirect("") has a readable empty Location header: headers.get("location") is "" and iteration and new Headers(h) include it. (#37893)
  • fetch(url, { unix }) follows a same-origin redirect (a relative Location, or an absolute one on the same host and port) over the unix socket. (#38394)
  • fetch() re-sends a Bun.file() body of 32 KiB or more when it follows a 307 or 308 (or a 301/302 for PUT, PATCH, DELETE). (#38406)
  • The verbose trace (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. (#38691)
  • A 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. (#41488, in part)
  • new Request() with no argument, undefined, null, a number, a boolean, a symbol, "" or an object without a url throws a TypeError instead of a plain Error, like an invalid URL already did. (#41551)
  • The curl command that verbose: "curl" and BUN_CONFIG_VERBOSE_FETCH=curl print can be pasted into bash or zsh as is. (#41726, in part)
  • fetch() sends the body's Content-Type for an empty URLSearchParams, typed Blob or File, also through fetch(new Request(...)). (#41752)
  • fetch(url, { unix }) rejects with a TypeError when unix is a Buffer, URL, number, true or any other truthy non-string, instead of ignoring it and sending the request to the URL's host over TCP. (#41786)
  • The redirect half is in with #35222 (Response.redirect(url, {}) is a 302), and new Request(url, { status: 0 }) no longer throws since #41994. (#41841, in part)
  • On macOS, fetch() no longer uploads a Bun.file() body with sendfile(2). (#41905)
  • new Request(input, init) keeps the input's method when init has no method, whatever kind of object input and init are. (#41994) Fixes #42759.
  • New test: a thread that ends between the delivery of a failed response body and the rejection of the next read releases the fetch once. (#42261)
  • The docs now state the size of fetch's idle connection pool (64 per pool, longest-idle eviction) and list BUN_CONFIG_HTTP_IDLE_TIMEOUT. (#42617, in part)
  • A headers value 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 of TypeError: Type error. (#42961, in part)
  • A Headers subclass or instance with its own Symbol.iterator is converted through that iterator wherever a HeadersInit is accepted, from new Headers(h) to server.upgrade(). (#43023)
  • fetch.preconnect("https://bun.com") and bun --fetch-preconnect https://bun.com work. (#43034) Fixes #19626.
  • fetch() accepts an HTTP/1.1 response with up to 1000 header fields. (#43181, in part)
  • fetch(undefined), fetch(123) and fetch({ toString() { return "/path" } }) reject with fetch() URL is invalid instead of the misleading fetch() URL must not be a blank string. (#43389, in part) Fixes #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. (#43964, in part)
  • fetch(), new Request() and server.fetch() keep a method that isn't in Bun's 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 a Bun.file() handle whose size was read as 0 before the file was written). (#44896)
  • text() and json() drop one leading UTF-8 BOM for every kind of body. (#45486, in part)
  • fetch() honors the server's Keep-Alive: timeout=N header: it stops reusing an idle connection 2 seconds before the timeout runs out, or halfway through it when N is under 4, so a POST no longer fails with ECONNRESET because it raced the server's idle close. (#45497, in part)
  • response.headers and request.headers keep the Content-Type of a typed body (Blob, Bun.file(), URLSearchParams, FormData with its boundary) when they are first read after .body, after a reader such as .text(), or after fetch(request). (#45601) Fixes #45245.
  • A Content-Encoding: gzip body with junk after its last member decodes the same way however the bytes are split across socket reads. (#45715)
  • A chunk that script reads from a Response or Request body no longer shares memory with the body's clone. (#46080, in part) Fixes #45782.
  • fetch() and static or file routes no longer send an empty Content-Type: header for a Blob whose type is "", such as file.slice(0, n) or an S3 file without type. (#46194)
  • With --experimental-http2-fetch, a request the server refuses with HTTP_1_1_REQUIRED is sent again over HTTP/1.1 instead of rejecting with HTTP2StreamReset or HTTP2GoAway. (#46259)
  • Response.json() decides by what JSON.stringify returns, like the spec and Node. (#46397)
  • A program that only reads res.status of large responses no longer stalls in await fetch() for about 15 seconds. (#46434, in part)
  • Hardening against misbehaving peers: #42091, #43618, #44534.

Other

  • After worker.disconnect() or cluster.disconnect() in the primary, a worker's IPC channel stays open until its servers and sockets have closed, as in Node, so a process.send() from a 'close' listener arrives.
  • A child's messages, 'disconnect', 'exit' and 'close' come in that order (onDisconnect before exited and onExit for Bun.spawn). On Linux without pidfd (kernels before 5.6, gVisor) 'exit' can still come first, as on 1.4.4. The callback of a send() that the other end never took is called with EPIPE instead of never.
  • Bun.gunzipSync() decodes every member of a concatenated gzip stream (cat a.gz b.gz, BGZF, WARC), with both library: "zlib" and "libdeflate". (#31522, in part)
  • Readable.fromWeb(stream) reads everything that is left in a native stream (Bun.file().stream(), blob.stream(), response.body, proc.stdout) that something has already started, and ends without data for one that was cancelled or read to the end. (#42281, in part)
  • response.clone() and request.clone() are typed as Bun's Response and Request in a project without lib.dom. (#42672) Fixes #41860. Thanks to @davidhevn.
  • Bun.serve({ unix, idleTimeout }) type-checks. (#46341) Fixes #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?

  • 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.
  • CI at 9d5ba068e0: every static check and every lane that has machines pass (Linux, the sanitizer lane, Windows, macOS arm64). Not done yet: the macOS x64 lane has had no machine since be1b3a23a4, and 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.