Skip to content

feat(tunnel): bridge public WebSockets over the tunnel - #703

Merged
slvnperron merged 2 commits into
masterfrom
sp/tunnel-websocket-bridge
Aug 1, 2026
Merged

feat(tunnel): bridge public WebSockets over the tunnel#703
slvnperron merged 2 commits into
masterfrom
sp/tunnel-websocket-bridge

Conversation

@slvnperron

Copy link
Copy Markdown
Member

Summary

The tunnel protocol was strictly request/response, so a visitor upgrading a WebSocket against a tunneled path (e.g. the VDK edge webchat behind tunnel.botpress.cloud/<tunnelId>/edge) got its 101 from the server's WSS and then hung forever — there was no way to relay frames to the tail. This adds WebSocket bridging, multiplexed over the tunnel's existing connection.

  • Protocol (types.ts): new frames keyed by a per-socket connection id — ws_open / ws_accept / ws_reject for the handshake, ws_frame (utf-8 text, base64 when binary) and ws_close for the lifetime. Existing request/response/hello schemas untouched.
  • Capability negotiation: tails advertise capabilities: ['ws'] in hello (now sent automatically on open). The head only bridges to tails that advertised it, and refuses other visitors with a new close code 4006 WS_UNSUPPORTED — a pre-websocket tail never receives frames it can't parse (which would make it close the whole tunnel), and old servers strip the unknown hello field harmlessly.
  • Server (tunnel-server.ts): upgrades on /:tunnelId/:path — previously closed as invalid tail registrations — now bridge to the tail: 10s accept timeout, frames the visitor sends before the tail accepts are buffered, closes propagate in both directions, and all visitor sockets close when their tunnel disconnects.
  • Client (tunnel-client.ts): TunnelTail.acceptWebSocket/rejectWebSocket + shared sendWebSocketFrame/closeWebSocket; TunnelHead.openWebSocket + supportsWebSockets. Also upstreams the patch VDK carried against the published dist: under Bun the bundled ws client rejects the 101, so the tail prefers Bun's native WebSocket there.

Once this publishes and the tunnel service redeploys with it, no service-side code changes should be needed if its WSS is a TunnelServer attached to its HTTP server — the bridging is entirely in the library. Consumer-side, vdk dev gains a ws_open → local workerd socket relay (separate PR in the vdk repo).

Test plan

  • pnpm exec tsc --noEmit clean.
  • New e2e: websocket-bridge (visitor → server → tail echo, path/query fidelity, pre-accept buffering, close propagation to the tail) and websocket-unsupported (clean 4006 refusal when the tail didn't advertise the capability). Existing nodejs-success / nodejs-invalid-request still pass.
  • Verified live against a real workerd edge socket: simulated browser → local TunnelServerTunnelTail (wired like vdk's dev-runtime) → VDK runtime worker's conversation WebSocket returned the edge protocol's ready frame through the bridge.

🤖 Generated with Claude Code

The tunnel protocol was strictly request/response, so a visitor upgrading
a WebSocket against a tunneled path (e.g. the VDK edge webchat behind
tunnel.botpress.cloud) got its 101 and then hung forever - the server had
no way to relay frames to the tail.

- New frames multiplexed over the existing tunnel connection, keyed by a
  per-socket id: ws_open / ws_accept / ws_reject (handshake), ws_frame
  (text, base64 for binary), ws_close.
- Capability negotiation: tails advertise `capabilities: ['ws']` in hello
  (sent automatically on open). The head only bridges to tails that
  advertised it and closes other visitors with 4006 WS_UNSUPPORTED, so a
  pre-websocket tail never receives frames it cannot parse and closes on.
- TunnelServer routes upgrades on `/:tunnelId/:path` (previously killed as
  invalid tail registrations) to the tail bridge, with an accept timeout,
  pre-accept frame buffering, and close propagation in both directions.
- Upstreams the VDK dist patch that swaps the bundled `ws` client for
  Bun's native WebSocket under Bun (`ws` rejects the 101 there).
- e2e: websocket-bridge (frames both ways + close propagation) and
  websocket-unsupported (clean refusal without the capability).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@slvnperron
slvnperron requested a review from a team as a code owner August 1, 2026 13:27
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR extends the tunnel protocol and client/server implementations to multiplex public WebSocket connections over an existing tunnel.

  • Adds capability negotiation and ws_open, ws_accept, ws_reject, ws_frame, and ws_close protocol messages.
  • Adds visitor upgrade routing, frame buffering and forwarding, close propagation, and selected visitor-header forwarding.
  • Adds tail/head WebSocket APIs, Bun-native client selection, and end-to-end bridge coverage.

Confidence Score: 4/5

The PR should not merge until public pre-accept buffering is bounded and subprotocol negotiation is made consistent across both WebSocket endpoints.

Public clients can accumulate uncapped frame data during the acceptance window, and the bridge discards the local endpoint's selected subprotocol after the visitor has already completed its handshake.

Files Needing Attention: tunnel/src/tunnel-server.ts, tunnel/src/tunnel-client.ts

Security Review

The public bridge buffers attacker-controlled frames without a byte, frame-count, or connection-wide limit while waiting up to ten seconds for tail acceptance. How this was verified: Public message data is appended to an uncapped pendingFrames array, and no application-level payload, connection, or rate limit surrounds that path.

Important Files Changed

Filename Overview
tunnel/src/tunnel-server.ts Implements public WebSocket lifecycle management, but pre-accept buffering is unbounded and the tail-selected subprotocol is discarded.
tunnel/src/tunnel-client.ts Adds capability advertisement and multiplexed WebSocket APIs, including a subprotocol parameter whose server-side result is not honored.
tunnel/src/types.ts Defines the capability and directional WebSocket protocol schemas with connection IDs and frame metadata.
tunnel/src/rooting.ts Adds parsing for tunnel-prefixed public paths and query strings.
tunnel/e2e/websocket.ts Covers basic bridging, path/query fidelity, pre-accept buffering, close propagation, and unsupported tails, but not subprotocol negotiation or resource limits.

Sequence Diagram

sequenceDiagram
    participant V as Public visitor
    participant H as Tunnel server/head
    participant T as Tunnel tail
    participant L as Local WebSocket
    V->>H: WebSocket upgrade + frames
    H->>T: ws_open(id, path, headers)
    Note over H: Buffer pre-accept frames
    T->>L: Open local socket
    L-->>T: Accept + subprotocol
    T->>H: ws_accept(id, subprotocol)
    H->>T: ws_frame(id, data)
    T->>L: WebSocket frame
    L-->>T: WebSocket frame
    T-->>H: ws_frame(id, data)
    H-->>V: WebSocket frame
    V->>H: close
    H->>T: ws_close(id)
Loading

Reviews (1): Last reviewed commit: "feat(tunnel): bridge public WebSockets o..." | Re-trigger Greptile

Comment thread tunnel/src/tunnel-server.ts
Comment thread tunnel/src/tunnel-server.ts
aj-botpress
aj-botpress previously approved these changes Aug 1, 2026
…ield

Review follow-ups:
- Pre-accept frames from the (unauthenticated) visitor are now hard-capped
  at 64 frames / 256KB; exceeding the cap closes the socket with 1009 and
  notifies the tail so it can abort its local dial. The accept timeout now
  notifies the tail too - a late accept no longer streams into the void.
- ws_accept loses its subprotocol field: the visitor's upgrade completes
  (without a subprotocol) before the tail answers, so a tail selection
  could never be honored - the field was a silent no-op. The
  sec-websocket-protocol header is no longer forwarded for the same reason.
- sendableCloseCode now permits all RFC 6455 sendable codes (1000-1003,
  1007-1011, 3000-4999) instead of flattening 1009 et al to 1000.
- e2e: websocket-pending-cap covers the flood path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@slvnperron
slvnperron merged commit 3b892f1 into master Aug 1, 2026
1 of 3 checks passed
@slvnperron
slvnperron deleted the sp/tunnel-websocket-bridge branch August 1, 2026 15:03
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.

3 participants