Files
Shade/docs/transport.md

225 lines
9.5 KiB
Markdown
Raw Permalink Normal View History

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