This directory owns how wrapper protocol frames travel between a host and a
viewer. The host (relay/host-bridge.ts) and viewer (client/attach-client.ts)
are transport-agnostic: they speak @repo/protocol frames through a small
Transport interface and never touch a socket directly.
Two transports exist:
| Transport | File | Used for |
|---|---|---|
WebSocketTransport |
transport.ts |
Relay path (always). Signaling + fallback. |
WebRtcDataChannelTransport |
webrtc.ts |
Direct P2P fast path (on by default; opt out with WRAPPER_P2P=0). |
A relay is a middle hop: every keystroke echoes viewer → relay → host → relay → viewer. For distant peers that's roughly 2x the RTT to the relay region, which is laggy for
interactive typing. WebRTC establishes a direct peer connection (NAT
traversal via STUN/ICE), giving SSH-like latency, and only falls back to the
relay when a direct path can't be formed. This mirrors how Mosh/Tailscale/SSH
think about interactive latency.
A dumb, opaque byte pipe:
interface Transport {
send(frame: string): void; // an already-encoded protocol frame; no-op if not open
readonly isOpen: boolean;
readonly describe: string; // short label for logs
close(): void;
}Handlers (onOpen/onMessage/onClose/onError) are passed in at
construction. Encoding/parsing stays in the host/viewer, not the transport.
- Library:
werift(pure TypeScript). Chosen deliberately over nativenode-datachannel: the CLI ships as abun build --compilesingle binary, and a native.nodeaddon cannot be bundled into that binary. werift bundles cleanly. (The browser/mobile viewers will use their native WebRTC instead.) - Signaling rides the relay. SDP offer/answer and ICE candidates are carried
as
signalprotocol frames over the existing relay WebSocket (seeapps/relay/src/hub.ts). No extra signaling server. - Discovery: the existing public Google and Twilio STUN servers. No TURN service or new third-party dependency was added. The authenticated Wrapper relay is the data fallback if ICE cannot establish a direct path.
negotiateWebRtc()returns aNegotiationwhosetransportpromise resolves with a data-channelTransportonce the channel opens, ornullon timeout/failure (so callers fall back).
sequenceDiagram
participant V as Viewer
participant R as Relay (signaling)
participant H as Host
V->>R: signal { kind: offer, to: host }
R->>H: signal { kind: offer, from: <peerId> } %% relay stamps authoritative peerId
H->>R: signal { kind: answer, to: <peerId> }
R->>V: signal { kind: answer }
V-->>H: ICE candidates (trickled via relay, both directions)
Note over V,H: data channel opens → viewer input prefers P2P
Note over H,R: host keeps relay output available for fallback
Note over V,H: on ICE failure/timeout → stay on the relay WebSocket
The viewer creates the offer + data channel; the host answers. The host keeps one peer connection per viewer.
- Host fans PTY
outputto the relay and every open data channel. WS viewers get the relay copy; P2P viewers get the data-channel copy. - Viewer prefers the data channel for
input/resize, and dedups: once its data channel is open it ignores the relay's duplicateoutputframes. - If the data channel drops or stays disconnected for three seconds, the viewer marks it closed and resumes input/output over the relay immediately.
- ICE candidates received before the SDP offer/answer are queued until the remote description exists instead of being dropped.
- If the signaling WebSocket closes after P2P is open, the direct channel keeps the session alive. If that channel later closes too, the attach ends because no fallback remains.
- Encryption: WebRTC data channels are DTLS-encrypted between peers. Viewer input prefers that direct channel, but the host currently keeps a relay output copy available for fallback and mixed relay/P2P viewers. Wrapper is therefore not a zero-knowledge relay service; relay infrastructure can process fallback terminal traffic after TLS termination.
- Authorization: only a viewer that already passed relay ticket auth can signal. The owner can join their own session; a non-owner must present the session's share code. Knowing a session id is not enough.
- No peer spoofing / cross-session: the relay assigns an authoritative
per-viewer
peerIdand stamps thesignal.fromfield (ignoring client claims); host→viewer signals are routed strictly bypeerIdwithin the same session. Signal payloads are capped at 64 KB. - Privacy note (inherent to P2P): a direct connection exposes each peer's IP
to the other, and STUN discloses your public IP to the STUN server. This is
normal WebRTC behaviour; documented in
apps/cli/.env.example.
WRAPPER_P2P is on by default, so host and viewer negotiate a direct data
channel automatically and fall back to the relay if it cannot be formed. Opt out
per session with WRAPPER_P2P=0:
# force the relay path (no P2P)
WRAPPER_P2P=0 bun run index.ts shell-host # then Ctrl+\ s to share
WRAPPER_P2P=0 bun run index.ts attach --relay -i <sessionId> -c <shareCode>Look for p2p data channel up in both logs. P2P applies only to relay
attaches; local 127.0.0.1 attaches are already direct. A direct connection
exposes each peer's IP to the other, which is why the opt-out exists.
- Unit-testable: the transport abstraction and the relay's signal routing
(
apps/relay/tests/hub.test.ts). - NAT traversal must be verified on two real machines/networks, since it cannot be exercised in CI/sandbox. P2P is default-on, but the authenticated relay remains connected as the working fallback.
transport.ts Transport interface + WebSocketTransport (relay/fallback)
webrtc.ts WebRtcDataChannelTransport + negotiateWebRtc (werift, default-on)