225 lines
9.5 KiB
Markdown
225 lines
9.5 KiB
Markdown
|
|
# 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.
|