ws: implement client 'upgrade' and 'unexpected-response' events

Merged
RO
3mo ago
+975-54

What does this PR do?

Implements the ws package client's 'upgrade' and 'unexpected-response' events, which Bun previously stubbed with:

[bun] Warning: ws.WebSocket 'upgrade' event is not implemented in bun
[bun] Warning: ws.WebSocket 'unexpected-response' event is not implemented in bun

This is the load-bearing fix for miniflare / wrangler / @cloudflare/vite-plugin hanging under Bun (their dispatchFetch resolves exclusively off these two events) and for several playwright / puppeteer CDP connection paths.

Fixes #5951
Fixes #24229
Fixes #31406
Fixes #31792

Closes #28114
Closes #31408
Closes #25777

Reproduction

const { createServer } = require("net");
const { once } = require("events");
const { WebSocket } = require("ws");

const server = createServer(s =>
  s.once("data", () => s.end("HTTP/1.1 503 Service Unavailable\r\nX-Reason: not-ready\r\n\r\nworkerd starting")),
).listen(0, "127.0.0.1");
await once(server, "listening");

const ws = new WebSocket("ws://127.0.0.1:" + server.address().port);
ws.on("error", () => {});
ws.once("unexpected-response", (req, res) => {
  console.log(res.statusCode, res.headers["x-reason"]);  // 503 not-ready
});

Before: prints the "not implemented" warning; unexpected-response never fires. After: 503 not-ready.

Fix

WebSocketUpgradeClient.rs drops the early "fail on non-101 status line" check so the response is parsed, and adds two functions:

  • dispatch_handshake(ws, &response, body): stateless; builds the raw-header FFI array and calls did_receive_handshake_response on the C++ WebSocket.
  • process_websocket_upgrade_response(this, full: &[u8]): re-parses the owned buffer into local scratch so nothing borrows self across the JS dispatch, surfaces 'handshake' (under one event-loop scope on 101 so microtasks drain after 'open', matching npm ws), then hands off to the existing process_response.

No new state on HTTPClient; handle_close / handle_end / terminate are unchanged from main. The non-101 body is whatever arrived in the first read with the headers, which covers the real-world error-page case (workerd restart text, JSON API errors). Net unsafe in this file vs main: -1.

WebSocket.cpp gains didReceiveHandshakeResponse, which packages {statusCode, statusMessage, rawHeaders, body} into a MessageEvent and dispatches 'handshake'. It early-returns when no listener is registered, so the browser-style new WebSocket() path pays nothing.

src/js/thirdparty/ws.js lazily registers the native handshake listener on first 'upgrade' / 'unexpected-response' subscription and folds the payload into an IncomingMessage-shaped Readable (duplicate-header coalescing matches Node's http.IncomingMessage). A minimal ClientRequest stub is passed as the first unexpected-response argument so code that inspects req.method/req.path/req.getHeader() does not crash. addListener / prependListener / prependOnceListener / addEventListener all arm the bridge. Once unexpected-response has fired, the native "Expected 101" error is suppressed across every registration style (on('error'), addEventListener('error'), onerror) so listeners fire exactly once, matching real ws.

How did you verify your code works?

test/js/first_party/ws/ws-upgrade-events.test.ts (12 tests):

  • unexpected-response fires with status / headers / body on non-101; req stub has method/path/getHeader()
  • native error still fires on non-101 with no unexpected-response listener
  • upgrade fires before open on 101 with the handshake headers
  • microtask queued in an upgrade handler sees readyState OPEN
  • duplicate headers coalesce like Node IncomingMessage
  • addListener / prependListener / prependOnceListener / addEventListener all arm the bridge
  • native error suppression after unexpected-response across on / addEventListener / onerror
  • native error still delivered to DOM-style handlers when there is no unexpected-response listener
  • on('error') fires once (not twice) for non-101 with no unexpected-response listener
  • mixed on('error') + addEventListener('error') each fire once
  • addEventListener dedup for 'upgrade' and 'error', removeEventListener detaches the suppression wrapper

test/js/web/websocket/websocket-handshake-event.test.ts exercises the native 'handshake' event directly.

8 of 12 tests fail (mostly time out) under USE_SYSTEM_BUN=1 and all pass under bun bd test. Existing test/js/first_party/ws/ws.test.ts (45 tests) and test/js/web/websocket/websocket-client*.test.ts pass unchanged.


[review] gate passed · iteration 1 · 9 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/first_party/ws/ws-upgrade-events.test.ts test/js/web/websocket/websocket-handshake-event.test.ts
bun test v1.4.0 (4234810c4)

test/js/first_party/ws/ws-upgrade-events.test.ts:
31 |     }
32 |     ws.on("error", () => {});
33 |     ws.terminate();
34 |     console.log("ok");
35 |   `);
36 |     expect(stderr).toBe("");
                        ^
error: expect(received).toBe(expected)

- ""
+ "[bun] Warning: ws.WebSocket 'upgrade' event is not implemented in bun
+ [bun] Warning: ws.WebSocket 'unexpected-response' event is not implemented in bun"

- Expected  - 1
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/first_party/ws/ws-upgrade-events.test.ts:36:20)
(fail) ws on/addListener/prepend*/addEventListener for upgrade/unexpected-response emit no warning [2913.96ms]
(pass) ws emits native 'error' on non-101 when no 'unexpected-response' listener [3738.15ms]
141 |       server.close();
142 |     });
143 |     await once(ws, "close");
144 |     console.log(order.join(","));
145 |   `);
146 |   expect(stdout).toMatchInli
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (48a8c517e)

test/js/first_party/ws/ws-upgrade-events.test.ts:
(pass) ws on/addListener/prepend*/addEventListener for upgrade/unexpected-response emit no warning [90.89ms]
(pass) ws emits native 'error' on non-101 when no 'unexpected-response' listener [73.27ms]
(pass) ws 'unexpected-response' coalesces duplicate headers like Node IncomingMessage [70.91ms]
(pass) ws still delivers the native error to addEventListener/onerror when there is no 'unexpected-response' listener [63.84ms]
(pass) ws error handlers each fire once when on('error') and addEventListener/onerror are mixed [60.57ms]
(pass) ws emits 'unexpected-response' with status, headers and body on non-101 [79.53ms]
(pass) ws 'unexpected-response' fires for addListener / prependListener / addEventListener [86.73ms]
(pass) ws addEventListener('upgrade', h) dedupes the same handler [79.29ms]
(pass) ws microtask queued in 'upgrade' handler observes readyState OPEN [91.76ms]
(pass) ws on('error') fires once (not twice) for a non-101 with no 'unexpected-response' listener [105.02ms]
(pass) ws suppresses the native error after 'unexpected-response' across on/addEventListener/onerror [120.66
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/first_party/ws/ws-upgrade-events.test.ts test/js/web/websocket/websocket-handshake-event.test.ts
bun test v1.4.0 (4234810c4)

test/js/first_party/ws/ws-upgrade-events.test.ts:
(pass) ws on/addListener/prepend*/addEventListener for upgrade/unexpected-response emit no warning [1911.22ms]
(pass) ws emits native 'error' on non-101 when no 'unexpected-response' listener [2273.72ms]
(pass) ws microtask queued in 'upgrade' handler observes readyState OPEN [2642.07ms]
(pass) ws emits 'unexpected-response' with status, headers and body on non-101 [2755.86ms]
(pass) ws emits 'upgrade' with headers before 'open' on 101 [2770.82ms]
(pass) ws 'unexpected-response' coalesces duplicate headers like Node IncomingMessage [3693.18ms]
(pass) ws 'unexpected-response' fires for addListener / prependListener / addEventListener [3541.24ms]
(pass) ws suppresses the native error after 'unexpected-response' across on/addEventListener/onerror [3486.96ms]
(pass) ws still delivers the native error to addEventListener/onerror when there is no 'unexpected-re
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 971ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/123] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/123] gen cpp.rs (cppbind)
[3/123] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[4/123] gen JS modules (bundle-modules)
Preprocess modules (16150ms)
Bundle modules (69ms)
Postprocesss modules (177ms)
Bundle Functions (985ms)
Generate Code (43ms)

[17.44s] Bundled "src/js" for production
  2575 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[4/123] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling bun_boringssl_sys v0.0.0 (/workspace/bun/sr
... (truncated)
diff hotspot
src/http_jsc/websocket_client/CppWebSocket.rs      |  47 ++
 .../websocket_client/WebSocketUpgradeClient.rs     | 100 ++--
 src/js/builtins/BunBuiltinNames.h                  |   3 +
 src/js/thirdparty/ws.js                            | 176 ++++++-
 src/jsc/bindings/webcore/EventNames.h              |  21 +-
 src/jsc/bindings/webcore/WebSocket.cpp             |  56 +++
 src/jsc/bindings/webcore/WebSocket.h               |   7 +
 test/js/first_party/ws/ws-upgrade-events.test.ts   | 560 +++++++++++++++++++++
 .../websocket/websocket-handshake-event.test.ts    |  59 +++
 9 files changed, 975 insertions(+), 54 deletions(-)

gate history · 4 passed · 0 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
src/http_jsc/websocket_client/CppWebSocket.rs                2      4      0
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs     12     33      0
src/js/builtins/BunBuiltinNames.h                            1      1      0
src/js/thirdparty/ws.js                                      8     16      0
src/jsc/bindings/webcore/EventNames.h                        0      0      0
src/jsc/bindings/webcore/WebSocket.cpp                       1      2      0
src/jsc/bindings/webcore/WebSocket.h                         0      0      0
test/js/first_party/ws/ws-upgrade-events.test.ts             1      1      0
test/js/web/websocket/websocket-handshake-event.test.ts      1      1      0