From 736d9917efa07b2d816cfef81e66f7ce48b248e8 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 27 Jan 2026 07:37:09 +0000 Subject: [PATCH] ws: implement upgrade and unexpected-response events Implements support for the `upgrade` and `unexpected-response` events in the `ws` package polyfill. This enables Playwright's `chromium.connectOverCDP()` and other tools that rely on these events to work correctly with Bun. Changes: - Add `upgradeStatusCode` property to native WebSocket that stores the HTTP status code from the upgrade handshake - Pass the status code from the HTTP upgrade response through Zig to C++ - Update ws.js polyfill to emit `upgrade` event before `open` event with the actual status code from the native WebSocket - Emit `unexpected-response` event on connection errors for compatibility - Add TypeScript types for the new `upgradeStatusCode` property - Add regression tests for the new events The `upgrade` event provides a response object with `statusCode`, `statusMessage`, and `headers` properties. Headers are currently empty but can be populated in a future enhancement if needed. Fixes #9911 Co-Authored-By: Claude Opus 4.5 --- packages/bun-types/bun.d.ts | 6 + src/bun.js/bindings/webcore/JSWebSocket.cpp | 15 ++ src/bun.js/bindings/webcore/WebSocket.cpp | 14 +- src/bun.js/bindings/webcore/WebSocket.h | 7 +- src/http/websocket_client/CppWebSocket.zig | 10 +- .../WebSocketUpgradeClient.zig | 4 +- src/js/thirdparty/ws.js | 50 +++- test/regression/issue/09911.test.ts | 219 ++++++++++++++++++ 8 files changed, 310 insertions(+), 15 deletions(-) create mode 100644 test/regression/issue/09911.test.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d49a78e62f..61ca3ad965 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3655,6 +3655,12 @@ declare module "bun" { */ readonly bufferedAmount: number; + /** + * The HTTP status code from the WebSocket upgrade handshake (typically 101). + * This is a Bun extension to support the ws package's 'upgrade' event. + */ + readonly upgradeStatusCode: number; + /** * The protocol selected by the server */ diff --git a/src/bun.js/bindings/webcore/JSWebSocket.cpp b/src/bun.js/bindings/webcore/JSWebSocket.cpp index b26cc913d3..1124d24f47 100644 --- a/src/bun.js/bindings/webcore/JSWebSocket.cpp +++ b/src/bun.js/bindings/webcore/JSWebSocket.cpp @@ -85,6 +85,7 @@ static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_URL); static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_url); static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_readyState); static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_bufferedAmount); +static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_upgradeStatusCode); static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_onopen); static JSC_DECLARE_CUSTOM_SETTER(setJSWebSocket_onopen); static JSC_DECLARE_CUSTOM_GETTER(jsWebSocket_onmessage); @@ -382,6 +383,7 @@ static const HashTableValue JSWebSocketPrototypeTableValues[] = { { "url"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_url, 0 } }, { "readyState"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_readyState, 0 } }, { "bufferedAmount"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_bufferedAmount, 0 } }, + { "upgradeStatusCode"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_upgradeStatusCode, 0 } }, { "onopen"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_onopen, setJSWebSocket_onopen } }, { "onmessage"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_onmessage, setJSWebSocket_onmessage } }, { "onerror"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute), NoIntrinsic, { HashTableValue::GetterSetterType, jsWebSocket_onerror, setJSWebSocket_onerror } }, @@ -501,6 +503,19 @@ JSC_DEFINE_CUSTOM_GETTER(jsWebSocket_bufferedAmount, (JSGlobalObject * lexicalGl return IDLAttribute::get(*lexicalGlobalObject, thisValue, attributeName); } +static inline JSValue jsWebSocket_upgradeStatusCodeGetter(JSGlobalObject& lexicalGlobalObject, JSWebSocket& thisObject) +{ + auto& vm = JSC::getVM(&lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = thisObject.wrapped(); + RELEASE_AND_RETURN(throwScope, (toJS(lexicalGlobalObject, throwScope, impl.upgradeStatusCode()))); +} + +JSC_DEFINE_CUSTOM_GETTER(jsWebSocket_upgradeStatusCode, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + return IDLAttribute::get(*lexicalGlobalObject, thisValue, attributeName); +} + static inline JSValue jsWebSocket_onopenGetter(JSGlobalObject& lexicalGlobalObject, JSWebSocket& thisObject) { UNUSED_PARAM(lexicalGlobalObject); diff --git a/src/bun.js/bindings/webcore/WebSocket.cpp b/src/bun.js/bindings/webcore/WebSocket.cpp index c1207d41c4..1d3654ffab 100644 --- a/src/bun.js/bindings/webcore/WebSocket.cpp +++ b/src/bun.js/bindings/webcore/WebSocket.cpp @@ -1482,9 +1482,10 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, this->disablePendingActivity(); } -void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx) +void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx, uint16_t upgradeStatusCode) { this->m_upgradeClient = nullptr; + this->m_upgradeStatusCode = upgradeStatusCode; setExtensionsFromDeflateParams(deflate_params); // Use TLS WebSocket client if connection type is TLS or ProxyTLS. @@ -1696,9 +1697,10 @@ void WebSocket::updateHasPendingActivity() extern "C" void* Bun__WebSocketClient__initWithTunnel(CppWebSocket* ws, void* tunnel, JSC::JSGlobalObject* globalObject, unsigned char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); extern "C" void WebSocketProxyTunnel__setConnectedWebSocket(void* tunnel, void* websocket); -void WebSocket::didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params) +void WebSocket::didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, uint16_t upgradeStatusCode) { this->m_upgradeClient = nullptr; + this->m_upgradeStatusCode = upgradeStatusCode; setExtensionsFromDeflateParams(deflate_params); // For wss:// through HTTP proxy, we use a plain (non-TLS) WebSocket client @@ -1724,14 +1726,14 @@ void WebSocket::didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bu } // namespace WebCore -extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params, void* customSSLCtx) +extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params, void* customSSLCtx, uint16_t upgradeStatusCode) { - webSocket->didConnect(socket, bufferedData, len, deflate_params, customSSLCtx); + webSocket->didConnect(socket, bufferedData, len, deflate_params, customSSLCtx, upgradeStatusCode); } -extern "C" void WebSocket__didConnectWithTunnel(WebCore::WebSocket* webSocket, void* tunnel, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params) +extern "C" void WebSocket__didConnectWithTunnel(WebCore::WebSocket* webSocket, void* tunnel, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params, uint16_t upgradeStatusCode) { - webSocket->didConnectWithTunnel(tunnel, bufferedData, len, deflate_params); + webSocket->didConnectWithTunnel(tunnel, bufferedData, len, deflate_params, upgradeStatusCode); } extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::WebSocketErrorCode errorCode) diff --git a/src/bun.js/bindings/webcore/WebSocket.h b/src/bun.js/bindings/webcore/WebSocket.h index b993aab6c3..6d498e56e7 100644 --- a/src/bun.js/bindings/webcore/WebSocket.h +++ b/src/bun.js/bindings/webcore/WebSocket.h @@ -142,6 +142,8 @@ public: String binaryType() const; ExceptionOr setBinaryType(const String&); + uint16_t upgradeStatusCode() const { return m_upgradeStatusCode; } + ScriptExecutionContext* scriptExecutionContext() const final; using RefCounted::deref; @@ -149,8 +151,8 @@ public: void didConnect(); void disablePendingActivity(); void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason); - void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx); - void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); + void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx, uint16_t upgradeStatusCode); + void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, uint16_t upgradeStatusCode); void didFailWithErrorCode(Bun::WebSocketErrorCode code); void didReceiveMessage(String&& message); @@ -248,6 +250,7 @@ private: String m_subprotocol; String m_extensions; void* m_upgradeClient { nullptr }; + uint16_t m_upgradeStatusCode { 0 }; ConnectionType m_connectionType { ConnectionType::Plain }; bool m_rejectUnauthorized { false }; AnyWebSocket m_connectedWebSocket { nullptr }; diff --git a/src/http/websocket_client/CppWebSocket.zig b/src/http/websocket_client/CppWebSocket.zig index a22eb8f08b..d36662315a 100644 --- a/src/http/websocket_client/CppWebSocket.zig +++ b/src/http/websocket_client/CppWebSocket.zig @@ -15,6 +15,7 @@ pub const CppWebSocket = opaque { buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, custom_ssl_ctx: ?*uws.SocketContext, + upgrade_status_code: u16, ) void; extern fn WebSocket__didConnectWithTunnel( websocket_context: *CppWebSocket, @@ -22,6 +23,7 @@ pub const CppWebSocket = opaque { buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, + upgrade_status_code: u16, ) void; extern fn WebSocket__didAbruptClose(websocket_context: *CppWebSocket, reason: ErrorCode) void; extern fn WebSocket__didClose(websocket_context: *CppWebSocket, code: u16, reason: *const bun.String) void; @@ -58,17 +60,17 @@ pub const CppWebSocket = opaque { defer loop.exit(); return WebSocket__rejectUnauthorized(this); } - pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, custom_ssl_ctx: ?*uws.SocketContext) void { + pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, custom_ssl_ctx: ?*uws.SocketContext, upgrade_status_code: u16) void { const loop = jsc.VirtualMachine.get().eventLoop(); loop.enter(); defer loop.exit(); - WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params, custom_ssl_ctx); + WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params, custom_ssl_ctx, upgrade_status_code); } - pub fn didConnectWithTunnel(this: *CppWebSocket, tunnel: *anyopaque, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params) void { + pub fn didConnectWithTunnel(this: *CppWebSocket, tunnel: *anyopaque, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, upgrade_status_code: u16) void { const loop = jsc.VirtualMachine.get().eventLoop(); loop.enter(); defer loop.exit(); - WebSocket__didConnectWithTunnel(this, tunnel, buffered_data, buffered_len, deflate_params); + WebSocket__didConnectWithTunnel(this, tunnel, buffered_data, buffered_len, deflate_params, upgrade_status_code); } extern fn WebSocket__incrementPendingActivity(websocket_context: *CppWebSocket) void; extern fn WebSocket__decrementPendingActivity(websocket_context: *CppWebSocket) void; diff --git a/src/http/websocket_client/WebSocketUpgradeClient.zig b/src/http/websocket_client/WebSocketUpgradeClient.zig index ff6589fcd9..bc47eb008b 100644 --- a/src/http/websocket_client/WebSocketUpgradeClient.zig +++ b/src/http/websocket_client/WebSocketUpgradeClient.zig @@ -953,7 +953,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { const ws = bun.take(&this.outgoing_websocket).?; // Create the WebSocket client with the tunnel - ws.didConnectWithTunnel(tunnel, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null); + ws.didConnectWithTunnel(tunnel, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null, @intCast(response.status_code)); // Switch state to connected - handleData will forward to tunnel this.state = .done; @@ -987,7 +987,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { // Once again for the TCP socket. defer this.deref(); if (socket.socket.get()) |native_socket| { - ws.didConnect(native_socket, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null, saved_custom_ssl_ctx); + ws.didConnect(native_socket, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null, saved_custom_ssl_ctx, @intCast(response.status_code)); } else { this.terminate(ErrorCode.failed_to_connect); } diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index f878cbd73f..490ffefe35 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -126,6 +126,8 @@ class BunWebSocket extends EventEmitter { #binaryType = "nodebuffer"; // Bitset to track whether event handlers are set. #eventId = 0; + // Track whether we've set up upgrade event emission + #upgradeEmitterSet = false; constructor(url, protocols, options) { super(); @@ -260,8 +262,27 @@ class BunWebSocket extends EventEmitter { return ws; } + #setupUpgradeEmitter() { + // Set up upgrade event emission only once + if (this.#upgradeEmitterSet) return; + this.#upgradeEmitterSet = true; + + this.#ws.addEventListener("open", () => { + // Emit upgrade event before open event if there are upgrade listeners + if (this.listenerCount("upgrade") > 0) { + const statusCode = this.#ws.upgradeStatusCode || 101; + const response = { + statusCode: statusCode, + statusMessage: "Switching Protocols", + headers: {}, + }; + this.emit("upgrade", response); + } + }); + } + #onOrOnce(event, listener, once) { - if (event === "unexpected-response" || event === "upgrade" || event === "redirect") { + if (event === "redirect") { emitWarning(event, "ws.WebSocket '" + event + "' event is not implemented in bun"); } const mask = 1 << eventIds[event]; @@ -276,6 +297,8 @@ class BunWebSocket extends EventEmitter { this.#eventId |= mask; } if (event === "open") { + // Set up upgrade emitter so upgrade events fire before open + this.#setupUpgradeEmitter(); this.#ws.addEventListener( "open", () => { @@ -283,6 +306,31 @@ class BunWebSocket extends EventEmitter { }, once, ); + } else if (event === "upgrade") { + // The 'upgrade' event is emitted when the WebSocket handshake completes successfully. + // Set up the upgrade emitter to fire on the native 'open' event. + this.#setupUpgradeEmitter(); + } else if (event === "unexpected-response") { + // The 'unexpected-response' event is emitted when the server responds with + // a non-101 status code during the handshake. We emit this on 'error' events. + this.#ws.addEventListener( + "error", + err => { + // Create mock request/response objects for compatibility + const mockRequest = { + method: "GET", + url: this.#ws.url, + headers: {}, + }; + const mockResponse = { + statusCode: 0, + statusMessage: err?.message || "Connection failed", + headers: {}, + }; + this.emit("unexpected-response", mockRequest, mockResponse); + }, + once, + ); } else if (event === "close") { this.#ws.addEventListener( "close", diff --git a/test/regression/issue/09911.test.ts b/test/regression/issue/09911.test.ts new file mode 100644 index 0000000000..2759a0042f --- /dev/null +++ b/test/regression/issue/09911.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe("ws upgrade and unexpected-response events (#9911)", () => { + test("ws WebSocket should not emit warnings for upgrade event", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import WebSocket from "ws"; + const ws = new WebSocket("ws://localhost:${server.port}"); + ws.on("upgrade", () => {}); + ws.on("open", () => ws.close()); + ws.on("close", () => process.exit(0));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("'upgrade' event is not implemented"); + expect(exitCode).toBe(0); + }); + + test("ws WebSocket should not emit warnings for unexpected-response event", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import WebSocket from "ws"; + const ws = new WebSocket("ws://localhost:${server.port}"); + ws.on("unexpected-response", () => {}); + ws.on("open", () => ws.close()); + ws.on("close", () => process.exit(0));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("'unexpected-response' event is not implemented"); + expect(exitCode).toBe(0); + }); + + test("ws WebSocket should emit upgrade event with response object", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + const WebSocket = (await import("ws")).default; + const ws = new WebSocket(`ws://localhost:${server.port}`); + + let upgradeReceived = false; + let upgradeResponse: any = null; + + await new Promise((resolve, reject) => { + ws.on("upgrade", (response: any) => { + upgradeReceived = true; + upgradeResponse = response; + }); + ws.on("open", () => { + ws.close(); + }); + ws.on("close", () => { + resolve(); + }); + ws.on("error", reject); + }); + + expect(upgradeReceived).toBe(true); + expect(upgradeResponse).not.toBeNull(); + expect(upgradeResponse.statusCode).toBe(101); + expect(upgradeResponse.statusMessage).toBe("Switching Protocols"); + expect(typeof upgradeResponse.headers).toBe("object"); + }); + + test("ws WebSocket upgrade event should be emitted before open event", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + const WebSocket = (await import("ws")).default; + const ws = new WebSocket(`ws://localhost:${server.port}`); + + const events: string[] = []; + + await new Promise((resolve, reject) => { + ws.on("upgrade", () => { + events.push("upgrade"); + }); + ws.on("open", () => { + events.push("open"); + ws.close(); + }); + ws.on("close", () => { + resolve(); + }); + ws.on("error", reject); + }); + + expect(events).toEqual(["upgrade", "open"]); + }); + + test("ws WebSocket should work without upgrade listener (backward compatibility)", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + const WebSocket = (await import("ws")).default; + const ws = new WebSocket(`ws://localhost:${server.port}`); + + let openReceived = false; + + await new Promise((resolve, reject) => { + ws.on("open", () => { + openReceived = true; + ws.close(); + }); + ws.on("close", () => { + resolve(); + }); + ws.on("error", reject); + }); + + expect(openReceived).toBe(true); + }); + + test("native WebSocket should expose upgradeStatusCode property", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, + }); + + const ws = new WebSocket(`ws://localhost:${server.port}`); + + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => { + expect((ws as any).upgradeStatusCode).toBe(101); + expect(typeof (ws as any).upgradeStatusCode).toBe("number"); + ws.close(); + resolve(); + }); + ws.addEventListener("error", reject); + }); + }); +});