http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests
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 forwardsContent-Encodingnext to a bodyfetch()already decoded. A body that went throughpipeThrough()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 isTransfer-Encoding: chunkedwhere 1.4.4 sentContent-Length. (#45187) idleTimeoutfollows progress, not only reads. A download stays open while the client takes at least 16 KB/s, where 1.4.4 cut every response whose write stayed blocked foridleTimeout. The rule is the same after every kind of request. (#41602, #44894)idleTimeoutapplies before the first request too. 1.4.4 used about 10 s there, whatever the option said. (#45788)- A response to
Connection: closeor HTTP/1.0 saysConnection: close, and responses without a body carryDate. (#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 beforeBun.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
maxRequestBodySizeabortsreq.signal, and a response that already started ends in a reset. (#45999) - A failed
Bun.serve()orBun.listen()has the kernel'scodeand a negativeerrno(-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
LISTor any other token it did not know. The six Fetch verbs are upper-cased, sofetch("s3://...", { method: "Delete" })was a read and now deletes. (#44718) text()andjson()drop a leading BOM for every kind of body, not only for bytes from the network. (#45486)(await req.blob()).typeis theContent-Typeheader, however the body arrived. On 1.4.4 it wastext/plainfor a body that hadn't arrived yet and empty for a stream. AformData()that can't be parsed always rejects with aTypeErrorwhosecodeisERR_FORMDATA_PARSE_ERROR, where 1.4.4 had four texts. (#45601)Keep-Alive: timeout=Nis 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
Locationis resolved by the URL parser only. A few malformed values now go wherenew URL(), browsers and Node say. (#30065) response.typeis"basic",response.urlhas no fragment, anddata:URLs go through the URL parser. (#33499, #34896)File.typeof a multipart part is the part'sContent-Type, not a guess from the file name. (#35244)fetch()of a missingfile: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 withreader.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/zerois read up to 4 GiB and then rejects withENOMEM. (#41488)
node:http
req.socketis a plainnet.Socketortls.TLSSocket, and'upgrade'and'connect'listeners get that same socket.socket.address()is the local address (1.4.4: the peer's),'close'getshadError,encryptedis undefined on a plain socket,_handle.fdis the descriptor, andunref()works after the hand-off.https.Serverextendstls.Server, as in Node, so an https server is noinstanceof http.Server. OpenTelemetry sees plainhttp.Serverrequests again. 1.4.4 lost them. (#46589) Fixes #46567.- An
https.Servernegotiates ALPN. A client that offers onlyh2gets theno_application_protocolalert, as in Node. 1.4.4 served it. A failed handshake emits'clientError'and'tlsClientError'. headersTimeoutdecides how long a server waits for a complete request head: 60 s by default, then a 408, as in Node. 1.4.4 closed the connection after about 12 s. (#35672)listen({ port, path })binds the TCP port, also forport: 0,undefinedornull, as in Node. 1.4.4 bound the unix socket.listen(null)picks a free port instead of 3000 or$PORT.listen({ fd })listens on the descriptor instead of a random port. (#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:clusterworkerslisten()asks the primary, soserver.address()is null until'listening',listen(0)gives all workers one port, andworker.disconnect()closes http servers. Not on Windows. (#45186) - Listen errors have Node's message and a negative
errno. Text that matchedFailed to start serverno longer matches.codeandsyscallare unchanged. (#42742, #44651) - Connections passed in with
emit('connection'), and HTTP/1.1 on anallowHTTP1server, run a port of Node's own connection code, so they follow Node's rules forHost, backpressure, trailers and the server's timeouts. (#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()anddestroy()put Node's frames on the wire.write(x); close(code)in one tick sendsRST_STREAM(code), not DATA with END_STREAM.destroy()sends onlyRST_STREAMand emits no'finish'. The session's'close'comes after its streams' events. A'frameError'closes the session. (#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()hasrstCode2, 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
maxSendHeaderBlockLengthis raised, connection-specific headers throw fromrespond()andrequest(), andrespond({ ':status': 102 })throws.res.setHeader()andres.writeHead()drop such headers with a warning instead, so a front end that copies an HTTP/1.1 upstream's headers keeps running (Node throws there too). (#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 theconnect()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
maxConnectionsPerHostsays 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
ccuse 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 inscripts/build/deps/lsquic.tsandlsqpack.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,ClientandProxyAgenthaverequest(),stream(),dispatch(),close()anddestroy(), each has its own connection pool, andundici.fetch()applies a dispatcher's proxy andconnectoptions. Sonew Agent({ connect: { rejectUnauthorized: false } })now really skips verification, andnew ProxyAgent()without a URI throws. (#27338, #35145, #39250) node-fetchrejects withFetchError, not the nativeTypeError.err.codeis 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, andreq.socket.remoteAddress = xsticks.node:http: a connection that closes under an ended response's last write still emits'finish'. Onlisten()connectionsres.cork()has no effect and the head is always Latin-1.node:http: a connection that reported a parse error is closed about 10 s later, even if the'clientError'listener leaves it open. A body under a non-chunkedTransfer-Encodingis sent withConnection: close.node:http2: without an'error'listener, a latewrite()on a response thatrespond()ended is dropped, a pushed stream's error isn't thrown, and an early NO_ERROR reset drops later writes. A client session with no'stream'listener cancels pushes.
Fixes
node: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,fetchandnode:quic, andnode:quicbuilds its TLS context with the code every other Bun TLS context uses. console.log()orBun.inspect()of aQuicEndpoint,QuicSessionorQuicStreamnow prints each session once. (#35742)- A second
connect()with differenttransportParams,application,cc,handshakeTimeoutorkeepAliveoptions now gets those options, also on an endpoint it shares with the first. (#35765) QuicEndpointnow appliesudpReceiveBufferSize,udpSendBufferSize,udpTTL,ipv6OnlyandreusePortto its UDP socket. (#37456, in part)- A
node:quicsession without theccoption now uses Cubic, Node's default. (#37468) - On an HTTP/3
node:quicstream, writing a body or ending the stream aftersendHeaders()returned false now resets the stream with H3_INTERNAL_ERROR, andclosedrejects on both ends. (#42892, in part) - Using two of
Bun.serve({ http3: true }),fetch(..., { protocol: "http3" })andnode:quicin one process no longer breaks QUIC handshakes that are in flight when the second one starts. (#42913) Fixes #42898. ondatagramstatus(id, status)now reports each datagram under its own id. (#44680)- A
node:quicclose 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()andsession.destroy()with{ code: 0n, type: "application" }, or with code 0 and areason, now send code 0. (#45702) Fixes #45499.node:quicstreams now havestream.opened(fulfilled once a stream created without stream credit gets its ID) andstream.onstopsending(called with the peer's application error). (#45712) Fixes #45501.- A
node:quicclient with an IP address asservername("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.closedreject with the same error assession.closed. (#45741) Fixes #45500. resetStream(),setBody()andsetOutbound()on a unidirectional stream that the peer opened no longer send RESET_STREAM or STREAM frames, which made the peer close the whole connection. (#45879, in part)stream.stopSending()andstream.destroy()on anode:quicstream whose send side is finished (every headers-only HTTP/3 GET) now send STOP_SENDING right away. (#45931)- When a
node:quicstream'spendingTrailerscan't be sent, the stream is now destroyed andstream.closedrejects (with the validation error, orERR_QUIC_STREAM_ABORTED). (#45941) - A QUIC connection no longer declares a packet lost that the peer acknowledged. (#46055)
- A non-HTTP/3
node:quicclient now enforces, and returns credit for, the stream limits it advertises. (#46305, in part) - With default
transportParams, anode:quicserver can now open a bidirectional stream and send more than 16 KiB on it, and a resumed client can send more than 12 KiB on a unidirectional stream in 0-RTT. (#46321) - A bidirectional stream that a
node:quicserver opens now respects the right flow-control window on both ends wheninitialMaxStreamDataBidiLocalandinitialMaxStreamDataBidiRemotediffer. (#46322, in part) onnewtoken(token, address)and thequic.session.new.tokendiagnostics channel now get the server'sSocketAddress. (#46343, in part)- A
node:quicclient that resumes a session (sessionTicket) and callsstream.resetStream(),stream.stopSending()orstream.destroy()beforeopenednow 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:quicsession 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 yourcaandserverName, 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-continuethat 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:http2session keeps one table of its streams, and shares its frame, settings and header checks with fetch's HTTP/2 client. Http2Stream.setTimeout()(andreq.setTimeout()/res.setTimeout()in the compat API) now arms a timer for that stream only. (#30308) Fixes #30307.stream.close(code)called right afterwrite()now resets the stream instead of ending it cleanly, so the peer no longer takes an aborted response or upload for a complete one. (#33380, in part)req.end(callback)on a request thatrequest()already ended (every GET, HEAD and DELETE) calls back asynchronously like Node, and passesERR_STREAM_ALREADY_FINISHEDorERR_STREAM_DESTROYEDonce the stream is past that point. (#33489)internalBinding('http2')exists in the--expose-internalstest shim, so Node's test-http2-binding.js and test-http2-respond-errors.js run and pass. (#34499)stream.write(""),res.write("")and a clientend()with trailers pending no longer put a zero-length DATA frame without END_STREAM on the wire. (#37712)- A client request with
endStream(explicit, or implied by GET/HEAD/DELETE) pluswaitForTrailersnow ends on its HEADERS frame like Node, instead of sending HEADERS and then an empty END_STREAM DATA frame. (#37718) - A client request that is still pending when
session.destroy()runs now reports Node'srstCode: INTERNAL_ERROR (2) instead of CANCEL (8), and a code set earlier byreq.close(code)or a received GOAWAY is kept. (#37744) - An array value in a raw
[name, value, ...]header list passed torespond()orrequest()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 })orpush.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:http2session keeps parsing inbound frames when code that continues after an awaited response spins the event loop (await expect(promise).resolvesinbun test,require()of a module with top-level await), on macOS and Linux. On Windows it can still stall, depending on timing, because libuv re-arms a socket only after its callback returns. (#39873) Fixes #39796. session.setLocalWindowSize(n)withnof 128 KiB or more no longer stalls every request or response body larger than 64 KiB. (#40180)http2.connect(url, { maxFrameSize: NaN })throwsERR_HTTP2_INVALID_SETTING_VALUEinstead of spinning inrequest()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:http2now treats outbound header objects like Node: a property set toundefinedis skipped instead of throwingERR_HTTP2_INVALID_HEADER_VALUE,nulland array items are stringified, andcontent-length: 0on a GET is sent instead of resetting the request. (#41614)stream.endAfterHeadersno longer throwsInvalid stream idon a request that has no id yet, keeps its value after the stream closes, and is only set by the request HEADERS that opened a server stream, like Node. (#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.closedis 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:http2client whose ownsend()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
maxConcurrentStreamslimit no longer sends the next queued request ahead of the RST_STREAM of the request thatdestroy()just cancelled. (#42763) stream.sentHeaderson anode:http2server isundefineduntilrespond(), like Node. (#43206)pushStream()takes its default:authorityfrom the request'shostwhen the request has no:authority. (#43298)node:http2now honors SETTINGS_HEADER_TABLE_SIZE. (#43345, in part) Fixes #19152.- A
node:http2server no longer drops responses without telling the client once more thanmaxSessionMemory(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:http2server whoserespond()is overmaxSendHeaderBlockLengthnow resets the stream with FRAME_SIZE_ERROR and closes the session gracefully like Node, instead of leaving the client hanging. (#43440) respond(),respondWithFile()andrespondWithFD()now throwERR_HTTP2_STATUS_INVALIDfor a 1xx:statuslike Node, andadditionalHeaders()no longer adds:status: 200to a block without one. (#43453, in part) Fixes #43447.respondWithFile()on a directory with noonErrornow resets the stream without sending response headers first, like Node. (#43463, in part)server.updateSettings()andserver.updateSettings(undefined)are no-ops again instead of throwingERR_INVALID_ARG_TYPE. (#43472)maxSendHeaderBlockLengthnow defaults to 65536 like Node. (#43474)respond(headers, options)only readsendStream,waitForTrailers,sendDateandpaddingStrategynow, 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()withcreateConnectionaccepts any URL protocol like Node,http2.connect({ port }, { protocol: 'http:' })sends:scheme: httpinstead ofhttps, and ERR_HTTP2_UNSUPPORTED_PROTOCOL has Node's message. (#43505)- A
node:http2server stream that was open beforesession.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()ordestroy()on it, when the header block arrived in the same socket read. (#43525) - After
session.close(),stream.pushAllowedis false andpushStream()throwsERR_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 throwingERR_INVALID_ARG_TYPE, and checkssettingsbeforecallback. (#43605, in part)- A
node:http2server stream that the handler paused now emits 'end' and 'close' once it has closed and holds no unread data. (#43631) - A
node:http2client no longer throws away response data that arrived before the server's RST_STREAM(NO_ERROR). (#43754, in part) stream.state,util.inspect(stream)andconsole.log(stream)no longer throwInvalid stream idon a stream that finished on the wire while its data is still unread, or on a pushed client stream. (#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:http2server no longer ends the whole session at the 100th stream it refuses for exceedingmaxConcurrentStreams. (#44337, in part) session.setTimeout()no longer fires 'timeout' in the middle of a download or upload. (#44481, in part)respond()andrequest()with a raw[name, value, ...]list no longer push a repeated name's value into an array the caller passed. (#44804)session.stateis{}aftersession.destroy(), like Node, instead ofundefined, sosession.state.lastProcStreamIDno longer throws. (#44816, in part)stream.close(code)on a stream that is read throughpipeline()now resets it with the given code. (#44848, in part)- A graceful
server.stop()orserver.closeIdleConnections()no longer cuts an HTTP/2 response that the kernel hasn't delivered yet. (#44874, in part) node:http2no longer emits 'aborted' on a stream whose writable side already ended. (#45056, in part)- A
node:http2client request that is reset withclose(code)while it waits for a stream slot (SETTINGS_MAX_CONCURRENT_STREAMS) is no longer sent. (#45279, in part) - A
node:http2client request that is destroyed or aborted while it waits for the connect or for a stream slot leaves the session's queue right away. (#45448, in part) session.close()andserver.close()no longer cut a large response or upload that was already framed but not yet written to the socket. (#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 itswrite()andend()callbacks, 'ready' and 'finish' in its own caller'sAsyncLocalStoragecontext. (#46036, in part) - Comment only: the one-tick cork in
ClientHttp2Session.request()is Bun's own. (#46234) stream.close()andclose(NGHTTP2_CANCEL)no longer emit 'error' ERR_STREAM_PUSH_AFTER_EOF when a DATA frame arrives while close() is still running. (#46428)session.settings(settings, callback)now runs the callback and that frame's'localSettings'event in the caller'sAsyncLocalStoragecontext, 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,ClientandAgentfrom the built-inundiciwork:request()andstream()send the request with Bun's fetch,close()waits for pending requests anddestroy()aborts them. (#27338, in part) Fixes #7920. Fixes #14498. Thanks to @igorls.ProxyAgentfrom the built-inundiciworks. (#35145, in part) Fixes #4474.dispatch()works onAgent,Pool,ClientandProxyAgentof the built-inundici, with the handler callbacks of undici 6 and of undici 7, including pause, resume and abort. (#39250, in part) Fixes #39247. Fixes #8961.- The body returned by the built-in
undici.request()hasdump()andbytes(). (#40206) Fixes #40205. - The error classes in the built-in
undicihave undici'sname,UND_ERR_*codeand default message. (#42716) Thanks to @steipete. new Request(url, { body })from the built-innode-fetchreads an old-styleStreambody such asform-data. (#43773, in part)Response.typefrom the built-innode-fetchis"default"for a 404 or 500 instead of"error". (#43966) Thanks to @breken-ai.FetchErrorfrom the built-innode-fetchhas the nameFetchError, setserrnoanderroredSysCall, and has nocodewithout a system error. (#43967, in part) Thanks to @breken-ai.Response.json(),Response.redirect()andResponse.error()from the built-innode-fetchreturn a node-fetchResponse, withbuffer(),headers.raw()and a Node streambody. (#43968) Thanks to @breken-ai.fetch()from the built-innode-fetchrejects with node-fetch'sAbortError(type: "aborted") on abort andFetchErrorwhen the request fails. (#44297, in part) Fixes #44282.- The built-in
undici.request()rejects amethodthat isn't a string, isn't an HTTP token, or isCONNECTwith undici'sInvalidArgumentError, instead of sendingGET(orCONNECT). (#44814, in part)
node:http and node:https
req.write(chunk, callback)on a client request whose socket is already destroyed now callscallbackwith the socket's error orERR_STREAM_DESTROYEDinstead of never calling it.res.assignSocket(socket)listens withsocket.on()like Node, so the socket stand-ins of serverless-http and @vendia/serverless-express (which have noonce()) work instead of throwingsocket.once is not a function.- The callback of
res.write()is always called when the connection has failed, and the callback ofres.end()wherever Node or 1.4.4 calls it. node:httpdoes less work for a pipelined response that waits for its turn and for each chunk of a request body, and a lone string written undersocket.cork()is no longer copied twice.httpandhttpsservers honorlisten({ ipv6Only: true }), likenet.Serverand Node.req.socketon anode:http/node:httpsserver is a plainnet.Socket/tls.TLSSocketfrom accept on, and 'upgrade' and 'connect' listeners get that same socket over the same handle.https.Server#listen()negotiates ALPN (ALPNProtocols,http/1.1by default,ALPNCallback,req.socket.alpnProtocol) and emits 'keylog', like atls.Server.server.emit('connection', socket),http._connectionListenerand http2'sallowHTTP1run a port of Node's own connection code, and every kind of connection has one queue of waiting responses.req.socket.bytesReadon anode:http/node:httpsserver now counts every byte the connection has read (after TLS decryption) instead of staying 0, and keeps its value after 'close'. (#28710) Fixes #28709.http._connectionListeneris exported (httpolyglot and spdy call it). (#35285, in part) Thanks to @steipete.- A 'data' or 'readable' listener on a
node:http/node:httpsserver's connection socket (server.on('connection'),'secureConnection',req.socket) now gets the raw request bytes, like Node. (#35661) Fixes #11924. res.end()now undoes ares.cork()that was never uncorked, so a later CONNECT or Upgrade on the same keep-alive connection can write to its socket instead of buffering forever. (#35664, in part)http.Servernow setsserver._handlewhile it listens (null beforelisten()and afterclose()), so a supertest request closes its server and the process exits instead of hanging. (#36685) Fixes #23648.- On
emit('connection')andallowHTTP1connections,res.end(body)followed right away byreq.socket.destroy(),res.destroy()orcloseAllConnections()no longer cuts the response off after its headers. (#37710, in part) - An HTTP/1.1 request without a
Hostheader now gets400 Bad RequestwithConnection: closeonemit('connection')andallowHTTP1connections, like a listeninghttp.Serverand like Node. (#37735) - On
emit('connection')andallowHTTP1connections, closing a socket that was handed to an 'upgrade' or 'connect' listener no longer emits 'aborted', 'error' and 'close' on the upgrade request. (#37748, in part) - A
Content-Length: 0request whose head arrives in two TCP reads now counts as complete. (#37889, in part) - A
node:httpserver'skeepAliveTimeoutis measured on the clock its timer runs on, sosetSystemTime()or a wall-clock step no longer closes an idle connection early or keeps it open, and underjest.useFakeTimers()it closes when the fake clock says so. (#37974) res.destroy()orreq.destroy()before the response head is sent now closes the connection without sending anything, like Node. (#39366, in part)res.statusMessagegoes on the wire as Latin-1 onlisten()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()andhttps.request()accept a URL object from another implementation or one created beforeglobalThis.URLwas replaced, andreq.write()/req.end()accept aUint8Arrayfrom anode:vmcontext. (#41117, in part)clientErrorreports an empty, non-numeric or oversized Content-Length as HPE_INVALID_CONTENT_LENGTH and two different values as HPE_UNEXPECTED_CONTENT_LENGTH, with Node's message, and every parse error haserr.reason. (#41234, in part)server.listen({ port, path })on anode:httpserver now binds the TCP port and ignorespath, andlisten({ path, host })binds the path instead of emitting an 'error', like Node. (#41660, in part)- A failed
server.listen()now reports Node's error:listen EADDRINUSE: address already in use 127.0.0.1:3000, witherrno,syscall,addressandportset. (#42742) Thanks to @steipete. server.listen({ port, reusePort: true })now sets SO_REUSEPORT outside a cluster worker, so two processes can share a port as they can withnode:netandBun.serve. (#43692) Fixes #43690.server.listen(null)andserver.listen(null, host)bind a free port, like Node, instead of port 3000 (orPORT). (#43726, in part)- A
node:httprequest with aContent-Lengthbody now emits 'end' after the promise jobs queued by the last chunk's 'data' listeners, like Node. (#43736, in part) server.timeoutand thesetTimeout()of a server, request, response or socket no longer cut a response that's still being written:res.write(),res.end(),res.flushHeaders(), 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 throwsERR_HTTP_BODY_NOT_ALLOWEDlike 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 anode:httpserver now sends its FIN once the bytesres.write()left buffered are out, even if the response never ends. 1.4.4 never sent it, so the client waited forever. (#44244, in part)- A
node:httpserver 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 replacedres.writeHeadcallsflushHeaders()orwrite(). (#44442)res.writeEarlyHints(),writeContinue()andwriteProcessing()send the 1xx response right away instead of when the handler returns, so Early Hints reach the client before synchronous rendering. (#44468, in part)http.Server.call(httpsServer)leaves that server alone, as in Node, andhttp.createServer({ _pfxExtraCACerts })no longer throws. (#44609, in part)- A failed
Bun.serve(),Bun.listen(),http.Server#listen()ornet.Server#listen()now reports the error of the call that actually failed, on every platform. (#44651) Fixes #30363. - A chunked
http.request()body written underreq.cork()is no longer sent after the terminating chunk whenreq.end()runs beforereq.uncork(),req.uncork()emits 'drain' after a corkedwrite()returned false, and anuncork()with no matchingcork()no longer holds the next chunks of a message back untilend(). (#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()andhttp.createServer({ keepAlive, keepAliveInitialDelay })now turn on TCP keep-alive for server connections, so a peer that vanishes under an SSE stream or a tunnel gets detected. (#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:clusterworker, http and https servers share a Unix socket path,listen(0)andlisten({ fd })through the primary, like Node. (#45186, in part) Fixes #44794. - When a 'checkContinue' listener answers
Expect: 100-continuewith a final status and never callswriteContinue(), the response now saysConnection: closeand the server closes the connection, like Node. (#45189) server.close(callback), the server's 'close' event andawait using serverno longer hang when user code removes the last socket's 'close' listeners or when one of its 'close' listeners throws. (#45257)node:http('connect' / 'upgrade' hand-off) andnode:dgram(Symbol.asyncDispose) no longer call the program'sFunction.prototype.bindon 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 --hotno longer keeps thehttp.Server(and the whole app behind it) of every earlier reload alive. (#46039)ConnectionsList#expired()fromprocess.binding('http_parser')no longer wraps timeouts above 4294 ms. (#46411)- A
node:httpserver process now exits right afterserver.close()orserver.unref()when the only thing left is a paused upload whose response was already sent, like Node and Bun 1.4.2. (#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.servesends the automatic100 Continuefor any casing ofExpect: 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 fallbackContent-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-Dispositionheader thatBun.serveadds to aBun.file()response now has a disposition type (inline; filename="a.bin"), so RFC 6266 parsers such as npmcontent-dispositionaccept it. (#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-continueand aContent-LengthovermaxRequestBodySizegets the413alone, over HTTP/1, HTTP/2 and HTTP/3. 1.4.4 sent100 Continuefirst, which started an upload that the server then refused. (#34960) - A
routesentry ofnew 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 thatBun.servesends when a handler returns no Response no longer carriesContent-Lengthtwice. (#35294) Fixes #29714. Bun.serveno longer sends the upstream'sContent-Encodingnext to a body thatfetch()already decoded, however the handler hands that body on:return fetch(upstream),new Response(res.body, res), a clone, atee()branch, a Blob, a static route,HTMLRewriter. (#35546)server.urlno longer throwsInvalid URLfor a unix socket path such asC:\test, and#,?or a space in the path no longer cut it short. (#35806) Fixes #40182.- A fetch handler that returns
new Response(Bun.file(path))for a path that can't be opened now gets404 Not Found(ENOENT, ENOTDIR, EISDIR, ENAMETOOLONG) or403 Forbidden(EACCES, EPERM) instead of a 500. (#36593) Fixes #4292. - A Worker that runs
Bun.serveornode:httpno longer leaks about 800 KB per server type (HTTP, HTTPS, HTTP/2 and HTTP/3) when it exits. (#37513) Bun.servecloses the connection after it answers aCONNECTrequest. (#37585)- An HTML route whose bundle or plugin load failed now answers every request with 500. (#37916)
req.paramsis no longer shifted when a route has a:inside a literal segment:/files:batchGet/:idnow gives{ id: "123" }instead of{ batchGet: "123", id: "" }. (#37983, in part)- A
Bun.file()response reaches a client that half-closes right after its request (nc -N, socat, Go'sCloseWrite, some health checkers) in full. (#38107) server.fetch("/path")on a server started withport: 0gives the handler arequest.urlwith the port the server bound, not:0. (#39027)routesaccepts an in-memoryBlobas a route value, so"/favicon.ico": Bun.file(favicon)withfaviconimportedwith { type: "file" }no longer throws at startup in abun build --compileexecutable. (#39304)req.bodyof an upload that arrived completely stays readable after the request ended. (#39660)--defineand the bunfig[define]table now apply to the scripts of HTML imports served byBun.serveandbun ./index.html, in development and production. (#40485) Fixes #40479.- Types only: a method key in
routesnow accepts every static value the runtime already accepts there, not just a Response or a handler. (#40901, in part) Bun.servefalls back toread()+write()whensendfile(2)refuses a file of 1 MiB or more with EINVAL, ENOSYS, EOPNOTSUPP or EPERM (a filesystem without splice support, a seccomp policy). (#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-*.jsand/chunk-*.cssURLs 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/416for a Range request or a slice, plusContent-TypeandContent-Disposition. (#41585, in part) Bun.serveno longer aborts a response that a slow client is still reading. (#41602)- When a
Bun.file()response runs out of bytes before itsContent-Length(the file shrank while it was being sent, orfstatoverstates it as for a sysfs file),Bun.servesends what the file has and closes the connection. (#41622) server.fetch()now calls the handler with(request, server), sofetch(req, server) { server.upgrade(req) }no longer throwsundefined is not an objectunderserver.fetch(). (#41755, in part)Bun.serve()defaults to production mode when the program setprocess.env.NODE_ENV(orBUN_ENV) to"production"before the call, as the docs say (@default process.env.NODE_ENV !== 'production'). (#41926)- A string
Responseis sent astext/plain;charset=utf-8after something read.bodyor cloned it. (#42064) - With
development: true, an HTML route that arrives throughserver.reload()(or a secondBun.serve()underbun --hot) on a server that started without one now gets the dev server. (#42834) Fixes #23564. - A static
Responseroute or aBun.file()route whose headers sayConnection: closenow closes the connection after that response and doesn't answer requests pipelined behind it. (#43107) - A
Bun.file()route ignoresTransfer-EncodingandContent-Lengthheaders on itsResponseand frames the body from the file's size, like static routes and fetch handlers do. (#43108) Bun.serveno longer adds the automaticContent-Dispositionheader when the file name isn't safe inside a quoted string. (#43742)Bun.servesendsConnection: closein the last response on an HTTP/1 connection: when the request saidConnection: close, when it was HTTP/1.0, and when a gracefulserver.stop()closes the connection after the response in flight. (#43867) Fixes #43853.- The server no longer cancels or closes a
Responsebody stream that another reader holds. (#43973, in part) - A
%that isn't followed by two hex digits stays a literal%inreq.paramsand 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 answers206withContent-Range: bytes 5-15/*, likeslice(5, 20)andslice(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
Responsenow sends its complete HTTP/1 head as soon as the handler's microtasks are done, instead of holding the end of the header section back until the first body chunk. (#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 whenBun.servelistens on a unix socket. (#45360) Fixes #43816.- With a
"/*"route that names only some methods, every other method now falls through tofetch(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 aBun.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.
fetchkeeps one table of its in-flight requests for HTTP/1.1, HTTP/2 and HTTP/3, and requests with the sametlsoptions share one TLS context through a CONNECT tunnel and over HTTP/3.new Request()reads its arguments by the rulefetch()uses, andtext(),json(),bytes(),arrayBuffer(),blob()andformData()read a body through one function.fetch()follows a relative redirect whoseLocationhas://in its path, query or fragment, like/login?next=https://app.example.com, because everyLocationis now resolved the waynew URL(location, currentURL)does. (#30065)- Acquiring a reader on
response.bodyorrequest.bodyno longer uses the body. (#33461, in part) - A fetched Response has
type === "basic"and aurlwithout 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 adata:URL follows the Fetch standard: unpadded or line-wrapped base64 decodes,;BASE64is recognized,Content-Typeis the URL's own MIME type, it stays readable after the body is consumed, and a bad URL rejects with a TypeError. (#34896)fetch()treats a 204 or 304 response as complete at its header block even if it carriesTransfer-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 thetypethe client sent. (#35244, in part) Fixes #33012.new Request(request, init)andnew Request(url, response)copy an empty body instead of sharing the input's stream, so both can be read. (#35858, in part)Headers.prototype.getSetCookieandgetAllthrow a TypeError whenthisis not a Headers, instead of returning undefined. (#36000, in part)fetch()andnode:httprequests send every header. (#36433)fetch("file:///missing")rejects with a TypeError (code: "ENOENT"), and a directory rejects with EISDIR, instead of resolving withstatus: 200, ok: trueand failing when the body is read. (#37425)fetch()with aBun.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 aBun.file()that can't be read rejects with a TypeError, like a bareBun.file()body (#37501). (#37514)fetch()no longer fails withECONNRESETwhen a POST or PATCH gets a 307/308 and the server closes the connection right after it withoutConnection: close. (#37522, in part)Response.redirect("")has a readable emptyLocationheader:headers.get("location")is""and iteration andnew Headers(h)include it. (#37893)fetch(url, { unix })follows a same-origin redirect (a relativeLocation, or an absolute one on the same host and port) over the unix socket. (#38394)fetch()re-sends aBun.file()body of 32 KiB or more when it follows a 307 or 308 (or a 301/302 for PUT, PATCH, DELETE). (#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 aurlthrows a TypeError instead of a plain Error, like an invalid URL already did. (#41551)- The
curlcommand thatverbose: "curl"andBUN_CONFIG_VERBOSE_FETCH=curlprint can be pasted into bash or zsh as is. (#41726, in part) fetch()sends the body's Content-Type for an emptyURLSearchParams, typedBloborFile, also throughfetch(new Request(...)). (#41752)fetch(url, { unix })rejects with a TypeError whenunixis a Buffer, URL, number,trueor any other truthy non-string, instead of ignoring it and sending the request to the URL's host over TCP. (#41786)- The redirect half is in with #35222 (
Response.redirect(url, {})is a 302), andnew Request(url, { status: 0 })no longer throws since #41994. (#41841, in part) - On macOS,
fetch()no longer uploads aBun.file()body withsendfile(2). (#41905) new Request(input, init)keeps the input's method wheninithas nomethod, whatever kind of objectinputandinitare. (#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
headersvalue that isn't an object (new Headers(1),fetch(url, { headers: "a: b" }),new Response("", { headers: true })) throws Node's ERR_INVALID_ARG_TYPE instead ofTypeError: Type error. (#42961, in part) - A
Headerssubclass or instance with its ownSymbol.iteratoris converted through that iterator wherever a HeadersInit is accepted, fromnew Headers(h)toserver.upgrade(). (#43023) fetch.preconnect("https://bun.com")andbun --fetch-preconnect https://bun.comwork. (#43034) Fixes #19626.fetch()accepts an HTTP/1.1 response with up to 1000 header fields. (#43181, in part)fetch(undefined),fetch(123)andfetch({ toString() { return "/path" } })reject withfetch() URL is invalidinstead of the misleadingfetch() URL must not be a blank string. (#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()andserver.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 aBun.file()handle whose size was read as 0 before the file was written). (#44896)text()andjson()drop one leading UTF-8 BOM for every kind of body. (#45486, in part)fetch()honors the server'sKeep-Alive: timeout=Nheader: it stops reusing an idle connection 2 seconds before the timeout runs out, or halfway through it when N is under 4, so aPOSTno longer fails withECONNRESETbecause it raced the server's idle close. (#45497, in part)response.headersandrequest.headerskeep theContent-Typeof a typed body (Blob,Bun.file(),URLSearchParams,FormDatawith its boundary) when they are first read after.body, after a reader such as.text(), or afterfetch(request). (#45601) Fixes #45245.- A
Content-Encoding: gzipbody 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 emptyContent-Type:header for a Blob whosetypeis"", such asfile.slice(0, n)or an S3 file withouttype. (#46194)- With
--experimental-http2-fetch, a request the server refuses withHTTP_1_1_REQUIREDis sent again over HTTP/1.1 instead of rejecting withHTTP2StreamResetorHTTP2GoAway. (#46259) Response.json()decides by whatJSON.stringifyreturns, like the spec and Node. (#46397)- A program that only reads
res.statusof large responses no longer stalls inawait fetch()for about 15 seconds. (#46434, in part) - Hardening against misbehaving peers: #42091, #43618, #44534.
Other
- After
worker.disconnect()orcluster.disconnect()in the primary, a worker's IPC channel stays open until its servers and sockets have closed, as in Node, so aprocess.send()from a 'close' listener arrives. - A child's messages, 'disconnect', 'exit' and 'close' come in that order (
onDisconnectbeforeexitedandonExitforBun.spawn). On Linux without pidfd (kernels before 5.6, gVisor) 'exit' can still come first, as on 1.4.4. The callback of asend()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 bothlibrary: "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()andrequest.clone()are typed as Bun'sResponseandRequestin a project withoutlib.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,
tscon 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 nonode: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 plainreq.socket236 files (227 pass, and the other 9 also fail onmainor need Docker, the internet or a non-root user),node:httpresponses 108 files (6,261 tests pass, 3 failed: one is fixed since, one belongs to #46522, one needs a non-root user),Bun.serve106 files (4,845 pass, 1 needs a non-root user), bodies 121 files (7,553 pass, 2 fail: that one, and a 10 s limit on a loaded machine),fetchand the shims 112 files (6,897 tests, 2 failed and are fixed),node:http213 files (1,232 pass). - Node's own tests during the second pass: 465 of 467
test-http-*andtest-https-*files (the other 2 fail onmaintoo), 257 of 259 http2 files (the other 2 pass when run separately), 305 of 311test-quic-*andtest-dgram-*files (6 failed before it too, 5 of them the same). - 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 theContent-Encodingchange differ, plus 2 rows by timing), 2,870 rows ofBun.serveoptions (only an internal key differs), 44 failed listens (all 22node:*rows equal Node, none did on 1.4.4), 1,440 rows ofnode:httpresponse bytes (450 changed, all to Node's), 798TLSSocketmembers of an httpsreq.socket(all equal atls.Serversocket's), 308 proxy shapes (78 lose a falseContent-Encoding, none a true one), 7,104node:http2teardown cases (2 differ, listed under #43433), 35 ways to end a server under LeakSanitizer (no leak). - 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:httpconnection costs the same 8 calls as before the pass, and 800Bun.servekeep-alive requests the samesendto,recvfromandepoll_pwait2counts. - Speed, on CI's release builds from before the second pass, with
mainagainst a copy of itself as the noise control (typically 1 to 3 %).Bun.serveover HTTP/1.1 andnode:httphello world were within noise.fetchkeep-alive (plain, TLS, and withKeep-Alive: timeout=5) was within 0.7 % ofmainin cycles per request, with 2 to 4 % fewer user instructions. Slower: anode:http2server with 100 streams (1 to 2.5 % more cycles) and anode:http2client downloading 1 MB bodies (4 to 5 %).require("node:http")took 1 % longer and the binary grew by 418 KB. - Speed during the pass. On CI's profile build with about a third of the
node:http2work: a server's 300,000 GETs take 4.0 % fewer user instructions thanmain(it was 3.7 % more), and a client's 1 GB download 0.3 % more cycles (it was 5.0 %). uWS by itself, compiled with release-like flags on stubbed sockets, in instructions per request: hello world 3,537 to 2,901, 16 headers 8,959 to 4,912, a static route 3,996 to 3,134, a file 3,875 to 2,786. - A known cost, not measured on a release build: a
node:httpresponse that has to wait for its turn (a pipelined request with an async handler) takes about twice the instructions. Without pipelining, or with a synchronous answer, it's the same or cheaper. - 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.