Add support for sending WebSockets over RPC, as part of a fetch upgrade Response.#1
Merged
Merged
Conversation
d477226 to
dac6749
Compare
kentonv
reviewed
Jun 4, 2026
kentonv
reviewed
Jun 4, 2026
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 16f140a. Configure here.
…de Response.
When a Response has a webSocket property -- the Cloudflare Workers
extension, as produced by a fetch() that performs a WebSocket upgrade --
it can now be passed over RPC, in arguments or return values, with the
socket's messages tunneled over the RPC session. Bare WebSockets remain
non-serializable; support is deliberately scoped to upgrade Responses.
The socket is represented as a pair of streams, reusing the existing
ReadableStream/WritableStream serialization: init.webSocket becomes
{readable, writable} on the wire (the slot protocol.md reserved for
this). Like any ReadableStream, the readable half is piped eagerly, so
the sender starts streaming the socket's messages the moment the
Response is serialized -- before the receiver even knows they're coming
-- and both directions inherit the streams' BDP flow control. Messages
are string/Uint8Array chunks; closure travels in-band as a final
{close: {code, reason}} chunk.
On Workers, the receiver gets a real WebSocket (one end of a
WebSocketPair pumped to/from the streams) on a status-101 Response,
suitable for returning from a fetch handler to complete a real upgrade.
On other platforms the receiver gets a WebSocket-like object supporting
send(), close(), accept(), readyState, and message/close/error events --
enough to pass to newWebSocketRpcSession() and run a nested Cap'n Web
session through the tunnel.
Lifetime follows the rules established for streams: the receiving
socket only takes its own references on the app's first interaction
(accept(), attaching a listener, send(), or close()), so an upgrade
Response whose socket nobody touches releases both streams when its
payload is disposed, letting the sender close the underlying connection
-- just as an unread ReadableStream is canceled unless the app locks it.
accept() is the idiomatic claim, mirroring Workers semantics. A socket
can be sent at most once (wrapping it attaches its listeners); a sender-
side Response disposed without being serialized closes its socket; and a
received Response can be re-returned onward, chaining tunnels (the proxy
must await the Response, as with stream-bearing Responses).
Tests include a transport-equivalence battery: the direct-WebSocket
cases from index.test.ts now live in a shared session battery which
also runs over a WebSocket obtained by calling a fetch upgrade handler
across another Cap'n Web session, proving a session over a tunneled
socket behaves like one over a direct socket. Focused tests cover
text/binary echo, close propagation both ways, upgrade Responses in
params, ignored-socket cleanup, accept-keeps-alive, double-send
rejection, two-hop proxying, and the native WebSocketPair path on
workerd.
Known limitations, documented in the README: ping/pong frames are not
forwarded (standard WebSocket APIs don't expose them); inbound flow
control ends at the sender's socket (the WebSocket API can't slow down
a peer); binary frames ride as base64 inside JSON for now; and on
platforms whose sockets don't buffer pre-accept (e.g. Node ws), messages
arriving before the Response is serialized are dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
70db0f5 to
f6cd686
Compare
jonastemplestein
added a commit
to iterate/iterate
that referenced
this pull request
Jun 10, 2026
Pins `capnweb` to our fork via a pnpm override in `pnpm-workspace.yaml`, so every workspace package (`packages/streams`, `apps/os`, the example app) and any transitive dependent (e.g. captun) resolves to it. The fork adds support for passing WebSocket-bearing upgrade `Response`s over RPC — see iterate/capnweb#1 for the full design (stream-pair tunneling with flow control, claim-on-first-use lifetime, transport-equivalence test battery). This unblocks capnweb-over-capnweb tunneling for e2e tests of WebSocket-using services (upstream issue: cloudflare/capnweb#187). How it's wired: * `overrides.capnweb` points at a **prebuilt tarball** attached to the fork's [v0.8.0-websocket.1 release](https://github.com/iterate/capnweb/releases/download/v0.8.0-websocket.1/capnweb-0.8.0.tgz), built from iterate/capnweb@4d384fa (`npm ci && npm run build && npm pack`). The lockfile pins it by integrity hash. Same pattern as captun, which already installs from a `pkg.pr.new` tarball URL. * To update: build + `npm pack` in the fork, attach to a new release, bump the URL, `pnpm install`. The first revision of this PR installed straight from git (`github:iterate/capnweb#<sha>`), relying on the fork's `prepare` script to build `dist/` at install time. That worked locally but turned out to be environment-sensitive: CI's Linux runners produced a dist with JS but **no type declarations** (the `test` job passed, `lint-typecheck` failed with TS7016). The prebuilt tarball sidesteps install-time builds entirely and is byte-identical everywhere. Verified locally: fresh install pulls the tarball with full `dist/` including declarations, `newWebSocketRpcSession` et al. import fine, and `packages/streams` passes typecheck and all 67 node tests against it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

When you send a
Responsethat has awebSocketproperty -- the Cloudflare Workers extension, as produced by afetch()that performs a WebSocket upgrade -- the receiving side now gets aResponsewith a working WebSocket attached. The socket's messages are tunneled over the RPC session.On Workers, the receiver gets a real
WebSocket(one end of aWebSocketPair, pumped to/from the tunnel) on a status-101Response, so theResponsecan even be returned from a fetch handler to complete a real HTTP upgrade. On other platforms, which have no native upgrade socket type, the receiver gets a WebSocket-like object supportingsend(),close(),accept(),readyState, andmessage/close/errorevents -- enough, in particular, to pass tonewWebSocketRpcSession()and run a nested Cap'n Web session through the tunnel.Bare
WebSockets remain non-serializable, intentionally. A live socket isn't really a value, and scoping support to upgradeResponses matches how sockets actually enter a program through the fetch API.Under the hood, the socket is represented as a pair of streams, reusing the existing
ReadableStream/WritableStreamserialization rather than inventing a parallel mechanism: the sender wraps its socket in a readable (messages arriving on the socket) and a writable (messages to send on it). Because the readable half is piped exactly like anyReadableStreamvalue, the sender begins streaming the socket's messages the moment theResponseis serialized -- before the receiver even knows they're coming -- and they wait in the receiver's stream buffer until the app attaches a listener. No round trip before messages flow, and both directions inherit the streams' BDP flow control. Messages are string/Uint8Arraychunks; closure travels in-band as a final{"close": {"code", "reason"}}chunk, since the streams themselves can only signal an undifferentiated end.On the wire, the pair rides in
init.webSocket-- the slot protocol.md already reserved for this ("not supported and must not be sent, though that may change ifWebSocketgains support for serialization"), evaluated likeinit.bodyis for["request", ...], and matching the WorkersResponseInitshape, wherewebSocketis a property of theResponseconstructor's init. A pre-upgrade receiver that encounters it fails with the precise existing error ("Can't deserialize a Response containing a webSocket.") rather than a generic parse error. A real captured trace, in which the backend sends "welcome" before the client has attached any listener:An upgrade response never carries
status/statusText-- the upgrade itself implies 101. (StandardResponseconstructors refuse 1xx statuses anyway, so non-Workers receivers keep the default 200 and signal the upgrade purely via thewebSocketproperty.) The full spec is in protocol.md.Ownership follows the rules established for streams -- literally, since the socket now is a pair of streams. The receiver's socket only takes its own references on the app's first interaction -- accept(), attaching a listener, send(), or close() -- by locking the readable and duplicating the writable's hook. An upgrade
Responsewhose socket nobody touches therefore releases both streams when its payload is disposed (e.g. when the call it arrived in returns), letting the sender close the underlying connection rather than hold it for a receiver that will never use it: the same reasoning as canceling an unreadReadableStreamunless the app locks it. The WebSocket analog of locking isaccept(), conveniently mirroring Workers semantics. On the sending side, aResponsethat is disposed without ever being serialized closes its socket rather than leaving the connection dangling, and a receivedResponsecan be re-returned onward, chaining tunnels, which is how proxying works (though, as with stream-bearing Responses, the proxy mustawaitthe Response rather than forward the unresolved promise). If the session breaks, the facade fireserrorandcloselike a failed WebSocket.For testing, beyond focused cases (text/binary echo, close propagation in both directions, upgrade Responses in params, ignored-socket cleanup, accept-keeps-alive, two-hop tunnel proxying, bare-socket rejection, the native path on workerd), I pulled the direct-WebSocket test cases out of index.test.ts into a shared battery -- calls, errors, capabilities in both directions, pipelining, Blobs -- which now runs twice: over a direct WebSocket connection on every runtime, and over a WebSocket obtained by calling a fetch handler across another Cap'n Web session. Same cases, both transports, so a session over a tunneled socket demonstrably behaves like a session over a direct one.
Known limitations, which are documented in the README:
addEventListener()API (browser WebSockets,ws, Workers WebSockets). Callback-based server sockets like Bun'sServerWebSocketwould need an adapter.Responseis serialized. Workers sockets buffer incoming messages untilaccept(), so nothing is lost there; on platforms whose sockets don't buffer (Nodews), messages arriving before that point are dropped.(Disclosure: the initial implementation was AI-written; it was then reviewed and substantially reworked -- also with AI assistance -- fixing among other things a listener leak when the same Response is serialized twice, a tripped
rpcTargetsassertion under deep-copy, and close codes that can't legally be re-sent. The full vitest matrix passes: node, workerd, chromium/firefox/webkit, 856 tests, plus bun, build, and type tests.)🤖 Generated with Claude Code
Note
Cursor Bugbot is generating a summary for commit 70db0f5. Configure here.