Skip to content

Add support for sending WebSockets over RPC, as part of a fetch upgrade Response.#1

Merged
jonastemplestein merged 1 commit into
mainfrom
codex/websocket-serialization
Jun 10, 2026
Merged

Add support for sending WebSockets over RPC, as part of a fetch upgrade Response.#1
jonastemplestein merged 1 commit into
mainfrom
codex/websocket-serialization

Conversation

@jonastemplestein

@jonastemplestein jonastemplestein commented Jun 3, 2026

Copy link
Copy Markdown

When you send a Response that has a webSocket property -- the Cloudflare Workers extension, as produced by a fetch() that performs a WebSocket upgrade -- the receiving side now gets a Response with 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 a WebSocketPair, pumped to/from the tunnel) on a status-101 Response, so the Response can 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 supporting send(), close(), accept(), readyState, and message/close/error events -- enough, in particular, to pass to newWebSocketRpcSession() 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 upgrade Responses 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/WritableStream serialization 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 any ReadableStream value, the sender begins streaming the socket's messages the moment the Response is 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/Uint8Array chunks; 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 if WebSocket gains support for serialization"), evaluated like init.body is for ["request", ...], and matching the Workers ResponseInit shape, where webSocket is a property of the Response constructor'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:

server -> client  ["pipe"]
server -> client  ["resolve", 1, ["response", null, {"webSocket": {"readable": ["readable", 1], "writable": ["writable", -1]}}]]
server -> client  ["stream", ["pipeline", 1, ["write"], ["welcome"]]]    <- streamed before the client is listening
client -> server  ["stream", ["pipeline", -1, ["write"], ["hello"]]]     <- socket.send("hello")
server -> client  ["stream", ["pipeline", 1, ["write"], ["hello"]]]      <- the echo
client -> server  ["stream", ["pipeline", -1, ["write"], [{"close": {"code": 1000, "reason": "done"}}]]]
client -> server  ["stream", ["pipeline", -1, ["close"], []]]

An upgrade response never carries status/statusText -- the upgrade itself implies 101. (Standard Response constructors refuse 1xx statuses anyway, so non-Workers receivers keep the default 200 and signal the upgrade purely via the webSocket property.) 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 Response whose 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 unread ReadableStream unless the app locks it. The WebSocket analog of locking is accept(), conveniently mirroring Workers semantics. On the sending side, a Response that is disposed without ever being serialized closes its socket rather than leaving the connection dangling, and a received Response can be re-returned onward, chaining tunnels, which is how proxying works (though, as with stream-bearing Responses, the proxy must await the Response rather than forward the unresolved promise). If the session breaks, the facade fires error and close like 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:

  • Inbound flow control ends at the sender's socket: the WebSocket API offers no way to slow down a peer, so a flooding peer buffers on the sending side. (Everything from the sender's socket onward is windowed by the streams' flow control.)
  • Binary frames ride as base64 inside the JSON messages for now. (The separate-binary-frame idea discussed in custom (de)serializer support cloudflare/capnweb#32 would help here too.)
  • Ping/pong control frames aren't forwarded -- standard WebSocket APIs don't expose them.
  • The sender's socket must support the standard addEventListener() API (browser WebSockets, ws, Workers WebSockets). Callback-based server sockets like Bun's ServerWebSocket would need an adapter.
  • Cap'n Web first touches the socket when the Response is serialized. Workers sockets buffer incoming messages until accept(), so nothing is lost there; on platforms whose sockets don't buffer (Node ws), 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 rpcTargets assertion 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.

Comment thread protocol.md Outdated
Comment thread protocol.md Outdated
@jonastemplestein jonastemplestein changed the title Add WebSocket serialization support Support fetch WebSocket upgrades Jun 5, 2026
@jonastemplestein jonastemplestein changed the title Support fetch WebSocket upgrades Support fetch WebSocket upgrade continuations Jun 5, 2026
@jonastemplestein jonastemplestein changed the title Support fetch WebSocket upgrade continuations Tunnel WebSockets attached to fetch upgrade Responses over RPC Jun 10, 2026
@jonastemplestein jonastemplestein changed the title Tunnel WebSockets attached to fetch upgrade Responses over RPC Add support for sending WebSockets over RPC, as part of a fetch upgrade Response. Jun 10, 2026
@jonastemplestein jonastemplestein marked this pull request as ready for review June 10, 2026 15:43
Comment thread src/core.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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.

Comment thread src/websocket-tunnel.ts Outdated
…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>
@jonastemplestein jonastemplestein force-pushed the codex/websocket-serialization branch from 70db0f5 to f6cd686 Compare June 10, 2026 19:08
@jonastemplestein jonastemplestein merged commit ff1e7a1 into main Jun 10, 2026
1 check was pending
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants