ws: implement client 'upgrade' and 'unexpected-response' events
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 callsdid_receive_handshake_responseon the C++WebSocket.process_websocket_upgrade_response(this, full: &[u8]): re-parses the owned buffer into local scratch so nothing borrowsselfacross the JS dispatch, surfaces'handshake'(under one event-loop scope on 101 so microtasks drain after'open', matching npmws), then hands off to the existingprocess_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-responsefires with status / headers / body on non-101;reqstub hasmethod/path/getHeader()- native
errorstill fires on non-101 with nounexpected-responselistener upgradefires beforeopenon 101 with the handshake headers- microtask queued in an
upgradehandler seesreadyState OPEN - duplicate headers coalesce like Node
IncomingMessage addListener/prependListener/prependOnceListener/addEventListenerall arm the bridge- native error suppression after
unexpected-responseacrosson/addEventListener/onerror - native error still delivered to DOM-style handlers when there is no
unexpected-responselistener on('error')fires once (not twice) for non-101 with nounexpected-responselistener- mixed
on('error')+addEventListener('error')each fire once addEventListenerdedup for'upgrade'and'error',removeEventListenerdetaches 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)
[1m[92m Compiling[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
[1m[92m Compiling[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
[1m[92m Compiling[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
[1m[92m Compiling[0m 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