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';
|
2026-09-11 03:05:45 +02:00
|
|
|
|
import { untrack } from 'svelte';
|
2026-09-11 00:59:49 +02:00
|
|
|
|
|
2026-09-11 03:05:45 +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';
|
|
|
|
|
|
|
2026-09-11 03:05:45 +02:00
|
|
|
|
/** 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;
|
|
|
|
|
|
|
2026-09-11 03:12:48 +02:00
|
|
|
|
/** 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;
|
|
|
|
|
|
|
2026-09-11 03:05:45 +02:00
|
|
|
|
/** 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 03:05:45 +02:00
|
|
|
|
|
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;
|
2026-09-11 03:05:45 +02:00
|
|
|
|
/** 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;
|
2026-09-11 03:12:48 +02:00
|
|
|
|
/** 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`).
|
2026-09-11 03:05:45 +02:00
|
|
|
|
*
|
|
|
|
|
|
* 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 {
|
2026-09-11 03:05:45 +02:00
|
|
|
|
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
|
|
|
|
|
2026-09-11 03:05:45 +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
|
|
|
|
|
2026-09-11 03:05:45 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 03:12:48 +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
|
2026-09-11 03:05:45 +02:00
|
|
|
|
* "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;
|
2026-09-11 03:05:45 +02:00
|
|
|
|
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 });
|
2026-09-11 03:05:45 +02:00
|
|
|
|
// 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 {
|
2026-09-11 03:05:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 03:05:45 +02:00
|
|
|
|
/** 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;
|
2026-09-11 03:05:45 +02:00
|
|
|
|
this.#consecutiveFailures = 0;
|
2026-09-11 03:12:48 +02:00
|
|
|
|
// 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 })
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-11 03:12:48 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-09-11 03:05:45 +02:00
|
|
|
|
// 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;
|
2026-09-11 03:05:45 +02:00
|
|
|
|
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));
|
2026-09-11 03:05:45 +02:00
|
|
|
|
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();
|