feat(frontend): polish web (logout, 401→pairing, reconnexion globale) (#13)

Flow logout : POST /api/logout puis retour à l'écran de pairing et
déconnexion du live. 401 renvoie au pairing sans boucle. Bannière de
reconnexion couvrant le cas live-only et le serveur indisponible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:23:11 +02:00
parent d538808a0d
commit 6117177957
10 changed files with 288 additions and 14 deletions

View File

@ -78,6 +78,14 @@ export interface WsLiveClientConfig {
setTimeoutImpl?: SetTimeoutLike;
/** Injected `clearTimeout`. */
clearTimeoutImpl?: ClearTimeoutLike;
/**
* Called after a reconnection attempt fails. The browser cannot read the HTTP
* status of a rejected WS upgrade, so an auth-revoked socket looks like any
* other outage from here. F6 wires this to a cheap HTTP probe (`health`): a
* `401` there routes back to pairing (via the invoker's `onUnauthorized`),
* while a network error just lets the backoff keep retrying.
*/
onReconnectFailed?: () => void;
}
/** A live terminal subscription tracked for output routing + reconnection replay. */
@ -137,6 +145,7 @@ export class WsLiveClient {
private readonly reconnectMaxMs: number;
private readonly setTimeoutImpl: SetTimeoutLike;
private readonly clearTimeoutImpl: ClearTimeoutLike;
private readonly onReconnectFailed?: () => void;
private socket: WebSocketLike | null = null;
private opening: Promise<void> | null = null;
@ -172,6 +181,7 @@ export class WsLiveClient {
config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms));
this.clearTimeoutImpl =
config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
this.onReconnectFailed = config.onReconnectFailed;
}
// -------------------------------------------------------------------------
@ -274,7 +284,7 @@ export class WsLiveClient {
}
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */
private needsReconnect(): boolean {
needsReconnect(): boolean {
return this.terminals.size > 0 || this.domainEventHandler !== null;
}
@ -297,7 +307,10 @@ export class WsLiveClient {
await this.connect();
await this.reattachAll();
} catch {
// Still down: back off and retry (unless nothing needs the socket anymore).
// Still down: probe (an auth-revoked upgrade is indistinguishable from an
// outage here — the probe surfaces a 401 as a pairing bounce), then back
// off and retry (unless nothing needs the socket anymore).
this.onReconnectFailed?.();
if (this.needsReconnect()) this.scheduleReconnect();
}
}
@ -479,6 +492,37 @@ export class WsLiveClient {
return bytesToBase64(bytes);
}
/**
* Soft teardown for a sign-out / auth bounce (F6): closes the socket, drops all
* live state and stops the reconnect loop, but — unlike {@link dispose} — leaves
* the client reusable. A later `ensureConnected` (after re-pairing) reconnects
* as a fresh `connecting`. The socket's own `onclose` is detached first so it
* cannot flip the state back to `reconnecting`.
*/
disconnect(): void {
if (this.reconnectHandle) {
this.clearTimeoutImpl(this.reconnectHandle);
this.reconnectHandle = null;
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
this.outputSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
const socket = this.socket;
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close();
}
this.socket = null;
this.opening = null;
this.everConnected = false;
this.reconnectAttempt = 0;
this.setConnState("closed");
}
/** Closes the socket and rejects everything pending. */
dispose(): void {
this.disposed = true;