feat(vscode): extension VS Code native (intégration native, pas un webview)
Nouveau workspace packages/vscode (git-arboretum, privé, non publié sur npm), client REST/WS réutilisant @arboretum/shared. Auth Authorization: Bearer sur REST et l'upgrade WS (via `ws`) ; un client Node sans en-tête Origin passe le check Origin strict du serveur. - Arbres temps réel : Repositories (repos → worktrees → sessions) et Groups, via le WebSocket. - Terminaux natifs (vscode.Pseudoterminal) pour attacher/observer une session — rendu et scrollback de VS Code ; décodage UTF-8 streaming + comptabilité ACK dans des modules purs. - Status bar (compteur waiting) + notifications natives sur passage en waiting, réponses Yes/No via la commande WS answer. - Mutations git (create worktree, commit, push, promote), start/kill/hide/resume/fork, session de groupe ; conscience du workspace (reveal + start/create here). - Bundle esbuild (format cjs, external vscode) inlinant @arboretum/shared → VSIX autonome. Logique réutilisable sans import vscode → testée par vitest (19 tests). - CI : .gitea/workflows/vscode-release.yml package le VSIX sur tag vscode-vX.Y.Z (artefact + asset de release best-effort). build:vscode hors du build principal (comme le site). - spikes/s5-vscode/STUDY.md : décision de conception (GO phasé A→D), marquée implémentée.
This commit is contained in:
173
packages/vscode/src/api/rest-client.ts
Normal file
173
packages/vscode/src/api/rest-client.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// Client REST typé du daemon Arboretum — module pur (pas d'import vscode), testable.
|
||||
// Auth par en-tête `Authorization: Bearer <token>` (le hook preValidation du serveur l'accepte
|
||||
// au même titre que le cookie). Un client Node n'envoie pas d'en-tête Origin → il passe le check
|
||||
// Origin strict côté serveur. Réutilise les types de `@arboretum/shared`.
|
||||
import type {
|
||||
ApiError,
|
||||
CommitWorktreeRequest,
|
||||
CreateGroupSessionRequest,
|
||||
CreateSessionRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateWorktreeResponse,
|
||||
GroupSessionResponse,
|
||||
GroupsListResponse,
|
||||
MeResponse,
|
||||
PromoteWorktreeRequest,
|
||||
PushWorktreeRequest,
|
||||
RepoBranchesResponse,
|
||||
ReposListResponse,
|
||||
SessionResponse,
|
||||
SessionsListResponse,
|
||||
StartRepoSessionRequest,
|
||||
WorktreeResponse,
|
||||
WorktreesListResponse,
|
||||
} from '@arboretum/shared';
|
||||
import { normalizeBaseUrl } from '../config.js';
|
||||
|
||||
/** Erreur normalisée portant le code/HTTP renvoyés par le serveur. */
|
||||
export class RestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RestError';
|
||||
}
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
export class RestClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null;
|
||||
|
||||
constructor(
|
||||
opts: { baseUrl: string; token?: string | null },
|
||||
private readonly fetchImpl: FetchLike = fetch,
|
||||
) {
|
||||
this.baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
this.token = opts.token ?? null;
|
||||
}
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = normalizeBaseUrl(url);
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
/** Valide le token courant ; lève RestError(401) si invalide. */
|
||||
me(): Promise<MeResponse> {
|
||||
return this.request<MeResponse>('GET', '/api/v1/auth/me');
|
||||
}
|
||||
|
||||
// ---- repos & worktrees ----
|
||||
listRepos(): Promise<ReposListResponse> {
|
||||
return this.request<ReposListResponse>('GET', '/api/v1/repos');
|
||||
}
|
||||
|
||||
listWorktrees(): Promise<WorktreesListResponse> {
|
||||
return this.request<WorktreesListResponse>('GET', '/api/v1/worktrees');
|
||||
}
|
||||
|
||||
getBranches(repoId: string): Promise<RepoBranchesResponse> {
|
||||
return this.request<RepoBranchesResponse>('GET', `/api/v1/repos/${encodeURIComponent(repoId)}/branches`);
|
||||
}
|
||||
|
||||
createWorktree(repoId: string, body: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
|
||||
return this.request<CreateWorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees`, body);
|
||||
}
|
||||
|
||||
commitWorktree(repoId: string, body: CommitWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/commit`, body);
|
||||
}
|
||||
|
||||
pushWorktree(repoId: string, body: PushWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body);
|
||||
}
|
||||
|
||||
promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body);
|
||||
}
|
||||
|
||||
startRepoSession(repoId: string, body: StartRepoSessionRequest): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body);
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
|
||||
}
|
||||
|
||||
listSessions(includeHidden = false): Promise<SessionsListResponse> {
|
||||
const q = includeHidden ? '?includeHidden=true' : '';
|
||||
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q}`);
|
||||
}
|
||||
|
||||
resumeSession(id: string): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/resume`, {});
|
||||
}
|
||||
|
||||
forkSession(id: string): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/fork`, {});
|
||||
}
|
||||
|
||||
hideSession(id: string): Promise<{ ok: true }> {
|
||||
return this.request<{ ok: true }>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/hide`, {});
|
||||
}
|
||||
|
||||
killSession(id: string): Promise<{ ok: true }> {
|
||||
return this.request<{ ok: true }>('DELETE', `/api/v1/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// ---- groupes ----
|
||||
listGroups(): Promise<GroupsListResponse> {
|
||||
return this.request<GroupsListResponse>('GET', '/api/v1/groups');
|
||||
}
|
||||
|
||||
createGroupSession(groupId: string, body: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||||
return this.request<GroupSessionResponse>('POST', `/api/v1/groups/${encodeURIComponent(groupId)}/session`, body);
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
||||
} catch (err) {
|
||||
throw new RestError(`Cannot reach Arboretum at ${this.baseUrl}: ${(err as Error).message}`, 0, 'NETWORK');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let code = `HTTP_${res.status}`;
|
||||
let message = `${method} ${path} → ${res.status}`;
|
||||
try {
|
||||
const data = (await res.json()) as ApiError;
|
||||
if (data?.error) {
|
||||
code = data.error.code ?? code;
|
||||
message = data.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* corps non-JSON : on garde le message générique */
|
||||
}
|
||||
throw new RestError(message, res.status, code);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
425
packages/vscode/src/api/ws-client.ts
Normal file
425
packages/vscode/src/api/ws-client.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
// 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;
|
||||
/** 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 '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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/vscode/src/api/ws-socket.ts
Normal file
33
packages/vscode/src/api/ws-socket.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Implémentation par défaut du transport RawSocket, basée sur le paquet `ws` (Node).
|
||||
// L'auth se fait en posant `Authorization: Bearer <token>` dans les en-têtes de l'upgrade HTTP
|
||||
// (un navigateur enverrait le cookie ; un client Node passe le token ici). Isolé du cœur ws-client
|
||||
// pour que celui-ci reste injectable et testable sans réseau.
|
||||
import WebSocket, { type RawData } from 'ws';
|
||||
import type { RawSocket, SocketFactory } from './ws-client.js';
|
||||
|
||||
/** Normalise un RawData (Buffer | ArrayBuffer | Buffer[]) en Uint8Array copiée (offset 0). */
|
||||
function toUint8(data: RawData): Uint8Array {
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
// Buffer (sous-classe de Uint8Array) : copie pour ne pas dépendre du buffer poolé réutilisé par ws.
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
export const wsSocketFactory: SocketFactory = (wsUrl: string, token: string): RawSocket => {
|
||||
const socket = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||
return {
|
||||
send: (d) => socket.send(d),
|
||||
close: () => socket.close(),
|
||||
onOpen: (cb) => socket.on('open', cb),
|
||||
onText: (cb) =>
|
||||
socket.on('message', (data: RawData, isBinary: boolean) => {
|
||||
if (!isBinary) cb(toUint8(data).toString());
|
||||
}),
|
||||
onBinary: (cb) =>
|
||||
socket.on('message', (data: RawData, isBinary: boolean) => {
|
||||
if (isBinary) cb(toUint8(data));
|
||||
}),
|
||||
onClose: (cb) => socket.on('close', () => cb()),
|
||||
onError: (cb) => socket.on('error', (err: Error) => cb(err)),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user