P1 complete: web front, test suite, CI — acceptance ALL GREEN
Fan-out integration + fixes found by the test/acceptance pass: - FIX ring-buffer: chunks >= capacity skipped bytes now count into the monotonic offset (invariant: stream byte k lives at k % capacity) — window order was corrupted on unaligned big chunks - FIX auth: non-numeric cookie expiry no longer bypasses expiration - FIX protocol: safe-integer validation on ack.bytes / hello.protocol - FIX @fastify/websocket v11: websocket route must be registered in an encapsulated context after plugin load (handler got REST signature) - FIX flow-control deadlock found by e2e acceptance: client only ACKs on data receipt, so pausing with an unACKed residue in (LOW, ACK_EVERY] stalled both sides at 0.9 MB. ACK_EVERY now 64 KiB (<= LOW invariant, tested) + trailing debounced ACK in the web client - Web: Vue 3 + Vite + Pinia + Tailwind 4 + vue-i18n (EN/FR) + xterm 6 (fit + webgl fallback), multiplexed ws-client with reconnect/backoff and resync epochs - Tests: 100 vitest (protocol fuzz, ring edges, auth, pty-manager flow control with mocked pty, REST e2e) ; CI Node 22/24 + pack-smoke - scripts/acceptance-p1.mjs: real daemon + real WS client — boot, login, attach, stdin, 10 MB flood w/ ACK (13.7 MB/1.9s, RSS bounded), brutal disconnect + replay resync, kill broadcast, SIGTERM drain
This commit is contained in:
70
.github/workflows/ci.yml
vendored
Normal file
70
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# CI Arboretum : build + tests sur Node 22/24, puis smoke de packaging npx.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [22, 24]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
- run: npx vitest run
|
||||
|
||||
pack-smoke:
|
||||
name: Pack & boot smoke (Node 22)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
# @arboretum/shared est une dépendance runtime non publiée : on packe les
|
||||
# deux tarballs et on les installe ensemble dans un projet vierge.
|
||||
- name: Pack tarballs
|
||||
run: |
|
||||
mkdir -p /tmp/tarballs
|
||||
npm pack -w @arboretum/shared -w git-arboretum --pack-destination /tmp/tarballs
|
||||
ls -l /tmp/tarballs
|
||||
- name: Install tarballs in an empty project
|
||||
run: |
|
||||
mkdir /tmp/smoke
|
||||
cd /tmp/smoke
|
||||
npm init -y
|
||||
npm i /tmp/tarballs/*.tgz
|
||||
- name: Boot smoke (expect 401 on /api/v1/sessions)
|
||||
run: |
|
||||
cd /tmp/smoke
|
||||
timeout 30 node_modules/.bin/arboretum --port 7999 --db ./t.db &
|
||||
server_pid=$!
|
||||
code=000
|
||||
for i in $(seq 1 20); do
|
||||
sleep 0.5
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7999/api/v1/sessions || true)
|
||||
if [ "$code" = "401" ]; then break; fi
|
||||
done
|
||||
echo "GET /api/v1/sessions -> HTTP $code"
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
test "$code" = "401"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ dist/
|
||||
.DS_Store
|
||||
spikes/**/tmp/
|
||||
spikes/**/captures/
|
||||
packages/server/public/
|
||||
|
||||
1694
package-lock.json
generated
1694
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"scripts": {
|
||||
"build": "npm run build -w @arboretum/shared -w git-arboretum -w @arboretum/web",
|
||||
"typecheck": "tsc -b packages/shared packages/server",
|
||||
"pack": "npm run build && npm pack -w git-arboretum",
|
||||
"test": "vitest run",
|
||||
"dev:server": "npm run dev -w git-arboretum",
|
||||
"dev:web": "npm run dev -w @arboretum/web"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch & node --watch dist/index.js",
|
||||
"prepack": "node scripts/copy-web.mjs",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
167
packages/server/scripts/acceptance-p1.mjs
Normal file
167
packages/server/scripts/acceptance-p1.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P1 (sans navigateur, sans quota Claude) : vrai daemon + vrai client WS.
|
||||
// Couvre : login, hello, sub, spawn bash, attach+replay, stdin/output, flood 10 Mo
|
||||
// avec flow control par ACK + RSS borné, déconnexion/reconnexion+replay, kill, drain.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 7541;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-'));
|
||||
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db')], {
|
||||
env: { ...process.env, ARBORETUM_LOG: 'warn' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
|
||||
try {
|
||||
await sleep(1500);
|
||||
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||
check('boot + token bootstrap', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
check('login → cookie', login.status === 200 && cookie.startsWith('arb_session='));
|
||||
|
||||
// Client WS minimal multiplexé
|
||||
function wsClient() {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const state = { msgs: [], outputs: new Map(), resyncs: new Map(), bytes: new Map() };
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
state.msgs.push(JSON.parse(String(data)));
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.from(data);
|
||||
const type = buf.readUInt8(0);
|
||||
const channel = buf.readUInt32LE(1);
|
||||
const payload = buf.subarray(5);
|
||||
if (type === 0x02) {
|
||||
state.resyncs.set(channel, (state.resyncs.get(channel) ?? 0) + 1);
|
||||
state.bytes.set(channel, 0); // resync : le compteur ACK repart de zéro
|
||||
state.outputs.set(channel, payload.toString('latin1'));
|
||||
} else {
|
||||
state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-300000));
|
||||
const total = (state.bytes.get(channel) ?? 0) + payload.length;
|
||||
state.bytes.set(channel, total);
|
||||
// ACK tous les 64 Kio traités (invariant : pas d'ACK <= LOW_WATERMARK)
|
||||
const acked = state.lastAck?.get(channel) ?? 0;
|
||||
if (total - acked >= 64 * 1024) {
|
||||
(state.lastAck ??= new Map()).set(channel, total);
|
||||
ws.send(JSON.stringify({ type: 'ack', channel, bytes: total }));
|
||||
}
|
||||
}
|
||||
});
|
||||
const waitMsg = async (pred, timeout = 8000) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const m = state.msgs.find(pred);
|
||||
if (m) return m;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||
}
|
||||
|
||||
const c1 = wsClient();
|
||||
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||
c1.send({ type: 'hello', protocol: 1 });
|
||||
check('hello_ok', !!(await c1.waitMsg((m) => m.type === 'hello_ok')));
|
||||
c1.send({ type: 'sub', topics: ['sessions'] });
|
||||
|
||||
// Spawn bash + attach
|
||||
const created = await fetch(`${ORIGIN}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie },
|
||||
body: JSON.stringify({ cwd: tmp, command: 'bash' }),
|
||||
});
|
||||
const sid = (await created.json()).session.id;
|
||||
check('spawn bash', created.status === 201 && !!sid);
|
||||
|
||||
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const att = await c1.waitMsg((m) => m.type === 'attached');
|
||||
check('attach interactif + controlling', att?.controlling === true);
|
||||
const ch = att.channel;
|
||||
|
||||
// stdin → output
|
||||
await sleep(400);
|
||||
c1.send({ type: 'stdin', channel: ch, data: 'echo ACCEPT-$((21*2))\r' });
|
||||
await sleep(800);
|
||||
check('stdin → output', (c1.state.outputs.get(ch) ?? '').includes('ACCEPT-42'));
|
||||
|
||||
// Flood 10 Mo (base64 → ~14 Mo de flux TTY) avec ACK, RSS serveur borné
|
||||
const rssBefore = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1];
|
||||
c1.send({ type: 'stdin', channel: ch, data: 'head -c 10485760 /dev/urandom | base64; echo FLOOD-END-MARKER\r' });
|
||||
const t0 = Date.now();
|
||||
let floodOk = false;
|
||||
let received = 0;
|
||||
while (Date.now() - t0 < 60000) {
|
||||
received = c1.state.bytes.get(ch) ?? 0;
|
||||
if ((c1.state.outputs.get(ch) ?? '').includes('FLOOD-END-MARKER')) {
|
||||
floodOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
const rssAfter = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1];
|
||||
const rssMb = Math.round((Number(rssAfter) - Number(rssBefore)) / 1024);
|
||||
check('flood 10 Mo complet via ACK', floodOk && received > 13_000_000, `${(received / 1048576).toFixed(1)} Mo reçus en ${Date.now() - t0} ms`);
|
||||
check('RSS serveur borné pendant le flood', Number(rssAfter) < 300 * 1024, `Δ ${rssMb} Mo (RSS ${Math.round(Number(rssAfter) / 1024)} Mo)`);
|
||||
|
||||
// Déconnexion brutale → la session survit → reconnexion + replay
|
||||
c1.ws.terminate();
|
||||
await sleep(2000);
|
||||
const c2 = wsClient();
|
||||
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
|
||||
c2.send({ type: 'hello', protocol: 1 });
|
||||
await c2.waitMsg((m) => m.type === 'hello_ok');
|
||||
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 100, rows: 30 });
|
||||
const att2 = await c2.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(500);
|
||||
const replay = c2.state.outputs.get(att2.channel) ?? '';
|
||||
check('reconnexion : session vivante + replay', att2?.controlling === true && c2.state.resyncs.get(att2.channel) === 1 && replay.length > 1000, `replay ${(replay.length / 1024).toFixed(0)} Kio`);
|
||||
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo AFTER-RECONNECT\r' });
|
||||
await sleep(800);
|
||||
check('terminal utilisable après reconnexion', (c2.state.outputs.get(att2.channel) ?? '').includes('AFTER-RECONNECT'));
|
||||
|
||||
// Kill + notification session_exit (sub)
|
||||
c2.send({ type: 'sub', topics: ['sessions'] });
|
||||
await fetch(`${ORIGIN}/api/v1/sessions/${sid}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
const exited = await c2.waitMsg((m) => m.type === 'session_exit' && m.sessionId === sid, 8000);
|
||||
const detached = await c2.waitMsg((m) => m.type === 'detached' && m.reason === 'session_exit', 8000);
|
||||
check('kill → session_exit + detached broadcast', !!exited && !!detached);
|
||||
c2.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P1: ALL GREEN' : `\nACCEPTANCE P1: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
31
packages/server/scripts/copy-web.mjs
Normal file
31
packages/server/scripts/copy-web.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
// Embarque la SPA buildée (packages/web/dist) dans public/ du package server,
|
||||
// pour que le tarball npm soit autonome. Branché sur le hook "prepack".
|
||||
// ARBORETUM_PACK_NO_WEB=1 : mode tolérant (CI/smoke) — placeholder à la place du front.
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const webDist = join(serverDir, '..', 'web', 'dist');
|
||||
const publicDir = join(serverDir, 'public');
|
||||
|
||||
if (!existsSync(webDist)) {
|
||||
if (process.env.ARBORETUM_PACK_NO_WEB === '1') {
|
||||
rmSync(publicDir, { recursive: true, force: true });
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(publicDir, 'index.html'),
|
||||
'<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Arboretum</title></head>\n' +
|
||||
'<body><p>Arboretum server is running, but this package was built without the web UI.</p></body>\n</html>\n',
|
||||
);
|
||||
console.log('copy-web: ARBORETUM_PACK_NO_WEB=1 — wrote placeholder public/index.html');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`copy-web: ${webDist} not found — run npm run build -w @arboretum/web first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(publicDir, { recursive: true, force: true });
|
||||
cpSync(webDist, publicDir, { recursive: true });
|
||||
console.log(`copy-web: copied ${webDist} -> ${publicDir}`);
|
||||
@@ -73,7 +73,11 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager);
|
||||
registerWsGateway(app, manager, serverVersion);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
registerWsGateway(scoped, manager, serverVersion);
|
||||
});
|
||||
|
||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
@@ -69,7 +69,9 @@ export class AuthService {
|
||||
const expected = this.sign(payload);
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const [tokenId, expiresStr] = payload.split('.');
|
||||
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
|
||||
if (!tokenId || !expiresStr) return null;
|
||||
const expires = Number(expiresStr);
|
||||
if (!Number.isFinite(expires) || expires < Date.now()) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
||||
.get(tokenId) as { id: string; label: string } | undefined;
|
||||
|
||||
@@ -244,14 +244,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||
s.exited = { exitCode, signal };
|
||||
if (s.killTimer) clearTimeout(s.killTimer);
|
||||
this.db
|
||||
.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), exitCode, s.id);
|
||||
const endedAt = new Date().toISOString();
|
||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||
for (const c of s.clients) c.onDetached('session_exit');
|
||||
s.clients.clear();
|
||||
this.live.delete(s.id);
|
||||
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited' });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited', endedAt });
|
||||
}
|
||||
|
||||
private summarize(s: ManagedSession): SessionSummary {
|
||||
|
||||
@@ -22,20 +22,23 @@ export class RingBuffer {
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
// Chunk plus grand que la fenêtre : on ne stocke que la queue, mais en comptant
|
||||
// les octets sautés dans l'offset pour préserver l'invariant « l'octet k du flux
|
||||
// est à la position k % capacity » (sinon tail/readFrom rendent la fenêtre désordonnée).
|
||||
if (chunk.length >= this.capacity) {
|
||||
chunk.copy(this.buf, 0, chunk.length - this.capacity);
|
||||
this.length = this.capacity;
|
||||
} else {
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
const skipped = chunk.length - this.capacity;
|
||||
this.total += skipped;
|
||||
chunk = chunk.subarray(skipped);
|
||||
}
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
this.total += chunk.length;
|
||||
}
|
||||
|
||||
|
||||
270
packages/server/test/app.e2e.test.ts
Normal file
270
packages/server/test/app.e2e.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp, type AppBundle } from '../src/app.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
import type { Config } from '../src/config.js';
|
||||
|
||||
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||
class FakePty {
|
||||
pid = 424242;
|
||||
write = vi.fn();
|
||||
resize = vi.fn();
|
||||
pause = vi.fn();
|
||||
resume = vi.fn();
|
||||
kill = vi.fn();
|
||||
onData(_cb: (d: string) => void): { dispose: () => void } {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
onExit(_cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
}
|
||||
return { default: { spawn: (): FakePty => new FakePty() } };
|
||||
});
|
||||
|
||||
process.env.ARBORETUM_LOG = 'silent';
|
||||
|
||||
const PORT = 7317;
|
||||
const ALLOWED_ORIGIN = 'https://arboretum.test-tailnet.example';
|
||||
|
||||
interface TestApp {
|
||||
bundle: AppBundle;
|
||||
db: Db;
|
||||
token: string;
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
const apps: TestApp[] = [];
|
||||
|
||||
function makeApp(name: string): TestApp {
|
||||
const dbPath = join(dir, `${name}.db`);
|
||||
const db = openDb(dbPath);
|
||||
const config: Config = {
|
||||
port: PORT,
|
||||
bind: '127.0.0.1',
|
||||
dbPath,
|
||||
dataDir: dir,
|
||||
allowedOrigins: [ALLOWED_ORIGIN],
|
||||
printToken: false,
|
||||
};
|
||||
const bundle = buildApp(config, db, '0.0.0-test');
|
||||
const token = bundle.auth.ensureBootstrapToken();
|
||||
if (!token) throw new Error('bootstrap token attendu sur une base vierge');
|
||||
const app: TestApp = { bundle, db, token };
|
||||
apps.push(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arboretum-e2e-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const a of apps) await a.bundle.app.close();
|
||||
for (const a of apps) a.db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('app e2e — auth, origin et sessions', () => {
|
||||
let t: TestApp;
|
||||
let cookieValue: string;
|
||||
|
||||
// Le rate limiter du login est GLOBAL à l'app : un seul login réussi ici,
|
||||
// toutes les variantes d'échec vivent dans le describe rate limit (app dédiée).
|
||||
beforeAll(async () => {
|
||||
t = makeApp('main');
|
||||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ ok: true, label: 'initial' });
|
||||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe('Strict');
|
||||
cookieValue = cookie!.value;
|
||||
});
|
||||
|
||||
it('routes API sans auth → 401', async () => {
|
||||
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
||||
const res = await t.bundle.app.inject({ method: 'GET', url });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
|
||||
}
|
||||
const post = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions', payload: { cwd: tmpdir() } });
|
||||
expect(post.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('cookie émis au login → /me identifié', async () => {
|
||||
const me = await t.bundle.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
cookies: { arb_session: cookieValue },
|
||||
});
|
||||
expect(me.statusCode).toBe(200);
|
||||
expect(me.json()).toEqual({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
|
||||
});
|
||||
|
||||
it('cookie altéré → 401', async () => {
|
||||
const tampered = cookieValue.slice(0, -1) + (cookieValue.endsWith('a') ? 'b' : 'a');
|
||||
const me = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', cookies: { arb_session: tampered } });
|
||||
expect(me.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('Bearer token accepté sur les routes API', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ sessions: [] });
|
||||
});
|
||||
|
||||
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}`, origin: 'https://evil.example' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json()).toMatchObject({ error: { code: 'BAD_ORIGIN' } });
|
||||
|
||||
const login = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { origin: 'https://evil.example' },
|
||||
payload: { token: t.token },
|
||||
});
|
||||
expect(login.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('Origins autorisées : loopback du port configuré et --allow-origin', async () => {
|
||||
for (const origin of [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`, ALLOWED_ORIGIN]) {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}`, origin },
|
||||
});
|
||||
expect(res.statusCode, `origin ${origin}`).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /sessions : cwd relatif → 400', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: { cwd: 'relative/path' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } });
|
||||
});
|
||||
|
||||
it('POST /sessions : cwd absent ou non-string → 400', async () => {
|
||||
const res1 = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(res1.statusCode).toBe(400);
|
||||
const res2 = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: { cwd: 42 },
|
||||
});
|
||||
expect(res2.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /sessions : cwd inexistant → 400 SPAWN_FAILED', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: { cwd: '/definitely/not/a/dir' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({ error: { code: 'SPAWN_FAILED' } });
|
||||
});
|
||||
|
||||
it('POST /sessions : command inconnue → 400', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: { cwd: tmpdir(), command: 'zsh' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /sessions valide (bash, pty mocké) → 201 puis listée', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
payload: { cwd: tmpdir(), command: 'bash' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const { session } = res.json() as { session: { id: string; cwd: string; command: string; live: boolean; status: string } };
|
||||
expect(session).toMatchObject({ cwd: tmpdir(), command: 'bash', live: true, status: 'running' });
|
||||
|
||||
const list = await t.bundle.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
});
|
||||
const sessions = (list.json() as { sessions: Array<{ id: string }> }).sessions;
|
||||
expect(sessions.some((s) => s.id === session.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /sessions/:id inconnu → 404', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/sessions/unknown-id',
|
||||
headers: { authorization: `Bearer ${t.token}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('logout → cookie effacé', async () => {
|
||||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/logout', cookies: { arb_session: cookieValue } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cleared = res.cookies.find((c) => c.name === 'arb_session');
|
||||
expect(cleared?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app e2e — rate limit du login', () => {
|
||||
it('mauvais token et bodies invalides → 401, puis 429 dès le 4e essai (backoff après 3 échecs consécutifs)', async () => {
|
||||
const t = makeApp('ratelimit-failures');
|
||||
const bad = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||||
expect(bad.statusCode).toBe(401);
|
||||
expect(bad.json()).toMatchObject({ error: { code: 'BAD_TOKEN' } });
|
||||
const noToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: {} });
|
||||
expect(noToken.statusCode).toBe(401);
|
||||
const numToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 42 } });
|
||||
expect(numToken.statusCode).toBe(401);
|
||||
|
||||
// 3 échecs consécutifs → verrou exponentiel : le backoff bloque AVANT la fenêtre 5/min
|
||||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||||
expect(blocked.statusCode).toBe(429);
|
||||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||||
// même un login avec le BON token est bloqué pendant le backoff
|
||||
const goodButLocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||||
expect(goodButLocked.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('limite 5 tentatives/minute : la 6e → 429 même avec le bon token', async () => {
|
||||
const t = makeApp('ratelimit-window');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||||
expect(res.statusCode, `tentative ${i + 1}`).toBe(200);
|
||||
}
|
||||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||||
expect(blocked.statusCode).toBe(429);
|
||||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||||
});
|
||||
});
|
||||
208
packages/server/test/auth.test.ts
Normal file
208
packages/server/test/auth.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AuthService, LoginRateLimiter, type AuthContext } from '../src/auth/service.js';
|
||||
import { getSetting, openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let db: Db;
|
||||
let auth: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDb(':memory:');
|
||||
auth = new AuthService(db);
|
||||
});
|
||||
|
||||
describe('bootstrap token', () => {
|
||||
it('créé une seule fois : le second appel renvoie null', () => {
|
||||
const token = auth.ensureBootstrapToken();
|
||||
expect(token).toMatch(/^arb_[0-9a-f]{48}$/);
|
||||
expect(auth.ensureBootstrapToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('recréé si tous les tokens sont révoqués', () => {
|
||||
const first = auth.ensureBootstrapToken();
|
||||
expect(first).not.toBeNull();
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
|
||||
const second = auth.ensureBootstrapToken();
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyRawToken', () => {
|
||||
it('token valide → contexte avec label', () => {
|
||||
const raw = auth.ensureBootstrapToken();
|
||||
const ctx = auth.verifyRawToken(raw as string);
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.label).toBe('initial');
|
||||
expect(typeof ctx?.tokenId).toBe('string');
|
||||
});
|
||||
|
||||
it('met à jour last_used_at au passage', () => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
const ctx = auth.verifyRawToken(raw) as AuthContext;
|
||||
const row = db.prepare('SELECT last_used_at FROM auth_tokens WHERE id = ?').get(ctx.tokenId) as { last_used_at: string | null };
|
||||
expect(row.last_used_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('token inconnu, trop court ou trop long → null', () => {
|
||||
auth.ensureBootstrapToken();
|
||||
expect(auth.verifyRawToken('arb_' + 'f'.repeat(48))).toBeNull(); // bon format, mauvaise valeur
|
||||
expect(auth.verifyRawToken('short')).toBeNull(); // < 8 chars
|
||||
expect(auth.verifyRawToken('a'.repeat(129))).toBeNull(); // > 128 chars
|
||||
expect(auth.verifyRawToken('')).toBeNull();
|
||||
});
|
||||
|
||||
it('token révoqué → null', () => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
expect(auth.verifyRawToken(raw)).not.toBeNull();
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
|
||||
expect(auth.verifyRawToken(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it('createToken : deux tokens coexistent avec leurs labels', () => {
|
||||
auth.ensureBootstrapToken();
|
||||
const raw2 = auth.createToken('laptop');
|
||||
expect(auth.verifyRawToken(raw2)?.label).toBe('laptop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookies', () => {
|
||||
let ctx: AuthContext;
|
||||
|
||||
beforeEach(() => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
ctx = auth.verifyRawToken(raw) as AuthContext;
|
||||
});
|
||||
|
||||
// Signe un payload arbitraire avec le vrai secret du serveur (lu en base),
|
||||
// pour forger des cookies à la structure valide mais au contenu choisi.
|
||||
function forge(payload: string): string {
|
||||
const secretHex = getSetting(db, 'server_secret') as string;
|
||||
const sig = createHmac('sha256', Buffer.from(secretHex, 'hex')).update(payload).digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
it('cookie émis puis vérifié → même tokenId', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
const back = auth.verifyCookie(cookie);
|
||||
expect(back).toEqual({ tokenId: ctx.tokenId, label: 'initial' });
|
||||
});
|
||||
|
||||
it('le secret persiste : un second AuthService sur la même base vérifie le cookie', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
const auth2 = new AuthService(db);
|
||||
expect(auth2.verifyCookie(cookie)?.tokenId).toBe(ctx.tokenId);
|
||||
});
|
||||
|
||||
it('cookie altéré (signature ou payload) → null', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
// un caractère de la signature inversé
|
||||
const last = cookie.slice(-1);
|
||||
const tamperedSig = cookie.slice(0, -1) + (last === 'a' ? 'b' : 'a');
|
||||
expect(auth.verifyCookie(tamperedSig)).toBeNull();
|
||||
// expiration repoussée dans le payload sans re-signer
|
||||
const lastDot = cookie.lastIndexOf('.');
|
||||
const [tokenId, expires] = cookie.slice(0, lastDot).split('.') as [string, string];
|
||||
const inflated = `${tokenId}.${Number(expires) + 1_000_000}.${cookie.slice(lastDot + 1)}`;
|
||||
expect(auth.verifyCookie(inflated)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie malformé ou absent → null', () => {
|
||||
expect(auth.verifyCookie(undefined)).toBeNull();
|
||||
expect(auth.verifyCookie('')).toBeNull();
|
||||
expect(auth.verifyCookie('garbage')).toBeNull();
|
||||
expect(auth.verifyCookie('.sig')).toBeNull();
|
||||
expect(auth.verifyCookie('a.b.c.d')).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie expiré forgé avec le vrai secret → null (la forge non expirée passe, preuve que seule l’expiration rejette)', () => {
|
||||
const fresh = forge(`${ctx.tokenId}.${Date.now() + 10_000}`);
|
||||
expect(auth.verifyCookie(fresh)?.tokenId).toBe(ctx.tokenId);
|
||||
const expired = forge(`${ctx.tokenId}.${Date.now() - 1}`);
|
||||
expect(auth.verifyCookie(expired)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie forgé avec tokenId vide → null', () => {
|
||||
expect(auth.verifyCookie(forge(`.${Date.now() + 10_000}`))).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie forgé avec expiration non numérique → null', () => {
|
||||
expect(auth.verifyCookie(forge(`${ctx.tokenId}.notanumber`))).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie valide mais token révoqué entre-temps → null', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), ctx.tokenId);
|
||||
expect(auth.verifyCookie(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie signé pour un tokenId inexistant → null', () => {
|
||||
const cookie = forge(`00000000-0000-0000-0000-000000000000.${Date.now() + 10_000}`);
|
||||
expect(auth.verifyCookie(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginRateLimiter', () => {
|
||||
it('5 tentatives par minute glissante, puis délai renvoyé', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(5, () => t);
|
||||
for (let i = 0; i < 5; i++) expect(lim.check()).toBeNull();
|
||||
expect(lim.check()).toBe(60_000); // toutes les tentatives à t=0
|
||||
t = 59_999;
|
||||
expect(lim.check()).toBe(1); // il reste 1 ms avant que la fenêtre glisse
|
||||
t = 60_000;
|
||||
expect(lim.check()).toBeNull(); // fenêtre vidée
|
||||
});
|
||||
|
||||
it('la fenêtre glisse tentative par tentative', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(2, () => t);
|
||||
expect(lim.check()).toBeNull(); // t=0
|
||||
t = 30_000;
|
||||
expect(lim.check()).toBeNull(); // t=30s
|
||||
t = 40_000;
|
||||
expect(lim.check()).toBe(20_000); // bloqué par la tentative t=0
|
||||
t = 60_000;
|
||||
expect(lim.check()).toBeNull(); // t=0 expirée, t=30s reste
|
||||
});
|
||||
|
||||
it('backoff exponentiel à partir de 3 échecs consécutifs', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t); // plafond haut pour isoler le backoff
|
||||
lim.recordFailure();
|
||||
lim.recordFailure();
|
||||
expect(lim.check()).toBeNull(); // 2 échecs : pas encore de lock
|
||||
lim.recordFailure(); // 3e échec → 30 s
|
||||
expect(lim.check()).toBe(30_000);
|
||||
t = 29_999;
|
||||
expect(lim.check()).toBe(1);
|
||||
t = 30_000;
|
||||
expect(lim.check()).toBeNull(); // lock levé
|
||||
lim.recordFailure(); // 4e échec → 60 s
|
||||
expect(lim.check()).toBe(60_000);
|
||||
t = 90_000;
|
||||
lim.recordFailure(); // 5e échec → 120 s
|
||||
expect(lim.check()).toBe(120_000);
|
||||
});
|
||||
|
||||
it('le backoff est plafonné à 30 minutes', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t);
|
||||
for (let i = 0; i < 20; i++) lim.recordFailure();
|
||||
expect(lim.check()).toBe(30 * 60_000);
|
||||
});
|
||||
|
||||
it('reset complet au succès', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t);
|
||||
for (let i = 0; i < 5; i++) lim.recordFailure();
|
||||
expect(lim.check()).not.toBeNull(); // verrouillé
|
||||
lim.recordSuccess();
|
||||
expect(lim.check()).toBeNull(); // déverrouillé immédiatement
|
||||
lim.recordFailure();
|
||||
lim.recordFailure();
|
||||
expect(lim.check()).toBeNull(); // le compteur d'échecs consécutifs est bien reparti de zéro
|
||||
});
|
||||
});
|
||||
416
packages/server/test/pty-manager.test.ts
Normal file
416
packages/server/test/pty-manager.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
interface FakePty {
|
||||
pid: number;
|
||||
file: string;
|
||||
args: string[];
|
||||
opts: { cwd: string; cols: number; rows: number };
|
||||
write: Mock;
|
||||
resize: Mock;
|
||||
pause: Mock;
|
||||
resume: Mock;
|
||||
emitData(data: string): void;
|
||||
emitExit(exitCode: number, signal?: number): void;
|
||||
}
|
||||
|
||||
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
|
||||
|
||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||
let nextPid = 100_000;
|
||||
class FakePtyImpl {
|
||||
pid = nextPid++;
|
||||
write = vi.fn();
|
||||
resize = vi.fn();
|
||||
pause = vi.fn();
|
||||
resume = vi.fn();
|
||||
kill = vi.fn();
|
||||
private dataCbs: Array<(d: string) => void> = [];
|
||||
private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
|
||||
constructor(
|
||||
readonly file: string,
|
||||
readonly args: string[],
|
||||
readonly opts: unknown,
|
||||
) {}
|
||||
onData(cb: (d: string) => void): { dispose: () => void } {
|
||||
this.dataCbs.push(cb);
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
|
||||
this.exitCbs.push(cb);
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
emitData(d: string): void {
|
||||
for (const cb of this.dataCbs) cb(d);
|
||||
}
|
||||
emitExit(exitCode: number, signal?: number): void {
|
||||
for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
|
||||
}
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
|
||||
const p = new FakePtyImpl(file, args, opts);
|
||||
ptyMock.instances.push(p);
|
||||
return p;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type BindingSpies = ClientBinding & {
|
||||
sendOutput: Mock<(payload: Buffer) => void>;
|
||||
sendResync: Mock<(payload: Buffer) => void>;
|
||||
onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
|
||||
onControlChanged: Mock<(controlling: boolean) => void>;
|
||||
};
|
||||
|
||||
let channelSeq = 1;
|
||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
||||
return {
|
||||
channel: channelSeq++,
|
||||
mode,
|
||||
controlling: false,
|
||||
sentBytes: 0,
|
||||
ackedBytes: 0,
|
||||
lagging: false,
|
||||
sendOutput: vi.fn(),
|
||||
sendResync: vi.fn(),
|
||||
onDetached: vi.fn(),
|
||||
onControlChanged: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function lastPty(): FakePty {
|
||||
return ptyMock.instances.at(-1) as FakePty;
|
||||
}
|
||||
|
||||
describe('PtyManager (pty mocké)', () => {
|
||||
let db: Db;
|
||||
let manager: PtyManager;
|
||||
const cwd = tmpdir();
|
||||
|
||||
beforeEach(() => {
|
||||
ptyMock.instances.length = 0;
|
||||
db = openDb(':memory:');
|
||||
manager = new PtyManager(db);
|
||||
});
|
||||
|
||||
function spawnBash(): { summary: SessionSummary; pty: FakePty } {
|
||||
const summary = manager.spawn({ cwd, command: 'bash' });
|
||||
return { summary, pty: lastPty() };
|
||||
}
|
||||
|
||||
describe('spawn', () => {
|
||||
it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
|
||||
const updates: SessionSummary[] = [];
|
||||
manager.on('session_update', (s) => updates.push(s));
|
||||
const { summary, pty } = spawnBash();
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toEqual(summary);
|
||||
expect(summary.status).toBe('running');
|
||||
expect(summary.live).toBe(true);
|
||||
expect(summary.clients).toBe(0);
|
||||
expect(summary.command).toBe('bash');
|
||||
|
||||
expect(pty.file).toBe('bash');
|
||||
expect(pty.args).toEqual(['--norc']);
|
||||
expect(pty.opts.cwd).toBe(cwd);
|
||||
expect(pty.opts.cols).toBe(120);
|
||||
expect(pty.opts.rows).toBe(32);
|
||||
|
||||
const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
|
||||
expect(row).toEqual({ cwd, command: 'bash' });
|
||||
expect(manager.get(summary.id)?.id).toBe(summary.id);
|
||||
});
|
||||
|
||||
it('cwd inexistant → erreur statusCode 400, rien en base', () => {
|
||||
expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
|
||||
expect(row.n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach', () => {
|
||||
it('session inconnue → NOT_FOUND', () => {
|
||||
const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
|
||||
expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
|
||||
for (const c of chunks) pty.emitData(c);
|
||||
|
||||
const b = makeBinding('interactive');
|
||||
const res = manager.attach(summary.id, b, 80, 24);
|
||||
expect(res).toEqual({ ok: true, controlling: true });
|
||||
|
||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
||||
const payload = b.sendResync.mock.calls[0]![0]!;
|
||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||
// compteurs de flow control remis à zéro après le resync initial
|
||||
expect(b.sentBytes).toBe(0);
|
||||
expect(b.ackedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('ring vide → resync avec payload vide', () => {
|
||||
const { summary } = spawnBash();
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
||||
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
|
||||
});
|
||||
|
||||
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
|
||||
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
|
||||
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
||||
|
||||
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
|
||||
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
||||
|
||||
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
|
||||
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
||||
expect(manager.get(summary.id)?.clients).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write / resize', () => {
|
||||
it('observer read-only : write refusé', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
|
||||
expect(pty.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
|
||||
expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
|
||||
expect(pty.write).toHaveBeenCalledTimes(2);
|
||||
expect(manager.write('nope', a, 'x')).toBe('gone');
|
||||
});
|
||||
|
||||
it('resize ignoré pour les non-contrôleurs', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
pty.resize.mockClear();
|
||||
manager.resize(summary.id, b, 50, 20);
|
||||
expect(pty.resize).not.toHaveBeenCalled();
|
||||
manager.resize(summary.id, a, 90, 28);
|
||||
expect(pty.resize).toHaveBeenCalledWith(90, 28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flow control', () => {
|
||||
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
|
||||
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
|
||||
pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
|
||||
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
|
||||
pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
|
||||
expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas
|
||||
|
||||
pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
|
||||
expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(pty.pause).toHaveBeenCalledTimes(1);
|
||||
expect(pty.resume).not.toHaveBeenCalled();
|
||||
|
||||
manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
|
||||
expect(pty.resume).toHaveBeenCalledTimes(1);
|
||||
expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
|
||||
});
|
||||
|
||||
it('les observers ne freinent jamais le PTY', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
|
||||
const chunk = 'y'.repeat(200 * 1024);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
pty.emitData(chunk);
|
||||
manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
|
||||
}
|
||||
expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('session sans interactif (observers seuls) : jamais de pause', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
pty.emitData('z'.repeat(500 * 1024));
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('client lagging', () => {
|
||||
it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
|
||||
|
||||
pty.emitData(chunks[0]!); // outstanding 1 Mio
|
||||
pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
|
||||
expect(a.lagging).toBe(false);
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(2);
|
||||
|
||||
pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
|
||||
expect(a.lagging).toBe(true);
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
|
||||
// le seul interactif est lagging : le PTY ne doit pas rester en pause
|
||||
expect(pty.resume).toHaveBeenCalled();
|
||||
|
||||
pty.emitData(chunks[3]!); // flux coupé pour le client lagging
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(3);
|
||||
|
||||
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
||||
expect(a.lagging).toBe(false);
|
||||
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
|
||||
const payload = a.sendResync.mock.calls[1]![0]!;
|
||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||
expect(a.sentBytes).toBe(0);
|
||||
expect(a.ackedBytes).toBe(0);
|
||||
|
||||
// le flux reprend normalement après le resync
|
||||
pty.emitData('tail');
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(4);
|
||||
expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detach et transfert de contrôle', () => {
|
||||
it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(a.controlling).toBe(true);
|
||||
expect(b.controlling).toBe(false);
|
||||
|
||||
manager.detach(summary.id, a);
|
||||
expect(b.controlling).toBe(true);
|
||||
expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
|
||||
expect(obs.onControlChanged).not.toHaveBeenCalled();
|
||||
expect(manager.get(summary.id)?.clients).toBe(2);
|
||||
});
|
||||
|
||||
it('detach d’un non-contrôleur : aucun transfert', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
manager.detach(summary.id, b);
|
||||
expect(a.controlling).toBe(true);
|
||||
expect(a.onControlChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
manager.detach(summary.id, a);
|
||||
expect(obs.controlling).toBe(false);
|
||||
expect(obs.onControlChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.detach(summary.id, a);
|
||||
const b = makeBinding('interactive');
|
||||
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('exit', () => {
|
||||
it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
|
||||
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
|
||||
const updates: SessionSummary[] = [];
|
||||
manager.on('session_exit', (e) => exits.push(e));
|
||||
manager.on('session_update', (s) => updates.push(s));
|
||||
|
||||
pty.emitExit(0);
|
||||
|
||||
expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
|
||||
expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
|
||||
|
||||
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });
|
||||
|
||||
const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
|
||||
expect(row.ended_at).not.toBeNull();
|
||||
expect(row.exit_code).toBe(0);
|
||||
|
||||
expect(manager.get(summary.id)).toBeNull();
|
||||
const listed = manager.list().find((s) => s.id === summary.id);
|
||||
expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('exit avec signal : propagé dans session_exit', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
|
||||
manager.on('session_exit', (e) => exits.push(e));
|
||||
pty.emitExit(1, 15);
|
||||
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
|
||||
});
|
||||
|
||||
it('après exit : write → gone, attach → NOT_FOUND', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
pty.emitExit(0);
|
||||
expect(manager.write(summary.id, a, 'x')).toBe('gone');
|
||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/server/test/ring-buffer.test.ts
Normal file
247
packages/server/test/ring-buffer.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RingBuffer } from '../src/core/ring-buffer.js';
|
||||
|
||||
// Modèle de référence naïf : garde tout le flux et recalcule la fenêtre à la demande
|
||||
class RefBuffer {
|
||||
private data = Buffer.alloc(0);
|
||||
constructor(readonly capacity: number) {}
|
||||
write(chunk: Buffer): void {
|
||||
this.data = Buffer.concat([this.data, chunk]);
|
||||
}
|
||||
get total(): number {
|
||||
return this.data.length;
|
||||
}
|
||||
get windowStart(): number {
|
||||
return Math.max(0, this.data.length - this.capacity);
|
||||
}
|
||||
tail(count: number): Buffer {
|
||||
const window = this.data.subarray(this.windowStart);
|
||||
return Buffer.from(window.subarray(Math.max(0, window.length - count)));
|
||||
}
|
||||
readFrom(offset: number): Buffer | null {
|
||||
if (offset > this.total) return null;
|
||||
if (offset === this.total) return Buffer.alloc(0);
|
||||
if (offset < this.windowStart) return null;
|
||||
return Buffer.from(this.data.subarray(offset));
|
||||
}
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const buf = (s: string): Buffer => Buffer.from(s, 'ascii');
|
||||
|
||||
describe('RingBuffer — écriture simple', () => {
|
||||
it('écrit puis relit sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('hello'));
|
||||
expect(ring.totalOffset).toBe(5);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(5).toString()).toBe('hello');
|
||||
expect(ring.tail(3).toString()).toBe('llo');
|
||||
expect(ring.tail(100).toString()).toBe('hello'); // borné à la fenêtre
|
||||
expect(ring.tail(0).length).toBe(0);
|
||||
});
|
||||
|
||||
it('écritures successives sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('def'));
|
||||
expect(ring.totalOffset).toBe(6);
|
||||
expect(ring.tail(6).toString()).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('chunk vide : no-op', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('ab'));
|
||||
ring.write(Buffer.alloc(0));
|
||||
expect(ring.totalOffset).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('ab');
|
||||
});
|
||||
|
||||
it('buffer vierge : tail vide, readFrom(0) vide', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
expect(ring.totalOffset).toBe(0);
|
||||
expect(ring.tail(8).length).toBe(0);
|
||||
expect(ring.readFrom(0)?.length).toBe(0);
|
||||
expect(ring.readFrom(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — wrap-around', () => {
|
||||
it('wrap simple : le chunk chevauche la frontière du buffer', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234')); // pos 0..5
|
||||
ring.write(buf('56789')); // 3 octets en queue, 2 au début
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.windowStart).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
expect(ring.tail(4).toString()).toBe('6789');
|
||||
});
|
||||
|
||||
it('wrap-around multiple : la fenêtre reste les N derniers octets', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
let stream = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const chunk = String.fromCharCode(97 + i).repeat(3); // 'aaa', 'bbb'…
|
||||
ring.write(buf(chunk));
|
||||
stream += chunk;
|
||||
expect(ring.totalOffset).toBe(stream.length);
|
||||
expect(ring.tail(8).toString()).toBe(stream.slice(Math.max(0, stream.length - 8)));
|
||||
}
|
||||
expect(ring.windowStart).toBe(30 - 8);
|
||||
});
|
||||
|
||||
it('écriture qui finit exactement sur la frontière (tailSpace == chunk.length)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcd'));
|
||||
ring.write(buf('efgh')); // remplit exactement jusqu'à la frontière
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
ring.write(buf('ij')); // repart de la position 0
|
||||
expect(ring.tail(8).toString()).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('total multiple exact de la capacité (end === 0)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
ring.write(buf('ijklmnop'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.tail(8).toString()).toBe('ijklmnop');
|
||||
expect(ring.tail(3).toString()).toBe('nop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — chunk >= capacité', () => {
|
||||
it('chunk == capacité sur un ring vierge', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
});
|
||||
|
||||
it('chunk == 2x capacité sur un ring vierge (total reste aligné)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789abcdef'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.windowStart).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(8)?.toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(7)).toBeNull();
|
||||
});
|
||||
|
||||
// Invariant : l'octet k du flux vit à la position k % capacity, même quand un
|
||||
// chunk dépasse la capacité (les octets sautés comptent dans l'offset).
|
||||
it('chunk > capacité non aligné : la fenêtre reste ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789')); // 10 octets, total final 10 % 8 != 0
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('chunk == capacité après un préfixe non aligné : fenêtre ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('ABCDEFGH'));
|
||||
expect(ring.tail(8).toString()).toBe('ABCDEFGH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — readFrom', () => {
|
||||
function filled(): RingBuffer {
|
||||
// total = 10, capacité 8 → fenêtre = offsets [2, 10)
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234'));
|
||||
ring.write(buf('56789'));
|
||||
return ring;
|
||||
}
|
||||
|
||||
it('offset dans la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(5)?.toString()).toBe('56789');
|
||||
expect(ring.readFrom(9)?.toString()).toBe('9');
|
||||
});
|
||||
|
||||
it('offset == windowStart : toute la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(ring.windowStart)?.toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('offset == total : buffer vide (rien de nouveau)', () => {
|
||||
const ring = filled();
|
||||
const out = ring.readFrom(10);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.length).toBe(0);
|
||||
});
|
||||
|
||||
it('offset sorti de fenêtre ou au-delà du total → null', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(1)).toBeNull(); // windowStart - 1
|
||||
expect(ring.readFrom(0)).toBeNull();
|
||||
expect(ring.readFrom(-1)).toBeNull();
|
||||
expect(ring.readFrom(11)).toBeNull(); // total + 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — totalOffset monotone', () => {
|
||||
it('croît exactement de la taille de chaque chunk, jamais ne décroît', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
const rand = mulberry32(0x5eed);
|
||||
let expected = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const size = Math.floor(rand() * 7); // 0..6 (< capacité)
|
||||
ring.write(Buffer.alloc(size, 65 + (i % 26)));
|
||||
expected += size;
|
||||
expect(ring.totalOffset).toBe(expected);
|
||||
expect(ring.windowStart).toBe(Math.max(0, expected - 8));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — équivalence avec le modèle de référence (property test)', () => {
|
||||
it('tail et readFrom identiques au modèle pour 500 écritures aléatoires (chunks < capacité)', () => {
|
||||
const capacity = 64;
|
||||
const ring = new RingBuffer(capacity);
|
||||
const ref = new RefBuffer(capacity);
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const size = Math.floor(rand() * capacity); // 0..63
|
||||
const chunk = Buffer.alloc(size);
|
||||
for (let j = 0; j < size; j++) chunk[j] = Math.floor(rand() * 256);
|
||||
ring.write(chunk);
|
||||
ref.write(chunk);
|
||||
|
||||
expect(ring.totalOffset).toBe(ref.total);
|
||||
expect(ring.windowStart).toBe(ref.windowStart);
|
||||
for (const n of [0, 1, 7, capacity - 1, capacity, capacity + 1, 500]) {
|
||||
expect(ring.tail(n).equals(ref.tail(n)), `tail(${n}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
const offsets = [
|
||||
ref.windowStart - 1,
|
||||
ref.windowStart,
|
||||
ref.windowStart + Math.floor((ref.total - ref.windowStart) / 2),
|
||||
ref.total - 1,
|
||||
ref.total,
|
||||
ref.total + 1,
|
||||
];
|
||||
for (const off of offsets) {
|
||||
const a = ring.readFrom(off);
|
||||
const b = ref.readFrom(off);
|
||||
if (b === null) {
|
||||
expect(a, `readFrom(${off}) après ${i + 1} écritures`).toBeNull();
|
||||
} else {
|
||||
expect(a?.equals(b), `readFrom(${off}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -34,9 +34,15 @@ export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: nu
|
||||
}
|
||||
|
||||
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
|
||||
// INVARIANT anti-deadlock : ACK_EVERY_BYTES <= LOW_WATERMARK. Le client n'ACK
|
||||
// qu'à réception de données ; si le serveur pouvait se mettre en pause avec un
|
||||
// reliquat non-ACKé > LOW mais < pas d'ACK, plus aucun des deux ne progresserait.
|
||||
// Avec ACK_EVERY <= LOW, une fois tout le flux en vol traité, le reliquat est
|
||||
// < LOW et le serveur reprend toujours. (Complété côté client par un ACK
|
||||
// traînant débouncé.)
|
||||
export const FLOW = {
|
||||
/** le client ACK tous les N octets réellement traités par xterm.write */
|
||||
ACK_EVERY_BYTES: 256 * 1024,
|
||||
ACK_EVERY_BYTES: 64 * 1024,
|
||||
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
|
||||
HIGH_WATERMARK: 384 * 1024,
|
||||
/** reprise quand le min repasse sous LOW */
|
||||
@@ -107,11 +113,12 @@ export function parseClientMessage(raw: string): ClientMessage | 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;
|
||||
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 0xffffffff;
|
||||
const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
||||
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 2 && v <= 1000;
|
||||
switch (m.type) {
|
||||
case 'hello':
|
||||
return typeof m.protocol === 'number' ? { type: 'hello', protocol: m.protocol } : null;
|
||||
return isCount(m.protocol) ? { 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 }
|
||||
@@ -127,7 +134,7 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
? { 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
|
||||
return isU32(m.channel) && isCount(m.bytes)
|
||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||
: null;
|
||||
case 'sub':
|
||||
|
||||
21
packages/shared/test/flow-invariants.test.ts
Normal file
21
packages/shared/test/flow-invariants.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FLOW, REPLAY_TAIL_BYTES } from '../src/index.js';
|
||||
|
||||
describe('invariants du flow control', () => {
|
||||
// Le client n'ACK qu'à réception de données : si le serveur pouvait se mettre
|
||||
// en pause avec un reliquat non-ACKé > LOW mais < pas d'ACK, plus rien ne
|
||||
// progresserait (deadlock observé en acceptation P1 avec ACK_EVERY = 256 Kio).
|
||||
it('ACK_EVERY_BYTES <= LOW_WATERMARK (anti-deadlock)', () => {
|
||||
expect(FLOW.ACK_EVERY_BYTES).toBeLessThanOrEqual(FLOW.LOW_WATERMARK);
|
||||
});
|
||||
|
||||
it('LOW < HIGH < LAGGING, tous strictement positifs', () => {
|
||||
expect(FLOW.LOW_WATERMARK).toBeGreaterThan(0);
|
||||
expect(FLOW.HIGH_WATERMARK).toBeGreaterThan(FLOW.LOW_WATERMARK);
|
||||
expect(FLOW.LAGGING_BYTES).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
});
|
||||
|
||||
it('le replay tient dans la fenêtre lagging (resync ne re-déclenche pas lagging)', () => {
|
||||
expect(REPLAY_TAIL_BYTES).toBeLessThan(FLOW.LAGGING_BYTES);
|
||||
});
|
||||
});
|
||||
241
packages/shared/test/protocol.test.ts
Normal file
241
packages/shared/test/protocol.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
decodeBinaryFrame,
|
||||
encodeBinaryFrame,
|
||||
parseClientMessage,
|
||||
type ClientMessage,
|
||||
} from '../src/protocol.js';
|
||||
|
||||
// PRNG déterministe pour le fuzz (reproductible d'un run à l'autre)
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const isU32 = (v: number): boolean => Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
|
||||
const isDim = (v: number): boolean => Number.isInteger(v) && v >= 2 && v <= 1000;
|
||||
|
||||
// Invariants que tout message accepté DOIT respecter, quel que soit l'input
|
||||
function assertInvariants(msg: ClientMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'hello':
|
||||
expect(typeof msg.protocol).toBe('number');
|
||||
break;
|
||||
case 'attach':
|
||||
expect(typeof msg.sessionId).toBe('string');
|
||||
expect(['interactive', 'observer']).toContain(msg.mode);
|
||||
expect(isDim(msg.cols)).toBe(true);
|
||||
expect(isDim(msg.rows)).toBe(true);
|
||||
break;
|
||||
case 'detach':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
break;
|
||||
case 'stdin':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(typeof msg.data).toBe('string');
|
||||
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
||||
break;
|
||||
case 'resize':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(isDim(msg.cols)).toBe(true);
|
||||
expect(isDim(msg.rows)).toBe(true);
|
||||
break;
|
||||
case 'ack':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(typeof msg.bytes).toBe('number');
|
||||
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
||||
break;
|
||||
case 'sub':
|
||||
expect(msg.topics.every((t) => t === 'sessions')).toBe(true);
|
||||
break;
|
||||
case 'ping':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseClientMessage — cas valides', () => {
|
||||
it('hello', () => {
|
||||
expect(parseClientMessage('{"type":"hello","protocol":1}')).toEqual({ type: 'hello', protocol: 1 });
|
||||
});
|
||||
|
||||
it('attach avec dimensions aux bornes', () => {
|
||||
const make = (cols: number, rows: number): string =>
|
||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
|
||||
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 });
|
||||
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 });
|
||||
});
|
||||
|
||||
it('attach en mode observer', () => {
|
||||
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
|
||||
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it('detach avec channel aux bornes u32', () => {
|
||||
expect(parseClientMessage('{"type":"detach","channel":0}')).toEqual({ type: 'detach', channel: 0 });
|
||||
expect(parseClientMessage(`{"type":"detach","channel":${0xffffffff}}`)).toEqual({ type: 'detach', channel: 0xffffffff });
|
||||
});
|
||||
|
||||
it('stdin avec data à la taille max', () => {
|
||||
const data = 'x'.repeat(65536);
|
||||
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data }))).toEqual({ type: 'stdin', channel: 1, data });
|
||||
});
|
||||
|
||||
it('resize', () => {
|
||||
expect(parseClientMessage('{"type":"resize","channel":3,"cols":120,"rows":32}'))
|
||||
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
|
||||
});
|
||||
|
||||
it('ack à zéro octet', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||
});
|
||||
|
||||
it('sub avec topics sessions (et tableau vide accepté)', () => {
|
||||
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
||||
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||
});
|
||||
|
||||
it('ping (champs superflus ignorés)', () => {
|
||||
expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClientMessage — cas malformés', () => {
|
||||
it('JSON invalide ou non-objet', () => {
|
||||
for (const raw of ['', 'not json', '{', '42', '"str"', 'null', 'true', '[]', '[1,2]']) {
|
||||
expect(parseClientMessage(raw)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('type absent, non-string ou inconnu', () => {
|
||||
expect(parseClientMessage('{}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":42}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":null}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"unknown"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('hello sans protocol ou protocol non numérique', () => {
|
||||
expect(parseClientMessage('{"type":"hello"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('attach : dimensions hors bornes ou non entières', () => {
|
||||
const make = (over: Record<string, unknown>): string =>
|
||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
|
||||
expect(parseClientMessage(make({ cols: 1 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ cols: 1001 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ rows: 0 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ rows: 1001 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ cols: 80.5 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ cols: -80 }))).toBeNull();
|
||||
expect(parseClientMessage(make({ mode: 'admin' }))).toBeNull();
|
||||
expect(parseClientMessage(make({ sessionId: 42 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('channel négatif, flottant ou > u32', () => {
|
||||
expect(parseClientMessage('{"type":"detach","channel":-1}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"detach","channel":1.5}')).toBeNull();
|
||||
expect(parseClientMessage(`{"type":"detach","channel":${0x100000000}}`)).toBeNull();
|
||||
expect(parseClientMessage('{"type":"stdin","channel":-1,"data":"x"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"ack","channel":-1,"bytes":0}')).toBeNull();
|
||||
});
|
||||
|
||||
it('stdin : data trop longue ou non-string', () => {
|
||||
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data: 'x'.repeat(65537) }))).toBeNull();
|
||||
expect(parseClientMessage('{"type":"stdin","channel":1,"data":42}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
|
||||
});
|
||||
|
||||
it('ack : bytes négatif ou non numérique', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('sub : topics inconnu ou non-tableau', () => {
|
||||
expect(parseClientMessage('{"type":"sub","topics":["other"]}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"sub","topics":["sessions","other"]}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"sub","topics":"sessions"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClientMessage — fuzz rapide', () => {
|
||||
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
||||
const rand = mulberry32(0xa5b0e7);
|
||||
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||
const values: unknown[] = [
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
|
||||
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
||||
];
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const obj: Record<string, unknown> = { type: pick(types) };
|
||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
|
||||
if (rand() < 0.7) obj[key] = pick(values);
|
||||
}
|
||||
const raw = JSON.stringify(obj);
|
||||
let msg: ClientMessage | null = null;
|
||||
expect(() => {
|
||||
msg = parseClientMessage(raw);
|
||||
}).not.toThrow();
|
||||
if (msg !== null) assertInvariants(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode/decodeBinaryFrame', () => {
|
||||
it('round-trip payload vide', () => {
|
||||
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0, new Uint8Array(0));
|
||||
expect(frame.byteLength).toBe(BINARY_FRAME.HEADER_BYTES);
|
||||
const decoded = decodeBinaryFrame(frame);
|
||||
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
|
||||
expect(decoded.channel).toBe(0);
|
||||
expect(decoded.payload.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trip gros payload (1 Mio) et channel max u32', () => {
|
||||
const rand = mulberry32(0xdeadbe);
|
||||
const payload = new Uint8Array(1024 * 1024);
|
||||
for (let i = 0; i < payload.length; i++) payload[i] = Math.floor(rand() * 256);
|
||||
const frame = encodeBinaryFrame(BINARY_FRAME.RESYNC, 0xffffffff, payload);
|
||||
const decoded = decodeBinaryFrame(frame);
|
||||
expect(decoded.type).toBe(BINARY_FRAME.RESYNC);
|
||||
expect(decoded.channel).toBe(0xffffffff);
|
||||
expect(Buffer.from(decoded.payload).equals(Buffer.from(payload))).toBe(true);
|
||||
});
|
||||
|
||||
it('layout : type u8 puis channel u32 little-endian', () => {
|
||||
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0x01020304, new Uint8Array([0xff]));
|
||||
expect([...frame]).toEqual([0x01, 0x04, 0x03, 0x02, 0x01, 0xff]);
|
||||
});
|
||||
|
||||
it('octets UTF-8 coupés en frontière de frame : le transport préserve les octets bruts', () => {
|
||||
const text = Buffer.from('héllo 😀 wörld', 'utf8');
|
||||
const cut = 8; // coupe au milieu d'une séquence multi-octets
|
||||
const part1 = new Uint8Array(text.subarray(0, cut));
|
||||
const part2 = new Uint8Array(text.subarray(cut));
|
||||
const d1 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part1));
|
||||
const d2 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part2));
|
||||
const reassembled = Buffer.concat([d1.payload, d2.payload]);
|
||||
expect(reassembled.equals(text)).toBe(true);
|
||||
expect(reassembled.toString('utf8')).toBe('héllo 😀 wörld');
|
||||
});
|
||||
|
||||
it('decode sur une vue avec byteOffset non nul (subarray d’un buffer plus large)', () => {
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 42, payload);
|
||||
const padded = new Uint8Array(frame.byteLength + 11);
|
||||
padded.set(frame, 11);
|
||||
const view = padded.subarray(11);
|
||||
const decoded = decodeBinaryFrame(view);
|
||||
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
|
||||
expect(decoded.channel).toBe(42);
|
||||
expect([...decoded.payload]).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
13
packages/web/index.html
Normal file
13
packages/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: #09090b">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Arboretum</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
packages/web/package.json
Normal file
29
packages/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@arboretum/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arboretum/shared": "*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.4"
|
||||
}
|
||||
}
|
||||
20
packages/web/src/App.vue
Normal file
20
packages/web/src/App.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="wsReconnecting"
|
||||
class="border-b border-amber-900/50 bg-amber-950/80 px-4 py-1.5 text-center text-xs text-amber-300"
|
||||
>
|
||||
{{ t('ws.reconnecting') }}
|
||||
</div>
|
||||
<RouterView class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { wsClient } from './lib/ws-client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
||||
</script>
|
||||
22
packages/web/src/components/LanguageSwitcher.vue
Normal file
22
packages/web/src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<select
|
||||
:value="locale"
|
||||
:aria-label="t('common.language')"
|
||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-1.5 py-1 text-xs text-zinc-300 outline-none focus:border-zinc-400"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="en">EN</option>
|
||||
<option value="fr">FR</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { setLocale, type AppLocale } from '../i18n';
|
||||
|
||||
const { locale, t } = useI18n({ useScope: 'global' });
|
||||
|
||||
function onChange(e: Event): void {
|
||||
setLocale((e.target as HTMLSelectElement).value as AppLocale);
|
||||
}
|
||||
</script>
|
||||
118
packages/web/src/components/TerminalView.vue
Normal file
118
packages/web/src/components/TerminalView.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="relative min-h-0 bg-[#09090b]">
|
||||
<div ref="container" class="h-full w-full" />
|
||||
<div
|
||||
v-if="!controlling && !ended"
|
||||
class="pointer-events-none absolute top-2 right-4 rounded bg-zinc-800/90 px-2 py-0.5 text-xs text-amber-300"
|
||||
>
|
||||
{{ t('terminal.observer') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="ended"
|
||||
class="absolute inset-x-0 top-0 bg-red-950/90 px-4 py-2 text-center text-sm text-red-200"
|
||||
>
|
||||
{{ t('terminal.sessionEnded') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
||||
// si controlling), flow control et resync entièrement délégués au ws-client.
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { wsClient, type Attachment } from '../lib/ws-client';
|
||||
|
||||
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||
mode: 'interactive',
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const container = useTemplateRef<HTMLDivElement>('container');
|
||||
const controlling = ref(true);
|
||||
const ended = ref(false);
|
||||
|
||||
let term: Terminal | null = null;
|
||||
let attachment: Attachment | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
if (!container.value) return;
|
||||
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#e4e4e7',
|
||||
selectionBackground: '#3f3f46',
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(container.value);
|
||||
|
||||
try {
|
||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
term.loadAddon(webgl);
|
||||
} catch {
|
||||
// WebGL indisponible : xterm retombe sur le renderer DOM
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
const activeTerm = term;
|
||||
|
||||
try {
|
||||
attachment = await wsClient.attach({
|
||||
sessionId: props.sessionId,
|
||||
mode: props.mode,
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
sink: {
|
||||
write: (data, callback) => activeTerm.write(data, callback),
|
||||
reset: () => activeTerm.reset(),
|
||||
onDetached: (reason) => {
|
||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||
},
|
||||
onControlChanged: (c) => {
|
||||
controlling.value = c;
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// session introuvable ou déjà terminée
|
||||
ended.value = true;
|
||||
controlling.value = false;
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
attachment.detach();
|
||||
return;
|
||||
}
|
||||
controlling.value = attachment.controlling;
|
||||
|
||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||
});
|
||||
resizeObserver.observe(container.value);
|
||||
if (props.mode === 'interactive') activeTerm.focus();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
attachment?.detach();
|
||||
term?.dispose();
|
||||
});
|
||||
</script>
|
||||
49
packages/web/src/i18n/en.ts
Normal file
49
packages/web/src/i18n/en.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: 'Arboretum',
|
||||
loading: 'Loading…',
|
||||
cancel: 'Cancel',
|
||||
logout: 'Log out',
|
||||
language: 'Language',
|
||||
},
|
||||
login: {
|
||||
title: 'Sign in with your access token',
|
||||
tokenLabel: 'Access token',
|
||||
tokenPlaceholder: 'arb_…',
|
||||
submit: 'Sign in',
|
||||
submitting: 'Signing in…',
|
||||
invalidToken: 'Invalid token',
|
||||
rateLimited: 'Too many attempts — please wait before retrying',
|
||||
genericError: 'Sign-in failed ({status})',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Sessions',
|
||||
refresh: 'Refresh',
|
||||
empty: 'No sessions yet — start one below.',
|
||||
newSession: 'New session',
|
||||
cwdLabel: 'Working directory',
|
||||
cwdPlaceholder: '/absolute/path/to/project',
|
||||
commandLabel: 'Command',
|
||||
creating: 'Creating…',
|
||||
open: 'Open',
|
||||
observe: 'Observe',
|
||||
kill: 'Kill',
|
||||
confirmKill: 'Confirm kill',
|
||||
live: 'live',
|
||||
clients: 'no client | 1 client | {n} clients',
|
||||
exitCode: 'exit code {code}',
|
||||
statusLabel: {
|
||||
starting: 'starting',
|
||||
running: 'running',
|
||||
exited: 'exited',
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observer (read-only)',
|
||||
sessionEnded: 'Session ended',
|
||||
back: 'Sessions',
|
||||
},
|
||||
ws: {
|
||||
reconnecting: 'Connection lost — reconnecting…',
|
||||
},
|
||||
};
|
||||
53
packages/web/src/i18n/fr.ts
Normal file
53
packages/web/src/i18n/fr.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type en from './en';
|
||||
|
||||
const fr: typeof en = {
|
||||
common: {
|
||||
appName: 'Arboretum',
|
||||
loading: 'Chargement…',
|
||||
cancel: 'Annuler',
|
||||
logout: 'Se déconnecter',
|
||||
language: 'Langue',
|
||||
},
|
||||
login: {
|
||||
title: 'Connectez-vous avec votre jeton d’accès',
|
||||
tokenLabel: 'Jeton d’accès',
|
||||
tokenPlaceholder: 'arb_…',
|
||||
submit: 'Se connecter',
|
||||
submitting: 'Connexion…',
|
||||
invalidToken: 'Jeton invalide',
|
||||
rateLimited: 'Trop de tentatives — patientez avant de réessayer',
|
||||
genericError: 'Échec de la connexion ({status})',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Sessions',
|
||||
refresh: 'Rafraîchir',
|
||||
empty: 'Aucune session — démarrez-en une ci-dessous.',
|
||||
newSession: 'Nouvelle session',
|
||||
cwdLabel: 'Répertoire de travail',
|
||||
cwdPlaceholder: '/chemin/absolu/du/projet',
|
||||
commandLabel: 'Commande',
|
||||
creating: 'Création…',
|
||||
open: 'Ouvrir',
|
||||
observe: 'Observer',
|
||||
kill: 'Tuer',
|
||||
confirmKill: 'Confirmer',
|
||||
live: 'en cours',
|
||||
clients: 'aucun client | 1 client | {n} clients',
|
||||
exitCode: 'code de sortie {code}',
|
||||
statusLabel: {
|
||||
starting: 'démarrage',
|
||||
running: 'en cours',
|
||||
exited: 'terminée',
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
sessionEnded: 'Session terminée',
|
||||
back: 'Sessions',
|
||||
},
|
||||
ws: {
|
||||
reconnecting: 'Connexion perdue — reconnexion…',
|
||||
},
|
||||
};
|
||||
|
||||
export default fr;
|
||||
24
packages/web/src/i18n/index.ts
Normal file
24
packages/web/src/i18n/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en';
|
||||
import fr from './fr';
|
||||
|
||||
export type AppLocale = 'en' | 'fr';
|
||||
|
||||
const STORAGE_KEY = 'arboretum.locale';
|
||||
|
||||
function initialLocale(): AppLocale {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'fr' || saved === 'en' ? saved : 'en';
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, fr },
|
||||
});
|
||||
|
||||
export function setLocale(locale: AppLocale): void {
|
||||
i18n.global.locale.value = locale;
|
||||
localStorage.setItem(STORAGE_KEY, locale);
|
||||
}
|
||||
38
packages/web/src/lib/api.ts
Normal file
38
packages/web/src/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Mini client REST : JSON, cookies de session, erreurs API normalisées.
|
||||
import type { ApiError as ApiErrorBody } from '@arboretum/shared';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = { method, credentials: 'same-origin' };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'content-type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
if (!res.ok) {
|
||||
let parsed: ApiErrorBody | null = null;
|
||||
try {
|
||||
parsed = (await res.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
// réponse non-JSON : on retombe sur le statusText
|
||||
}
|
||||
throw new ApiError(res.status, parsed?.error.code ?? 'UNKNOWN', parsed?.error.message ?? res.statusText);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||
};
|
||||
370
packages/web/src/lib/ws-client.ts
Normal file
370
packages/web/src/lib/ws-client.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Client WebSocket multiplexé — UNE seule connexion pour toute l'app.
|
||||
// Handshake hello/hello_ok, reconnexion avec backoff (ré-attache des terminaux
|
||||
// ouverts + re-sub), corrélation FIFO des 'attached', flow control par ACK
|
||||
// (cumul d'octets traités PAR CANAL, remis à zéro à chaque RESYNC).
|
||||
import { ref, type Ref } from 'vue';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
FLOW,
|
||||
PROTOCOL_VERSION,
|
||||
decodeBinaryFrame,
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
} from '@arboretum/shared';
|
||||
|
||||
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
|
||||
|
||||
export type DetachReason = 'client' | 'session_exit' | 'replaced';
|
||||
|
||||
/** Interface minimale attendue côté terminal (implémentée par xterm). */
|
||||
export interface TerminalSink {
|
||||
/** le callback est invoqué quand xterm a réellement traité le chunk (base du flow control) */
|
||||
write(data: Uint8Array, callback?: () => void): void;
|
||||
/** reset complet avant le replay d'une frame RESYNC */
|
||||
reset(): void;
|
||||
onDetached(reason: DetachReason): void;
|
||||
onControlChanged(controlling: boolean): void;
|
||||
}
|
||||
|
||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
mode: 'interactive' | 'observer';
|
||||
cols: number;
|
||||
rows: number;
|
||||
sink: TerminalSink;
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 500;
|
||||
const BACKOFF_MAX_MS = 10_000;
|
||||
|
||||
export class Attachment {
|
||||
/** canal courant — réassigné à chaque reconnexion, -1 tant que non attaché */
|
||||
channel = -1;
|
||||
controlling = false;
|
||||
closed = false;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** époque de resync : invalide les callbacks d'écritures encore en vol après un reset */
|
||||
epoch = 0;
|
||||
/** octets de payload réellement traités par le terminal depuis le dernier resync */
|
||||
processedBytes = 0;
|
||||
/** dernier cumul envoyé en ack (repart à 0 à chaque resync, comme côté serveur) */
|
||||
lastAckBytes = 0;
|
||||
/** ACK traînant : garantit l'envoi du reliquat même quand le flux s'arrête (anti-deadlock) */
|
||||
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** résolution du premier 'attached' de ce canal (corrélation FIFO) */
|
||||
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: WsClient,
|
||||
readonly sessionId: string,
|
||||
readonly mode: 'interactive' | 'observer',
|
||||
readonly sink: TerminalSink,
|
||||
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 });
|
||||
}
|
||||
|
||||
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
|
||||
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 class WsClient {
|
||||
readonly status: Ref<WsStatus> = ref('idle');
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private ready = false;
|
||||
private stopped = true;
|
||||
private backoffMs = BACKOFF_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
||||
private readonly attachments = new Set<Attachment>();
|
||||
private readonly byChannel = new Map<number, Attachment>();
|
||||
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
||||
private awaitingAttached: Attachment[] = [];
|
||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
if (this.socket || this.reconnectTimer) return;
|
||||
if (this.status.value === 'idle') this.status.value = 'connecting';
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopped = true;
|
||||
this.clearReconnectTimer();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.ready = false;
|
||||
this.status.value = 'idle';
|
||||
for (const att of this.attachments) {
|
||||
att.closed = true;
|
||||
att.pending?.reject(new Error('disconnected'));
|
||||
att.pending = null;
|
||||
att.sink.onDetached('client');
|
||||
}
|
||||
this.attachments.clear();
|
||||
this.byChannel.clear();
|
||||
this.awaitingAttached = [];
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
|
||||
this.sessionListeners.add(listener);
|
||||
this.connect();
|
||||
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
||||
return () => {
|
||||
this.sessionListeners.delete(listener);
|
||||
if (this.sessionListeners.size === 0) this.sendControl({ type: 'sub', topics: [] });
|
||||
};
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/** détache côté serveur et oublie l'attache (appelé par Attachment.detach) */
|
||||
releaseAttachment(att: Attachment): void {
|
||||
this.attachments.delete(att);
|
||||
if (att.channel >= 0) {
|
||||
this.byChannel.delete(att.channel);
|
||||
this.sendControl({ type: 'detach', channel: att.channel });
|
||||
att.channel = -1;
|
||||
}
|
||||
// si l'attache attend encore son 'attached', elle reste dans la FIFO pour
|
||||
// préserver la corrélation : closed=true la fera détacher à la réception
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
|
||||
private openSocket(): void {
|
||||
const socket = new WebSocket(`${location.origin.replace(/^http/, 'ws')}/ws`);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
|
||||
socket.send(JSON.stringify(hello));
|
||||
};
|
||||
socket.onmessage = (ev: MessageEvent) => this.handleMessage(ev);
|
||||
socket.onclose = () => this.handleClose(socket);
|
||||
socket.onerror = () => {
|
||||
// onclose suit systématiquement : la reconnexion se joue là-bas
|
||||
};
|
||||
}
|
||||
|
||||
private handleClose(socket: WebSocket): 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 (this.stopped) {
|
||||
this.status.value = 'idle';
|
||||
return;
|
||||
}
|
||||
this.status.value = '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 handleMessage(ev: MessageEvent): void {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
this.handleBinary(new Uint8Array(ev.data));
|
||||
return;
|
||||
}
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) 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 après un resync : reset terminal, replay
|
||||
// du ring, et compteurs d'octets remis à zéro (le payload du replay ne compte pas)
|
||||
att.epoch += 1;
|
||||
att.processedBytes = 0;
|
||||
att.lastAckBytes = 0;
|
||||
att.sink.reset();
|
||||
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
|
||||
return;
|
||||
}
|
||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||
|
||||
const epoch = att.epoch;
|
||||
const length = frame.payload.byteLength;
|
||||
att.sink.write(frame.payload, () => {
|
||||
// compté seulement si aucun resync/reconnexion n'est passé entre temps
|
||||
if (att.closed || att.epoch !== epoch || 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 });
|
||||
}
|
||||
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé,
|
||||
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
|
||||
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.status.value = 'open';
|
||||
if (this.sessionListeners.size > 0) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
||||
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
||||
for (const att of this.attachments) {
|
||||
if (!att.closed) this.sendAttach(att);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'attached': {
|
||||
const att = this.awaitingAttached.shift();
|
||||
if (!att) return;
|
||||
if (att.closed) {
|
||||
// détaché pendant l'attente : on libère le canal côté serveur
|
||||
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) {
|
||||
// ré-attache après reconnexion : le statut controlling peut avoir changé
|
||||
att.sink.onControlChanged(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.onDetached(msg.reason);
|
||||
return;
|
||||
}
|
||||
case 'control_changed': {
|
||||
const att = this.byChannel.get(msg.channel);
|
||||
if (!att || att.closed) return;
|
||||
att.controlling = msg.controlling;
|
||||
att.sink.onControlChanged(msg.controlling);
|
||||
return;
|
||||
}
|
||||
case 'session_update':
|
||||
case 'session_exit': {
|
||||
for (const cb of this.sessionListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
// un échec d'attach est la seule erreur sans canal corrélable à une requête
|
||||
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.onDetached('session_exit');
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.warn(`[ws] ${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
case 'pong':
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WsClient();
|
||||
8
packages/web/src/main.ts
Normal file
8
packages/web/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { i18n } from './i18n';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
||||
20
packages/web/src/router/index.ts
Normal file
20
packages/web/src/router/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
||||
{ path: '/', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
});
|
||||
|
||||
// Garde globale : tout sauf /login exige une session valide (GET /api/v1/auth/me, 401 → /login)
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.authenticated === null) await auth.check();
|
||||
if (to.name === 'login') return auth.authenticated ? { name: 'sessions' } : true;
|
||||
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
|
||||
});
|
||||
42
packages/web/src/stores/auth.ts
Normal file
42
packages/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient } from '../lib/ws-client';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
/** null = pas encore vérifié auprès du serveur */
|
||||
const authenticated = ref<boolean | null>(null);
|
||||
const tokenLabel = ref<string | null>(null);
|
||||
const serverVersion = ref<string | null>(null);
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
try {
|
||||
const me = await api.get<MeResponse>('/api/v1/auth/me');
|
||||
tokenLabel.value = me.tokenLabel;
|
||||
serverVersion.value = me.serverVersion;
|
||||
authenticated.value = true;
|
||||
} catch {
|
||||
authenticated.value = false;
|
||||
}
|
||||
return authenticated.value === true;
|
||||
}
|
||||
|
||||
async function login(token: string): Promise<void> {
|
||||
const res = await api.post<LoginResponse>('/api/v1/auth/login', { token });
|
||||
tokenLabel.value = res.label;
|
||||
authenticated.value = true;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.post<{ ok: true }>('/api/v1/auth/logout');
|
||||
} finally {
|
||||
wsClient.disconnect();
|
||||
authenticated.value = false;
|
||||
tokenLabel.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated, tokenLabel, serverVersion, check, login, logout };
|
||||
});
|
||||
76
packages/web/src/stores/sessions.ts
Normal file
76
packages/web/src/stores/sessions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { CreateSessionRequest, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||
|
||||
export const useSessionsStore = defineStore('sessions', () => {
|
||||
const sessions = ref<SessionSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
||||
function sorted(list: SessionSummary[]): SessionSummary[] {
|
||||
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
function upsert(session: SessionSummary): void {
|
||||
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
||||
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
||||
else sessions.value.push(session);
|
||||
sessions.value = sorted(sessions.value);
|
||||
}
|
||||
|
||||
function onEvent(e: SessionEvent): void {
|
||||
if (e.type === 'session_update') {
|
||||
upsert(e.session);
|
||||
return;
|
||||
}
|
||||
const current = sessions.value.find((s) => s.id === e.sessionId);
|
||||
if (!current) return;
|
||||
upsert({
|
||||
...current,
|
||||
status: 'exited',
|
||||
live: false,
|
||||
exitCode: e.exitCode,
|
||||
endedAt: new Date().toISOString(),
|
||||
clients: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSessions(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const res = await api.get<SessionsListResponse>('/api/v1/sessions');
|
||||
sessions.value = sorted(res.sessions);
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
||||
}
|
||||
|
||||
function stopRealtime(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
async function createSession(cwd: string, command: NonNullable<CreateSessionRequest['command']>): Promise<SessionSummary> {
|
||||
const body: CreateSessionRequest = { cwd, command };
|
||||
const res = await api.post<SessionResponse>('/api/v1/sessions', body);
|
||||
upsert(res.session);
|
||||
return res.session;
|
||||
}
|
||||
|
||||
async function killSession(id: string): Promise<void> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||
}
|
||||
|
||||
return { sessions, loading, loadError, fetchSessions, startRealtime, stopRealtime, createSession, killSession };
|
||||
});
|
||||
31
packages/web/src/style.css
Normal file
31
packages/web/src/style.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-zinc-950);
|
||||
color: var(--color-zinc-200);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input {
|
||||
@apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-400;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-red-900 bg-red-950 px-3 py-1.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium;
|
||||
}
|
||||
}
|
||||
64
packages/web/src/views/LoginView.vue
Normal file
64
packages/web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center px-4">
|
||||
<form
|
||||
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900/60 p-6"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('common.appName') }}</h1>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400">{{ t('login.title') }}</p>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('login.tokenLabel') }}
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="t('login.tokenPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary" :disabled="submitting || token.trim() === ''">
|
||||
{{ submitting ? t('login.submitting') : t('login.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const token = ref('');
|
||||
const error = ref<string | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await auth.login(token.value.trim());
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||
await router.replace(redirect);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) error.value = t('login.invalidToken');
|
||||
else if (err instanceof ApiError && err.status === 429) error.value = t('login.rateLimited');
|
||||
else error.value = t('login.genericError', { status: err instanceof ApiError ? err.status : '?' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
40
packages/web/src/views/SessionView.vue
Normal file
40
packages/web/src/views/SessionView.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
||||
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
||||
← {{ t('terminal.back') }}
|
||||
</RouterLink>
|
||||
<span v-if="session" class="badge bg-zinc-800 font-mono text-zinc-300">{{ session.command }}</span>
|
||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||
{{ session?.cwd ?? sessionId }}
|
||||
</span>
|
||||
<div class="ml-auto">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
<TerminalView :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useSessionsStore();
|
||||
|
||||
const sessionId = computed(() => String(route.params.id));
|
||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
|
||||
|
||||
onMounted(() => {
|
||||
// navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande)
|
||||
if (store.sessions.length === 0) void store.fetchSessions();
|
||||
store.startRealtime();
|
||||
});
|
||||
</script>
|
||||
151
packages/web/src/views/SessionsListView.vue
Normal file
151
packages/web/src/views/SessionsListView.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||
<header class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
||||
{{ t('sessions.refresh') }}
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end"
|
||||
@submit.prevent="onCreate"
|
||||
>
|
||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('sessions.cwdLabel') }}
|
||||
<input
|
||||
v-model="newCwd"
|
||||
type="text"
|
||||
class="input font-mono"
|
||||
:placeholder="t('sessions.cwdPlaceholder')"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('sessions.commandLabel') }}
|
||||
<select v-model="newCommand" class="input">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="creating || newCwd.trim() === ''">
|
||||
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
|
||||
|
||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
||||
<p v-else-if="store.loading && store.sessions.length === 0" class="text-sm text-zinc-500">
|
||||
{{ t('common.loading') }}
|
||||
</p>
|
||||
<p v-else-if="store.sessions.length === 0" class="text-sm text-zinc-500">{{ t('sessions.empty') }}</p>
|
||||
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="s in store.sessions"
|
||||
:key="s.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
{{ t('sessions.live') }}
|
||||
</span>
|
||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="truncate font-mono text-sm text-zinc-300" :title="s.cwd">{{ s.cwd }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{{ t(`sessions.statusLabel.${s.status}`) }}</span>
|
||||
<span v-if="s.live">· {{ t('sessions.clients', s.clients) }}</span>
|
||||
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
||||
<div v-if="s.live" class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">
|
||||
{{ t('sessions.open') }}
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }" class="btn">
|
||||
{{ t('sessions.observe') }}
|
||||
</RouterLink>
|
||||
<template v-if="killArmedId === s.id">
|
||||
<button class="btn-danger" :disabled="killing" @click="onConfirmKill(s.id)">
|
||||
{{ t('sessions.confirmKill') }}
|
||||
</button>
|
||||
<button class="btn" @click="killArmedId = null">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn-danger" @click="killArmedId = s.id">{{ t('sessions.kill') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const store = useSessionsStore();
|
||||
|
||||
const newCwd = ref('');
|
||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||
const creating = ref(false);
|
||||
const createError = ref<string | null>(null);
|
||||
const killArmedId = ref<string | null>(null);
|
||||
const killing = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
void store.fetchSessions();
|
||||
store.startRealtime();
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value);
|
||||
}
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
creating.value = true;
|
||||
createError.value = null;
|
||||
try {
|
||||
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
|
||||
newCwd.value = '';
|
||||
await router.push({ name: 'session', params: { id: session.id } });
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmKill(id: string): Promise<void> {
|
||||
killing.value = true;
|
||||
try {
|
||||
await store.killSession(id);
|
||||
} catch {
|
||||
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
|
||||
} finally {
|
||||
killing.value = false;
|
||||
killArmedId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
store.stopRealtime();
|
||||
await auth.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
16
packages/web/tsconfig.json
Normal file
16
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
29
packages/web/vite.config.ts
Normal file
29
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const DAEMON_HTTP = 'http://127.0.0.1:7317';
|
||||
const DAEMON_WS = 'ws://127.0.0.1:7317';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
// L'Origin est réécrite vers celle du daemon : son check Origin strict
|
||||
// rejetterait sinon l'origine du serveur de dev Vite.
|
||||
'/api': {
|
||||
target: DAEMON_HTTP,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => proxyReq.setHeader('origin', DAEMON_HTTP));
|
||||
},
|
||||
},
|
||||
'/ws': {
|
||||
target: DAEMON_WS,
|
||||
ws: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReqWs', (proxyReq) => proxyReq.setHeader('origin', DAEMON_HTTP));
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['packages/*/test/**/*.test.ts'],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user