WebSocket: real closing handshake, one failure path, `ws` rewritten on native transports (consolidates the open WebSocket PRs)

Merged
JA
21d ago
+35606-9680

What does this PR do?

This PR replaces the open WebSocket pull requests (listed in the table at the end) with one change. It covers the global WebSocket client, Bun.serve({ websocket }), the built-in ws package, the parts of node:http that ws depends on, undici's WebSocket exports, and the types, docs and tests for all of them.

I treated every open PR as a bug report. For each one I reproduced the bug on main, ran the same script on Node 26.3 (undici) and on npm ws, checked the RFCs, and then wrote the fix the way the code should have been structured in the first place. Often that meant deleting a mechanism that caused several bugs at once. Many fixes below had no PR. They came out of those comparisons and are marked "(no PR)".

Size: native and builtin source outside ws is +5,893 / -5,374. ws.js (1,744 lines) becomes ws.ts (3,399 lines, about 450 of them types). Tests are +22,739 / -1,786.

How I decided close calls:

  • require("ws") behaves like npm ws on Node.
  • The global WebSocket behaves like the WHATWG spec and Node's global. Bun's extras stay.
  • Bun.serve follows RFC 6455 / 7692 on the wire and does what its docs and types say.

Behavior changes to look at first

These are the changes I expect users to notice. They're listed so that any of them can be vetoed.

Global WebSocket

  1. Real closing handshake. Before, close fired about 0.2 ms after the close() call with our own code and wasClean: true, no matter what happened next. Now close() sends the Close frame, sets readyState to CLOSING and keeps the process alive. Once both Close frames are exchanged, the client closes its side (FIN, close_notify), and close fires when the connection closes, as in Node (undici and npm ws) and browsers. The event has the server's code and reason (1005 if its frame was empty) and wasClean: true.
    • If the server echoes the Close frame but never closes the connection, the client drops it at the timeout. The close is still clean and still has the server's code.
    • If the connection ends before the server's Close frame, or that frame doesn't arrive within BUN_CONFIG_WS_CLOSE_TIMEOUT, the result is 1006, not clean. The timeout is 30 s, in practice 32 to 36 s because uSockets sweeps every 4 s.
    • Cost: a script that ends in ws.close() exits one round trip later. If the server never answers, the process stays alive until the timeout. terminate() and process.exit() are the ways out. npm ws also waits 30 s; undici waits forever.
    • Data queued before close() still goes out first. The timeout starts over whenever more of it can be written, so a slow upload isn't cut off (100 MB at 2 MB/s arrives whole, also through a proxy), and a peer that stops reading is dropped one timeout after its last read. Progress means the kernel reports the socket writable: on loopback with the default 30 s, a reader at 20 KB/s counted as stalled and one at 50 KB/s didn't. main waits forever.
    • Two tests from #44618 pinned the old model on purpose. I rewrote them.
  2. close() without a code sends an empty Close frame, and both ends report 1005, as in browsers, Node and npm ws. A Bun.serve close(ws, code) handler now sees 1005 when a Bun client calls close(). When the server closes first, the client echoes what it received: 1001 stays 1001, empty stays empty.
  3. One failure path. Whenever the client fails the connection (bad handshake, TLS, proxy, protocol error, message too big, invalid UTF-8, bad close code), it fires error, then close with 1006, reason "", not clean. Before, close had 1002 / 1007 / 1009 / 1011 / 1015 with an English reason, sometimes wasClean: true, and often no error fired. The text is now in ErrorEvent.message, and ErrorEvent.error.code is new (ECONNREFUSED, ENOTFOUND, DEPTH_ZERO_SELF_SIGNED_CERT, ERR_TLS_CERT_ALTNAME_INVALID, WS_ERR_INVALID_UTF8, ...). If the failure happens after open, the client sends a Close frame with 1002 / 1007 / 1009 first. Losing the TCP connection after open isn't a failure: only close 1006 fires, with reason "" (was "Connection ended").
  4. The 101 response is validated strictly. The connection fails if the server picks an extension or permessage-deflate parameter that wasn't offered or is invalid (RFC 6455 9.1, RFC 7692 7.1). That includes a server that echoes client_max_window_bits without a value. A response head over --max-http-header-size + 1 KB now fails no matter how TCP splits it into reads. Before, a head of about 17 KB or more got through if it arrived in one read.
  5. Header values are sent one byte per code unit, like fetch and Node: "café" is 63 61 66 e9 (was UTF-8). Host / Proxy-Connection in proxy.headers replace Bun's own line in the CONNECT request. fetch shares that writer, so this changes fetch too.
  6. terminate(), a failed connection and the close timeout now close with a FIN instead of a RST. The kernel still sends a RST if data is unread. The client answers a peer's FIN with a FIN.
  7. Messages that arrive together with the 101 are delivered after open's microtasks, as in browsers and undici. npm ws has the old order, so ws on Bun now differs from it there, in the harmless direction.
  8. Smaller changes:
    • An invalid binaryType is ignored (used to throw).
    • ws.url is ws: / wss: when the input was http: / https:.
    • MessageEvent.origin is the origin, not the URL with its password.
    • bufferedAmount no longer drops to 0 at close.
    • send(Bun.file()) throws (used to send an empty frame).
    • A ping or pong that nothing listens for is dropped. It used to be queued for a later listener, so a flood of them grew memory to gigabytes. Automatic pongs are unaffected.
    • Interim 1xx responses count toward the response head's size limit (--max-http-header-size). An endless stream of 100 Continue used to freeze the event loop.

Bun.serve({ websocket })

  1. websocket.error is called as (error, ws) and is now typed. It used to be called as (error), so existing handlers keep working and new ones can tell which socket failed. The docs said (ws, error), which never matched the implementation. They now say (error, ws).
  2. server.stop(true) closes each WebSocket with 1001 "Server closed", including through using server. The client and the close handler used to see 1006. stop() is unchanged.
  3. A long ws.close(code, reason) reason is cut at 123 bytes on a character boundary. It used to be cut mid-character, so the client saw 1007. Every numeric code is still accepted and sent as before.
  4. server.upgrade():
    • reads the handshake from req.headers only;
    • returns false once a Response has been accepted for the request;
    • throws for a Request that another server received;
    • ignores Upgrade, Connection, Sec-WebSocket-Accept / -Key / -Version / -Extensions, Content-Length and Transfer-Encoding in options.headers;
    • treats an empty Sec-WebSocket-Protocol in options.headers as declining the subprotocol.
    • Related: an HTTP/1.1 Upgrade or CONNECT request without Host gets a 400 (node:http is unchanged).
  5. The configured compressor is actually used. Every compress: "dedicated" / "4KB"..."256KB" setting ran as "3KB". Each now uses the window its name says, which costs memory. Per connection after traffic: "dedicated" 149 → 282 KB, "128KB" → 218, "64KB" → 186, "16KB" → 154. Off, true and "shared" stay at 2 KB. "3KB" is what every setting used to run as. The libdeflate compress fast path is removed (because of the Safari bug described below; numbers are under Performance). x-webkit-deflate-frame (Safari 14 and older) is no longer accepted, so those clients run uncompressed. A dedicated compressor accepts and echoes an offer of server_max_window_bits=8, as npm ws does. The shared compressor (the default) is always 15 bits, so it declines a smaller window, and so does any offer that also has server_no_context_takeover. Those connections run uncompressed. Released Bun answered it with 9, which the RFC doesn't allow. (zlib deflates with 9 bits, and that output always inflates with an 8-bit window.)
  6. When the server fails a connection, the client now gets a Close frame with 1002 / 1007 / 1009 and the reason. The close handler still gets 1006, but its reason is never empty now. The socket then stays open until the peer's FIN arrives, or for 4 to 16 s.
  7. idleTimeout: 0 arms no timer, so there are no automatic pings either. Those connections used to die at about 256 s.
  8. maxPayloadLength no longer applies to Ping, Pong and Close frames. They have their own 125-byte limit.
  9. The error messages of ServerWebSocket methods are now consistent with each other. A closed socket still ignores the call, whatever the arguments are.
  10. A throw after server.upgrade() is no longer swallowed. It goes to error() or stderr, the server keeps serving, a surrounding bun test fails, and the process exits with code 1 when it ends naturally, same as for any printed fetch error. This holds for a sync fetch, and for an async one that was really suspended (a timer, I/O) before it called upgrade(). If an async fetch upgrades before its first real suspension and then rejects, that is still an unhandled rejection and ends the process, as on main.
  11. After server.reload(), the new websocket options also apply to sockets that are already open. The handlers already did.

ws

  1. 'error' gets an Error (was an ErrorEvent) and is fatal without a listener, as in Node. A failed connection attempt, or on a server a hostile frame, ends a process that has no 'error' listener.
  2. 'close' is (code, reason: Buffer) (was a string plus a third argument). It fires when the connection closes, or at closeTimeout. Failures are 1006: 'error' fires immediately and 'close' when the connection closes, as in npm. (The global WebSocket fires both immediately, as undici does.) close() without a code gives 1005. 'unexpected-response' is not followed by 'close'.
  3. A text message is a Buffer for every binaryType.
  4. ws servers have no idle timeout, no automatic pings and no backpressure limit. They used to inherit 120 s, a ping at 104 s and 16 MiB from Bun.serve.
    • Automatic Pongs are the one thing still capped at 16 MiB, because the peer controls how many there are. They're skipped while that much is buffered for the socket (RFC 6455 5.5.3; npm queues them all). The cap applies on every transport and also to the clients (ws and the global WebSocket), which had no cap on main.
    • The default maxPayload is 100 MiB (was 16).
    • uWS no longer preallocates the whole length a frame header announces before the bytes arrive. It preallocates at most 16 MiB. This also applies to Bun.serve with a large maxPayloadLength.
  5. WebSocketServer({ perMessageDeflate }) actually negotiates permessage-deflate, and ws.extensions is a string. The uWS workaround for Safari 15.0-15.3 no longer applies to ws on node:http (npm ws has none). Bun.serve keeps it.
  6. With an HttpsProxyAgent, each hop is verified with its own TLS options, as on Node. The agent's options (rejectUnauthorized, ca, ...) apply only to the connection to the proxy, and the WebSocket's options only to the target. Before, both sets were merged and used for both hops, so new HttpsProxyAgent(url, { rejectUnauthorized: false }), meant for a self-signed proxy, also turned off certificate checks for the target. Code that relied on that has to pass the option to new WebSocket(...) too.
    • All 42 combinations I measured on Node with the real https-proxy-agent (14 sets of options, for http→wss, https→ws and https→wss) now give the same result.
    • The native client gets a new option for this, proxy: { url, headers, tls }, also on the global WebSocket. With proxy.tls (even {}), the proxy hop uses only proxy.tls and the target only tls. Without it, tls applies to both hops as before. serverName and checkServerIdentity are never shared: tls's are for the target and proxy.tls's for the proxy. Proxy agents stay on the native client.
    • proxy.tls: true means {}, falsy values mean "not set", and any other non-object throws. pfx works in it.
    • An agent that has neither connectOpts nor proxyRequestOptions gets no proxy.tls, so it behaves as on main. With credentials in an agent's proxy URL, they win over a Proxy-Authorization header, as in https-proxy-agent.
    • 'error' gets the value checkServerIdentity returned, as in Node. It used to get a generic TLS error. If the proxy can't be reached, 'error' names the proxy, not the target.
  7. Smaller changes:
    • A client stays CONNECTING when an 'upgrade' listener throws, as in npm. It used to emit 'open'.
    • A client no longer fails on a Sec-WebSocket-Version response header, a repeated permessage-deflate entry, or window bits written like 015 or 0xf, all of which npm ws accepts.
    • send() while CONNECTING throws.
    • ws.url is undefined on server sockets.
    • on* / addEventListener are built on the emitter, as in npm: they use ws's own event classes (not instanceof Event), addEventListener("upgrade") does nothing, and the signal option is ignored.
    • A server socket is instanceof WebSocket.
    • After 'open', only protocol errors are 'error' events. A reset, a broken TLS record or a failed renegotiation is just 'close' 1006, as in npm and on main.

Differences from npm ws that I kept (veto any):

  • An invalid close code throws and leaves the socket OPEN. That matches ws 8.22.0. 8.18.3, the version in test/node_modules, leaves it CLOSING forever.
  • A header whose value is an array is sent as one line joined with ", ". npm sends one line per element. Both join cookie with "; ".
  • mask: false / generateMask are ignored, binaryType: "fragments" gives one fragment, and the Sender / Receiver classes throw.
  • After a native upgrade, req.socket.pause() doesn't stop reads (ws.pause() does), and req.socket emits no 'end'.
  • terminate(): on a server, 'close' fires before the callbacks of sends made after terminate(). On a natively connected client, data that is already buffered is delivered during terminate(), not after it returns. terminate() in a 'message' listener drops the rest of the packet being parsed.
  • A 'message' listener that throws doesn't stop later messages (npm's receiver stalls). addEventListener("ping" | "pong") works.
  • A Close reason that isn't valid UTF-8 is sent with U+FFFD (npm sends the raw bytes). If that makes it longer than 123 bytes, close() throws RangeError and the socket stays OPEN.
  • After the body of a non-101 response, the client closes the connection (Node keeps it for its agent).
  • send() of a file-backed Blob throws (npm reads it asynchronously).
  • Pings aren't answered while more than 16 MiB is buffered for the socket (npm queues a Pong for each, with no limit).
  • A frame that breaks two rules at once (say RSV2 set and unmasked) is rejected with Close 1002 like in npm, but 'error' may name the other broken rule. Frames that break one rule get npm's exact error. Released Bun emits no 'error' and sends no Close frame for any of them.
  • On a paused, natively connected client, bytes that arrive after pause() stay in the kernel's buffer, so terminate() and the close timer drop them. Node has already read them and delivers them.
  • A paused native socket (client or server) doesn't read, so it doesn't see the peer's FIN until resume() or terminate(). That includes a createWebSocketStream() whose consumer has stalled. Node keeps reading up to 64 KiB while paused, so it sees a FIN that follows little or no data.
  • req.destroy(), req.abort() or an aborted signal inside an 'upgrade' listener of a natively connected client fails the handshake: 'error', then 'close' 1006. Node emits 'open', then 'close' 1006. I tried Node's order and took it out again: it leaked the connection when a listener threw.
  • req.setTimeout() does nothing on a natively connected client, so an 'unexpected-response' listener can't change or turn off handshakeTimeout, which stays armed until the body ends. Until the response head is complete, handshakeTimeout is a fixed deadline that interim responses don't restart.
  • On a server, send callbacks that are still waiting when the closing handshake finishes run when the connection closes, right before 'close', not at the moment of the flush: with no error if the kernel took everything, else with ERR_STREAM_DESTROYED. readyState is already CLOSED there. On TLS, a drain can report success while uSockets still holds the last record.
  • If a send callback throws, the ones queued behind it still run, each once. Node drops some of them, depending on how the writes were batched.
  • ws.send() followed by ws._socket.destroy() in a 'message' or 'ping' listener of a server drops that message. terminate() and close() deliver it.
  • Through a proxy, the request passed to finishRequest emits no 'socket' event before end(). The native client connects with the final headers, so there is no socket yet.

Not taken:

  • #35031 (WHATWG-only close codes): breaks close(1001) / close(1011), which work today, with no gain for portable code.
  • #41712 (env proxy by default): a product decision, stays open.
  • #36749 (serialize async message handlers).
  • #39255 (keep the ping deadline across sends): TCP already notices a dead peer, and it could drop a slow one that is alive.
  • #44122, #20533, #37559: refactors.
  • #35730, #36561: speed-ups of tests that CI skips.
  • #31205: can't be reproduced.

Also not here:

  • closeOnBackpressureLimit over TLS still takes about 12 s to close. It's an old bug, and the fix changes EOF routing in the TLS engine.
  • ws.remoteAddress can still be undefined when it's first read inside close(). Filling it in eagerly costs a getpeername per connection. The docs say to read it while the socket is open; the value is kept after that.
  • Throws inside websocket.* handlers are routed the way they are today.
  • The ws client option secureContext is honored on direct connections but still ignored when connecting through a proxy (same as before this PR).

Changes

Global WebSocket (client)

The connected client now tracks two flags (our Close frame was sent, the peer's Close frame was received) and has one exit, finish(). The C++ side has one finish(), one constructor path and one sendFrame(). That replaces six dispatch sites, three copies of the failure block, a 130-line switch and 14 send / ping / pong overloads.

Crashes, hangs, lost data:

  • send(); send(); close() lost data. On ws:// the socket was shut down in both directions right after the Close frame, so anything the server sent next triggered a RST that also destroyed unsent bytes. A plain echo server got 10 of 32 small messages (2 of 32 at 64 KiB) while the client reported 1000, clean, and 50 ordinary closes caused about 50 RSTs. Now nothing is shut down after the Close frame, and the FIN is sent once both Close frames are exchanged: 32 of 32 in each case, including when the script just ends. (#45196)
  • close() with unsent data queued and a peer that stopped reading never finished, and terminate() was a no-op in CLOSING, so the process hung forever. The timeout is now armed when the Close frame is queued. terminate() works in every state except CLOSED and reports a close code that was already received. (#36112; the timeout: no PR)
  • A server's Close frame that arrived under send backpressure left readyState OPEN, and later send()s were queued behind our own Close echo. (#36112)
  • A send queue whose tail landed on the end of its ring buffer never flushed the wrapped part (bufferedAmount stuck at 7). (#44821) Growing such a queue blocked send() for 2.9 / 11.4 / 43.6 s at 16 / 32 / 64 MB. LinearFifo::realign is now one rotate_left. (no PR in this set; #44929 touches the same function)
  • wss:// through a proxy: Close and Pong frames overtook queued messages (BIN x185, CLOSE, BIN x71), so 71 of 256 messages were lost behind a "clean" close. (#44822)
  • A masked Ping, Close or continuation frame from a server was parsed with the mask key as payload, and the connection continued out of sync. Any masked frame now fails the connection. (no PR)
  • permessage-deflate; client_max_window_bits=8 was accepted, and then the first compressed message failed with 1011 "Compression not implemented yet". (#38632)
  • send() / ping() / pong() of Bun.file() or an S3 file silently sent an empty frame. They now throw ServerWebSocket's error, in every state. (#39082)
  • A detached buffer hit ASSERTION FAILED: m_ptr on debug builds. It's now an empty payload, as in WebIDL, ServerWebSocket, Blob and Response. (#35029, without its TypeError)
  • new WebSocket(url, ["ÿ".repeat(357913942)]) aborted the process. (#42216, reduced to the one call site that expands its input)
  • Deleted EncodedSlice::utf16_global, a safe fn that marked a borrowed slice as "C++ frees this". Text frames now reach C++ as an owned string. Fixes #31968. (#31982)
  • pause(); close() kept the fd open (on TLS the server's too) until the timeout. Closing now resumes reading. ws is the exception: there the socket stays paused, as in npm. (#42974)

Events and state:

  • Every failed connection fires error, then close 1006. Subprotocol mismatches used to give close 1002 ... wasClean: true and no error. (#45122) Failures after open do the same and send a Close frame first. Before, 22 kinds of bad frame reset the TCP connection and reported 1002 / 1007 / 1009 / 1011 without error. RSV1 without the extension was 1011, "clean". An illegal close code from the server was 1002, "clean". Corrupt deflate data was 1002 and is now 1007. (no PR)
  • Errors have error.code, and the message includes the detail: Expected 101 status code: 503, TLS handshake failed: self signed certificate, Proxy connection failed: 403 Forbidden. (no PR)
  • A connect failure detected synchronously (a missing unix socket) left readyState at 3 right after the constructor and put the whole sentence, URL included, in close.reason. It now looks like an asynchronous failure and reports ENOENT. (no PR)
  • The automatic Pong is sent before the ping event fires and never after our Close frame. Before, close() in a ping listener on wss:// sent an empty Pong after the Close. (no PR)
  • Events run in the AsyncLocalStorage context of the new WebSocket() call, as in Node. Sockets created inside a Bun.ModuleGraph get their store too. The snapshot leaves out the graph's own frames, so an open socket doesn't keep its graph alive. (#33066)
  • Frames that arrive with the 101 wait for resume() if the socket was paused in open or earlier (#43018), and are delivered after open's microtasks (#43696). This takes one field, with no native microtask and no special cases for nested event loops.
  • isPaused is false, and pause() / resume() return false, unless the socket is CONNECTING or OPEN. (#42978)
  • After the connection closes, bufferedAmount keeps the bytes that were never sent, and then grows only by the payload bytes of later send() calls. It used to count framing and pings and skip Blobs. This is what #31760 (already closed) asked for. (#31761, as one snapshot in the single exit, with no FFI override)
  • A length of 0 encoded in 16 or 64 bits is accepted, like every other non-minimal length (undici and npm ws accept them too). A length of 2^53 or more has its own error. (no PR)

Constructor and arguments:

  • Options are read through the prototype chain, stopping at Object.prototype. Before, inherited keys and getters were ignored, while Object.prototype.perMessageDeflate = false turned compression off everywhere. (#41674)
  • The documented protocols: "chat" threw "Value is not a sequence", and protocol: "chat" was dead code. (#41705) A second argument such as null or 5 is converted per WebIDL (was TypeError: Type error). ['a', 'A'] is a duplicate. (no PR)
  • One invalid proxy.headers entry dropped all of them, including Proxy-Authorization. It now throws. (#37638)
  • ws://:token@host sent no credentials, and user:p%E9ss sent the wrong password. (#44964) An explicit Proxy-Authorization header beats the proxy URL's credentials, matching fetch and the target (non-proxy) path. #44964 pinned the opposite. (no PR)
  • The URL's credentials are kept out of ErrorEvent.message and the SyntaxErrors. (#41698, using URL::removeCredentials(); its fetch half landed in #42692)
  • ping(undefined) sent "undefined". (#39071) ping(null) sends no payload either, (no PR)

Opening handshake:

  • A proxy's 407 was reported as "Proxy connection failed" whenever it arrived in one read, and HTTP/1.1 200 without a reason phrase was rejected. (#43445) Any 2xx opens the tunnel, as in fetch. (#43511)
  • A 101 with more than 128 header fields failed with "Invalid response". server.upgrade(req, { headers }) with 125 headers triggers it. (#43243, bounded by the byte limit instead of a new constant) The byte limit gave different results for different TCP segmentation. (#41573, WebSocket half)
  • 100 Continue / 103 Early Hints before the 101, or before the proxy's 200, are skipped. (no PR)
  • Bytes read while a handshake listener spins the event loop were parsed as a new HTTP head. (#43763, the buffer simplification only)
  • Header values are sent as latin-1. (#41482) close() / terminate() while CONNECTING close TCP with a FIN. (no PR)
  • Found by fuzzing (no PR): the two floods in item 8, and a missing exception check where a binary message is queued because nothing listens yet.
  • A response of fewer than 9 bytes that can't be HTTP (foo\r\n) left the client waiting for the 120 s handshake timeout. It now fails immediately with "Invalid response". A RST instead of a response reports "Connection ended: read ECONNRESET". (no PR)

Bun.serve WebSocket server

Crashes and memory safety:

  • server.upgrade(req) returned true after a streamed Response had started. It wrote the handshake headers into the chunked body and then segfaulted the release build (heap-use-after-free at the next ws.send() under ASAN). A request can no longer be upgraded once a Response has been accepted for it. (no PR)
  • ws.subscribe(new String(...)) with a toPrimitive that closes the socket caused a heap-use-after-free at the next publish(), and send / ping reported bytes sent on a dead socket. All arguments are now converted first, followed by one state check. Nine methods became four bodies, and server.publish() shares their payload helpers. (#39385)
  • Upgrading a socket that had already been ended (a ws 'headers' listener that calls req.socket.end()) was a SEGV on debug and a hang on release. HttpResponse::upgrade() now rejects it up front, and us_socket_adopt returns NULL instead of the same pointer. The check runs again after the 101 is written, because a head longer than the cork buffer is a real TLS write, which can shut down the socket. upgrade() then returns false. (#37447, only this part; #44618 fixed its two other paths)
  • ws.send(sharedMemory, true) while another thread writes to that memory overran libdeflate's output buffer. That code path is deleted, and #42318's two race tests pass under ASAN. (#42318 stays open for Bun.gzipSync and friends)
  • server.reload() leaked a uWS context per route (12 KiB per reload with 12 routes). There's now one context per owner. A reload refreshes settings and never replaces a live context's closures. (#41680)

Lost data, hangs:

  • A new TEXT / BINARY frame inside a fragmented message was appended to it, and both were delivered as one message. Fixes #33010. (#33011)
  • ws.close() while over backpressureLimit lost accepted frames and the Close frame (the peer got 62 of 79, then a bare FIN). ws.close() inside ws.cork(), drain, or open after an async upgrade sent its FIN 16 s late. end() is now the only writer of a Close frame and owns the uncork and the FIN. (#39843, plus TLS variants of its tests)
  • terminate(), a throwing open, stop(true) and process exit dropped data that send() / publish() had accepted in the same tick. (#43711, a third of it; no flush flag) A throwing open now always lets the 101 be sent first.
  • The reply to a valid frame was lost when a later frame in the same read failed the connection (Autobahn 3.2, 4.1.4, 5.15). (falls out of #35874)
  • idleTimeout: 65536, maxPayloadLength: 2 ** 32 + 16 and backpressureLimit: 2 ** 32 + 1 wrapped to 0, 16 and 1. idleTimeout above 960 now always throws, and the other two are clamped. Fixes #13918. (#36998; docs from #40097)
  • idleTimeout: 0 meant about 256 s (0 - 4 in an unsigned short). static_asserts now guard the arithmetic. Part of #26554. (#34222)

Protocol and API:

  • When the server fails a connection, it sends a Close frame with 1002 / 1007 / 1009 and the reason. That includes an invalid Close payload, which used to be echoed as an empty Close. Fixes #22723. (#35874, but the handler keeps 1006, which is what npm ws and browsers report)
  • stop(true) sends 1001. Part of #25722. (#34961, the stop(true) half, in TemplatedApp::close())
  • permessage-deflate:
    • The negotiator is new and does a plain split and compare. The old tokenizer compared the sum of the characters, so deflate-permessage matched. server_no_context_takeover or server_max_window_bits is honored and echoed, invalid offers are declined, and fallback offers are tried. (#41629)
    • compress: false actually turns compression off, and decompress: "16KB" no longer sets the compressor. { compress: on, decompress: off } keeps working. (#41637, without its throw)
    • Inbound BFINAL=1 blocks are accepted. (#33592)
    • Corrupt data closes with 1007. A message that inflates past the limit closes with 1009.
    • Each dedicated compressor and each dedicated decompressor uses 4 KB less.
  • perMessageDeflate: true sent messages that end in a final DEFLATE block, which makes Safari close with 1002 (WebKit bug 326651). All outbound messages now end in a sync flush. Fixes #25673. I checked this on the wire; I couldn't run Safari. (no PR)
  • A long ws.close() reason is truncated to 123 bytes on a character boundary. It used to be cut mid-character, so the client saw 1007. (#41665)
  • upgrade({ headers }) duplicated Sec-WebSocket-Accept, Upgrade and Connection, and removed a field from the caller's Headers. (#41875) headers: {} threw, and a subprotocol couldn't be declined. Fixes #18243. (no PR) The result depended on an unrelated req.headers read or await. (#41962) Another server's Request cross-wired handlers and topics. (#41811, upgrade() only)
  • The 101 for an upgrade made after an await was sent in 25 writes (33 TLS records). It's now one write. (no PR)
  • An Upgrade / CONNECT request without Host gets a 400. It used to reach fetch with a relative URL. (#41670)
  • upgrade(); throw, and a rejection from a handler that upgraded after an await, were silently dropped. (#45000, but reported through error() / the fetch reporter instead of ending the process) async: upgrade(); throw is still an unhandledRejection.
  • websocket.error(error, ws), typed. Fixes #11634. (no PR in this set; #41728 touches the types)
  • New: ws.pause(), ws.resume(), ws.isPaused. The idle timer is suspended while paused. Fixes #32483. (instead of #36749)
  • drain only fires after a send() that returned -1. A TLS socket used to get one right after the upgrade with nothing buffered, and each pause() / resume() would have caused one. (no PR)
  • ws.cork(cb) runs cb on a closed socket. server.publish(t, d, undefined) compresses like publish(t, d). (#39253) websocket: {}, the first example in the docs, no longer throws "expects a message handler". (no PR)

ws

src/js/thirdparty/ws.js (two classes that forwarded lazily to native objects) is replaced by ws.ts: one class WebSocket extends EventEmitter ported from ws 8.22.0 (websocket.js, websocket-server.js, event-target.js, stream.js, extension.js, subprotocol.js, validation.js, and the negotiation half of permessage-deflate.js). Framing, masking, UTF-8 validation and deflate stay native, so there is no JS Sender / Receiver. All nine JS-only PRs together moved the old shim from 18 to 24 of my 212 client cases. The state machine had to live in one place. There are three transports:

  1. Client: the native client connects. In this mode it calls one shared handler table (this = the ws object) instead of dispatching DOM events, so there are no listener objects per socket and no MessageEvent per frame.
  2. Server: upstream's code builds the 101 in JS, native code writes those bytes, and uWS adopts the node:http connection into a context that has this WebSocketServer's options. _socket is the real node:http socket, and its 'close' is what emits the ws 'close'. There are no queues and no ticks in JS.
  3. Any JS stream (the stream transport), both roles: the native client engine reads from and writes to a JS stream. It's the slower path (numbers under Performance), and only these cases use it:
    • Server: any Duplex, server.emit("connection", socket), http2 allowHTTP1, fastify's injectWS, a manual setSocket(), and a node:http connection on which frames were read before handleUpgrade() ran.
    • Client: a custom agent (SOCKS agents), createConnection, lookup, finishRequest. These go through the real http.request(), which is upstream's own path. family, localAddress and localPort take that path too.

Crashes, lost or corrupted data:

  • One request from any client ended the process with an uncaught TypeError when the connection wasn't native: http2.createSecureServer({ allowHTTP1: true }) (vite --https), server.emit("connection", socket) (socket.io's sticky sessions), or any Duplex. They now get a 101 over the stream transport. (#44460: took its native binding and can_upgrade(), replaced its 500; #28152's Proxy case is a test)
  • await events.once(ws, "open") on a port that refuses connections killed the process, and once("message") + on("message") delivered each message twice. (#37972; there are no per-socket closures at all now)
  • Server: a message after ws.onmessage = null was an uncaught TypeError, addEventListener("message") got a Buffer for text, and onclose got a number. Fixes #36060. (#44111)
  • Server: 64 x 1 MiB arrived as 83 frames, 62 of them out of order, and close() behind a backlog delivered 4 of 24. The second send queue in JS is deleted. Fixes #44971, #21376. (#44520, #39802)
  • send(Buffer.from([0xff, 0x68]), { binary: false }) arrived as ef bf bd 68. binary didn't decide the frame type for ArrayBuffer and Blob. { fin: false } sent three whole messages. (#44535: its native entries, plus fin and compress)
  • send() after close() acknowledged a dropped message with cb(null). (#39840) Callbacks now mean "written": they wait if the peer isn't reading, and they fail when the socket dies. (no PR)
  • finishRequest with a late req.end() sent two handshakes, and 'open' never fired. A request that was never ended connected anyway. (#44123, with no deferred native socket)
  • maxPayload was ignored in both directions: a limit of 100 let 101 bytes through, the default rejected 20 MiB, and socket.io's maxHttpBufferSize did nothing. It's now a native limit per WebSocketServer, checked at the frame header. Fixes #8261. (#36650) Client maxPayload works too. (no PR)
  • A custom agent was silently dropped, so a SOCKS proxy the user asked for was bypassed by a direct connection. The agent is now used. The tests run three kinds of SOCKS agent against a SOCKS5 server and check that a dead proxy isn't bypassed. Fixes #23208. (no PR)
  • Frames that arrive before a deferred handleUpgrade() (after an await, or a verifyClient that answers late) were lost: ["later"] where Node gives ["early", "later"]. Such a connection now uses the stream transport, which starts with head and whatever the socket has buffered. (#43176, without its replay on nextTick)
  • Server close(999), close(1004) and close(-1) were sent on the wire. (#42011)

Features and parity:

  • The closing handshake, in both roles (#39802; client: no PR):
    • After close() the socket is CLOSING, and messages that arrive before the peer's Close frame are still delivered.
    • 'close' reports the peer's code (4444 "theirs", was our own 4000 "mine"), or 1006 if the peer only sends a FIN.
    • A listener added after close() or terminate() fires.
    • terminate() after close() is 1006.
    • close() leaves a paused socket paused.
    • The closeTimeout option works.
    • uWS got a mode for this: write the Close frame, keep reading, and report when the peer's Close, the FIN or socket.destroy() arrives. Bun.serve doesn't use it.
  • createWebSocketStream() is upstream's stream.js, with backpressure. Fixes #4568. (#35459)
  • options.WebSocket is honored, server sockets are instanceof WebSocket, and patches to WebSocket.prototype apply to them. Fixes #13317. (no PR)
  • ws._socket is the node:http socket on servers. On clients it's a small object with remoteAddress / remotePort / remoteFamily, ref(), unref() (engine.io-client's autoUnref) and destroy(). (no PR)
  • Server pause() / resume() / isPaused. Fixes #44453. (#44554; the tests fail against a pause() that only sets a flag)
  • Options that were accepted but ignored now work. Client: handshakeTimeout, origin, auth, followRedirects / maxRedirects / 'redirect', protocolVersion, autoPong, skipUTF8Validation. Server: autoPong, skipUTF8Validation, perMessageDeflate. handshakeTimeout and closeTimeout are no longer capped by the native 120 s / 30 s timers, because no native timer runs for a ws client. protocolVersion: 8 is actually sent, with Sec-WebSocket-Origin. (no PR)
  • Headers that 'headers' listeners push are included in the 101 (engine.io's cookies and headers event). handleProtocols: () => false no longer selects a protocol (graphql-ws). (no PR)
  • Errors match npm's: Unexpected server response: 403 for every kind of listener (#36375) and with or without a listener (#45125, minus its two regressions); connect ECONNREFUSED ... with code; WS_ERR_* for protocol errors on both sides, with Close 1002 / 1007 / 1009 on the wire. (no PR)
  • req.destroy() / req.abort() work in 'unexpected-response' and finishRequest. (#37973) ping(null | false) sends an empty frame. (#39093) Text is a Buffer for every binaryType, built natively. (#44255, #44553, #44112) terminate.call(undefined) throws. (#34873)
  • send(null), send([1, 2]) and send({}) do what npm does (empty binary, bytes, ERR_INVALID_ARG_TYPE). ping / pong callbacks run later, with null. Accessors are enumerable, and the ready-state constants are read-only. ["Foo", "foo"] are two subprotocols (the global WebSocket treats them as duplicates). (no PR)
  • Bun's extras carry over: proxy, tls, HttpsProxyAgent-style agents (connected natively), ws+unix:, wss+unix:.
  • perMessageDeflate: { serverNoContextTakeover, clientMaxWindowBits, ... } goes into the client's offer (it was always permessage-deflate; client_max_window_bits), and the server's response is validated against what was offered. (no PR)
  • A RST instead of a response is read ECONNRESET with syscall: "read". A FIN is socket hang up. (no PR)
  • The body of a non-101 response in 'unexpected-response': res now gets the whole body, decoded (Content-Length, chunked with fetch's decoder, or until EOF), even when it arrives after the head. The event itself isn't delayed. Before, a chunked body arrived raw and a body that came after the head was lost, so a gateway's JSON error didn't parse. The body has backpressure: a res that isn't read pauses the native read. A complete body sets res.complete. A truncated one ends as in Node: 'aborted', 'error' ECONNRESET, no 'end'. (no PR)
  • A 101 with no value in Upgrade, or without the upgrade option in Connection, is a final response with no body, as it is for node:http's parser: 'unexpected-response', then 'end' on the response immediately. Without a listener it's 'error' "Unexpected server response: 101". The global WebSocket fails such a 101 as before. (no PR)
  • Found by fuzzing (no PR):
    • A handleUpgrade() that runs a few ms late (an await, an async verifyClient) lost bytes, or the peer's FIN, that node:http had read but not yet handed to JS. Lost bytes failed a valid client in about 1% of such connections. A lost FIN left the socket open for good, which a remote peer could provoke (main too). Both now take the stream transport.
    • req.socket.remoteAddress, servername, authorized and authorizationError can still be read in 'close', whether the upgrade came from an 'upgrade' or a 'request' listener.
    • A send callback that throws no longer skips 'close', a stream's 'finish', or the callbacks behind it. On the stream transport each runs exactly once, although node:net on Bun drops some write callbacks and repeats others.
    • handshakeTimeout also covers the body of an 'unexpected-response'. A refused upgrade behind a pipelined request answers on the right response. createWebSocketStream() finishes when the closing handshake is done.
    • tls / proxy.tls: inherited, non-enumerable and getter options are honored, also next to a pfx, and Object.prototype is ignored.

node:http

  • socket.bytesWritten of an 'upgrade' / 'connect' socket was 0. It now counts raw writes (queued ones too) and the 101 that ws sent, and it keeps its value after close. bytesRead counts the tunnel's bytes. engine.io closes every upgraded socket on its http.Server whose bytesWritten is 0 after 1 s, which is the cause of "vite / SvelteKit dev + socket.io reloads every second". Fixes #23747. (#45030: its sum and its test lines, plus corked, backpressured and closed sockets) Not counted: WebSocket frames after a native upgrade, and HTTP heads in bytesRead.
  • A node:http server no longer registers a Bun.serve-style websocket route. The NodeHTTPResponse arm of server.upgrade(), NodeHTTPResponse::upgrade() and UpgradeCTX are deleted (375 lines).
  • Pinned by tests: socket.destroy() and socket.setTimeout(0) after an upgrade; http.Server#close() leaves ws connections open.

undici

  • Added require("undici").ping(ws, body?), and undici:websocket:ping / :pong on node:diagnostics_channel, with a Buffer payload for every binaryType. Without them, Slack Socket Mode / Bolt reconnected forever. Fixes #37110. (#37111, with two shared listeners instead of two closures per socket, undici's order of argument checks, and "blob" handled)
  • require("undici").WebSocket is now a subclass of the global one instead of the same object. Only its sockets publish to those channels. The open / close / socket_error channels stay silent.

Types and docs

  • error / onerror are ErrorEvent. Fixes #36329. (#42673, #39069) Added ping / pong events, binaryType: "blob" and Blob payloads. (#39069) headers accepts Headers and pairs, and with lib.dom loaded, Bun.WebSocket's message event has .data again. (no PR) The fixtures assert on Bun.WebSocket, so the list of expected lib.dom diagnostics didn't change.
  • serve.d.ts: error?(error, ws), pause / resume / isPaused, message? is optional, cork<R> (its T shadowed the data type), option ranges and what 0 means, close() codes, compress defaults, and four examples that called the wrong method.
  • websockets.mdx: every example runs and type-checks as pasted (the first one threw). New "Closing connections" and "Errors" sections. One Origin check for cookie-authenticated upgrades, which allows a missing Origin so that non-browser clients work (#36155). Also compress defaults (#39253), backpressure (#41679, WebSocket half), pause() (#42976, the paragraph but not its JSDoc) and casing (#42443). The hand-copied Reference block had drifted on nine members and is replaced by links, which makes #40879 moot.
  • ws.ts is fully typed (tsc on src/js: 0 errors, also with noImplicitAny). Adding the types left the generated module byte-identical.

Tests

  • #39370 took websocket-server.test.ts on a debug build from 40 s with 4 timeouts to 13 s and green (the file has grown since).
  • The writev fault test never reached us_socket_write2. (#38174)
  • websocket-pause.test.ts streams 32 MiB instead of 256 MiB and fails immediately when a send is dropped. (#41109, the constant and about 15 lines)
  • ws-syscall-fault.test.ts went from 7.6 s to 3.2 s on debug. (#37115, without the asserts that froze four differences from npm)
  • setSocketOptions() of bun:internal-for-testing (SO_SNDBUF / SO_RCVBUF) now takes any socket Bun owns: a ws or global WebSocket, a ServerWebSocket, TLS sockets, node:net and node:http sockets. It also works on Windows, where it did nothing. With a 64 KiB send buffer a sender hits backpressure after about 200 KB instead of 4.2 MB, so the backpressure tests send 1 MiB instead of 12 to 64 MiB.
  • No test sits through a real close timeout (uSockets sweeps every 4 s, so "1 s" is 4 to 8 s). Those were removed, and the numbers are under "Fuzzing before merge".
  • Autobahn fan-out (#35421): not run here, it needs docker.
  • hostname: "127.0.0.1" where a test connects to 127.0.0.1. (#41331, plus websocket-proxy.test.ts) I haven't swept the other files that have the same pattern.
  • Two stale todos are now real tests, and so are ws's five early-frame todos (#43176). The empty perMessageDeflate todo and an empty skip are removed.
  • New in test/js/first_party/ws: the ws-*-compat-*.test.ts files, ws-stream-transport.test.ts, a rewritten ws-upgrade-events.test.ts (18 files) and one raw-peer helper. Every block runs twice: on the built-in, and on node_modules/ws loaded by path. No sleeps.

Performance

bench/websocket-server (the chat room in this repo, 32 clients in 4 client processes as its README suggests, server on 4 reserved cores, 6 interleaved rounds, this PR at 6564a70):

Throughput against main: Bun.serve pub/sub -1.1% (main against a copy of itself: +1.4%), ws -4.6% (copy: -4.1%). That's inside the noise, so I also counted instructions per delivered message. Bun.serve: 758 on main, 775 here. ws: 12,477 on main, 12,628 here (95% of the time is in the kernel's TCP stack).

Receiving compressed messages in a client (global WebSocket, server without context takeover, which is Bun.serve's default; user-space instructions per message, this PR at 6605b96):

JSON messagemainthis PR
512 B35,95816,375
2 KB57,94826,022
16 KB224,533101,941
64 KB718,398333,248

The client tried libdeflate first, which can't inflate an unfinished stream, so it decoded every message, failed at the end, and zlib decoded it again. It now appends a final empty block, as uWS does on the server, and only uses libdeflate without context takeover.

My own scripts:

Both binaries are CI's own release builds: this PR at d51fcce, and 9510e74, the main commit it's based on. 8 physical cores were reserved: 4 for the side being measured and 4 for the other end, which is always main's binary. Runs are interleaved, and each number is the median of 7 rounds. "main again" is a byte-identical copy of main's binary, so the difference between the first two columns is the noise.

ws client (echo server: main's Bun.serve):

shapemainmain againthis PR
ping-pong 64 B, round trips/s43,46943,83846,403
pipelined echo 64 B, msg/s261,627263,397299,713
echo 16 KB, msg/s98,23094,59197,603
compressed echo 2 KB, msg/s106,904106,657107,253
idle connection, KB9.29.110.4

ws server (load generator: main's native WebSocket):

shapemainmain againthis PR
ping-pong 64 B, round trips/s43,01143,78445,172
pipelined echo 64 B, msg/s271,840276,301273,717
echo 16 KB, msg/s81,85883,69782,962
send() to 100 clients in a loop, 128 B text, deliveries/s345,531352,509381,826
idle connection, KB7.97.77.4

The loop row is for (const c of wss.clients) c.send(data, { binary: false }), not pub/sub. It measures per-send() overhead. The old shim decoded the Buffer to a string on every such call; the new code passes the bytes and the opcode straight through.

Compressed echo on the server can't be compared, because the old shim never negotiated permessage-deflate.

The global WebSocket client and Bun.serve are level with main on the same three shapes (ping-pong, pipelined echo, echo 16 KB): every difference is under 1.6%, and main differs from its own copy by up to 2.4%.

Bun.serve, ws.send(x, true) echo with the libdeflate compress path deleted (older numbers: local release builds, a busy machine): the shared compressor is 10% to 2.5x faster than main from 64 B to 16 KB. The dedicated one is 7% slower at 64 B and 1.3x to 5x faster from 512 B to 16 KB. Bytes on the wire for 64 B to 4 KB slices of package.json: the shared compressor is within 4 bytes of main, and the dedicated one is 11 to 26% smaller from 512 B up.

Supporting the server role in the client engine costs a natively connected client one predictable branch per received frame and two per sent frame. The global WebSocket table above measures that. Every JSWebSocket cell grew by 16 bytes (owner and handler table). Each upgrade client is 4 KB smaller (a header array was removed).

Binary size: +64 to +128 KB per target (CI's table).

How did you verify your code works?

Oracles. Node 26.3.0 (its global WebSocket is undici 8.3); npm ws 8.18.3 (the repo's test dependency), on Node and, loaded by path, on Bun; the source and tests of ws 8.22.0; RFC 6455, RFC 7692 and the WHATWG standard. Where Node and a spec disagree, I say so above.

Against Node + npm ws (release build):

whatmainthis PR
ws's own test suite, 271 tests, in a sandbox without network94234
212 client cases: one process each, raw node:net peer, output compared with Node's byte for byte18179
159 server cases, same method67150
new permanent suite, built-in rows that fail (measured at 1,233 rows; it has grown since)800 of the 1,082 that finish0
  • The remaining failures in ws's own suite reach into the JS Sender / Receiver / net.Socket of a natively connected client (ws._receiver, ws._socket.on, generateMask, file-backed Blobs), which don't exist here.
  • The remaining client cases differ in the wording of engine errors (V8 / JSC), ws 8.22 versus the 8.18 that produced the recorded output, the order of request headers, and the kept differences listed above.

Each new or changed test was first run on main's release build, and I read the failure. It has to fail for the stated reason, not by timing out. Some new tests pass on main: they pin existing behavior to guard the rewrites.

Debug + ASAN build, with BUN_JSC_validateExceptionChecks=1 as on CI's ASAN lane: every file under test/js/web/websocket, test/js/bun/websocket and test/js/first_party/{ws,undici}, plus node-http, node-https, node-http-connect, node-http-with-ws, node-tls-connect, bun-server, request-smuggling, fastify-websocket and bake/deinitialization. 80 files and 6,001 tests, all pass.

  • Wire logs from a raw peer and the client on one clock: send x 32 + close delivers 32 of 32 (main: 10, and 2 at 64 KiB); 256 of 256 through the tunnel with the Close last; Pong before Close on wss://.

CI: every platform built, and the new ws suite passed on macOS and Windows.

Fuzzing before merge. Up to seven agents, one area each, ran seeded generators in a jail on CI's release builds of this PR, main, released Bun and Node, and on debug + ASAN. After each round I fixed what they found, another agent turned each repro into a test that fails on the old binary, and the hunt ran again on the new one.

round 1round 2round 3round 4
casesover 1M400k300k200k
crashes, sanitizer reports001 use-after-free0
regressions from main6500
bugs in the fixes of the round before652, low
  • Proxy trust: over 350,000 combinations of options, certificates and routes against a model. Nothing opened that must be rejected, and no credential or client certificate reached the wrong peer, except a pfx next to inherited or hidden options (fixed). The same model flags main in 90 of 800 cases.
  • The use-after-free came from one of my fixes for round 1: it ran the ws drain handler after uWS's close handler, when GC may already have freed the native socket. It needed a script that clears private symbols. That code is gone, and uWS again calls nothing after the close handler.
  • Close timeout, measured on CI's release build on ws, wss, a unix socket and through http, https and authenticated proxies: send(100 MB); close() at 2 MB/s delivered 82.5 MB and closed 1006 before the fix, 100 MB and a clean 1000 after it. A peer that stops reading at once, halfway or 1 KB before the end is dropped one timeout after its last read. One that reads everything but never answers is dropped one timeout after the flush.
  • Late handleUpgrade(): 9 of 1,250 connections lost bytes before the fix, 0 of 1,500 after it, then 0 of about 50,000 generated cases of valid traffic (the generator fails 119 of 8,000 on the build before).

Bun.serve against main, for "keeps accepting what released Bun accepts": the same 8,700 server calls, 1,492 client calls, 26 handshakes and 19 close codes on both. What only this PR refuses: an HTTP/1.1 upgrade without Host, a repeated Sec-WebSocket-Key, idleTimeout of 65,536 or more, and upgrade() with another server's request. Five more were found this way and taken out. At scale (50,000 connections, a 20-minute soak with 438,000 connections, fan-out, dead consumers, reconnect storms, stop() / reload() with 20,000 open) only item 13's memory and item 10's close code differ from main.

Two security scans of the whole diff found nothing above low severity except two denial-of-service bugs in ws. All four findings were reproduced and fixed with tests:

  • Unbounded automatic Pongs (described above). The cap is on all three transports, because a peer can choose the transport by sending a frame before a deferred upgrade.
  • A redirect Location (/#, ws+unix::/x) threw out of the native handshake callback and ended the process.
  • Bun's tls: { ca, checkServerIdentity, ... } option was dropped whenever the client connects through http.request() (finishRequest, lookup, family, ...), so a certificate pin was lost (1.4.3 enforces it).
  • tls, proxy and agent could be set through a polluted Object.prototype (1.4.3 has one such case too).

A review of the whole branch found 15 issues, fixed with tests. The worst: a use-after-free of the server when handleUpgrade() gets a request from a server that was already freed, a stream wrapping the socket that handleUpgrade() bypassed, and the ignored client options signal and secureContext.

Lints: cargo clippy --workspace, cargo check --workspace --all-targets, rust:check-all (every target), bun lint, the three tsc projects, clang-format, rustfmt, prettier, test/internal/source-lints.

Types: bun-types.test.ts passes 22 of 22. With main's .d.ts and the new fixtures, 10 of 22 fail.

Not run:

  • #44633's script on Windows. It's worth trying, since the 101 and the Close now leave in one write. Some backpressure tests are skipped on Windows, as they were in the original PRs.
  • Autobahn (needs docker), Safari, WebView with a real Chrome.
  • "No ping and no idle close on a ws server" has no automated test, because it needs 135 s. I checked it by hand, with the three options injected into main.

The pull requests

Thanks to the outside contributors whose reports and patches are in here: @C0casio45 (#28152), @hughescr (#34222), @steipete (#44553, #44554, #45030), @davidhevn (#42673), @deepshekhardas (#40879, #42443), @ousamabenyounes (#36749), @alex-mextner (#31205); and to @1cedsoda, whose #44061 / #44165 stay open.

"ext" = from an outside contributor. taken = as it was, or nearly. reworked = same bug, different fix. superseded = falls out of a larger change here.

PRauthorverdictwhy
#20533Jarred-Sumnernot takenclang already emits bswap / rol, so the assembly is identical
#24914Jarred-Sumnerobsoletefixed by #25475; in BoringSSL the line marks every TLS socket low priority
#28152C0casio45 (ext)obsoletenext-ws awaits before handleUpgrade(); #39642 fixed that. Proxy case kept as a test
#31205alex-mextner (ext)not takenits cases compile on main; I widened headers, which is probably what the author hit
#31761robobunreworkedone snapshot in the single exit; counts payload bytes only
#31982robobuntaken
#33011robobuntakenplus BINARY inside TEXT
#33066robobunreworkedone dispatchEvent override, not 20 renamed call sites
#33592robobuntaken
#34222hughescr (ext)reworkedthe one-line fix + static_asserts; no test binding, no 20 s tests that pass without the fix
#34873robobunsupersededby the port
#34961robobunreworkedstop(true) half only; stop() stays graceful as documented
#35029robobunreworkedspan() kept; detached = empty, no TypeError
#35031robobunnot takenbreaks close(1001) / close(1011)
#35421robobuntaken
#35459robobunsupersededupstream's stream.js, with the backpressure the PR dropped
#35730robobunnot taken0.8 s on a test that CI skips
#35874robobunreworkedhandler keeps 1006; 1007 vs 1009 for inflate errors
#36112robobuntakenplus the missing timeout
#36155robobunreworkedone check and a short warning; a missing Origin is allowed
#36375robobunsupersededupstream's abortHandshake, every kind of listener
#36561robobunnot takensame as #35730
#36650robobunreworkednative limit per WebSocketServer; 'close' is 1006, not 1009
#36749ousamabenyounes (ext)not takenframes from one read still overlap; deadlocks request / response over one socket. ws.pause() instead
#36808robobunobsoletereplaced by #37275
#36998robobuntaken
#37111robobunreworkedshared listeners, "blob", undici's check order
#37115robobunreworkeddescribe.concurrent only
#37439robobunstays openWebSocket half landed in #40624; the rest is fetch
#37447robobunreworkedthe early rejection only; #44618 fixed two of its three paths
#37463robobunobsoletelanded in #43557; test kept
#37559robobunnot takenrefactor of code that #40018 rewrote
#37638robobuntaken
#37972cirospaciarisupersededone handler table, so no listeners to install twice
#37973cirospaciarisupersededone request class; npm's rows
#38174robobuntaken
#38177robobunobsoletefixed by #40566
#38632robobuntaken
#39069robobunreworked.d.ts taken; fixture on Bun.WebSocket
#39071robobuntakenplus null
#39082robobunreworkedthe server's error class, one FFI call
#39093robobunsupersededupstream's line
#39253robobuntakenplus publish(t, d, undefined)
#39255robobunnot takena send() cancels the ping deadline, as released
#39370robobuntaken
#39385Jarred-Sumnertakenported by hand; sendBinary, close, ping / pong too
#39608robobunstays opennot WebSocket; its two ErrorEvent lines are here
#39802robobunreworkedno JS queue, no tick; native "wait for the peer's Close"
#39840robobunsupersededupstream's lines
#39843robobuntakenplus TLS tests
#40097robobunsupersededby #36998; docs lines kept
#40879deepshekhardas (ext)obsoletethe block it fixes is deleted
#41109robobunreworkedthe constant + 15 lines
#41331robobuntaken
#41482robobunreworkedRust only, smaller
#41573robobunreworkedWebSocket half; fetch half dropped
#41629robobunreworkednew negotiator, fallback offers, no x-webkit
#41637robobunreworkedno throw for a config that works today
#41665robobuntaken
#41670robobuntaken
#41674robobuntaken
#41679robobunreworkedthree sentences; routing.mdx half not taken
#41680robobunreworkednever replaces a live context's closures
#41698robobunreworkedremoveCredentials(); fetch half landed in #42692
#41705robobuntakeninside #41674's code
#41712robobunstays openproduct decision
#41728robobunstays opennot only WebSocket; overlaps in websockets.mdx and error
#41811robobunreworkedupgrade(Request) only
#41875robobuntakenplus headers: {} and declining a protocol
#41962robobuntaken
#42011robobunsupersededupstream's checks, both roles
#42216robobunreworked5 lines, no budgets
#42318robobunstays openthe libdeflate patch is a vendoring decision; the WebSocket path is deleted, its two tests are kept
#42443deepshekhardas (ext)taken
#42673davidhevn (ext)takenits two lines; a different fixture, because the lib.dom run fails with the PR's own
#42974robobunreworkedresume() on close; the rest follows from readyState
#42976robobuntakentrimmed
#42978robobuntakenderived from readyState
#43018robobunreworkedwith #43696: one field instead of four
#43176robobunreworkedthese connections use the stream transport; as written, the PR would deliver head twice
#43243robobunreworkedbounded by the byte limit
#43445robobuntaken
#43511robobuntaken
#43614robobunstays opengoes with #43441
#43696robobunreworkedthe deletion only; no nested-event-loop cases
#43711robobunreworkedterminate() and exit flush; no flag
#43763robobunreworked"always buffer"; no handshake_dispatched
#440611cedsoda (ext)stays openRFC 8441 is a separate feature; needs a rebase
#44111robobunsupersededupstream's event-target.js, both roles
#44112robobunreworkedsecond native handler set, no per-event flag
#44122robobunnot takenused its options struct and test table; no validate() / dial() split
#44123robobunreworkedreal http.request(); no deferred native socket
#441651cedsoda (ext)stays openbuilt on #44061
#44255robobunsupersedednative Buffer for text
#44460robobunreworkedbinding + can_upgrade(); 101 instead of 500
#44520robobunreworkedno backpressure limit, JS queue deleted
#44535robobunreworkednative entries + fin, compress; no lint
#44553steipete (ext)supersededsame result, built natively; test kept
#44554steipete (ext)reworkedpublic ServerWebSocket.pause(); tests that fail against a no-op pause(); no 120 s kill
#44821robobuntakenplus realign
#44822robobunreworkedthree small changes instead of five mechanisms
#44964robobuntakenexplicit Proxy-Authorization wins
#45000robobunreworkedreports the error and keeps serving
#45030steipete (ext)reworkedits sum and tests; plus cork, backpressure, close, bytesRead, the 101
#45122robobuntakenextended to failures after open, and 1006
#45125robobunsupersededfalls out of emit("error"); two regressions avoided
#45196robobunreworkedmoves the close event; no second parser

Issues that can also be closed:

  • #22870: the client option works; the reporter set it on the server.
  • #18869: the code in its trace was rewritten by #39642 / #39435, and it has no script to run.
  • #7202: native methods check this, as new Proxy(new Map(), {}).get() does.