pi-remote-control/extensions/remote-control/server/routes/side.ts

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);
});
}