feat(tunnel): bridge public WebSockets over the tunnel - #703
Conversation
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>
Greptile SummaryThis PR extends the tunnel protocol and client/server implementations to multiplex public WebSocket connections over an existing tunnel.
Confidence Score: 4/5The 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
|
| 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)
Reviews (1): Last reviewed commit: "feat(tunnel): bridge public WebSockets o..." | Re-trigger Greptile
…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>
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 its101from 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.types.ts): new frames keyed by a per-socket connection id —ws_open/ws_accept/ws_rejectfor the handshake,ws_frame(utf-8 text, base64 whenbinary) andws_closefor the lifetime. Existing request/response/hello schemas untouched.capabilities: ['ws']inhello(now sent automatically on open). The head only bridges to tails that advertised it, and refuses other visitors with a new close code4006 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 unknownhellofield harmlessly.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.tunnel-client.ts):TunnelTail.acceptWebSocket/rejectWebSocket+ sharedsendWebSocketFrame/closeWebSocket;TunnelHead.openWebSocket+supportsWebSockets. Also upstreams the patch VDK carried against the published dist: under Bun the bundledwsclient rejects the101, 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
TunnelServerattached to its HTTP server — the bridging is entirely in the library. Consumer-side,vdk devgains aws_open → local workerd socketrelay (separate PR in the vdk repo).Test plan
pnpm exec tsc --noEmitclean.websocket-bridge(visitor → server → tail echo, path/query fidelity, pre-accept buffering, close propagation to the tail) andwebsocket-unsupported(clean4006refusal when the tail didn't advertise the capability). Existingnodejs-success/nodejs-invalid-requeststill pass.TunnelServer→TunnelTail(wired like vdk's dev-runtime) → VDK runtime worker's conversation WebSocket returned the edge protocol'sreadyframe through the bridge.🤖 Generated with Claude Code