# Shade Transport — WebRTC P2P Layer (V3.11) `@shade/transport-webrtc` adds a direct peer-to-peer chunk transport on top of the existing `@shade/transfer` engine. When two clients can reach each other through NAT/firewall, large transfers (`@shade/files`, `@shade/transfer`) flow over a single bidirectional `RTCDataChannel` instead of paying the round-trip cost of HTTP-relayed POSTs. When NAT traversal fails, the multi-transport fallback automatically demotes the chain back to HTTP — without losing any chunks already in flight. The wire payload is unchanged: every chunk is still a Shade ratchet / streams envelope (AES-256-GCM under HKDF-derived per-lane keys). DTLS- SRTP is only the WebRTC transport secret; turning a TURN-relay on does not give the relay operator access to plaintext. ``` ┌───────────────────────────────────────────────────────────────┐ │ application code │ │ │ │ shade.upload({ to: 'bob', input: file }) │ └────────────────────────────────┬──────────────────────────────┘ │ ┌─────────▼──────────┐ │ TransferEngine │ └─────────┬──────────┘ │ ITransferTransport ┌─────────▼──────────┐ │ MultiTransport │ │ Fallback (sticky) │ └────┬─────┬─────┬───┘ │ │ │ ┌─────────────▼┐ ┌─▼─┐ ┌▼────────────┐ │ WebRtcTransfer│ │WS │ │ ShadeTransfer│ │ Transport │ │… │ │ HttpTransport│ └─────┬─────────┘ └───┘ └──────────────┘ │ DataChannel binary frames ┌─────▼─────────┐ │ WebRtcConn │ ←──── SDP/ICE over Shade.send │ Manager │ (ratchet-encrypted) └───────────────┘ ``` ## When to reach for it | Scenario | Default (HTTP) | + WebRTC | |---------------------------------------|----------------|----------------| | Two clients on the same LAN | server-relayed | direct, P2P | | One peer behind enterprise NAT only | works | TURN-relay | | Both peers behind symmetric NAT | works | falls back to HTTP | | One peer offline | inbox-buffered | inbox-buffered (HTTP path) | | Browser extension with strict CSP | works | works (uses RTCPeerConnection) | Use cases: - `@shade/transfer` upload of multi-MB / multi-GB files - `@shade/files` `read`/`write` of large inline blobs - Future: `@shade/streams` real-time channels (V5.0 reuses this same DataChannel) ## Quick start (browser) ```ts import { createShade } from '@shade/sdk'; import { nativeRtcFactory } from '@shade/transport-webrtc'; const shade = await createShade({ prekeyServer: 'https://prekey.example.com' }); // IMPORTANT: configureWebRTC MUST be called BEFORE the first upload() / // onIncomingTransfer() / transferRoute() call, because those build the // transfer engine — and the engine captures its transport stack at // construction time. shade.configureWebRTC({ factory: nativeRtcFactory(), // Optional — defaults to two public Google STUN servers. iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'turn:turn.example.com:3478', username: 'shade', credential: 'YOUR_TURN_SECRET', }, ], }); shade.configureTransfers({ resolveBaseUrl: async (peer) => directory.lookup(peer), }); await shade.upload({ to: 'bob', input: file }); // → P2P when NAT allows ``` ## Quick start (Bun / Node) Bun does not yet expose `RTCPeerConnection` natively. Use one of: - [`node-datachannel`](https://github.com/murat-dogan/node-datachannel) — small, stable, libdatachannel under the hood - [`@roamhq/wrtc`](https://www.npmjs.com/package/@roamhq/wrtc) — fork of the Google `wrtc` bindings Wrap the chosen library behind an `IRtcFactory` (the package only depends on a narrow surface — `createPeerConnection`, `createDataChannel`, `addEventListener`): ```ts import { IRtcFactory, IPeerConnection, IDataChannel } from '@shade/transport-webrtc'; // pseudo-adapter for node-datachannel class NodeDataChannelFactory implements IRtcFactory { createPeerConnection(config) { /* ... return adapter wrapping nodeDc PeerConnection */ } } shade.configureWebRTC({ factory: new NodeDataChannelFactory(), iceServers }); ``` ## Connection flow ``` Alice initiates Bob receives ─────────────── ──────────── 1. createOffer() → SDP 2. shade.send delivers offer → Bob.createAnswer() 3. shade.send delivers answer 4. setRemoteDescription(answer) 5. trickle ICE candidates (both directions) 6. trickle ICE candidates 7. DataChannel onopen (both sides) 7. DataChannel onopen ``` All four signaling kinds (`shade.webrtc-offer/v1`, `shade.webrtc-answer/v1`, `shade.webrtc-ice/v1`, `shade.webrtc-bye/v1`) ride the existing Shade ratchet — the relay sees only ciphertext envelopes. ### Glare resolution If both peers call `getOrCreate()` simultaneously, the manager uses lexicographic tiebreak: the side with the smaller address wins caller-role; the side with the larger address closes its outgoing connection and accepts the inbound offer instead. Both peers ultimately converge on a single `WebRtcConnection`. ## Backpressure The `WebRtcTransferTransport` polls `RTCDataChannel.bufferedAmount` and suspends new sends once the buffer crosses `backpressureThresholdBytes` (default 4 MiB). This avoids SCTP queue runaway when the application pushes faster than the network can drain. Tune lower for memory- constrained clients (mobile / extension contexts). ## Auto-fallback Configuring WebRTC wires `MultiTransportFallback([webrtc, http])` as the engine's transport. The chain is sticky-after-first-failure: when WebRTC raises a `TransferTransportError` (timeout, ICE failed, data channel closed, frame too large), the fallback advances to HTTP and stays there for the lifetime of the engine. For three-tier composition (e.g. WebRTC → WebSocket → HTTP), build the fallback yourself and pass a custom transport via the engine deps: ```ts import { MultiTransportFallback } from '@shade/sdk'; const stack = new MultiTransportFallback([ { name: 'webrtc', transport: rtcTransport }, { name: 'ws', transport: wsTransport }, { name: 'http', transport: httpTransport }, ]); stack.onSwitch((from, to) => metrics.observe('shade.transport.demoted', { from, to })); ``` The `WebRtcConnectionManager`'s connect timeout (default 30 s) is the upper bound on how long the chain dwells on WebRTC before demoting. The V3.11 acceptance criterion is "P2P-død → HTTP innen 5 s" — set `connectTimeoutMs: 4_000` in your `configureWebRTC()` call to keep the upper bound at 4 seconds and meet the SLO with margin. ## ICE server config | Setting | Default | When to override | |------------------------|-----------------------------------|------------------| | `iceServers` | Google public STUN (×2) | Production — pin your own STUN to avoid Google rate limits, plus your TURN credentials | | `iceTransportPolicy` | `'all'` (host + reflexive + relay)| `'relay'` to mandate TURN-only routing (e.g. inside a corporate network where direct connectivity must never leak) | | `bundlePolicy` | spec default (`'balanced'`) | rarely | Public STUN works for ~80% of consumer NATs. The remaining 20% (symmetric NAT, paranoid corporate proxies, mobile carrier-grade NAT) need TURN. Run your own [coturn](https://github.com/coturn/coturn) or use a managed provider — but **TURN traffic is real bandwidth through your server**, so budget accordingly. Shade's wire format is at least as efficient over TURN as over HTTPS (no per-request HTTP framing overhead). ## NAT-traversal: hopes and realities What works without TURN, in our testing: - Same NAT (LAN): always - Two clients behind cone NATs: usually - One client behind symmetric NAT, the other behind any cone NAT: usually - Two clients behind symmetric NATs: rarely — falls back to TURN What doesn't work: - Two clients behind strict carrier-grade NAT (CGNAT): TURN required - Clients on networks that block UDP entirely: TURN over TCP/443 required When in doubt, configure TURN over TCP/443 — it impersonates HTTPS and gets through nearly every middlebox. ## Diagnostics The SDK exposes the live runtime via `shade.getWebRtcRuntime()`: ```ts const runtime = shade.getWebRtcRuntime(); if (runtime !== null) { console.log('active transport:', runtime.fallback.activeName); console.log('peers:', [...runtime.manager.byPeer ?? []]); runtime.fallback.onSwitch((from, to) => { console.warn(`shade transport demoted ${from} → ${to}`); }); } ``` The `failures` array on `MultiTransportFallback` records every demotion's reason — wire it to your observability backend to track NAT/TURN problems in production. ## Sample code End-to-end test using `MemoryRtcFactory` (no real network): ```ts import { MemoryRtcFactory } from '@shade/transport-webrtc'; const factory = new MemoryRtcFactory(); alice.configureWebRTC({ factory }); bob.configureWebRTC({ factory }); await alice.upload({ to: 'bob', input: bytes }); // → P2P loopback ``` See `packages/shade-sdk/tests/webrtc-integration.test.ts` for the full loopback test, `webrtc-failover.test.ts` for the auto-fallback test, and `packages/shade-transport-webrtc/tests/` for the unit tests covering wire format, signaling, glare, and TURN-only configuration. ## Wire format inside the DataChannel The DataChannel is a single bidirectional pipe shared by every in-flight stream between two peers. Each frame is a self-describing binary blob: ``` client → server server → client ─────────────── ─────────────── 0x01 chunk reqId(16) sid(16) lane(u32) seq(u64) env(...) 0x81 chunk-ack reqId(16) lastSeq(u32) bytesRecv(u32) 0x02 resume-query reqId(16) sid(16) 0x82 resume-state reqId(16) jsonBody(utf-8) 0x03 ping reqId(16) nonce(u64) 0x83 pong reqId(16) nonce(u64) 0xFE error reqId(16) jsonBody(utf-8) ``` `reqId` is a 16-byte random correlation token; the responder echoes it verbatim so multiple in-flight requests can be matched without a stream multiplexer on top of SCTP. The wire matches `ShadeTransferWsTransport` exactly — adapters for either transport can interoperate by translating between SCTP message- framing and WS binary frames at the byte level. ## Limits - Max DataChannel message: **256 KiB** (Chrome's safe ceiling). Configure `chunkSize` ≤ 256 KiB on uploads that prefer WebRTC. The transport raises a clear error when an envelope exceeds the cap; the engine then retries via HTTP. - One DataChannel per peer pair (label `shade-transfer/v1`). Multiple in-flight transfers from the same peer pair multiplex via `reqId`. - No SFU/MCU — group transfers fan out at the application layer. - DTLS-fingerprint binding to Shade's identity-fingerprint is **not** in V3.11 (deferred as hardening work — DataChannel is already inside a ratchet-authenticated session, so the practical exposure window is limited to in-process MITM scenarios that already require malware). ## Migration Opt-in. If you don't call `configureWebRTC`, your existing HTTP/WS transport stack is unchanged. When you do opt in, the **engine must not be built yet** — the easy way to ensure this is to call `configureWebRTC` before `configureTransfers` or before any of `upload` / `onIncomingTransfer` / `transferRoute`. Receiver-side: the WebRTC manager wires receiver-hooks into the engine during `engine()` construction, so make sure both sides do `configureWebRTC` + `configureTransfers` before the first `transferRoute()` call. ## Related modules - [`@shade/transfer`](../packages/shade-transfer/) — engine, lane queues, HTTP transport, multi-fallback wrapper. - [`@shade/streams`](./streams.md) — chunk encryption + lane key derivation. Indirect dep. - [`@shade/transport-bridge`](./transport.md) — V3.7 bridge layer (WS / SSE / long-poll for control envelopes). Orthogonal to V3.11. - [V5.0 — real-time channels](./V5.0.md) — downstream consumer of the same DataChannel for voice/video/broadcast.