/** * V4.7 — presence subscription client. * * Consumes the SSE feed at `/v1/bridge/presence?…` and fires * `onPresenceChange` whenever a watched address transitions * online/offline. Tracking is server-side: the inbox-server emits * presence events on the 0↔1 boundary across WS + SSE bridge * connections, and this client filters by the watcher's declared * address list. * * Threat model context: the typical consumer (Prism, password * managers, anything sender-key-broadcasting) wires this to * `BroadcastChannel.removeMember` so a clean WS/SSE close on a * paired-peer device revokes its sender-key membership within * ~50ms. Long-poll bridges are deliberately NOT tracked on the * server (see `inbox-server` `events.ts`); presence here is * push-transport only. * * Watched-list mutations (`addPeer` / `removePeer`) trigger a * reconnect with a fresh signed query so the server-side filter * reflects the new set. Mutations are expected to be rare (only on * pair / unpair, not on every message), so the brief reconnect gap * is acceptable. */ import type { CryptoProvider } from '@shade/core'; import { signPresenceQuery } from './auth.js'; import { BridgeError } from './errors.js'; export type PresenceVia = 'ws' | 'sse'; export interface PresenceChange { address: string; status: 'online' | 'offline'; /** Server's wall-clock time (ms since epoch) when the change happened. */ at: number; /** Which transport carried the connection. Absent on the initial snapshot. */ via?: PresenceVia; } export interface PresenceBridgeOptions { /** Bridge base URL — same as `LongPollBridge` / `SseBridge`. */ baseUrl: string; crypto: CryptoProvider; /** Watcher's Ed25519 signing key (the address must be a registered inbox). */ signingPrivateKey: Uint8Array; /** Watcher's address (the registered inbox owner). */ address: string; /** Override `fetch` (tests). */ fetch?: typeof fetch; /** Initial reconnect backoff (ms). Default 250. */ initialBackoffMs?: number; /** Max reconnect backoff (ms). Default 10_000. */ maxBackoffMs?: number; /** Disable automatic reconnect. Default false. */ disableAutoReconnect?: boolean; } export interface PresenceSubscribeOptions { /** Initial set of addresses to watch. May be empty. */ watch: readonly string[]; /** Fired whenever a watched address transitions, plus once per address on initial open. */ onPresenceChange: (change: PresenceChange) => void | Promise; /** Optional reconnect / parse error reporter. */ onError?: (err: Error) => void; } export interface PresenceSubscription { /** Add an address to the watched set. Triggers a reconnect. */ addPeer(address: string): Promise; /** Remove an address from the watched set. Triggers a reconnect. */ removePeer(address: string): Promise; /** Snapshot of the currently-watched addresses. */ watching(): readonly string[]; /** Tear down. Idempotent. */ unsubscribe(): Promise; } const DEFAULT_INITIAL_BACKOFF = 250; const DEFAULT_MAX_BACKOFF = 10_000; export class PresenceBridge { private readonly fetchFn: typeof fetch; constructor(private readonly options: PresenceBridgeOptions) { const f = options.fetch ?? globalThis.fetch; this.fetchFn = f.bind(globalThis); } async subscribe(opts: PresenceSubscribeOptions): Promise { const session = new PresenceSession(this.options, this.fetchFn, opts); await session.start(); return session; } } class PresenceSession implements PresenceSubscription { private watched: string[]; private abortController: AbortController | null = null; private currentReader: ReadableStreamDefaultReader | null = null; private disposed = false; private readLoopPromise: Promise | null = null; private readonly onPresenceChange: PresenceSubscribeOptions['onPresenceChange']; private readonly onError: NonNullable; private firstOpenResolve: (() => void) | null = null; private firstOpenReject: ((err: Error) => void) | null = null; private firstOpenSettled = false; constructor( private readonly options: PresenceBridgeOptions, private readonly fetchFn: typeof fetch, opts: PresenceSubscribeOptions, ) { this.watched = [...opts.watch]; this.onPresenceChange = opts.onPresenceChange; this.onError = opts.onError ?? ((err) => console.warn('[shade-bridge:presence]', err.message)); } watching(): readonly string[] { return [...this.watched]; } async start(): Promise { return this.openAndPump(); } /** * Open one SSE connection and resolve once the first response has * been received (so that callers can `await subscribe()` and know * the connection is established before the first state change). * The read loop continues in the background. */ private openAndPump(): Promise { return new Promise((resolve, reject) => { this.firstOpenSettled = false; this.firstOpenResolve = () => { if (this.firstOpenSettled) return; this.firstOpenSettled = true; resolve(); }; this.firstOpenReject = (err: Error) => { if (this.firstOpenSettled) return; this.firstOpenSettled = true; reject(err); }; this.readLoopPromise = this.runLoop(); }); } private async runLoop(): Promise { let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF; const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF; let firstAttempt = true; while (!this.disposed) { try { await this.openOnce(); if (firstAttempt) { firstAttempt = false; this.firstOpenResolve?.(); } // Reset backoff on a successful open. backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF; await this.consume(); } catch (err) { if (this.disposed) return; if (firstAttempt) { // Failed before we ever got a 200 — surface to the caller of subscribe(). this.firstOpenReject?.(err as Error); return; } this.onError(err as Error); } this.currentReader = null; if (this.disposed || this.options.disableAutoReconnect) return; await sleep(backoff); backoff = Math.min(backoff * 2, maxBackoff); } } private async openOnce(): Promise { const qs = await signPresenceQuery({ crypto: this.options.crypto, signingPrivateKey: this.options.signingPrivateKey, address: this.options.address, watched: this.watched, }); const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/presence?${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' }, signal: this.abortController.signal, }); } catch (err) { throw new BridgeError(`presence connect failed: ${(err as Error).message}`); } if (!res.ok) { throw new BridgeError(`presence connect failed: HTTP ${res.status}`, res.status); } if (!res.body) { throw new BridgeError('presence response has no body'); } this.currentReader = res.body.getReader() as ReadableStreamDefaultReader; } private async consume(): Promise { const reader = this.currentReader; if (!reader) return; const decoder = new TextDecoder(); let buf = ''; let dataLines: string[] = []; let eventName: string | null = null; while (true) { let chunk: Awaited>; try { chunk = await reader.read(); } catch (err) { // Reader cancelled (mutation / unsubscribe) — exit cleanly. if (this.disposed || (err as Error).name === 'AbortError') return; throw err; } if (chunk.done) return; buf += decoder.decode(chunk.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 === '') { if (dataLines.length > 0) { await this.dispatch(eventName, dataLines.join('\n')); } dataLines = []; eventName = null; continue; } if (line.startsWith(':')) continue; 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); if (field === 'data') dataLines.push(val); else if (field === 'event') eventName = val; } } } private async dispatch(name: string | null, data: string): Promise { if (name !== null && name !== '' && name !== 'presence') return; let parsed: unknown; try { parsed = JSON.parse(data); } catch (err) { this.onError(new BridgeError(`malformed presence data: ${(err as Error).message}`)); return; } const change = parsed as PresenceChange; if ( typeof change.address !== 'string' || (change.status !== 'online' && change.status !== 'offline') || typeof change.at !== 'number' ) { this.onError(new BridgeError('presence frame missing required fields')); return; } try { await this.onPresenceChange(change); } catch (err) { this.onError(err as Error); } } async addPeer(address: string): Promise { if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed'); if (this.watched.includes(address)) return; this.watched = [...this.watched, address]; await this.reconnect(); } async removePeer(address: string): Promise { if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed'); if (!this.watched.includes(address)) return; this.watched = this.watched.filter((a) => a !== address); await this.reconnect(); } /** * Tear down the current SSE connection so the run loop reopens with * the new watched list. Cancels via abort + reader.cancel — both are * tolerated by the consume() catch path. */ private async reconnect(): Promise { const reader = this.currentReader; this.currentReader = null; this.abortController?.abort(); if (reader) { try { await reader.cancel(); } catch { /* ignore */ } } } async unsubscribe(): Promise { if (this.disposed) return; this.disposed = true; const reader = this.currentReader; this.currentReader = null; this.abortController?.abort(); if (reader) { try { await reader.cancel(); } catch { /* ignore */ } } if (this.readLoopPromise) { try { await this.readLoopPromise; } catch { /* ignore */ } } } } function stripTrailingSlash(s: string): string { return s.endsWith('/') ? s.slice(0, -1) : s; } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }