// Message-bus WebSocket client — one connection per tab. // // Owns the single `/api/rt/ws` connection, refcounted per-topic // subscriptions, JSON-RPC request/response correlation, and reconnect // with jittered exponential backoff. Consumers reach for this through // the `useTopic` / `useFolderTopic` composables and never see the // connection directly. // // Related files: // * `frames.ts` — JSON-RPC framing (pure functions). // * `error-codes.ts` — named constants for `RtErrorObject.code`. // * `$lib/composables/useTopic.svelte.ts` — per-component lifecycle. // * `$lib/generated/message-bus/` — wire DTOs (Modelina, auto). // // Auth: same-origin WS carries the session cookie automatically. DPoP- // required deployments need the ticket flow (Phase F, deferred); the // unauthenticated close is surfaced through `state = 'disconnected'` // and the console logger so users can diagnose without a redeploy. import log from 'loglevel'; import { untrack } from 'svelte'; import { apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import { RtErrorCode } from './error-codes'; import { parseIncoming, pingFrame, subscribeFrame, unsubscribeFrame, type IncomingFrame } from './frames'; import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; /** Response body from `POST /api/rt/ticket`. Matches the Rust * `RtTicketResponse` shape — see `handlers/rt_ticket_handler.rs`. */ interface RtTicketResponse { /** Opaque ticket UUID. Redeemed once server-side. */ ticket: string; /** Seconds until server-side expiry (informational; the client * should open the WS immediately). */ expires_in_seconds: number; /** Full `Sec-WebSocket-Protocol` value the client MUST pass on * the upgrade — assembled server-side so a FE bug can't emit * the wrong prefix. */ subprotocol: string; } /** Logger namespace — matches `frontend/AGENTS.md § Logging`. Users * tune with `oxi.setLogLevel('oxi:message-bus', 'debug')`. */ const busLog = log.getLogger('oxi:message-bus'); /** Reactive connection state. `idle` before the first `subscribe`; * `connecting` while the handshake is in flight; `connected` once * the server has accepted the upgrade; `disconnected` after any * close (reconnect fires from the client). */ export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected'; /** Callback invoked for every `rt.event` notification on a topic. */ export type EventHandler = (params: RtEventParams) => void; /** Callback invoked when the server sends `rt.revoked` for a topic — * the subscription is already gone server-side by the time the frame * arrives; the client removes it from the local refcount map and * fires this so the consumer can toast / redirect / whatever. */ export type RevokedHandler = (params: RtRevokedParams) => void; /** Callback invoked when the WS reconnects AFTER a prior disconnect — * never on the first connect. Fires after client-side sub replay has * been kicked off (`#sendSubscribe` for every known topic), so the * handler can safely call `reload()`-style refetches knowing the * post-reconnect event stream is armed. Bridges the "events published * during the disconnect window are lost" gap — see * `project_message_bus_reconnect_gap` memory. */ export type ReconnectHandler = () => void; /** Handle returned by `subscribe`. Call to release one refcount on the * topic; the client unsubscribes over the wire only when the last * refcount drops. Idempotent — calling twice from the same subscriber * is safe (second call is a no-op). */ export type UnsubscribeHandle = () => void; /** * Shape returned by a rejected JSON-RPC call. Structurally a superset * of `RtErrorObject` — every server-side error slots in, and this * type also lets the client raise synthetic errors (`ws_closed`, * `send_failed`, `not_connected`) whose `message` is a plain string * outside the wire's `RtErrorMessage` enum. */ export interface MessageBusError { code: number; message: string; data?: unknown; } /** Reconnect backoff — 250 ms doubling with full jitter, capped at 30 s. * Same shape as the HTTP retry we use in the fetch interceptor. */ const RECONNECT_MIN_MS = 250; const RECONNECT_MAX_MS = 30_000; /** Circuit breaker — after N consecutive failed attempts (either a * ticket-exchange rejection or a WS close before `onopen` fires), * give up and stay `disconnected` until the caller explicitly asks * to `reconnect()`. Prevents an unrecoverable auth state (revoked * session, wrong CSRF cookie, missing DPoP nonce) from flooding * logs. Twenty attempts × exponential-backoff-with-jitter caps * around 5 minutes of retrying — comfortably covers a cargo-release * server restart on a hot machine while still short-circuiting a * genuine permanent failure before it becomes noise. */ const MAX_CONSECUTIVE_FAILURES = 20; interface SubEntry { count: number; handlers: Set; revokedHandlers: Set; /** True once the server has ack'd `rt.subscribe`. Used by * reconnect: on wire-up we re-send every already-ack'd topic. */ acked: boolean; } interface PendingCall { resolve: (result: unknown) => void; reject: (error: MessageBusError) => void; } export class MessageBusClient { /** Reactive connection state — exposed for a debug indicator or * Playwright test. Not consumed by the composables directly. */ state = $state('idle'); /** Last observed round-trip in ms, updated on each `rt.pong`. * `null` until the first ping completes. */ latencyMs = $state(null); #ws: WebSocket | null = null; /** Backoff for the NEXT reconnect attempt. Reset to * `RECONNECT_MIN_MS` on every successful open. */ #backoffMs = RECONNECT_MIN_MS; /** setTimeout handle for a scheduled reconnect. Cleared on * explicit `close()` so we don't reconnect after teardown. */ #reconnectTimer: ReturnType | null = null; /** Consecutive failures — incremented on every attempt that dies * before `#onOpen()` gets to reset it. Once it hits * `MAX_CONSECUTIVE_FAILURES` the client stops reconnecting and * requires an explicit `reconnect()` from the caller. */ #consecutiveFailures = 0; /** True once we've observed at least one successful `#onOpen`. * Used to distinguish "initial connect" (don't fire onReconnect * handlers — the initial load path is doing the fetch already) * from "reconnect" (do fire — events during the outage window * were lost, consumers must refetch). */ #hasConnectedBefore = false; /** Reconnect handlers, invoked from `#onOpen` on the SECOND-onwards * successful connect. Plain Set — internal registry, not * reactive. Same rationale as `#subs` / `#pending`. */ // eslint-disable-next-line svelte/prefer-svelte-reactivity #reconnectHandlers = new Set(); /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop * ⇒ send `rt.unsubscribe`. Plain `Map` (not `SvelteMap`) — this is * internal plumbing keyed by topic string; a reactive collection * would re-run every component's `$effect` on unrelated * subscribes. */ // eslint-disable-next-line svelte/prefer-svelte-reactivity #subs = new Map(); /** Pending JSON-RPC requests keyed by id. Same rationale as * `#subs` — internal state, not reactive. */ // eslint-disable-next-line svelte/prefer-svelte-reactivity #pending = new Map(); #nextId = 1; /** URL for the WebSocket. Injectable so tests can point at a mock. */ #url: string; /** WebSocket constructor. Injectable for the same reason. */ #WebSocketCtor: typeof WebSocket; constructor(opts?: { url?: string; WebSocketCtor?: typeof WebSocket }) { // Default to same-origin `/api/rt/ws`. `location` is unavailable // in SSR; the client is instantiated lazily on first `subscribe` // so this executes in the browser. const defaultUrl = () => { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${window.location.host}/api/rt/ws`; }; this.#url = opts?.url ?? (typeof window !== 'undefined' ? defaultUrl() : ''); this.#WebSocketCtor = opts?.WebSocketCtor ?? WebSocket; } /** * Refcounted subscribe. Adds `onEvent` (and optional `onRevoked`) * to the local handlers for `topic`, sends `rt.subscribe` on the * wire only for the first ref, and returns an unsubscribe fn that * drops that same ref (last ref out sends `rt.unsubscribe`). * * Wrapped in `untrack` because `this.state` is `$state`. Without * this, a caller invoking `subscribe` from a Svelte `$effect` * (which `useTopic` does) would take a reactive dep on `state`. * Every `state` transition (idle → connecting → disconnected → * connecting → …) would then re-fire the caller's `$effect`, * which re-calls `subscribe`, which flips `state`, which re-fires * the effect — a 1000+/s runaway loop, observed on server-down * (2026-09-11). `subscribe` is a mutation entry point; its reads * of internal state MUST NOT contaminate reactive callers. */ subscribe(topic: string, onEvent: EventHandler, onRevoked?: RevokedHandler): UnsubscribeHandle { return untrack(() => { let entry = this.#subs.get(topic); if (!entry) { // Plain Sets: internal callback registries, not reactive. // Same rationale as `#subs` / `#pending` — see the doc // there. entry = { count: 0, // eslint-disable-next-line svelte/prefer-svelte-reactivity handlers: new Set(), // eslint-disable-next-line svelte/prefer-svelte-reactivity revokedHandlers: new Set(), acked: false }; this.#subs.set(topic, entry); } entry.count += 1; entry.handlers.add(onEvent); if (onRevoked) entry.revokedHandlers.add(onRevoked); // Kick the connection if nothing is holding it yet, otherwise // send `rt.subscribe` if this is the first ref on this topic. if (this.state === 'idle' || this.state === 'disconnected') { this.#connect(); } else if (entry.count === 1 && this.state === 'connected') { this.#sendSubscribe(topic).catch((err) => busLog.warn('subscribe failed', { topic, error: err }) ); } let released = false; return () => { if (released) return; released = true; // Cleanup path — Svelte `$effect` cleanup doesn't track // anyway, but stay defensive: untrack around the // internal state reads inside #releaseOne. untrack(() => this.#releaseOne(topic, onEvent, onRevoked)); }; }); } /** * Register a handler that fires when the WS reconnects AFTER a * prior disconnect (server restart, network blip, sleep/wake). * NOT called on the initial connect — that path is already * handled by the consumer's own load logic. Returns an * unsubscribe fn. * * Wrapped in `untrack` for the same reason `subscribe` is — * reading `#hasConnectedBefore` etc. inside a caller's `$effect` * would leak a reactive dep. Callers reach for this via the * `useReconnect` composable, which manages the lifecycle. * * Bridges the "events lost during outage window" gap: consumers * refetch on reconnect to bring their view back in line with the * server, since bus publishes during the disconnect never reached * this session. See `project_message_bus_reconnect_gap` memory. */ onReconnect(cb: ReconnectHandler): () => void { return untrack(() => { this.#reconnectHandlers.add(cb); let released = false; return () => { if (released) return; released = true; this.#reconnectHandlers.delete(cb); }; }); } /** Force a fresh reconnect — for a live-updates toggle or a manual * "reconnect" button. Rare; not part of the normal flow. Also the * escape hatch after the circuit breaker trips: zeroes the * consecutive-failure counter so the next attempt actually fires. */ reconnect(): void { if (this.#ws) this.#ws.close(); this.#backoffMs = RECONNECT_MIN_MS; this.#consecutiveFailures = 0; this.#scheduleReconnect(0); } /** Tear down. Currently only meaningful in tests — the singleton * lives for the lifetime of the tab. */ close(): void { if (this.#reconnectTimer !== null) { clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; } if (this.#ws) { this.#ws.close(); this.#ws = null; } this.state = 'idle'; this.#subs.clear(); this.#pending.clear(); } // ─────────────────────── connection lifecycle ──────────────────── #connect(): void { if (this.state === 'connecting' || this.state === 'connected') return; this.state = 'connecting'; busLog.debug('connecting', { url: this.#url }); // Ticket exchange runs off a Promise; the connection is // finalised inside its `.then`. Errors during exchange land in // `#onTicketFailure`, which mirrors the WS-close reconnect path // so a transient auth blip retries with backoff. void this.#exchangeAndOpen(); } /** POST `/api/rt/ticket`, then open the WS with the returned * subprotocol. The POST runs through `apiFetch` — DPoP proof * and session cookie handled by the interceptor — and we attach * the CSRF header ourselves per every state-changing endpoint's * convention (see `endpoints/shares.ts` for the pattern). */ async #exchangeAndOpen(): Promise { let subprotocol: string; try { const res = await apiJson('/api/rt/ticket', { method: 'POST', headers: getCsrfHeaders() }); subprotocol = res.subprotocol; busLog.debug('ticket issued', { expires_in_seconds: res.expires_in_seconds }); } catch (err) { this.#onTicketFailure(err); return; } // A close/reconnect could have raced this in-flight exchange; // bail if we lost the "connecting" role in the meantime. if (this.state !== 'connecting') { busLog.debug('ticket exchange raced with close — discarding', { state: this.state }); return; } let ws: WebSocket; try { ws = new this.#WebSocketCtor(this.#url, [subprotocol]); } catch (err) { busLog.warn('WebSocket ctor threw — reconnect scheduled', { error: err }); this.state = 'disconnected'; this.#scheduleReconnect(); return; } this.#ws = ws; ws.onopen = () => this.#onOpen(); ws.onmessage = (ev) => this.#onMessage(ev); ws.onerror = (ev) => busLog.debug('ws error event', { ev }); ws.onclose = (ev) => this.#onClose(ev); } /** Handle a failed ticket exchange. Same shape as a WS close — * we're not going to retry inline (a bad auth state won't fix * itself in 250 ms), so schedule the next attempt through the * standard reconnect path. */ #onTicketFailure(err: unknown): void { busLog.warn('ticket exchange failed — reconnect scheduled', { error: err }); this.state = 'disconnected'; if (this.#subs.size > 0) this.#scheduleReconnect(); } #onOpen(): void { busLog.debug('connected'); this.state = 'connected'; this.#backoffMs = RECONNECT_MIN_MS; this.#consecutiveFailures = 0; // Snapshot whether this is a reconnect BEFORE we flip the // `hasConnectedBefore` bit, so handlers only fire on 2nd+ open. const isReconnect = this.#hasConnectedBefore; this.#hasConnectedBefore = true; // Replay every already-known topic. `entry.acked` is reset here // because the fresh connection has no server-side memory of // prior subscriptions. for (const [topic, entry] of this.#subs) { entry.acked = false; this.#sendSubscribe(topic).catch((err) => busLog.warn('resubscribe failed', { topic, error: err }) ); } // Fire reconnect handlers AFTER sub replay is kicked (the // `rt.subscribe` frames are on the socket; ack may be // in-flight). Handlers refetching state via REST will see a // consistent post-reconnect view; any events published between // resubscribe and the handler's refetch race safely — a stale // event just means one extra `reload()` on the next tick. if (isReconnect && this.#reconnectHandlers.size > 0) { busLog.debug('firing reconnect handlers', { count: this.#reconnectHandlers.size }); for (const cb of this.#reconnectHandlers) { try { cb(); } catch (err) { busLog.warn('reconnect handler threw', { error: err }); } } } } #onMessage(ev: MessageEvent): void { if (typeof ev.data !== 'string') { // Binary frames are the Yjs sync protocol (Phase G) — not in // scope yet. Silently drop; a future collab store will // receive them via a separate handler. busLog.debug('binary frame dropped (Phase G)'); return; } const frame = parseIncoming(ev.data); this.#dispatch(frame); } #dispatch(frame: IncomingFrame): void { switch (frame.kind) { case 'event': { const entry = this.#subs.get(frame.params.topic); if (!entry) { busLog.debug('event for unknown topic', { topic: frame.params.topic }); return; } // Trace each delivered event so devs can watch the bus // live in the console. Level `debug` — silent under the // default `warn`. See `frontend/AGENTS.md § Logging` // for the tune knob (`oxi.setLogLevel('oxi:message-bus', // 'debug')`). busLog.debug('event received', { topic: frame.params.topic, kind: frame.params.event, actor: (frame.params.data as { actor?: string })?.actor }); for (const handler of entry.handlers) { try { handler(frame.params); } catch (err) { busLog.warn('event handler threw', { topic: frame.params.topic, error: err }); } } break; } case 'revoked': { const entry = this.#subs.get(frame.params.topic); if (!entry) { busLog.debug('revoked for unknown topic', { topic: frame.params.topic }); return; } busLog.warn('subscription revoked', { topic: frame.params.topic, reason: frame.params.reason }); // Server-side sub is already gone; drop local state // BEFORE firing consumer handlers so any handler that // re-subscribes gets a fresh entry with `count = 1`. const revokedHandlers = [...entry.revokedHandlers]; this.#subs.delete(frame.params.topic); for (const handler of revokedHandlers) { try { handler(frame.params); } catch (err) { busLog.warn('revoked handler threw', { topic: frame.params.topic, error: err }); } } break; } case 'success': { const pending = this.#pending.get(frame.id); if (!pending) return; this.#pending.delete(frame.id); pending.resolve(frame.result); break; } case 'error': { busLog.warn('rt.error', { id: frame.id, error: frame.error }); if (frame.id === null) return; const pending = this.#pending.get(frame.id); if (!pending) return; this.#pending.delete(frame.id); pending.reject(frame.error); break; } case 'ignore': { busLog.warn('ignored frame', { reason: frame.reason, raw: frame.raw }); break; } } } #onClose(ev: CloseEvent): void { busLog.debug('close', { code: ev.code, reason: ev.reason }); this.#ws = null; this.state = 'disconnected'; // Reject every pending call — the caller sees a synthetic // error rather than hanging. Reconnect will re-issue the // subscribe via `#onOpen`, not by resolving these. const closed: MessageBusError = { code: RtErrorCode.INTERNAL_ERROR, message: 'ws_closed' }; for (const pending of this.#pending.values()) pending.reject(closed); this.#pending.clear(); // Only reconnect if we still have subscribers waiting. if (this.#subs.size > 0) this.#scheduleReconnect(); } #scheduleReconnect(overrideMs?: number): void { if (this.#reconnectTimer !== null) return; this.#consecutiveFailures += 1; // Circuit breaker: after too many failures in a row, stop // retrying and require an explicit `reconnect()` call from // the caller. Prevents a bad auth state (session revoked, // CSRF cookie stripped, DPoP nonce mismatch) from flooding // server logs with the same 401/403 forever. `reconnect()` // zeroes the counter and re-arms. if (this.#consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { busLog.error('circuit breaker tripped — reconnect suspended after too many failures', { consecutiveFailures: this.#consecutiveFailures, max: MAX_CONSECUTIVE_FAILURES, remedy: 'call messageBus.reconnect() to retry, or refresh the page' }); return; } const delay = overrideMs ?? this.#backoffMs; // Full jitter — random in [0, backoff]. Prevents thundering // herd if the server was momentarily overloaded. const jittered = Math.floor(Math.random() * (delay + 1)); busLog.warn('reconnect scheduled', { attemptBackoffMs: delay, jitteredMs: jittered, consecutiveFailures: this.#consecutiveFailures }); this.#reconnectTimer = setTimeout(() => { this.#reconnectTimer = null; this.#backoffMs = Math.min(this.#backoffMs * 2, RECONNECT_MAX_MS); this.#connect(); }, jittered); } // ─────────────────────── request/response ──────────────────────── #sendSubscribe(topic: string): Promise { return this.#call((id) => subscribeFrame(id, topic)).then((result) => { const entry = this.#subs.get(topic); if (entry) entry.acked = true; busLog.debug('subscribed', { topic, result }); }); } #sendUnsubscribe(topic: string): Promise { // Fire-and-forget — the server accepts idempotently. Not chained // on the promise because by the time we send this the caller // has already cleaned up its local state. return this.#call((id) => unsubscribeFrame(id, topic)).then(() => { busLog.debug('unsubscribed', { topic }); }); } /** Public latency probe. Sends `rt.ping` and updates `latencyMs`. */ async ping(): Promise { const started = performance.now(); await this.#call((id) => pingFrame(id)); const elapsed = Math.round(performance.now() - started); this.latencyMs = elapsed; return elapsed; } #call(makeFrame: (id: number) => object): Promise { if (this.state !== 'connected' || !this.#ws) { const err: MessageBusError = { code: RtErrorCode.INTERNAL_ERROR, message: 'not_connected' }; return Promise.reject(err); } const id = this.#nextId++; const frame = makeFrame(id); return new Promise((resolve, reject) => { this.#pending.set(id, { resolve, reject }); try { this.#ws!.send(JSON.stringify(frame)); } catch (err) { this.#pending.delete(id); busLog.warn('send failed', { id, error: err }); reject({ code: RtErrorCode.INTERNAL_ERROR, message: 'send_failed' }); } }); } // ─────────────────────── refcount teardown ──────────────────────── #releaseOne(topic: string, onEvent: EventHandler, onRevoked?: RevokedHandler): void { const entry = this.#subs.get(topic); if (!entry) return; entry.handlers.delete(onEvent); if (onRevoked) entry.revokedHandlers.delete(onRevoked); entry.count -= 1; if (entry.count > 0) return; this.#subs.delete(topic); if (this.state === 'connected' && entry.acked) { void this.#sendUnsubscribe(topic).catch(() => { // Server drops idempotently; nothing to do if it errors. }); } } } /** * Process-wide singleton — one WebSocket per tab. Lazy: nothing opens * until the first `subscribe`. Exported for `useTopic` to consume; * app code should reach for the composables instead. */ export const messageBus = new MessageBusClient();