Support fetch WebSocket upgrade continuations#1
Conversation
d477226 to
dac6749
Compare
| At this time, `init.signal` is not supported and must not be sent, though that will change when `AbortSignal` gains support for serialization. | ||
|
|
||
| `["response", body, init]` | ||
| `["response", body, init, webSocketExpression]` |
There was a problem hiding this comment.
Don't add a fourth parameter. Instead, add a webSocket property to init.
| At this time, `init.webSocket` (a Cloudflare Workers extension) is not supported and must not be sent, though that may change if `WebSocket` gains support for serialization. | ||
| The 4-element form represents a response with an attached WebSocket, as in the Cloudflare Workers `Response.webSocket` extension. `webSocketExpression` evaluates to a live WebSocket capability. The receiver reconstructs a `Response` with a non-standard `.webSocket` property; the presence of that property is the upgrade signal. Since standard `Response` constructors cannot represent 1xx statuses, senders omit `status` and `statusText` from `init` when a WebSocket-bearing response has a 1xx status. A runtime that requires a native upgrade socket may create a local WebSocket pair and bridge it to `webSocketExpression`. | ||
|
|
||
| `["websocket", exportExpression]` |
There was a problem hiding this comment.
I wonder if WebSocket could be represented as a pair of ReadableStream<string|Uint8Array> and WritableStream<string|Uint8Array>. This would reduce the complexity considerably as you could build on the existing support for ReadableStream rather than create a new parallel type that works similarly. The implementation of ReadableStream is fairly complex.
It looks like your agent sidestepped the ReadableStream issue by instead having the WebSocket object expose addEventListener as an RPC method. I think that's not the right way to support it as that means a full network round trip is required for the receiving end to start actually receiving messages. You really want something similar to what ReadableStream does today, where as soon as you send it, the implementation begins streaming messages, even before the other end knows they are coming.
What changed
This PR adds support for fetch WebSocket upgrade continuations in Cap'n Web.
A Cap'n Web target can now implement:
The caller can then do:
This is intentionally scoped to
Response.webSocket/ HTTP upgrade behavior. It does not make arbitrary bareWebSocketobjects generally serializable RPC values.Before this PR
The same target could return a
Responsewith a non-standard.webSocketproperty, but Cap'n Web treated it as an ordinaryResponsevalue.For a response created like this:
Cap'n Web effectively serialized only:
The caller received a normal
Responsewith no.webSocket, so there was no upgrade channel to use:In Workers-style runtimes, a native upgrade response such as
new Response(null, { status: 101, webSocket })also could not be represented portably: standardResponseconstructors reject 1xx statuses, and the socket itself was not carried across the RPC boundary.Now
A
Responsewith.webSocketis serialized as a response-scoped upgrade continuation:The exported object is an internal upgrade session. The receiver reconstructs a
Responsewith.webSocket. In Workers-compatible runtimes, when the response has no explicit status and the body isnull, the receiver can create a nativeWebSocketPair, bridge one side to the upgrade session, and return a nativeResponse(101, { webSocket }).Worked protocol example
Assume a fresh Cap'n Web session:
0.1.-1.-1.1. Caller invokes
fetch(upgradeRequest)Caller to target:
The target application opens its upstream WebSocket and returns
Response(null)with.webSocket = upstream.Target to caller:
Caller releases the completed fetch result promise:
2. Caller-side
response.webSocketstarts the upgrade sessionWhen the caller evaluates the returned response, Cap'n Web constructs an upgrade WebSocket facade and starts receiving events from the exported upgrade session.
Caller to target:
Target to caller:
Caller to target:
3. Caller sends a text WebSocket message
User code:
Caller to target:
The target-side upgrade session sends
helloon the upstream WebSocket.Target to caller:
Caller to target:
4. Upstream echoes the message back
The upstream WebSocket emits a message event. The target calls the caller's event receiver.
Target to caller:
Caller delivers a local
messageevent onresponse.webSocket, then resolves the receiver callback:Target to caller:
5. Binary frames use the existing bytes expression
User code:
Caller to target:
If the upstream echoes those bytes, the event comes back as:
6. Close propagates through the upgrade session
User code:
Caller to target:
If the target observes the upstream close, it reports it back:
Sequence diagram
sequenceDiagram participant Caller as Caller / Captun client participant RPC as Cap'n Web RPC session participant Target as fetch(Request) RpcTarget participant Upstream as Upstream WebSocket service Caller->>RPC: push fetch(Request with Upgrade: websocket) RPC->>Target: fetch(request) Target->>Upstream: open WebSocket Upstream-->>Target: open Target-->>RPC: Response(null) with .webSocket = upstream RPC-->>Caller: resolve ["response", null, {}, ["upgrade", ["export", -1]]] Caller->>RPC: start(receiver) on upgrade session RPC->>Target: attach receiver for message/close/error events Target-->>RPC: start resolved RPC-->>Caller: response.webSocket is usable Caller->>RPC: response.webSocket.send("hello") RPC->>Target: upgradeSession.send("hello") Target->>Upstream: upstream.send("hello") Upstream-->>Target: message "hello" Target-->>RPC: receiver({ type: "message", data: "hello" }) RPC-->>Caller: dispatch local message event Caller->>RPC: response.webSocket.close(1000, "done") RPC->>Target: upgradeSession.close(1000, "done") Target->>Upstream: close(1000, "done") Upstream-->>Target: close Target-->>RPC: receiver({ type: "close", code: 1000, reason: "done" }) RPC-->>Caller: dispatch local close eventValidation
npx vitest run --project node __tests__/websocket-serialization.test.ts- 2 passednpx vitest run --project workerd __tests__/workerd.test.ts -t "WebSocket-bearing Response"- 1 passed, 13 skippednpm run test:types- passednpm run build- passednpm run test:bun- 16 passedgit diff --check HEAD~1..HEAD- passedAutobahn conformance smoke
Autobahn Testsuite 25.10.1 was run via Docker against a local WebSocket server whose echo path was:
Autobahn client -> local Node
wsfrontend -> Cap'n Web RPCfetch()->Response.webSocketupgrade session -> local Nodewsecho origin.Results:
1.*,2.*: 27 cases, 27 OK.1.*through7.*: 246 cases total, 230 OK, 13 NON-STRICT, 3 INFORMATIONAL, 0 FAILED.The NON-STRICT / INFORMATIONAL cases are Autobahn's classification of the outer Node
wsserver behavior for reserved-bit, invalid UTF-8, and close-handshake edge cases. The Cap'n Web-mediated data echo path passed the exercised text, binary, fragmentation, and close cases.Captun proof
Fresh proof run on 2026-06-05, using a temporary Captun Worker deployed from local Captun source linked to this Cap'n Web branch. The temporary Workers were deleted after verification.
Programmatic API proof:
{"mode":"programmatic","publicUrl":"https://captun-capnweb-proof-option2.iterate-dev-preview.workers.dev/option2-programmatic-beb06ac2","square":81,"greeting":"hello programmatic"}Localhost-port proof:
{"mode":"localhost-port","publicUrl":"https://captun-capnweb-proof-option2.iterate-dev-preview.workers.dev/option2-localhost-port-899210c5","localPort":19191,"square":121,"greeting":"hello localhost-port"}Known limitation
Pure Web-standard and Workers WebSocket APIs do not expose ping/pong control frames to application code. This PR preserves application data frames and close/error behavior through the upgrade session, but it does not claim raw control-frame forwarding where the runtime does not expose those frames.