release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,64 @@
/**
* Bridge auth primitive.
*
* SSE/EventSource cannot carry custom headers in browsers, so the bridge
* protocol uses signed query parameters for every endpoint kind. The
* signature is over the canonical `{address, kind, since, signedAt}` payload
* using the recipient's Ed25519 signing key. The server looks up the owner
* key for `address` (registered via `/v1/inbox/register`) and verifies the
* signature with the same `verifyPayload` path used by the inbox.
*/
import type { CryptoProvider } from '@shade/core';
import { signPayload } from '@shade/server';
export type BridgeKind = 'stream' | 'poll' | 'ws';
export interface BridgeAuthInput {
crypto: CryptoProvider;
signingPrivateKey: Uint8Array;
address: string;
kind: BridgeKind;
since: number;
}
/**
* Returns the URLSearchParams that must be appended to the bridge URL
* (`/v1/bridge/stream`, `/v1/bridge/poll`, `/v1/bridge/ws`). The same shape
* is consumed by `verifyBridgeAuth` on the server side.
*/
export async function signBridgeQuery(input: BridgeAuthInput): Promise<URLSearchParams> {
const signed = await signPayload(input.crypto, input.signingPrivateKey, {
address: input.address,
kind: input.kind,
since: input.since,
});
const qs = new URLSearchParams();
qs.set('address', input.address);
qs.set('kind', input.kind);
qs.set('since', String(input.since));
qs.set('signedAt', String(signed.signedAt));
qs.set('signature', signed.signature);
return qs;
}
export function bridgeQueryToCanonical(qs: URLSearchParams): {
address: string;
kind: BridgeKind;
since: number;
signedAt: number;
signature: string;
} | null {
const address = qs.get('address');
const kind = qs.get('kind') as BridgeKind | null;
const sinceStr = qs.get('since');
const signedAtStr = qs.get('signedAt');
const signature = qs.get('signature');
if (!address || !kind || sinceStr === null || !signedAtStr || !signature) return null;
if (kind !== 'stream' && kind !== 'poll' && kind !== 'ws') return null;
const since = Number(sinceStr);
const signedAt = Number(signedAtStr);
if (!Number.isFinite(since) || since < 0) return null;
if (!Number.isFinite(signedAt)) return null;
return { address, kind, since, signedAt, signature };
}

View File

@@ -0,0 +1,23 @@
import { ShadeError } from '@shade/core';
/**
* Thrown when a bridge transport fails — connect rejected, malformed wire
* data, abrupt disconnect with no reconnect possible, etc.
*
* Carries `httpStatus` when the failure has a recognizable HTTP root cause
* so a fallback chain can decide whether to skip to the next bridge.
*/
export class BridgeError extends ShadeError {
constructor(message: string, public readonly httpStatus?: number) {
super('SHADE_BRIDGE_ERROR', message);
this.name = 'BridgeError';
}
override toJSON(): { name: string; code: string; message: string; httpStatus?: number } {
const base = super.toJSON();
if (this.httpStatus !== undefined) {
return { ...base, httpStatus: this.httpStatus };
}
return base;
}
}

View File

@@ -0,0 +1,78 @@
/**
* FallbackBridgeTransport — composes a chain of bridges in priority order.
*
* On `connect`, tries each bridge in turn. The first one that completes
* `connect` without throwing wins; later bridges are dropped on the floor.
* If every bridge throws, the composed call re-throws the last error so the
* caller sees a real failure rather than silent degradation.
*
* Fallback is sticky: once a transport is selected, it stays selected for
* the lifetime of the instance. To re-evaluate the chain (e.g. after a
* proxy goes down), instantiate a new `FallbackBridgeTransport`.
*/
import type { BridgeConnectOptions, BridgeTransport } from './types.js';
import { BridgeError } from './errors.js';
export class FallbackBridgeTransport implements BridgeTransport {
readonly kind = 'fallback';
private active: BridgeTransport | null = null;
private connectStarted = false;
private disposed = false;
private attemptedKinds: string[] = [];
constructor(private readonly chain: BridgeTransport[]) {
if (chain.length === 0) {
throw new BridgeError('FallbackBridgeTransport requires a non-empty chain');
}
}
/** Stable identifier of the picked transport, e.g. "ws" / "sse" / "long-poll". */
get activeKind(): string | null {
return this.active?.kind ?? null;
}
/** Read-only list of bridges that were attempted (in order) before success. */
get attempts(): readonly string[] {
return this.attemptedKinds;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('FallbackBridgeTransport.connect already called');
this.connectStarted = true;
let lastErr: Error | null = null;
for (const bridge of this.chain) {
this.attemptedKinds.push(bridge.kind);
try {
await bridge.connect(opts);
if (this.disposed) {
// disconnect was called concurrently — clean up the just-connected
// bridge before returning.
await bridge.disconnect().catch(() => {});
return;
}
this.active = bridge;
return;
} catch (err) {
lastErr = err as Error;
// Best-effort cleanup: the bridge may have started reconnect timers
// even though `connect` rejected.
try {
await bridge.disconnect();
} catch {
/* ignore */
}
}
}
throw lastErr ?? new BridgeError('all bridges failed to connect');
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.active) {
const cur = this.active;
this.active = null;
await cur.disconnect();
}
}
}

View File

@@ -0,0 +1,38 @@
/**
* @shade/transport-bridge — V3.7
*
* Transport-agnostic delivery for environments that cannot or will not run
* a WebSocket: SSE primary fallback, long-poll secondary, plus a thin WS
* adapter for the happy path. All three implementations surface the same
* {@link IncomingMessage} shape so application code stays portable.
*
* Server-side routes live in `@shade/inbox-server`'s `createBridgeRoutes`.
* Both ends share the same auth scheme: signed query parameters using the
* recipient's Ed25519 signing key, verified against the address-owner key
* registered via `/v1/inbox/register`.
*/
export type {
IncomingMessage,
IncomingMessageHandler,
BridgeConnectOptions,
BridgeTransport,
BridgeWireMessage,
} from './types.js';
export { decodeWireMessage } from './types.js';
export { BridgeError } from './errors.js';
export { signBridgeQuery, bridgeQueryToCanonical } from './auth.js';
export type { BridgeKind, BridgeAuthInput } from './auth.js';
export { SseBridge } from './sse-bridge.js';
export type { SseBridgeOptions } from './sse-bridge.js';
export { LongPollBridge } from './long-poll-bridge.js';
export type { LongPollBridgeOptions } from './long-poll-bridge.js';
export { WsBridge } from './ws-bridge.js';
export type { WsBridgeOptions } from './ws-bridge.js';
export { FallbackBridgeTransport } from './fallback.js';

View File

@@ -0,0 +1,161 @@
/**
* Long-poll bridge.
*
* Repeatedly issues `GET /v1/bridge/poll?since=<cursor>&…` with a 25-second
* default timeout (under typical proxy idle cutoffs). Each request blocks on
* the server until either a new envelope is available for the address or the
* timeout fires. The contract is simple by design — at any time exactly one
* outstanding request per client.
*
* The signed query string is regenerated for every request because the
* inbox auth layer rejects signatures older than 5 minutes — long-poll
* connections may live indefinitely, but each individual request lives at
* most `pollTimeoutMs` (server) + a small buffer.
*/
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface LongPollBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
fetch?: typeof fetch;
/** Server-side hold timeout, in ms. Default 25_000. */
pollTimeoutMs?: number;
/** Client-side request budget, must exceed pollTimeoutMs. Default +5_000. */
requestTimeoutMs?: number;
/** Initial cursor — start of stream by default. */
startCursor?: number;
/** Backoff after an unrecoverable network error. Default 2_000. */
errorBackoffMs?: number;
/** Disable auto-loop (single request only — for tests). */
disableLoop?: boolean;
}
const DEFAULT_POLL_TIMEOUT_MS = 25_000;
const DEFAULT_ERROR_BACKOFF_MS = 2_000;
export class LongPollBridge implements BridgeTransport {
readonly kind = 'long-poll';
private readonly fetchFn: typeof fetch;
private cursor: number;
private disposed = false;
private connectStarted = false;
private inflight: AbortController | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:long-poll]', err.message);
private loopPromise: Promise<void> | null = null;
constructor(private readonly options: LongPollBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('LongPollBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
// Single probe request — establishes that the endpoint is reachable and
// authenticates correctly. We surface auth/4xx errors as connect-time
// failures so a fallback chain can switch to the next transport.
await this.pollOnce({ probe: true });
if (this.options.disableLoop) return;
this.loopPromise = this.runLoop();
}
async disconnect(): Promise<void> {
this.disposed = true;
this.inflight?.abort();
if (this.loopPromise) {
try {
await this.loopPromise;
} catch {
/* swallow loop teardown errors */
}
}
}
getCursor(): number {
return this.cursor;
}
private async runLoop(): Promise<void> {
while (!this.disposed) {
try {
await this.pollOnce({ probe: false });
} catch (err) {
if (this.disposed) return;
this.onError(err as Error);
await sleep(this.options.errorBackoffMs ?? DEFAULT_ERROR_BACKOFF_MS);
}
}
}
private async pollOnce(args: { probe: boolean }): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'poll',
since: this.cursor,
});
const pollTimeoutMs = this.options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
qs.set('timeoutMs', String(pollTimeoutMs));
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/poll?${qs.toString()}`;
this.inflight = new AbortController();
const requestBudget = this.options.requestTimeoutMs ?? pollTimeoutMs + 5_000;
const watchdog = setTimeout(() => this.inflight?.abort(), requestBudget);
try {
const res = await this.fetchFn(url, {
method: 'GET',
headers: { accept: 'application/json' },
signal: this.inflight.signal,
});
if (!res.ok) {
// For a probe call, surface the error so the caller can fall back.
// For a steady-state call, the loop's catch will rate-limit retries.
throw new BridgeError(`long-poll failed: HTTP ${res.status}`, res.status);
}
const body = (await res.json()) as { blobs: BridgeWireMessage[]; cursor?: number };
if (!Array.isArray(body.blobs)) {
throw new BridgeError('long-poll body missing `blobs` array');
}
for (const wire of body.blobs) {
if (typeof wire.receivedAt === 'number' && wire.receivedAt > this.cursor) {
this.cursor = wire.receivedAt;
}
const msg = decodeWireMessage(wire);
if (this.onMessage) {
try {
await this.onMessage(msg);
} catch (err) {
this.onError(err as Error);
}
}
}
if (typeof body.cursor === 'number' && body.cursor > this.cursor) {
this.cursor = body.cursor;
}
} catch (err) {
if (this.disposed) return;
// Probe call surfaces the error; loop call passes it back to the loop.
if (args.probe) throw err;
throw err;
} finally {
clearTimeout(watchdog);
}
}
}
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,246 @@
/**
* SSE (Server-Sent Events) bridge.
*
* Connects to `<base>/v1/bridge/stream?…` with a signed query string and
* parses an SSE feed manually using `fetch` + `ReadableStream`. We intentionally
* do NOT use the platform `EventSource` because:
*
* 1. `EventSource` does not let callers attach custom headers — query-param
* auth is the only portable route, but custom retry / reconnect knobs
* are also useful and the platform API hides them.
* 2. `EventSource` is not available in Node by default; rolling our own
* reader keeps the package zero-dep across runtimes.
*
* The wire format follows the standard SSE spec:
* `id: <receivedAt>\nevent: envelope\ndata: <json>\n\n`
*
* Only the `envelope` event carries payload; comment lines (`: ping`) are
* tolerated as keepalives. The `id` field is fed back to the server as
* `Last-Event-ID` (also encoded as the `since` query param) on reconnect to
* resume from the highest-seen cursor.
*/
import type { BridgeConnectOptions, BridgeTransport, IncomingMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface SseBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
/** Override `fetch` (tests). */
fetch?: typeof fetch;
/** Initial reconnect backoff (ms). Default 250. */
initialBackoffMs?: number;
/** Max backoff (ms). Default 10_000. */
maxBackoffMs?: number;
/** Initial cursor — start of stream by default. */
startCursor?: number;
/** Disable auto-reconnect (tests). Default false. */
disableAutoReconnect?: boolean;
}
const DEFAULT_INITIAL_BACKOFF = 250;
const DEFAULT_MAX_BACKOFF = 10_000;
export class SseBridge implements BridgeTransport {
readonly kind = 'sse';
private readonly fetchFn: typeof fetch;
private cursor: number;
private abortController: AbortController | null = null;
private connected = false;
private disposed = false;
private connectStarted = false;
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:sse]', err.message);
constructor(private readonly options: SseBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('SseBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
// Open the first connection; throw if it fails immediately so callers
// can fall back to a different transport.
await this.openOnce();
// Spawn the read loop; subsequent reconnects happen in the background.
void this.readLoop();
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.currentReader) {
try {
await this.currentReader.cancel();
} catch {
/* ignore */
}
}
this.abortController?.abort();
this.connected = false;
}
/** Public so tests / observability can read the latest cursor. */
getCursor(): number {
return this.cursor;
}
/**
* Opens a single SSE connection and stores the reader on the instance.
* Throws on hard errors (network refused, non-200 status). Caller drives
* the read loop.
*/
private async openOnce(): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'stream',
since: this.cursor,
});
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/stream?${qs.toString()}`;
this.abortController = new AbortController();
let res: Response;
try {
res = await this.fetchFn(url, {
method: 'GET',
headers: {
accept: 'text/event-stream',
'cache-control': 'no-cache',
'last-event-id': String(this.cursor),
},
signal: this.abortController.signal,
});
} catch (err) {
throw new BridgeError(`SSE connect failed: ${(err as Error).message}`);
}
if (!res.ok) {
throw new BridgeError(`SSE connect failed: HTTP ${res.status}`, res.status);
}
if (!res.body) {
throw new BridgeError('SSE response has no body');
}
this.currentReader = res.body.getReader();
this.connected = true;
}
private async readLoop(): Promise<void> {
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
while (!this.disposed) {
try {
if (!this.currentReader) {
await this.openOnce();
}
await this.consume(this.currentReader!);
// Stream closed cleanly — server-side close. Reconnect.
} catch (err) {
if (this.disposed) return;
this.onError(err as Error);
}
this.currentReader = null;
this.connected = false;
if (this.disposed || this.options.disableAutoReconnect) return;
await sleep(backoff);
backoff = Math.min(backoff * 2, maxBackoff);
}
}
private async consume(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
const decoder = new TextDecoder();
let buf = '';
let dataLines: string[] = [];
let eventName: string | null = null;
let eventId: string | null = null;
while (true) {
const { value, done } = await reader.read();
if (done) return;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const rawLine = buf.slice(0, idx);
buf = buf.slice(idx + 1);
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line === '') {
// dispatch
if (dataLines.length > 0) {
const dataStr = dataLines.join('\n');
await this.handleEvent(eventName, eventId, dataStr);
}
dataLines = [];
eventName = null;
eventId = null;
continue;
}
if (line.startsWith(':')) continue; // comment / keepalive
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
switch (field) {
case 'data':
dataLines.push(val);
break;
case 'event':
eventName = val;
break;
case 'id':
eventId = val;
break;
// 'retry' ignored; we drive backoff ourselves.
}
}
}
}
private async handleEvent(name: string | null, id: string | null, data: string): Promise<void> {
if (id !== null) {
const n = Number(id);
if (Number.isFinite(n) && n > this.cursor) this.cursor = n;
}
if (name && name !== 'envelope' && name !== 'message' && name !== '') {
// Ignore non-payload events (e.g. ready, heartbeat).
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch (err) {
this.onError(new BridgeError(`malformed SSE data: ${(err as Error).message}`));
return;
}
const wire = parsed as { msgId: string; ciphertext: string; receivedAt: number; from?: string };
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
this.onError(new BridgeError('SSE event missing required fields'));
return;
}
const msg: IncomingMessage = decodeWireMessage(wire);
if (this.onMessage !== null) {
try {
await this.onMessage(msg);
} catch (err) {
this.onError(err as Error);
}
}
}
/** True between connect()'s first successful open and disconnect/error. */
get isConnected(): boolean {
return this.connected;
}
}
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,94 @@
/**
* Common types for the @shade/transport-bridge package.
*
* The bridge layer abstracts "how do I receive ciphertext envelopes" so that
* an application can stay transport-agnostic. Three implementations ship in
* v0.1.0: WS, SSE, long-poll. A `FallbackBridgeTransport` wraps them in
* priority order so a client blocked from WebSockets by a strict proxy
* automatically slides down to SSE, then long-poll.
*
* Every bridge surfaces the same {@link IncomingMessage} shape — application
* code never branches on transport.
*/
/**
* A single transport-level incoming message.
*
* `from` is the relay-known sender fingerprint (8-byte hex of the sender's
* Ed25519 signing key, computed by the inbox server at PUT time). It is
* empty for legacy blobs that were stored before the inbox started tracking
* sender provenance, and for non-inbox bridges that have no upstream sender
* notion. The authoritative sender identity always lives inside the Double
* Ratchet envelope and is recovered post-decrypt — `from` is a hint, not a
* trust anchor.
*/
export interface IncomingMessage {
from: string;
bytes: Uint8Array;
receivedAt: number;
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
msgId?: string;
}
/** Subscriber callback. Bridges MAY invoke it concurrently. */
export type IncomingMessageHandler = (msg: IncomingMessage) => void | Promise<void>;
export interface BridgeConnectOptions {
onMessage: IncomingMessageHandler;
/** Caller-supplied error handler; defaults to console.warn. */
onError?: (err: Error) => void;
}
/**
* Common contract for every bridge transport.
*
* Implementations are expected to:
* - block in `connect` until they have either established a session OR
* surfaced a hard failure as a thrown error;
* - keep delivering messages until `disconnect` is called;
* - reconnect transparently on transient drops while bumping the cursor
* to avoid duplicate delivery (Last-Event-ID for SSE, `since` cursor for
* long-poll, etc.).
*
* Bridges are *single-use*: call `connect` once, then `disconnect`. Calling
* `connect` twice on the same instance must throw.
*/
export interface BridgeTransport {
connect(options: BridgeConnectOptions): Promise<void>;
disconnect(): Promise<void>;
/** Resolves to a stable identifier ("ws" | "sse" | "long-poll" | …). */
readonly kind: string;
}
/**
* Wire-format for a single message inside an SSE event, long-poll batch
* entry, or WS frame. The bridge protocol is JSON-over-the-wire with
* base64-encoded ciphertext — the same shape regardless of transport.
*/
export interface BridgeWireMessage {
msgId: string;
/** base64 ciphertext */
ciphertext: string;
receivedAt: number;
expiresAt?: number;
/** Sender fingerprint hint (may be empty). */
from?: string;
}
export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage {
const bytes = base64ToBytes(wire.ciphertext);
const msg: IncomingMessage = {
from: wire.from ?? '',
bytes,
receivedAt: wire.receivedAt,
};
if (wire.msgId !== undefined) msg.msgId = wire.msgId;
return msg;
}
function base64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

View File

@@ -0,0 +1,205 @@
/**
* WebSocket bridge.
*
* Connects to `<base>/v1/bridge/ws?…` (where `base` uses the http(s):// URL
* — the bridge swaps to ws(s):// internally) and consumes JSON-encoded
* `BridgeWireMessage` frames pushed by the server.
*
* Auth uses the same signed query-string scheme as SSE/long-poll. The server
* runs the verification at upgrade time and rejects upgrades with a 4xx;
* once the WebSocket is open, frames are unauthenticated at the WS layer
* (the address-owner relationship is bound at upgrade and the connection is
* dedicated to that address).
*/
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface WsBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
/** WebSocket constructor override (browsers vs Node vs Bun). */
WebSocketCtor?: typeof WebSocket;
/** Connect timeout (ms). Default 5_000. */
connectTimeoutMs?: number;
/** Initial cursor. */
startCursor?: number;
/** Disable auto-reconnect on drop. Default false. */
disableAutoReconnect?: boolean;
/** Initial reconnect backoff (ms). Default 250. */
initialBackoffMs?: number;
/** Max reconnect backoff (ms). Default 10_000. */
maxBackoffMs?: number;
}
const DEFAULT_CONNECT_TIMEOUT = 5_000;
const DEFAULT_INITIAL_BACKOFF = 250;
const DEFAULT_MAX_BACKOFF = 10_000;
export class WsBridge implements BridgeTransport {
readonly kind = 'ws';
private readonly Ctor: typeof WebSocket;
private cursor: number;
private connectStarted = false;
private disposed = false;
private current: WebSocket | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:ws]', err.message);
private reconnectPromise: Promise<void> | null = null;
constructor(private readonly options: WsBridgeOptions) {
const ctor =
options.WebSocketCtor ??
((globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket);
if (ctor === undefined) {
throw new BridgeError('WebSocket constructor not available; pass options.WebSocketCtor');
}
this.Ctor = ctor;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('WsBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
await this.openOnce();
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.current) {
try {
this.current.close(1000, 'client disconnect');
} catch {
/* ignore */
}
this.current = null;
}
if (this.reconnectPromise) {
try {
await this.reconnectPromise;
} catch {
/* swallow */
}
}
}
getCursor(): number {
return this.cursor;
}
private async openOnce(): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'ws',
since: this.cursor,
});
const url = `${toWsUrl(this.options.baseUrl)}/v1/bridge/ws?${qs.toString()}`;
const ws = new this.Ctor(url);
ws.binaryType = 'arraybuffer';
const connectTimeoutMs = this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
try {
ws.close();
} catch {
/* ignore */
}
reject(new BridgeError(`WS connect timeout to ${url}`));
}, connectTimeoutMs);
ws.addEventListener('open', () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener('error', () => {
clearTimeout(timer);
reject(new BridgeError(`WS connect error to ${url}`));
});
});
this.current = ws;
this.attachListeners(ws);
}
private attachListeners(ws: WebSocket): void {
ws.addEventListener('message', (ev) => {
const data = ev.data;
let text: string;
if (typeof data === 'string') {
text = data;
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder().decode(data);
} else {
this.onError(new BridgeError('WS frame neither text nor ArrayBuffer'));
return;
}
let wire: BridgeWireMessage;
try {
wire = JSON.parse(text);
} catch (err) {
this.onError(new BridgeError(`malformed WS frame: ${(err as Error).message}`));
return;
}
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
this.onError(new BridgeError('WS frame missing required fields'));
return;
}
if (wire.receivedAt > this.cursor) this.cursor = wire.receivedAt;
const msg = decodeWireMessage(wire);
if (this.onMessage) {
Promise.resolve(this.onMessage(msg)).catch((err) => this.onError(err as Error));
}
});
const onCloseOrError = (): void => {
if (this.disposed) return;
this.current = null;
if (this.options.disableAutoReconnect) return;
this.reconnectPromise = this.reconnectLoop();
};
ws.addEventListener('close', onCloseOrError);
ws.addEventListener('error', () => {
// Treat error as a close — the platform fires 'close' too in most
// implementations, but Node's `ws` library does not always.
if (this.current === ws) {
try {
ws.close();
} catch {
/* ignore */
}
}
});
}
private async reconnectLoop(): Promise<void> {
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
while (!this.disposed) {
await sleep(backoff);
if (this.disposed) return;
try {
await this.openOnce();
return;
} catch (err) {
this.onError(err as Error);
backoff = Math.min(backoff * 2, maxBackoff);
}
}
}
}
function toWsUrl(httpUrl: string): string {
const trimmed = httpUrl.endsWith('/') ? httpUrl.slice(0, -1) : httpUrl;
if (trimmed.startsWith('https://')) return 'wss://' + trimmed.slice('https://'.length);
if (trimmed.startsWith('http://')) return 'ws://' + trimmed.slice('http://'.length);
return trimmed; // already ws(s)://
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}