feat(frontend): xterm.js sur WebSocket avec reconnexion et scrollback (#13)

Lot F3 du chantier server/client mode : le client web branche xterm.js sur
le terminal distant via WebSocket, en face de l'endpoint PTY B5.

- wsLiveClient.ts : transport WebSocket du terminal avec reconnexion.
- streamGateways.ts : gateway terminal (open/attach/data/resize/close).
- frames.ts : frames PTY alignées sur le contrat serveur B5.
- Tests : terminalGateway.test.ts, wsLiveClientReconnect.test.ts.

Validé : frontend 743 tests verts (F3 ciblé 12), cohérence des frames
B5↔F3 confirmée, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:09:25 +02:00
parent 917be995a4
commit 7487902ddb
5 changed files with 685 additions and 61 deletions

View File

@ -1,28 +1,45 @@
/**
* WebSocket live client skeleton for the web transport — ticket #13, lot F1.
* WebSocket live client for the web transport — ticket #13, lot F3 (finalised
* from the F1 skeleton).
*
* Owns a single WS connection to `{wsUrl}/ws/live` and multiplexes over it, per
* the B0 draft: PTY output, structured chat output and the low-frequency domain
* event stream (`event.domain`, replacing Tauri `listen("domain://event")`).
* Owns a single WS connection to `{wsUrl}/ws/live` (authenticated by the session
* cookie at the upgrade — same-origin, no URL secret) and multiplexes over it,
* aligned on the **B5 frozen frame contract** (`crates/app-tauri/src/server.rs`):
*
* **F1 scope = skeleton.** The connection, frame envelope, request/reply
* correlation by `id`, and the per-session output routing are implemented and
* unit-testable (inject a fake WebSocket factory). What is deliberately deferred
* to **F3/B5** (when a server exists — B3/B4): reconnection/backpressure, exact
* `open`/`launch` reply shape, sequence-gap replay policy, and the full xterm
* round-trip. Those are marked `TODO(F3/B5)`.
* - client→server: `terminal.open` `{cwd,rows,cols}`, `terminal.attach`
* `{sessionId,rows?,cols?,lastSeq?}`, `terminal.input` `{sessionId,bytesBase64}`,
* `terminal.resize` `{sessionId,rows,cols}`, `terminal.detach` `{sessionId}`,
* `terminal.close` `{sessionId}`, `ping`.
* - server→client: `terminal.attached` (ack: `{session,scrollback,nextSeq,status,
* gap,assignedConversationId}`), `terminal.output` `{sessionId,seq,bytesBase64}`,
* `terminal.status` `{sessionId,status,exitCode}`, `error`, `pong`, plus the
* low-frequency `event.domain` stream (domain events).
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
* F3 additions over F1: a **connection state machine** (connecting / connected /
* reconnecting / closed) with automatic reconnection + re-attach and bounded
* scrollback repaint, and per-session **status routing** (`exited`). The terminal
* lifecycle is owned here so a browser reload / network drop transparently
* re-attaches the surviving server-side PTY. Multi-tab "last attach wins" is
* silent on the wire (the evicted attachment simply stops receiving output — see
* the F3 report), so there is nothing to crash on; the socket-close path handles
* disconnection uniformly.
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`. The port
* (`TerminalGateway`/`TerminalHandle`) and `TerminalView` are unchanged; the UI
* indication of connection state is written into xterm through the session's own
* output sink (a notice line), so no component needs to know about this client.
*/
import type { DomainEvent } from "@/domain";
import type { DomainEvent, Unsubscribe } from "@/domain";
import {
base64ToBytes,
bytesToBase64,
type AttachedPayload,
type ClientFrame,
type ClientFrameKind,
type OutputPayload,
type ServerFrame,
type StatusPayload,
} from "./frames";
/** Minimal WebSocket surface used here; injectable so tests pass a fake. */
@ -38,14 +55,48 @@ export interface WebSocketLike {
/** Factory building a {@link WebSocketLike} for a URL (defaults to global WS). */
export type WebSocketFactory = (url: string) => WebSocketLike;
/** Injectable deferred-call primitives (defaults to global timers). */
export type SetTimeoutLike = (fn: () => void, ms: number) => unknown;
export type ClearTimeoutLike = (handle: unknown) => void;
/** UI-facing connection state of the live socket. */
export type ConnectionState = "connecting" | "connected" | "reconnecting" | "closed";
/** Configuration for the live client. */
export interface WsLiveClientConfig {
/** Base WS URL, e.g. `wss://host:port`. No trailing slash. */
wsUrl: string;
/** Bearer token (ticket #13 auth). B0 note: prefer header/cookie over URL. */
/** Bearer token (dev/tests only). Prod auth is the session cookie at upgrade. */
token?: string;
/** Injected WebSocket factory (defaults to `new WebSocket(url)`). */
socketFactory?: WebSocketFactory;
/** Base reconnect delay in ms (exponential backoff, capped). Default 500. */
reconnectBaseMs?: number;
/** Max reconnect delay in ms. Default 10000. */
reconnectMaxMs?: number;
/** Injected `setTimeout` (tests drive reconnection deterministically). */
setTimeoutImpl?: SetTimeoutLike;
/** Injected `clearTimeout`. */
clearTimeoutImpl?: ClearTimeoutLike;
}
/** A live terminal subscription tracked for output routing + reconnection replay. */
interface TerminalSubscription {
/** Delivers raw PTY bytes (and adapter notices) to the xterm view. */
onData: (bytes: Uint8Array) => void;
/** Optional lifecycle callback (`exited`), for tests / future UI. */
onStatus?: (status: string, exitCode?: number | null) => void;
/** Last output sequence seen — sent as `lastSeq` when re-attaching. */
lastSeq: number;
}
/** Result of opening/attaching a terminal over the WS. */
export interface TerminalAttachResult {
sessionId: string;
/** Bounded scrollback bytes to repaint (empty on a fresh open). */
scrollback: Uint8Array;
assignedConversationId?: string;
status?: string;
}
let frameCounter = 0;
@ -55,20 +106,50 @@ function nextFrameId(): string {
return `c${frameCounter}`;
}
const encoder = new TextEncoder();
/** Encodes a human notice line to bytes for writing into xterm. */
function notice(text: string): Uint8Array {
return encoder.encode(`\r\n\x1b[2m[${text}]\x1b[0m\r\n`);
}
/** Concatenates an attached-frame scrollback list into a single byte buffer. */
function scrollbackBytes(payload: AttachedPayload): Uint8Array {
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
}
/**
* Manages the single live WebSocket. Callers subscribe an output sink per PTY
* session and a single domain-event handler; the client routes inbound frames.
* Manages the single live WebSocket, the connection state machine, and the live
* terminal sessions (so reconnection re-attaches them transparently).
*/
export class WsLiveClient {
private readonly wsUrl: string;
private readonly token?: string;
private readonly socketFactory: WebSocketFactory;
private readonly reconnectBaseMs: number;
private readonly reconnectMaxMs: number;
private readonly setTimeoutImpl: SetTimeoutLike;
private readonly clearTimeoutImpl: ClearTimeoutLike;
private socket: WebSocketLike | null = null;
private opening: Promise<void> | null = null;
private disposed = false;
private everConnected = false;
private connState: ConnectionState = "connecting";
private reconnectAttempt = 0;
private reconnectHandle: unknown = null;
/** Per-session PTY output sinks (sessionId → onData). */
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
/** Live terminal subscriptions tracked for reconnection replay + status. */
private readonly terminals = new Map<string, TerminalSubscription>();
/** Pending command acknowledgements, keyed by client frame id. */
private readonly pending = new Map<
string,
@ -76,6 +157,8 @@ export class WsLiveClient {
>();
/** The single domain-event handler (set by the system gateway). */
private domainEventHandler: ((event: DomainEvent) => void) | null = null;
/** Connection-state listeners (UI / tests). */
private readonly connListeners = new Set<(state: ConnectionState) => void>();
constructor(config: WsLiveClientConfig) {
this.wsUrl = config.wsUrl.replace(/\/+$/, "");
@ -83,65 +166,248 @@ export class WsLiveClient {
this.socketFactory =
config.socketFactory ??
((url: string) => new WebSocket(url) as unknown as WebSocketLike);
this.reconnectBaseMs = config.reconnectBaseMs ?? 500;
this.reconnectMaxMs = config.reconnectMaxMs ?? 10000;
this.setTimeoutImpl =
config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms));
this.clearTimeoutImpl =
config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
}
/** Registers the domain-event handler (replaces any previous one). */
// -------------------------------------------------------------------------
// Connection state
// -------------------------------------------------------------------------
/** Current connection state. */
getConnectionState(): ConnectionState {
return this.connState;
}
/** Subscribes to connection-state transitions (returns an unsubscribe). */
onConnectionStateChange(listener: (state: ConnectionState) => void): Unsubscribe {
this.connListeners.add(listener);
return () => this.connListeners.delete(listener);
}
private setConnState(state: ConnectionState): void {
if (this.connState === state) return;
this.connState = state;
for (const listener of this.connListeners) listener(state);
}
// -------------------------------------------------------------------------
// Domain events (system gateway)
// -------------------------------------------------------------------------
setDomainEventHandler(handler: (event: DomainEvent) => void): void {
this.domainEventHandler = handler;
}
/** Clears the domain-event handler. */
clearDomainEventHandler(): void {
this.domainEventHandler = null;
}
/** Registers a per-session PTY output sink. */
// -------------------------------------------------------------------------
// Low-level output sink map (used by the agent gateway too)
// -------------------------------------------------------------------------
setOutputSink(sessionId: string, onData: (bytes: Uint8Array) => void): void {
this.outputSinks.set(sessionId, onData);
}
/** Drops a per-session PTY output sink (view detached). */
removeOutputSink(sessionId: string): void {
this.outputSinks.delete(sessionId);
}
// -------------------------------------------------------------------------
// Connection lifecycle
// -------------------------------------------------------------------------
/** Ensures the socket is connected, connecting on first use. */
async ensureConnected(): Promise<void> {
if (this.socket) return;
if (this.opening) return this.opening;
return this.connect();
}
private connect(): Promise<void> {
this.setConnState(this.everConnected ? "reconnecting" : "connecting");
this.opening = new Promise<void>((resolve, reject) => {
// B0 auth note: token should ride an `Authorization` header / secure
// cookie at upgrade, NOT a URL secret. Browsers can't set WS upgrade
// headers, so the token placement is a contract point to confirm (see the
// F1 report). The query below is only a placeholder for the skeleton.
// Prod: the session cookie rides the upgrade (same-origin). The optional
// token is a dev/test convenience only (never a prod secret in the URL).
const url = this.token
? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}`
: `${this.wsUrl}/ws/live`;
const socket = this.socketFactory(url);
socket.onopen = () => {
this.socket = socket;
this.opening = null;
this.everConnected = true;
this.reconnectAttempt = 0;
this.setConnState("connected");
resolve();
};
socket.onerror = (event) => {
this.opening = null;
reject(event);
};
socket.onclose = () => {
// TODO(F3/B5): reconnection + re-attach of live sessions. For F1 we drop
// the socket so a later call reconnects fresh.
this.socket = null;
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
};
socket.onclose = () => this.handleSocketClosed();
socket.onmessage = (event) => this.handleMessage(event.data);
});
return this.opening;
}
private handleSocketClosed(): void {
this.socket = null;
this.opening = null;
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
if (this.disposed) {
this.setConnState("closed");
return;
}
// Only reconnect when there is a live terminal to re-attach; otherwise stay
// idle until the next explicit use.
if (this.terminals.size > 0) {
this.setConnState("reconnecting");
for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…"));
this.scheduleReconnect();
} else {
this.setConnState("reconnecting");
}
}
private scheduleReconnect(): void {
if (this.disposed || this.reconnectHandle) return;
const delay = Math.min(
this.reconnectMaxMs,
this.reconnectBaseMs * 2 ** this.reconnectAttempt,
);
this.reconnectAttempt += 1;
this.reconnectHandle = this.setTimeoutImpl(() => {
this.reconnectHandle = null;
void this.reconnectNow();
}, delay);
}
private async reconnectNow(): Promise<void> {
if (this.disposed) return;
try {
await this.connect();
await this.reattachAll();
} catch {
// Still down: back off and retry (unless everything was detached/closed).
if (this.terminals.size > 0) this.scheduleReconnect();
}
}
/** Re-attaches every tracked terminal after a reconnect and repaints scrollback. */
private async reattachAll(): Promise<void> {
for (const [sessionId, sub] of this.terminals) {
try {
const ack = await this.send("terminal.attach", {
sessionId,
lastSeq: sub.lastSeq,
});
const payload = ack.payload as unknown as AttachedPayload;
sub.onData(notice("reconnecté"));
const bytes = scrollbackBytes(payload);
if (bytes.length > 0) sub.onData(bytes);
if (typeof payload.nextSeq === "number") {
sub.lastSeq = Math.max(0, payload.nextSeq - 1);
}
} catch {
// A single session failing to re-attach (e.g. server killed it) must not
// block the others; leave it tracked for the next reconnect cycle.
}
}
}
// -------------------------------------------------------------------------
// Terminal sessions (high-level; used by HttpTerminalGateway)
// -------------------------------------------------------------------------
/** Opens a fresh terminal (`terminal.open`) and starts tracking it. */
openTerminal(params: {
cwd: string;
rows: number;
cols: number;
nodeId?: string | null;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"terminal.open",
{ cwd: params.cwd, rows: params.rows, cols: params.cols, nodeId: params.nodeId ?? null },
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
);
}
/** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */
attachTerminal(params: {
sessionId: string;
rows?: number;
cols?: number;
lastSeq?: number;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"terminal.attach",
{
sessionId: params.sessionId,
rows: params.rows,
cols: params.cols,
lastSeq: params.lastSeq ?? null,
},
{ onData: params.onData, onStatus: params.onStatus, lastSeq: params.lastSeq ?? 0 },
params.sessionId,
);
}
private async attachInternal(
kind: ClientFrameKind,
payload: Record<string, unknown>,
sub: TerminalSubscription,
knownSessionId?: string,
): Promise<TerminalAttachResult> {
const ack = await this.send(kind, payload);
const ap = ack.payload as unknown as AttachedPayload;
const sessionId = knownSessionId ?? ap.session.sessionId;
if (typeof ap.nextSeq === "number") sub.lastSeq = Math.max(0, ap.nextSeq - 1);
this.terminals.set(sessionId, sub);
// Route output to the subscription's onData (the low-level map is the single
// source of routing; lastSeq is tracked in handleMessage).
this.setOutputSink(sessionId, sub.onData);
return {
sessionId,
scrollback: scrollbackBytes(ap),
assignedConversationId: ap.assignedConversationId,
status: ap.status,
};
}
/** Detaches the view (server PTY keeps running): stop tracking + `terminal.detach`. */
async detachTerminal(sessionId: string): Promise<void> {
this.removeTerminal(sessionId);
await this.sendFireAndForget("terminal.detach", { sessionId });
}
/** Kills the PTY (`terminal.close`) and stops tracking. */
async closeTerminalSession(sessionId: string): Promise<void> {
this.removeTerminal(sessionId);
await this.send("terminal.close", { sessionId });
}
private removeTerminal(sessionId: string): void {
this.terminals.delete(sessionId);
this.outputSinks.delete(sessionId);
}
// -------------------------------------------------------------------------
// Frame I/O
// -------------------------------------------------------------------------
/**
* Sends a client frame and resolves with its acknowledgement frame (the server
* frame whose `replyTo` equals this frame's `id`). Fire-and-forget frames
* (input/resize/detach) can ignore the returned promise.
* Sends a client frame and resolves with its acknowledgement (the server frame
* whose `replyTo` equals this frame's `id`).
*/
async send(
kind: ClientFrameKind,
@ -150,8 +416,7 @@ export class WsLiveClient {
await this.ensureConnected();
const socket = this.socket;
if (!socket) {
const err = { code: "WS_CLOSED", message: "socket not connected" };
throw err;
throw { code: "WS_CLOSED", message: "socket not connected" };
}
const id = nextFrameId();
const frame: ClientFrame = { id, kind, payload };
@ -180,12 +445,19 @@ export class WsLiveClient {
/** Closes the socket and rejects everything pending. */
dispose(): void {
this.disposed = true;
if (this.reconnectHandle) {
this.clearTimeoutImpl(this.reconnectHandle);
this.reconnectHandle = null;
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
this.outputSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
this.socket?.close();
this.socket = null;
this.opening = null;
this.setConnState("closed");
}
private rejectAllPending(err: unknown): void {
@ -198,20 +470,25 @@ export class WsLiveClient {
try {
frame = JSON.parse(data) as ServerFrame;
} catch {
return; // Ignore malformed frames in the skeleton.
return; // Ignore malformed frames.
}
// Route unsolicited streams first (no replyTo).
switch (frame.kind) {
case "terminal.output": {
const payload = frame.payload as unknown as OutputPayload;
const sub = this.terminals.get(payload.sessionId);
if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq;
const sink = this.outputSinks.get(payload.sessionId);
// TODO(F3/B5): honour `seq` ordering + gap detection for precise replay.
if (sink) sink(base64ToBytes(payload.bytesBase64));
return;
}
case "terminal.status": {
const payload = frame.payload as unknown as StatusPayload;
this.handleStatus(payload);
return;
}
case "event.domain": {
// `payload` is a DomainEventDto (kind-tagged); forward as-is.
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
return;
}
@ -229,4 +506,25 @@ export class WsLiveClient {
}
}
}
private handleStatus(payload: StatusPayload): void {
const sub = this.terminals.get(payload.sessionId);
if (!sub) return;
if (payload.status === "exited" || payload.status === "closed") {
const code = payload.exitCode;
sub.onData(
notice(
code === null || code === undefined
? "session terminée"
: `session terminée (code ${code})`,
),
);
sub.onStatus?.(payload.status, payload.exitCode);
// The PTY is gone: stop tracking so a later disconnect does not try to
// re-attach a dead session.
this.removeTerminal(payload.sessionId);
} else {
sub.onStatus?.(payload.status, payload.exitCode);
}
}
}