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:
@ -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";
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user