120 lines
2.8 KiB
TypeScript
120 lines
2.8 KiB
TypeScript
/**
|
|
* S-07 — state side-channel route.
|
|
*
|
|
* WS endpoint: GET /sessions/:id/side
|
|
*
|
|
* Pushes IC-1 ServerToClient JSON frames (state, session-meta, error).
|
|
* Does NOT carry binary output (that's /stream).
|
|
* Lightweight channel for UI state updates without full output stream.
|
|
*
|
|
* Owner: T-1.6
|
|
*/
|
|
|
|
import type { IncomingMessage } from "node:http";
|
|
import type { Socket } from "node:net";
|
|
import type { StateEvent } from "../../pi/events.js";
|
|
import { getSession } from "../../tmux/manager.js";
|
|
import type { WsClient, WsServer } from "../types.js";
|
|
|
|
export interface SideRouteOptions {
|
|
wss: WsServer;
|
|
isAuthenticated: (req: IncomingMessage) => boolean;
|
|
getCurrentState?: () => StateEvent | null;
|
|
}
|
|
|
|
// Subscribers per session: sessionId → set of ws clients
|
|
const _subscribers = new Map<string, Set<WsClient>>();
|
|
|
|
/**
|
|
* Broadcast a state event to all side-channel subscribers of a session.
|
|
*/
|
|
export function broadcastState(sessionId: string, event: StateEvent): void {
|
|
const subs = _subscribers.get(sessionId);
|
|
if (!subs) return;
|
|
const msg = JSON.stringify({
|
|
type: "state",
|
|
value: event.value,
|
|
tool: event.tool,
|
|
ts: event.ts,
|
|
});
|
|
for (const ws of subs) {
|
|
if (ws.readyState === 1 /* OPEN */) {
|
|
ws.send(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a WebSocket upgrade for /sessions/:id/side.
|
|
*/
|
|
export function handleSideUpgrade(
|
|
sessionId: string,
|
|
request: IncomingMessage,
|
|
socket: Socket,
|
|
head: Buffer,
|
|
opts: SideRouteOptions,
|
|
): void {
|
|
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
|
handleSideConnection(sessionId, ws, opts);
|
|
});
|
|
}
|
|
|
|
async function handleSideConnection(
|
|
sessionId: string,
|
|
ws: WsClient,
|
|
opts: SideRouteOptions,
|
|
): Promise<void> {
|
|
const session = await getSession(sessionId).catch(() => null);
|
|
if (!session) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
code: "session_not_found",
|
|
message: `Session "${sessionId}" not found`,
|
|
}),
|
|
);
|
|
ws.terminate();
|
|
return;
|
|
}
|
|
|
|
// Push session-meta on connect
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "session-meta",
|
|
name: session.name,
|
|
description: session.description,
|
|
createdAt: session.createdAt,
|
|
}),
|
|
);
|
|
|
|
// Push current state
|
|
const currentState = opts.getCurrentState?.();
|
|
if (currentState) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "state",
|
|
value: currentState.value,
|
|
tool: currentState.tool,
|
|
ts: currentState.ts,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Register subscriber
|
|
let subs = _subscribers.get(sessionId);
|
|
if (!subs) {
|
|
subs = new Set();
|
|
_subscribers.set(sessionId, subs);
|
|
}
|
|
subs.add(ws);
|
|
|
|
ws.on("close", () => {
|
|
subs?.delete(ws);
|
|
if (subs?.size === 0) _subscribers.delete(sessionId);
|
|
});
|
|
|
|
ws.on("error", () => {
|
|
subs?.delete(ws);
|
|
});
|
|
}
|