Skip to content

node:http: emit 'upgrade' on ClientRequest for 101 responses#32204

Closed
robobun wants to merge 2 commits into
mainfrom
farm/e12755dc/http-client-upgrade-event
Closed

node:http: emit 'upgrade' on ClientRequest for 101 responses#32204
robobun wants to merge 2 commits into
mainfrom
farm/e12755dc/http-client-upgrade-event

Conversation

@robobun

@robobun robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

This is the client half of #32195 (the server half, raw socket writes after the server's 'upgrade' event, is #30664). Fixes #18945.

Reproduction

// raw TCP server stands in for any websocket server
const net = require("node:net");
const http = require("node:http");

const server = net.createServer(socket => {
  socket.once("data", () => {
    socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
    socket.pipe(socket);
  });
});
server.listen(0, "127.0.0.1", () => {
  const req = http.request({
    port: server.address().port,
    headers: { Connection: "Upgrade", Upgrade: "websocket" },
  });
  req.end();
  req.on("upgrade", (res, socket, head) => {
    console.log("got upgraded!");
    socket.end();
    server.close();
  });
});

Node prints got upgraded! and exits. Bun emits 'response' (status 101) instead, never fires 'upgrade', and hangs forever. This breaks every client that drives a websocket handshake through http.request(): the real ws package fails with Unexpected server response: 101, playwright's connectOverCDP times out, etc.

Root cause

ClientRequest is fetch-based and has no 101 handling: the only delivery path is self.emit("response", res). The native side already supports the whole flow (an Upgrade: header sets upgrade_state = Pending in src/http/lib.rs, a 101 stops HTTP parsing and streams the raw bytes as the response body, streaming request-body writes skip chunked framing after the upgrade, and ending the body stream shuts the connection down), but nothing on the JS side used it; kUpgradeOrConnect was initialized and never read.

Fix

All in src/js/node/_http_client.ts:

  • detect upgrade requests the same way the native client does (an Upgrade header other than h2/h2c)
  • force such requests into streaming-body mode and keep the body generator alive past req.end(), so the connection retains a writable channel
  • on a 101, emit upgrade(res, socket, head) instead of response: socket is a Duplex that reads from the fetch response body (the raw post-upgrade bytes) and writes through the body generator, which the native client writes unframed after the 101; write acks ride the sink's pulls so native backpressure reaches the socket
  • with no 'upgrade' listener, destroy the connection and emit no 'response', matching Node
  • non-101 responses to upgrade requests release the generator so the request completes normally

Event order (socket, finish, upgrade, close), res.complete, socket === req.socket, and req.destroyed were verified side by side against Node v24.3.0 (reference: lib/_http_client.js socketOnData).

head is always empty: bytes that arrived together with the 101 flow through the socket instead. Upgrade consumers unshift(head) back onto the socket before reading, so both shapes behave the same.

Verification

test/js/node/http/node-http-client-upgrade.test.ts, 8 tests against raw net/tls servers so that only the client side is exercised: echo roundtrip plus res/socket invariants, Node event order, no-listener destroy, 101+data in one packet, non-101 completion, flushHeaders-without-end, TLS, and a spawned child proving the process exits instead of hanging.

USE_SYSTEM_BUN=1 bun test test/js/node/http/node-http-client-upgrade.test.ts  # 7 fail, 1 pass (hangs / 'response' instead of 'upgrade')
bun bd test test/js/node/http/node-http-client-upgrade.test.ts               # 8 pass

Also verified the real ws@8.18.3 client (required by path to bypass the bundled shim) completes open/send/echo/close against a Node ws server: fails with Unexpected server response: 101 on bun 1.4.0, works with this change. test/js/node/http/ suite has no new failures (the pre-existing proxy and uaf failures reproduce on main).

Not covered: request bodies sent before the 101 are still held back by the native client while the upgrade is pending (write_to_stream returns early in the Pending state), so the dockerode "send a chunked body, then read the 101" flow (#29012) still deadlocks. That part needs a native-side change.

Supersedes #28470 (reroutes upgrade requests through a JS-parsed net.connect path, stalled since March) and #29015 (same fetch-based approach, but its native half targets the dormant .zig reference files from before the Rust port).

Related issues (scoped, not auto-closed)

Of the issues the bot flagged, none is fully closed by this PR alone:

A 101 Switching Protocols response was delivered as a regular 'response'
event with no way to reach the raw connection, so every client that
drives a WebSocket-style handshake through http.request() hung waiting
for 'upgrade'.

The native client already supports the whole flow (an Upgrade header
sets upgrade_state = Pending, a 101 stops HTTP parsing and streams the
raw bytes as the response body, and streaming request-body writes skip
chunked framing after the upgrade); only the JS ClientRequest never used
it. Detect upgrade requests, keep the request body generator alive past
end(), and on a 101 emit 'upgrade' with a Duplex socket that reads from
the response body stream and writes through the body generator. With no
'upgrade' listener the connection is destroyed, matching Node. Non-101
responses to upgrade requests release the generator so the request can
finish.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 6 minutes and 47 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 25ce5398-1947-4000-a15e-73b01e8cbfef

📥 Commits

Reviewing files that changed from the base of the PR and between ac312f0 and 01d9361.

📒 Files selected for processing (2)
  • src/js/node/_http_client.ts
  • test/js/node/http/node-http-client-upgrade.test.ts

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:20 PM PT - Jun 12th, 2026

@robobun, your commit 01d9361 has 1 failures in Build #62150 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32204

That installs a local version of the PR into your bun-32204 executable, so you can run:

bun-32204 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 7 issues this PR may fix:

  1. ws client does not emit upgrade event in Bun 1.3.8 #31406 - ws client never emits 'upgrade' event because node:http ClientRequest doesn't emit it on 101 responses
  2. ws.WebSocket 'upgrade' and 'unexpected-response' event is not implemented in bun #5951 - ws.WebSocket 'upgrade' event not implemented; depends on node:http client-side upgrade emission (the 'upgrade' half)
  3. Proxying WebSockets with node-http-proxy (from Vite) doesn't work, works with Node #10441 - node-http-proxy WebSocket proxying fails because http.request() emits 'response' instead of 'upgrade' on 101
  4. Bun cannot not proxy websocket requests #14522 - WebSocket proxy via node-http-proxy hangs; 'upgrade' event never fires on the client request
  5. Proxy and websockets not work with bun #16819 - http-proxy WebSocket proxying times out due to missing client-side 'upgrade' event
  6. 'websocket' package does not work #20547 - websocket npm package hangs because its http.request() upgrade handshake never receives the 'upgrade' event
  7. Vite dev server not starting with @cloudflare/vite-plugin and Bun ^1.3 #24229 - Vite dev server with Cloudflare plugin fails with "ws.WebSocket 'upgrade' event is not implemented" warning

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #31406
Fixes #5951
Fixes #10441
Fixes #14522
Fixes #16819
Fixes #20547
Fixes #24229

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(node:http): emit 'upgrade' event on ClientRequest for 101 responses #27859 - Same feature: emit 'upgrade' event on ClientRequest for 101 responses
  2. Emit upgrade event on http.ClientRequest for 101 responses #28470 - Same feature: emit upgrade event on http.ClientRequest for 101 responses
  3. http: support client upgrade event #28828 - Same feature: HTTP client upgrade event support
  4. http: emit 'upgrade' event on 101 and honor chunked body on upgrade #29015 - Same feature: emit 'upgrade' event on 101 and honor chunked body on upgrade
  5. node:http: hand off upgrade socket to userland #30664 - Same feature: hand off upgrade socket to userland for HTTP upgrade

🤖 Generated with Claude Code

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #28828, which I missed when scanning for prior work (the dedupe bot caught it): same architecture (gate duplex on the Upgrade header, Duplex socket bridging the fetch streams), maintainer-driven, and with a much deeper test suite, including the real-ws and connectOverCDP regression tests. It is mergeable and waiting on review.

Two data points from this PR that may still be useful:

@robobun robobun closed this Jun 12, 2026
Comment on lines +712 to +714
res[noBodySymbol] = true;
res.complete = true;
process.nextTick(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The IncomingMessage passed to the 'upgrade' event is missing res.upgrade = true. Node sets this in parserOnHeadersComplete (and Bun mirrors that at _http_common.ts:105 for the parser path), but the fetch-based path here never assigns it, so listeners see undefined for the documented message.upgrade property. One-line fix: add res.upgrade = true; next to res.complete = true;.

Extended reasoning...

What the bug is

Node's documented message.upgrade property is supposed to be true on the IncomingMessage handed to an 'upgrade' listener. In Node this is set by parserOnHeadersComplete in lib/_http_common.js (parser.incoming.upgrade = upgrade), and Bun already mirrors that for the HTTP-parser path at src/js/node/_http_common.ts:105 (incoming.upgrade = upgrade;).

This PR introduces a new path that constructs the IncomingMessage directly from a fetch Response (the NodeHTTPIncomingRequestType.FetchResponse constructor branch), bypassing parserOnHeadersComplete. The isUpgradeResponse block sets res[noBodySymbol] = true and res.complete = true, but never sets res.upgrade. A grep of src/js/node/_http_incoming.ts confirms IncomingMessage has no default for this property, so it stays undefined.

Code path

// src/js/node/_http_client.ts (this PR)
if (isUpgradeResponse) {
  res[noBodySymbol] = true;
  res.complete = true;
  // ↓ missing
  // res.upgrade = true;
  process.nextTick((self, res) => {
    ...
    self.emit("upgrade", res, socket, kEmptyBuffer);
  }, this, res);
}

Step-by-step proof

  1. Client sends a request with Upgrade: websocket; server replies 101 Switching Protocols.
  2. nodeHttpClient(...).then(response => ...) runs with response.status === 101, so isUpgradeResponse is true.
  3. handleResponse() constructs res = new IncomingMessage(response, { [typeSymbol]: FetchResponse, ... }). Neither the IncomingMessage constructor (no upgrade reference in _http_incoming.ts) nor this block assigns res.upgrade.
  4. self.emit("upgrade", res, socket, head) fires.
  5. In the listener, res.upgrade evaluates to undefined. In Node it is true.

Why existing code doesn't prevent it

The only place Bun sets .upgrade on an IncomingMessage is _http_common.ts:105, inside the llhttp-driven parserOnHeadersComplete. ClientRequest is fetch-based and never goes through that parser; before this PR the 'upgrade' event was never emitted on ClientRequest, so the gap was unobservable. This PR creates the path where res.upgrade becomes observable but doesn't populate it.

Impact

Low. Code already inside an 'upgrade' handler knows it's an upgrade, and the major consumers (ws, Playwright's CDP client) inspect res.statusCode / res.headers, not res.upgrade. But it is a documented Node API, the PR's purpose is Node-compat for upgrades, and if (res.upgrade) is a reasonable thing for user code to write — it would be falsy here and truthy in Node.

Fix

Add one line alongside the existing assignments:

res[noBodySymbol] = true;
res.complete = true;
res.upgrade = true;

Comment on lines +287 to +292
destroy: err => {
endUpgradeBody();
const callback = upgradeWriteCallback;
upgradeWriteChunk = upgradeWriteCallback = undefined;
callback?.(err);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When the upgrade socket is destroyed without an error (e.g. socket.destroy() with no arg, or via the abort-signal listener), the pending write callback is invoked with undefined, which Writable treats as success — so a user's socket.write(data, cb) fires cb(null) even though upgradeWriteChunk was cleared and never yielded to the body generator. Consider callback?.(err ?? $ERR_STREAM_DESTROYED('write')) so an in-flight write is errored on teardown.

Extended reasoning...

What the bug is

In createUpgradeSocket's channel.destroy handler, when the upgrade socket is destroyed without an error, any pending _write callback is invoked with err = undefined:

destroy: err => {
  endUpgradeBody();
  const callback = upgradeWriteCallback;
  upgradeWriteChunk = upgradeWriteCallback = undefined;
  callback?.(err);   // err is undefined when socket.destroy() had no arg
},

That callback is Writable's internal onwrite. Calling onwrite(undefined) tells the Writable machinery the chunk was written successfully, which then fires the user's socket.write(data, cb) callback with no error — even though upgradeWriteChunk was cleared on the line above and was never yielded to the body generator, so the bytes never reached the wire.

How it triggers — step-by-step proof

  1. User calls socket.write(data, cb) on the upgrade socket. Writable invokes UpgradeSocket._write(chunk, enc, onwrite), which calls channel.write(chunk, onwrite).
  2. channel.write synchronously sets upgradeWriteChunk = chunk and upgradeWriteCallback = onwrite, then calls wakeUpgradeBody() to resolve upgradeWake. The body generator is parked on await new Promise(...), so it won't actually pick the chunk up until a microtask runs.
  3. In the same tick (before that microtask), user calls socket.destroy() with no argument — or req.abort() / req.setTimeout fires, which aborts kAbortController, whose listener calls socket.destroy() with no argument.
  4. Duplex.prototype.destroy sets state.destroyed = true and synchronously calls UpgradeSocket._destroy(undefined, cb), which calls channel.destroy(undefined).
  5. channel.destroy calls endUpgradeBody() (sets upgradeBodyEnded = true), captures callback = onwrite, clears upgradeWriteChunk/upgradeWriteCallback, then calls onwrite(undefined).
  6. Writable's onwrite(undefined) takes the success path → afterWrite → user's cb(null).
  7. When the generator's microtask later resumes, upgradeWriteCallback is already undefined and upgradeBodyEnded is true, so the loop breaks without ever yielding the chunk.

Net result: the user is told the write succeeded, but the data was dropped.

Why existing code doesn't prevent it

channel.write does guard with if (upgradeBodyEnded) callback($ERR_STREAM_DESTROYED('write')), but that only covers writes after destroy. The race here is a write that was accepted before destroy and is still pending when destroy runs. Writable also won't supply its own ERR_STREAM_DESTROYED here because _write already handed the chunk to onwrite before destroyed was set.

Impact

Edge case during teardown: socket.write(d, cb); socket.destroy(); in the same tick, or an abort/timeout landing while a write is in flight. Real upgrade consumers (ws, playwright) track socket 'close' rather than relying on per-write callback errors during destroy, so this is unlikely to break them — hence nit. But it is a correctness divergence from Node's net.Socket, which errors the in-flight write when the handle closes, and it's in new code with a one-token fix.

Fix

destroy: err => {
  endUpgradeBody();
  const callback = upgradeWriteCallback;
  upgradeWriteChunk = upgradeWriteCallback = undefined;
  callback?.(err ?? $ERR_STREAM_DESTROYED('write'));
},

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:http and node:https strange behaviour with upgrade event

1 participant