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

@ -79,14 +79,33 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): {
*/
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
const { baseUrl, wsUrl } = resolveEndpoints(config);
// Route 401s back to the pairing screen via the shared web session (F2).
// Route 401s back to the pairing screen via the shared web session (F2/F6).
const session = getWebSession();
// `ws` is referenced by the invoker's 401 handler (declared below); the closure
// only runs after both are constructed, so the forward reference is safe.
let ws: WsLiveClient;
const http = new HttpInvoker({
baseUrl,
token: config.token,
onUnauthorized: () => session.notifyUnauthorized(),
onUnauthorized: () => {
// F6: a 401 means the session cookie is gone/revoked. Route back to pairing
// AND tear down the live socket so it stops its (now hopeless) reconnect
// loop. Re-pairing brings the same client back up cleanly.
session.notifyUnauthorized();
ws?.disconnect();
},
});
ws = new WsLiveClient({
wsUrl,
token: config.token,
// F6: when a WS reconnect fails we cannot read its HTTP status, so probe with
// a cheap `health` call. A revoked session answers `401` → the invoker's
// `onUnauthorized` above bounces to pairing; a network error is swallowed and
// the WS backoff keeps retrying.
onReconnectFailed: () => {
void http.invoke("health", { request: null }).catch(() => {});
},
});
const ws = new WsLiveClient({ wsUrl, token: config.token });
// Share the live client with the web feature so live surfaces can re-sync on
// reconnect (F5). Inert on desktop (never called there).
setWebLiveClient(ws);
@ -120,5 +139,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
export { HttpInvoker } from "./httpInvoker";
export { WsLiveClient } from "./wsLiveClient";
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
export { getWebLiveClient, setWebLiveClient } from "./webLive";
export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive";
export type { ConnectionState } from "./wsLiveClient";

View File

@ -26,3 +26,12 @@ export function setWebLiveClient(client: WsLiveClient | null): void {
export function getWebLiveClient(): WsLiveClient | null {
return liveClient;
}
/**
* Soft-disconnects the shared live client (sign-out / auth bounce — F6): tears
* down the socket and stops the reconnect loop but keeps the client reusable, so
* re-pairing reconnects cleanly. Inert (no client registered) on desktop.
*/
export function disconnectWebLive(): void {
liveClient?.disconnect();
}

View File

@ -90,3 +90,32 @@ describe("WebSession unauthorized handling", () => {
expect(session.isPaired()).toBe(false);
});
});
describe("WebSession logout", () => {
it("POSTs /api/logout same-origin and clears the flag on success", async () => {
const store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { revoked: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
session.markPaired();
await session.logout();
expect(session.isPaired()).toBe(false);
expect(calls[0].url).toBe("https://host/api/logout");
const init = calls[0].init as { method: string; credentials: string };
expect(init.method).toBe("POST");
expect(init.credentials).toBe("same-origin");
});
it("still clears the flag when the server is unreachable (best-effort)", async () => {
const store = memStore();
const fetchImpl: FetchLike = async () => {
throw new Error("network down");
};
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
session.markPaired();
await expect(session.logout()).resolves.toBeUndefined();
expect(session.isPaired()).toBe(false);
});
});

View File

@ -96,6 +96,26 @@ export class WebSession {
this.store.removeItem(PAIRED_FLAG_KEY);
}
/**
* Full sign-out (F6): asks the server to revoke the session cookie
* (`POST /api/logout`, same-origin so the cookie rides along) and then clears
* the local flag. Best-effort — a network failure or an already-expired session
* still clears the flag locally, so the UI always returns to pairing without a
* dead end. Disconnecting the live WS is the caller's job (composition seam).
*/
async logout(): Promise<void> {
try {
await this.fetchImpl(`${this.baseUrl}/api/logout`, {
method: "POST",
credentials: "same-origin",
});
} catch {
// Server unreachable / already gone: fall through to the local clear.
} finally {
this.forget();
}
}
/**
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
* server sets the session cookie and this records the paired flag. A wrong code

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;

View File

@ -190,4 +190,66 @@ describe("WsLiveClient connection state + reconnection", () => {
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
});
it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => {
const h = harness();
h.ws.setDomainEventHandler(() => {});
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and
// the detached socket's onclose cannot flip the state back to reconnecting.
h.ws.disconnect();
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
expect(h.ws.needsReconnect()).toBe(false);
// Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing).
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
});
it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => {
const failed = vi.fn();
let openNextSocket = true;
let timerFn: (() => void) | null = null;
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
onReconnectFailed: failed,
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
// First socket connects; the reconnect socket errors (upgrade rejected).
if (openNextSocket) queueMicrotask(() => s.onopen?.());
else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected")));
return s;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
ws.setDomainEventHandler(() => {});
await ws.ensureConnected();
await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected"));
// Drop the socket; the next connect attempt will fail the upgrade.
openNextSocket = false;
sockets[0].close();
expect(ws.getConnectionState()).toBe("reconnecting");
const fire = timerFn as (() => void) | null;
timerFn = null;
fire?.();
// The failed reconnect fires the probe hook and schedules another attempt.
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
expect(timerFn).not.toBeNull();
});
});