P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
31
packages/shared/src/api.ts
Normal file
31
packages/shared/src/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Types REST partagés (préfixe /api/v1) — sous-ensemble P1.
|
||||
import type { SessionSummary } from './protocol.js';
|
||||
|
||||
export interface ApiError {
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
token: string;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
ok: true;
|
||||
label: string;
|
||||
}
|
||||
export interface MeResponse {
|
||||
ok: true;
|
||||
tokenLabel: string;
|
||||
serverVersion: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
cwd: string;
|
||||
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
|
||||
command?: 'claude' | 'bash';
|
||||
}
|
||||
export interface SessionsListResponse {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
export interface SessionResponse {
|
||||
session: SessionSummary;
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './protocol.js';
|
||||
export * from './api.js';
|
||||
142
packages/shared/src/protocol.ts
Normal file
142
packages/shared/src/protocol.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// Protocole WebSocket Arboretum — une connexion multiplexée par client.
|
||||
// Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES
|
||||
// (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le
|
||||
// décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport).
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
// ---- Frames binaires ----
|
||||
// Layout : [type u8][channel u32le][payload...]
|
||||
export const BINARY_FRAME = {
|
||||
HEADER_BYTES: 5,
|
||||
/** serveur → client : sortie terminal */
|
||||
OUTPUT: 0x01,
|
||||
/** serveur → client : resync — le client doit reset son terminal avant d'écrire le payload */
|
||||
RESYNC: 0x02,
|
||||
} as const;
|
||||
|
||||
export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array {
|
||||
const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength);
|
||||
const view = new DataView(frame.buffer);
|
||||
view.setUint8(0, type);
|
||||
view.setUint32(1, channel, true);
|
||||
frame.set(payload, BINARY_FRAME.HEADER_BYTES);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
return {
|
||||
type: view.getUint8(0),
|
||||
channel: view.getUint32(1, true),
|
||||
payload: data.subarray(BINARY_FRAME.HEADER_BYTES),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
|
||||
export const FLOW = {
|
||||
/** le client ACK tous les N octets réellement traités par xterm.write */
|
||||
ACK_EVERY_BYTES: 256 * 1024,
|
||||
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
|
||||
HIGH_WATERMARK: 384 * 1024,
|
||||
/** reprise quand le min repasse sous LOW */
|
||||
LOW_WATERMARK: 128 * 1024,
|
||||
/** au-delà : client marqué lagging, flux coupé, resync au rattrapage */
|
||||
LAGGING_BYTES: 2 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */
|
||||
export const REPLAY_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
||||
export type SessionRuntimeStatus =
|
||||
| 'starting'
|
||||
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
|
||||
| 'exited';
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
title: string | null;
|
||||
status: SessionRuntimeStatus;
|
||||
live: boolean;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
exitCode: number | null;
|
||||
clients: number;
|
||||
}
|
||||
|
||||
// ---- Messages client → serveur ----
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; protocol: number }
|
||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
||||
| { type: 'detach'; channel: number }
|
||||
| { type: 'stdin'; channel: number; data: string }
|
||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||
| { type: 'ack'; channel: number; bytes: number }
|
||||
| { type: 'sub'; topics: Array<'sessions'> }
|
||||
| { type: 'ping' };
|
||||
|
||||
// ---- Messages serveur → client ----
|
||||
export type ServerMessage =
|
||||
| { type: 'hello_ok'; protocol: number; serverVersion: string }
|
||||
| { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean }
|
||||
| { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' }
|
||||
| { type: 'control_changed'; channel: number; controlling: boolean }
|
||||
| { type: 'session_update'; session: SessionSummary }
|
||||
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||
| { type: 'pong' };
|
||||
|
||||
export type ErrorCode =
|
||||
| 'BAD_PROTOCOL'
|
||||
| 'BAD_MESSAGE'
|
||||
| 'NOT_FOUND'
|
||||
| 'NOT_ATTACHED'
|
||||
| 'NOT_CONTROLLING'
|
||||
| 'SESSION_EXITED'
|
||||
| 'INTERNAL';
|
||||
|
||||
export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null;
|
||||
const m = obj as Record<string, unknown>;
|
||||
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
|
||||
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 2 && v <= 1000;
|
||||
switch (m.type) {
|
||||
case 'hello':
|
||||
return typeof m.protocol === 'number' ? { type: 'hello', protocol: m.protocol } : null;
|
||||
case 'attach':
|
||||
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
|
||||
: null;
|
||||
case 'detach':
|
||||
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
||||
case 'stdin':
|
||||
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
||||
? { type: 'stdin', channel: m.channel, data: m.data }
|
||||
: null;
|
||||
case 'resize':
|
||||
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||
: null;
|
||||
case 'ack':
|
||||
return isU32(m.channel) && typeof m.bytes === 'number' && m.bytes >= 0
|
||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||
: null;
|
||||
case 'sub':
|
||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions')
|
||||
? { type: 'sub', topics: m.topics as Array<'sessions'> }
|
||||
: null;
|
||||
case 'ping':
|
||||
return { type: 'ping' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user