Skip to content

Support fetch WebSocket upgrade continuations#1

Draft
jonastemplestein wants to merge 2 commits into
mainfrom
codex/websocket-serialization
Draft

Support fetch WebSocket upgrade continuations#1
jonastemplestein wants to merge 2 commits into
mainfrom
codex/websocket-serialization

Conversation

@jonastemplestein
Copy link
Copy Markdown

@jonastemplestein jonastemplestein commented Jun 3, 2026

What changed

This PR adds support for fetch WebSocket upgrade continuations in Cap'n Web.

A Cap'n Web target can now implement:

class TunnelTarget extends RpcTarget {
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
      return new Response("Expected WebSocket upgrade", { status: 426 });
    }

    const upstream = new WebSocket("ws://127.0.0.1:19191");
    await waitOpen(upstream);

    const response = new Response(null);
    Object.defineProperty(response, "webSocket", {
      value: upstream,
      configurable: true,
    });
    return response;
  }
}

The caller can then do:

const response = await tunnel.fetch(new Request("https://service.example/chat", {
  headers: { Upgrade: "websocket" },
}));

const socket = response.webSocket;
socket.send("hello");
socket.addEventListener("message", event => {
  console.log(event.data); // "hello" from the upstream echo server
});

This is intentionally scoped to Response.webSocket / HTTP upgrade behavior. It does not make arbitrary bare WebSocket objects generally serializable RPC values.

Before this PR

The same target could return a Response with a non-standard .webSocket property, but Cap'n Web treated it as an ordinary Response value.

For a response created like this:

const response = new Response(null);
Object.defineProperty(response, "webSocket", { value: upstream });
return response;

Cap'n Web effectively serialized only:

["response", null, {}]

The caller received a normal Response with no .webSocket, so there was no upgrade channel to use:

const response = await tunnel.fetch(upgradeRequest);
response.webSocket; // undefined before this PR

In Workers-style runtimes, a native upgrade response such as new Response(null, { status: 101, webSocket }) also could not be represented portably: standard Response constructors reject 1xx statuses, and the socket itself was not carried across the RPC boundary.

Now

A Response with .webSocket is serialized as a response-scoped upgrade continuation:

["response", null, {}, ["upgrade", ["export", -1]]]

The exported object is an internal upgrade session. The receiver reconstructs a Response with .webSocket. In Workers-compatible runtimes, when the response has no explicit status and the body is null, the receiver can create a native WebSocketPair, bridge one side to the upgrade session, and return a native Response(101, { webSocket }).

Worked protocol example

Assume a fresh Cap'n Web session:

  • The caller imports the target's main object as import 0.
  • The caller's first call result is import 1.
  • The target exports the upgrade session as export -1.
  • The caller exports its upgrade event receiver as export -1.
  • Import/export IDs are from the sender's perspective. The same number can appear in both directions because each peer has its own tables.

1. Caller invokes fetch(upgradeRequest)

Caller to target:

["push", ["pipeline", 0, ["fetch"], [["request", "https://service.example/chat", {"headers": [["upgrade", "websocket"]]}]]]]
["pull", 1]

The target application opens its upstream WebSocket and returns Response(null) with .webSocket = upstream.

Target to caller:

["resolve", 1, ["response", null, {}, ["upgrade", ["export", -1]]]]

Caller releases the completed fetch result promise:

["release", 1, 1]

2. Caller-side response.webSocket starts the upgrade session

When 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:

["push", ["pipeline", -1, ["start"], [["export", -1]]]]
["pull", 2]

Target to caller:

["resolve", 2, ["undefined"]]

Caller to target:

["release", 2, 1]

3. Caller sends a text WebSocket message

User code:

response.webSocket.send("hello");

Caller to target:

["push", ["pipeline", -1, ["send"], ["hello"]]]
["pull", 3]

The target-side upgrade session sends hello on the upstream WebSocket.

Target to caller:

["resolve", 3, ["undefined"]]

Caller to target:

["release", 3, 1]

4. Upstream echoes the message back

The upstream WebSocket emits a message event. The target calls the caller's event receiver.

Target to caller:

["push", ["pipeline", -1, [], [{"type": "message", "data": "hello"}]]]
["pull", 1]

Caller delivers a local message event on response.webSocket, then resolves the receiver callback:

["resolve", 1, ["undefined"]]

Target to caller:

["release", 1, 1]

5. Binary frames use the existing bytes expression

User code:

response.webSocket.send(new Uint8Array([1, 2, 3]));

Caller to target:

["push", ["pipeline", -1, ["send"], [["bytes", "AQID"]]]]

If the upstream echoes those bytes, the event comes back as:

["push", ["pipeline", -1, [], [{"type": "message", "data": ["bytes", "AQID"]}]]]

6. Close propagates through the upgrade session

User code:

response.webSocket.close(1000, "done");

Caller to target:

["push", ["pipeline", -1, ["close"], [1000, "done"]]]

If the target observes the upstream close, it reports it back:

["push", ["pipeline", -1, [], [{"type": "close", "code": 1000, "reason": "done"}]]]

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 event
Loading

Validation

  • npx vitest run --project node __tests__/websocket-serialization.test.ts - 2 passed
  • npx vitest run --project workerd __tests__/workerd.test.ts -t "WebSocket-bearing Response" - 1 passed, 13 skipped
  • npm run test:types - passed
  • npm run build - passed
  • npm run test:bun - 16 passed
  • git diff --check HEAD~1..HEAD - passed

Autobahn conformance smoke

Autobahn Testsuite 25.10.1 was run via Docker against a local WebSocket server whose echo path was:

Autobahn client -> local Node ws frontend -> Cap'n Web RPC fetch() -> Response.webSocket upgrade session -> local Node ws echo origin.

Results:

  • Smoke profile 1.*, 2.*: 27 cases, 27 OK.
  • Expanded core profile 1.* through 7.*: 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 ws server 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.

@jonastemplestein jonastemplestein force-pushed the codex/websocket-serialization branch from d477226 to dac6749 Compare June 3, 2026 16:52
Comment thread protocol.md Outdated
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]`
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Don't add a fourth parameter. Instead, add a webSocket property to init.

Comment thread protocol.md Outdated
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]`
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@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
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