P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
157
packages/server/src/ws/gateway.ts
Normal file
157
packages/server/src/ws/gateway.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
PROTOCOL_VERSION,
|
||||
encodeBinaryFrame,
|
||||
parseClientMessage,
|
||||
type ServerMessage,
|
||||
type SessionSummary,
|
||||
} from '@arboretum/shared';
|
||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
interface ChannelState {
|
||||
sessionId: string;
|
||||
binding: ClientBinding;
|
||||
}
|
||||
|
||||
export function registerWsGateway(app: FastifyInstance, manager: PtyManager, serverVersion: string): void {
|
||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
|
||||
const channels = new Map<number, ChannelState>();
|
||||
let nextChannel = 1;
|
||||
let helloDone = false;
|
||||
let subscribedSessions = false;
|
||||
let alive = true;
|
||||
|
||||
const send = (msg: ServerMessage): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
||||
};
|
||||
const sendBinary = (type: number, channel: number, payload: Buffer): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(encodeBinaryFrame(type, channel, payload));
|
||||
};
|
||||
|
||||
const onSessionUpdate = (session: SessionSummary): void => {
|
||||
if (subscribedSessions) send({ type: 'session_update', session });
|
||||
};
|
||||
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
||||
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
||||
};
|
||||
manager.on('session_update', onSessionUpdate);
|
||||
manager.on('session_exit', onSessionExit);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!alive) {
|
||||
socket.terminate();
|
||||
return;
|
||||
}
|
||||
alive = false;
|
||||
socket.ping();
|
||||
}, HEARTBEAT_MS);
|
||||
socket.on('pong', () => {
|
||||
alive = true;
|
||||
});
|
||||
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (isBinary) return; // P1 : aucune frame binaire attendue côté client
|
||||
const msg = parseClientMessage(String(raw));
|
||||
if (!msg) {
|
||||
send({ type: 'error', code: 'BAD_MESSAGE', message: 'Unparseable or invalid message' });
|
||||
return;
|
||||
}
|
||||
if (!helloDone && msg.type !== 'hello') {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: 'Expected hello first' });
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
if (msg.protocol !== PROTOCOL_VERSION) {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: `Server speaks protocol ${PROTOCOL_VERSION}` });
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
helloDone = true;
|
||||
send({ type: 'hello_ok', protocol: PROTOCOL_VERSION, serverVersion });
|
||||
return;
|
||||
}
|
||||
case 'sub': {
|
||||
subscribedSessions = msg.topics.includes('sessions');
|
||||
return;
|
||||
}
|
||||
case 'attach': {
|
||||
const channel = nextChannel++;
|
||||
const binding: ClientBinding = {
|
||||
channel,
|
||||
mode: msg.mode,
|
||||
controlling: false,
|
||||
sentBytes: 0,
|
||||
ackedBytes: 0,
|
||||
lagging: false,
|
||||
sendOutput: (payload) => sendBinary(BINARY_FRAME.OUTPUT, channel, payload),
|
||||
sendResync: (payload) => sendBinary(BINARY_FRAME.RESYNC, channel, payload),
|
||||
onDetached: (reason) => {
|
||||
channels.delete(channel);
|
||||
send({ type: 'detached', channel, reason });
|
||||
},
|
||||
onControlChanged: (controlling) => send({ type: 'control_changed', channel, controlling }),
|
||||
};
|
||||
const res = manager.attach(msg.sessionId, binding, msg.cols, msg.rows);
|
||||
if (!res.ok) {
|
||||
send({ type: 'error', code: res.code, message: `Cannot attach: ${res.code}` });
|
||||
return;
|
||||
}
|
||||
channels.set(channel, { sessionId: msg.sessionId, binding });
|
||||
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
||||
return;
|
||||
}
|
||||
case 'detach': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) return;
|
||||
channels.delete(msg.channel);
|
||||
manager.detach(st.sessionId, st.binding);
|
||||
send({ type: 'detached', channel: msg.channel, reason: 'client' });
|
||||
return;
|
||||
}
|
||||
case 'stdin': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) {
|
||||
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
|
||||
return;
|
||||
}
|
||||
const r = manager.write(st.sessionId, st.binding, msg.data);
|
||||
if (r === 'not_controlling') {
|
||||
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
|
||||
} else if (r === 'gone') {
|
||||
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'resize': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
||||
return;
|
||||
}
|
||||
case 'ack': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.ack(st.sessionId, st.binding, msg.bytes);
|
||||
return;
|
||||
}
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
manager.off('session_update', onSessionUpdate);
|
||||
manager.off('session_exit', onSessionExit);
|
||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||
channels.clear();
|
||||
});
|
||||
|
||||
void req;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user