Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
// Client WebSocket multiplexé Node : portage du client web (packages/web/src/lib/ws-client.ts).
|
|
// UNE connexion, plusieurs canaux ; handshake hello/hello_ok, sub des topics, corrélation FIFO des
|
|
// 'attached', flow control par ACK (cumul d'octets PAR CANAL, remis à 0 à chaque RESYNC), reconnexion
|
|
// avec backoff. Différences vs web :
|
|
// - transport injecté (RawSocket) → testable sans réseau ; l'implémentation par défaut utilise `ws`
|
|
// avec l'en-tête `Authorization: Bearer` posé à l'upgrade (le navigateur, lui, envoie le cookie).
|
|
// - pas de callback de rendu (xterm) : on ACK les octets AU MOMENT où on les remet au sink (onDidWrite
|
|
// d'un Pseudoterminal émet de façon synchrone). Un ACK traînant débouncé flushe le reliquat < 64 Ko.
|
|
import {
|
|
BINARY_FRAME,
|
|
FLOW,
|
|
PROTOCOL_VERSION,
|
|
decodeBinaryFrame,
|
|
type ClientMessage,
|
|
type ServerMessage,
|
|
} from '@arboretum/shared';
|
|
import { wsUrlFromBase } from '../config.js';
|
|
|
|
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
|
|
export type DetachReason = 'client' | 'session_exit' | 'replaced';
|
|
|
|
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
|
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
|
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
|
|
|
/** Sink d'un canal : reçoit les octets BRUTS (le décodage UTF-8 est délégué au SessionBridge). */
|
|
export interface AttachmentSink {
|
|
data(payload: Uint8Array): void;
|
|
reset(): void;
|
|
detached(reason: DetachReason): void;
|
|
controlChanged(controlling: boolean): void;
|
|
}
|
|
|
|
export interface AttachOptions {
|
|
sessionId: string;
|
|
mode: 'interactive' | 'observer';
|
|
cols: number;
|
|
rows: number;
|
|
sink: AttachmentSink;
|
|
}
|
|
|
|
/** Transport minimal : abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */
|
|
export interface RawSocket {
|
|
send(data: string): void;
|
|
close(): void;
|
|
onOpen(cb: () => void): void;
|
|
onText(cb: (text: string) => void): void;
|
|
onBinary(cb: (data: Uint8Array) => void): void;
|
|
onClose(cb: () => void): void;
|
|
onError(cb: (err: Error) => void): void;
|
|
}
|
|
|
|
export type SocketFactory = (wsUrl: string, token: string) => RawSocket;
|
|
|
|
const BACKOFF_MIN_MS = 500;
|
|
const BACKOFF_MAX_MS = 10_000;
|
|
|
|
export class Attachment {
|
|
channel = -1;
|
|
controlling = false;
|
|
closed = false;
|
|
cols: number;
|
|
rows: number;
|
|
epoch = 0;
|
|
processedBytes = 0;
|
|
lastAckBytes = 0;
|
|
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
|
|
|
|
constructor(
|
|
private readonly client: ArbWsClient,
|
|
readonly sessionId: string,
|
|
readonly mode: 'interactive' | 'observer',
|
|
readonly sink: AttachmentSink,
|
|
cols: number,
|
|
rows: number,
|
|
) {
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
}
|
|
|
|
sendStdin(data: string): void {
|
|
if (this.closed || this.channel < 0) return;
|
|
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
|
|
}
|
|
|
|
/** Répond à un dialogue Claude sans clavier ; le serveur traduit en keystrokes. */
|
|
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
|
|
if (this.closed || this.channel < 0) return;
|
|
this.client.sendControl(
|
|
action === 'select' && optionN !== undefined
|
|
? { type: 'answer', channel: this.channel, action, optionN }
|
|
: { type: 'answer', channel: this.channel, action },
|
|
);
|
|
}
|
|
|
|
resize(cols: number, rows: number): void {
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
if (this.closed || this.channel < 0 || !this.controlling) return;
|
|
this.client.sendControl({ type: 'resize', channel: this.channel, cols, rows });
|
|
}
|
|
|
|
detach(): void {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
this.pending = null;
|
|
this.client.releaseAttachment(this);
|
|
}
|
|
}
|
|
|
|
export interface ArbWsClientOptions {
|
|
socketFactory: SocketFactory;
|
|
onStatus?: (status: WsStatus) => void;
|
|
onSession?: (e: SessionEvent) => void;
|
|
onWorktree?: (e: WorktreeEvent) => void;
|
|
onGroup?: (e: GroupEvent) => void;
|
|
/** P10 : une session managée terminée vient d'être auto-archivée. */
|
|
onSessionArchived?: (sessionId: string) => void;
|
|
/** appelé au premier hello_ok après (re)connexion → re-seed du store via REST. */
|
|
onReady?: () => void;
|
|
log?: (msg: string) => void;
|
|
}
|
|
|
|
export class ArbWsClient {
|
|
status: WsStatus = 'idle';
|
|
|
|
private baseUrl = '';
|
|
private token = '';
|
|
private socket: RawSocket | null = null;
|
|
private ready = false;
|
|
private stopped = true;
|
|
private backoffMs = BACKOFF_MIN_MS;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private readonly attachments = new Set<Attachment>();
|
|
private readonly byChannel = new Map<number, Attachment>();
|
|
private awaitingAttached: Attachment[] = [];
|
|
|
|
constructor(private readonly opts: ArbWsClientOptions) {}
|
|
|
|
/** (Re)configure l'URL/token ; si la connexion était active, elle est relancée. */
|
|
configure(baseUrl: string, token: string): void {
|
|
const wasActive = !this.stopped;
|
|
this.baseUrl = baseUrl;
|
|
this.token = token;
|
|
if (wasActive) {
|
|
this.hardReset();
|
|
this.connect();
|
|
}
|
|
}
|
|
|
|
connect(): void {
|
|
this.stopped = false;
|
|
if (this.socket || this.reconnectTimer) return;
|
|
if (!this.token) return; // pas authentifié : on n'ouvre rien
|
|
this.setStatus('connecting');
|
|
this.openSocket();
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.stopped = true;
|
|
this.hardReset();
|
|
this.setStatus('idle');
|
|
}
|
|
|
|
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
|
attach(opts: AttachOptions): Promise<Attachment> {
|
|
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
|
|
const promise = new Promise<Attachment>((resolve, reject) => {
|
|
att.pending = { resolve, reject };
|
|
});
|
|
this.attachments.add(att);
|
|
this.connect();
|
|
if (this.ready) this.sendAttach(att);
|
|
return promise;
|
|
}
|
|
|
|
sendControl(msg: ClientMessage): void {
|
|
if (!this.ready || !this.socket) return;
|
|
try {
|
|
this.socket.send(JSON.stringify(msg));
|
|
} catch {
|
|
/* socket en cours de fermeture : la reconnexion rejouera */
|
|
}
|
|
}
|
|
|
|
releaseAttachment(att: Attachment): void {
|
|
this.attachments.delete(att);
|
|
if (att.trailingAckTimer) {
|
|
clearTimeout(att.trailingAckTimer);
|
|
att.trailingAckTimer = null;
|
|
}
|
|
if (att.channel >= 0) {
|
|
this.byChannel.delete(att.channel);
|
|
this.sendControl({ type: 'detach', channel: att.channel });
|
|
att.channel = -1;
|
|
}
|
|
}
|
|
|
|
// ---- interne ----
|
|
|
|
private setStatus(s: WsStatus): void {
|
|
this.status = s;
|
|
this.opts.onStatus?.(s);
|
|
}
|
|
|
|
/** ferme la socket courante et invalide tous les états sans toucher au flag stopped. */
|
|
private hardReset(): void {
|
|
this.clearReconnectTimer();
|
|
const socket = this.socket;
|
|
this.socket = null;
|
|
this.ready = false;
|
|
for (const att of this.attachments) {
|
|
if (att.trailingAckTimer) {
|
|
clearTimeout(att.trailingAckTimer);
|
|
att.trailingAckTimer = null;
|
|
}
|
|
att.pending?.reject(new Error('disconnected'));
|
|
att.pending = null;
|
|
att.closed = true;
|
|
att.sink.detached('client');
|
|
}
|
|
this.attachments.clear();
|
|
this.byChannel.clear();
|
|
this.awaitingAttached = [];
|
|
socket?.close();
|
|
}
|
|
|
|
private openSocket(): void {
|
|
const socket = this.opts.socketFactory(wsUrlFromBase(this.baseUrl), this.token);
|
|
this.socket = socket;
|
|
socket.onOpen(() => {
|
|
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
|
|
socket.send(JSON.stringify(hello));
|
|
});
|
|
socket.onText((text) => this.handleText(text));
|
|
socket.onBinary((data) => this.handleBinary(data));
|
|
socket.onClose(() => this.handleClose(socket));
|
|
socket.onError((err) => this.opts.log?.(`ws error: ${err.message}`));
|
|
}
|
|
|
|
private handleClose(socket: RawSocket): void {
|
|
if (this.socket !== socket) return;
|
|
this.socket = null;
|
|
this.ready = false;
|
|
this.byChannel.clear();
|
|
this.awaitingAttached = [];
|
|
for (const att of this.attachments) {
|
|
att.channel = -1;
|
|
att.epoch += 1;
|
|
if (att.trailingAckTimer) {
|
|
clearTimeout(att.trailingAckTimer);
|
|
att.trailingAckTimer = null;
|
|
}
|
|
}
|
|
if (this.stopped) {
|
|
this.setStatus('idle');
|
|
return;
|
|
}
|
|
this.setStatus('reconnecting');
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
this.clearReconnectTimer();
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
if (!this.stopped) this.openSocket();
|
|
}, this.backoffMs);
|
|
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
}
|
|
|
|
private clearReconnectTimer(): void {
|
|
if (this.reconnectTimer !== null) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
private sendAttach(att: Attachment): void {
|
|
this.awaitingAttached.push(att);
|
|
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
|
|
}
|
|
|
|
private handleText(text: string): void {
|
|
let msg: ServerMessage;
|
|
try {
|
|
msg = JSON.parse(text) as ServerMessage;
|
|
} catch {
|
|
return;
|
|
}
|
|
this.handleServerMessage(msg);
|
|
}
|
|
|
|
private handleBinary(data: Uint8Array): void {
|
|
if (data.byteLength < BINARY_FRAME.HEADER_BYTES) return;
|
|
const frame = decodeBinaryFrame(data);
|
|
const att = this.byChannel.get(frame.channel);
|
|
if (!att || att.closed) return;
|
|
|
|
if (frame.type === BINARY_FRAME.RESYNC) {
|
|
// le serveur repart de sentBytes=0 : reset terminal, replay, compteurs à zéro (le replay ne compte pas)
|
|
att.epoch += 1;
|
|
att.processedBytes = 0;
|
|
att.lastAckBytes = 0;
|
|
att.sink.reset();
|
|
if (frame.payload.byteLength > 0) att.sink.data(frame.payload);
|
|
this.scheduleTrailingAck(att, att.epoch);
|
|
return;
|
|
}
|
|
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
|
|
|
// Émission synchrone vers le sink (onDidWrite) → on ACK directement les octets remis.
|
|
const length = frame.payload.byteLength;
|
|
att.sink.data(frame.payload);
|
|
if (att.channel < 0) return;
|
|
att.processedBytes += length;
|
|
if (att.processedBytes - att.lastAckBytes >= FLOW.ACK_EVERY_BYTES) {
|
|
att.lastAckBytes = att.processedBytes;
|
|
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
|
}
|
|
// Filet : flushe le reliquat < ACK_EVERY_BYTES pour que le serveur sorte toujours de pause.
|
|
this.scheduleTrailingAck(att, att.epoch);
|
|
}
|
|
|
|
private scheduleTrailingAck(att: Attachment, epoch: number): void {
|
|
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
|
att.trailingAckTimer = setTimeout(() => {
|
|
att.trailingAckTimer = null;
|
|
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
|
if (att.processedBytes > att.lastAckBytes) {
|
|
att.lastAckBytes = att.processedBytes;
|
|
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
private handleServerMessage(msg: ServerMessage): void {
|
|
switch (msg.type) {
|
|
case 'hello_ok': {
|
|
this.ready = true;
|
|
this.backoffMs = BACKOFF_MIN_MS;
|
|
this.setStatus('open');
|
|
this.sendControl({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
|
|
for (const att of this.attachments) {
|
|
if (!att.closed) this.sendAttach(att);
|
|
}
|
|
this.opts.onReady?.();
|
|
return;
|
|
}
|
|
case 'attached': {
|
|
const att = this.awaitingAttached.shift();
|
|
if (!att) return;
|
|
if (att.closed) {
|
|
this.sendControl({ type: 'detach', channel: msg.channel });
|
|
return;
|
|
}
|
|
const wasControlling = att.controlling;
|
|
att.channel = msg.channel;
|
|
att.controlling = msg.controlling;
|
|
this.byChannel.set(msg.channel, att);
|
|
if (att.pending) {
|
|
att.pending.resolve(att);
|
|
att.pending = null;
|
|
} else if (wasControlling !== msg.controlling) {
|
|
att.sink.controlChanged(msg.controlling);
|
|
}
|
|
return;
|
|
}
|
|
case 'detached': {
|
|
const att = this.byChannel.get(msg.channel);
|
|
if (!att) return;
|
|
this.byChannel.delete(msg.channel);
|
|
att.channel = -1;
|
|
if (msg.reason === 'client') return;
|
|
att.closed = true;
|
|
this.attachments.delete(att);
|
|
att.sink.detached(msg.reason);
|
|
return;
|
|
}
|
|
case 'control_changed': {
|
|
const att = this.byChannel.get(msg.channel);
|
|
if (!att || att.closed) return;
|
|
att.controlling = msg.controlling;
|
|
att.sink.controlChanged(msg.controlling);
|
|
return;
|
|
}
|
|
case 'session_update':
|
|
case 'session_exit':
|
|
this.opts.onSession?.(msg);
|
|
return;
|
|
case 'session_archived':
|
|
this.opts.onSessionArchived?.(msg.sessionId);
|
|
return;
|
|
case 'repo_update':
|
|
case 'repo_removed':
|
|
case 'worktree_update':
|
|
case 'worktree_removed':
|
|
this.opts.onWorktree?.(msg);
|
|
return;
|
|
case 'group_update':
|
|
case 'group_removed':
|
|
this.opts.onGroup?.(msg);
|
|
return;
|
|
case 'error': {
|
|
if (msg.channel !== undefined) {
|
|
this.opts.log?.(`channel ${msg.channel}: ${msg.code}: ${msg.message}`);
|
|
return;
|
|
}
|
|
if ((msg.code === 'NOT_FOUND' || msg.code === 'SESSION_EXITED') && this.awaitingAttached.length > 0) {
|
|
const att = this.awaitingAttached.shift();
|
|
if (!att) return;
|
|
this.attachments.delete(att);
|
|
if (att.pending) {
|
|
att.pending.reject(new Error(msg.code));
|
|
att.pending = null;
|
|
} else if (!att.closed) {
|
|
att.closed = true;
|
|
att.sink.detached('session_exit');
|
|
}
|
|
return;
|
|
}
|
|
this.opts.log?.(`${msg.code}: ${msg.message}`);
|
|
return;
|
|
}
|
|
case 'pong':
|
|
return;
|
|
}
|
|
}
|
|
}
|