# 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.
