feat(msg-bus): add DPoP support, fix floow from client, correct deletion

This commit is contained in:
Edouard Vanbelle
2026-09-11 03:05:45 +02:00
parent 821f76b471
commit 75a123ae6c
15 changed files with 1025 additions and 118 deletions
+158 -36
View File
@@ -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);