Files
Oxicloud/frontend/src/lib/message-bus/client.svelte.ts
T

613 lines
23 KiB
TypeScript
Raw Normal View History

2026-09-11 00:59:49 +02:00
// 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';
2026-09-11 00:59:49 +02:00
import { apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
2026-09-11 00:59:49 +02:00
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;
}
2026-09-11 00:59:49 +02:00
/** 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;
2026-09-11 00:59:49 +02:00
/** 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
2026-09-11 03:20:32 +02:00
* 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;
2026-09-11 00:59:49 +02:00
interface SubEntry {
count: number;
handlers: Set<EventHandler>;
revokedHandlers: Set<RevokedHandler>;
/** 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<ConnectionState>('idle');
/** Last observed round-trip in ms, updated on each `rt.pong`.
* `null` until the first ping completes. */
latencyMs = $state<number | null>(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<typeof setTimeout> | 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<ReconnectHandler>();
2026-09-11 00:59:49 +02:00
/** `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<string, SubEntry>();
/** 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<number, PendingCall>();
#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.
2026-09-11 00:59:49 +02:00
*/
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);
2026-09-11 00:59:49 +02:00
// 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 })
);
}
2026-09-11 00:59:49 +02:00
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));
};
});
2026-09-11 00:59:49 +02:00
}
/**
* 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);
};
});
}
2026-09-11 00:59:49 +02:00
/** 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. */
2026-09-11 00:59:49 +02:00
reconnect(): void {
if (this.#ws) this.#ws.close();
this.#backoffMs = RECONNECT_MIN_MS;
this.#consecutiveFailures = 0;
2026-09-11 00:59:49 +02:00
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<void> {
let subprotocol: string;
try {
const res = await apiJson<RtTicketResponse>('/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;
}
2026-09-11 00:59:49 +02:00
let ws: WebSocket;
try {
ws = new this.#WebSocketCtor(this.#url, [subprotocol]);
2026-09-11 00:59:49 +02:00
} 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();
}
2026-09-11 00:59:49 +02:00
#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;
2026-09-11 00:59:49 +02:00
// 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 });
}
}
}
2026-09-11 00:59:49 +02:00
}
#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
});
2026-09-11 00:59:49 +02:00
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;
}
2026-09-11 00:59:49 +02:00
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
});
2026-09-11 00:59:49 +02:00
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<void> {
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<void> {
// 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<number> {
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<unknown> {
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();