# Shade Transport — Bridge Layer (V3.7) > **Looking for V3.11 (peer-to-peer chunk transport via `RTCDataChannel`)?** > See [docs/webrtc.md](./webrtc.md). This page covers the V3.7 bridge > layer that ships ciphertext *envelopes* (control plane) over > WS / SSE / long-poll. The two are orthogonal: the bridge handles > store-and-forward control envelopes; WebRTC handles direct chunk data. The bridge layer is the answer to: **"my client is a browser extension / strict-corp-proxy / edge-runtime / iOS app — I cannot keep a WebSocket open. How do I receive ciphertext envelopes?"** It is built on top of the V3.6 inbox: every transport delivers the same inbox blobs, with the same authentication semantics. Application code sees a single `IncomingMessage` shape and never branches on transport. ``` ┌─────────────────────────────────────────────────────────────────┐ │ application code │ │ │ │ bridge.connect({ onMessage: (m) => decrypt(m.bytes) }) │ └────────────────────────────────┬────────────────────────────────┘ │ ┌─────────────────────────┴──────────────────────────┐ │ FallbackBridgeTransport │ │ (sticky-after-first-success) │ └──┬──────────────────┬─────────────────────────┬────┘ │ │ │ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ │ WsBridge │ │ SseBridge │ │ LongPoll │ │ /v1/ │ │ /v1/ │ │ Bridge │ │ bridge/ws │ │ bridge/ │ │ /v1/bridge │ │ │ │ stream │ │ /poll │ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │ │ │ └──────────────────┼─────────────────────────┘ │ ┌─────▼──────┐ │ inbox │ ← the same V3.6 store │ blobs │ and events └────────────┘ ``` ## When to reach for which | Transport | Latency | Proxy resilience | Browser | Server cost | |-------------|----------|------------------|---------|-------------| | WebSocket | ms | breaks under strict CONNECT-blocking proxies | ✓ | one socket per client | | SSE | ms | passes most HTTP proxies (text/event-stream) | ✓ | one streamed response per client | | long-poll | ≤ 25 s | passes anything that allows GET | ✓ | one held request per client | The recommended composition: ```ts import { FallbackBridgeTransport, WsBridge, SseBridge, LongPollBridge, } from '@shade/transport-bridge'; const auth = { crypto, // CryptoProvider signingPrivateKey, // recipient's Ed25519 private key address: 'bob', }; const bridge = new FallbackBridgeTransport([ new WsBridge({ baseUrl: 'https://relay.example.com', auth }), new SseBridge({ baseUrl: 'https://relay.example.com', auth }), new LongPollBridge({ baseUrl: 'https://relay.example.com', auth }), ]); await bridge.connect({ onMessage: async (msg) => { // msg.bytes is a Uint8Array — pass it to your decrypt path. // msg.from is the relay-known sender hint (may be empty); the // authoritative sender comes from the decrypted envelope. // msg.msgId is the relay's deterministic message id (sha256(ciphertext)). const envelope = decodeEnvelope(msg.bytes); await shade.receive(senderAddress, envelope); }, }); // Read which transport the fallback chain settled on: console.log(bridge.activeKind); // "ws" | "sse" | "long-poll" ``` ## The IncomingMessage shape ```ts interface IncomingMessage { from: string; // relay-side sender hint (may be "") bytes: Uint8Array; // the ciphertext envelope, exactly as PUT receivedAt: number; // relay-monotonic cursor — NOT wall-clock arrival msgId?: string; // sha256(bytes) — useful for ack/dedup } ``` `from` is intentionally a hint — sender provenance lives inside the encrypted envelope and is recovered post-decrypt. The bridge layer is plaintext-blind by design. ## Auth — signed query parameters Every bridge request signs the canonical `{address, kind, since, signedAt}` payload with the recipient's Ed25519 signing private key. The server looks up the address-owner key registered via `/v1/inbox/register` and verifies the signature. `kind` is bound into the canonical payload so a signature for `/poll` cannot be replayed against `/stream` or `/ws`. The browser `EventSource` API does not let callers attach custom headers; query parameters are the only portable carrier and so the bridge protocol uses them uniformly across all three transports. ## Server-side — `createBridgeRoutes` ```ts import { createBridgeRoutes } from '@shade/inbox-server'; import { Hono } from 'hono'; const inbox = new MemoryInboxStore(); const events = new InboxServerEvents(); const bridge = createBridgeRoutes({ store: inbox, crypto, events, longPollTimeoutMs: 25_000, // default — under typical proxy idle limits heartbeatIntervalMs: 15_000, // SSE keepalive comments fallbackPollIntervalMs: 1_000, // when no `events` emitter is wired }); const app = new Hono(); app.route('/', bridge.app); Bun.serve({ port: 3900, fetch: (req, srv) => app.fetch(req, srv), websocket: bridge.websocket as any, }); ``` The bridge subscribes to `InboxServerEvents` (`inbox.blob_stored`) for push-style delivery — when an event fires for a connected address, the server fetches new blobs and forwards them. If no events emitter is wired, the server falls back to a small in-process polling timer at `fallbackPollIntervalMs` cadence. ## Cursor & resume Every `IncomingMessage.receivedAt` is the relay's monotonic cursor for the address. Bridges expose `getCursor()` so applications can persist the high-water mark and pass it as `startCursor` on the next `connect()`: ```ts const sse = new SseBridge({ baseUrl, auth, startCursor: await persistedCursor.load(), }); await sse.connect({ onMessage: async (msg) => { await persistedCursor.save(msg.receivedAt); // … }, }); ``` For SSE specifically, the server emits an `id:` field per event; the bridge sends it back as `Last-Event-ID` plus the `since=` query parameter on reconnect, so a flapping connection picks up exactly where it left off without duplicates. ## Reconnect & backoff | Bridge | Auto-reconnect | Backoff | |-------------|----------------|----------------------| | WS | yes (default) | 250 ms → 10 s exponential | | SSE | yes (default) | 250 ms → 10 s exponential | | long-poll | always on (the loop *is* the reconnect) | 2 s on hard error | Pass `disableAutoReconnect: true` (WS / SSE) for tests where you want a single attempt and immediate surfaced error. ## Long-poll concurrency The `LongPollBridge` issues exactly one request at a time. The next request fires after the previous one resolves. This guarantees a client never holds more than one TCP connection on the server, which matches the V3.7 acceptance criterion and keeps capacity planning simple: max in-flight long-poll requests = number of connected clients. ## Failure modes - **WS handshake rejected (4xxx code).** `WsBridge.connect` rejects. Caller (or `FallbackBridgeTransport`) moves on. - **SSE returns non-200.** `SseBridge.connect` throws a `BridgeError` with `httpStatus`. - **Long-poll returns non-200.** Same — `BridgeError` with `httpStatus`. - **Mid-stream error after connect.** WS/SSE auto-reconnect; long-poll swallows transient errors and continues looping. Errors flow to the caller's `onError` handler. ## Acceptance test coverage (V3.7) `packages/shade-transport-bridge/tests/bridge.test.ts` covers: - "Send 100 small messages" — one test per transport, all pass. - "WS blocked by proxy → SSE → long-poll" — fallback test boots a server where the WS endpoint is unreachable and the SSE endpoint returns 502, verifies the chain falls all the way through to long-poll without message loss. - "Long-poll uses ≤ 1 outstanding request" — wraps `fetch` to count in-flight requests over 1.5 s of steady-state operation. - Cursor resume — tears down an SSE connection mid-stream, pushes more blobs, reconnects with the persisted cursor, asserts exactly the new blobs are delivered (no overlap with the pre-disconnect set). - Auth rejection — wrong signing key and unregistered address both produce hard `connect` rejections so the fallback chain advances.