feat(msg-bus): add DPoP support, fix floow from client, correct deletion
This commit is contained in:
@@ -18,7 +18,10 @@
|
||||
// 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,
|
||||
@@ -30,6 +33,20 @@ import {
|
||||
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');
|
||||
@@ -73,6 +90,16 @@ export interface MessageBusError {
|
||||
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. Ten attempts × exponential-backoff-with-jitter is roughly a
|
||||
* minute of trying — long enough for a transient blip, short enough
|
||||
* to stop before it's noise. */
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
interface SubEntry {
|
||||
count: number;
|
||||
handlers: Set<EventHandler>;
|
||||
@@ -102,6 +129,11 @@ export class MessageBusClient {
|
||||
/** 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;
|
||||
|
||||
/** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount
|
||||
* drives the wire: first refcount ⇒ send `rt.subscribe`; last drop
|
||||
@@ -139,49 +171,68 @@ export class MessageBusClient {
|
||||
* 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 {
|
||||
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
|
||||
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));
|
||||
};
|
||||
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;
|
||||
this.#releaseOne(topic, onEvent, onRevoked);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Force a fresh reconnect — for a live-updates toggle or a manual
|
||||
* "reconnect" button. Rare; not part of the normal flow. */
|
||||
* "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);
|
||||
}
|
||||
|
||||
@@ -207,9 +258,40 @@ export class MessageBusClient {
|
||||
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;
|
||||
}
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new this.#WebSocketCtor(this.#url);
|
||||
ws = new this.#WebSocketCtor(this.#url, [subprotocol]);
|
||||
} catch (err) {
|
||||
busLog.warn('WebSocket ctor threw — reconnect scheduled', { error: err });
|
||||
this.state = 'disconnected';
|
||||
@@ -223,10 +305,21 @@ export class MessageBusClient {
|
||||
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;
|
||||
// Replay every already-known topic. `entry.acked` is reset here
|
||||
// because the fresh connection has no server-side memory of
|
||||
// prior subscriptions.
|
||||
@@ -258,6 +351,16 @@ export class MessageBusClient {
|
||||
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);
|
||||
@@ -330,11 +433,30 @@ export class MessageBusClient {
|
||||
|
||||
#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 });
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user