import type { ServerWebSocket } from 'bun'; import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry'; import type { PtyInitConfig } from '../../sidecar/protocol'; type WSData = { userId: number; email: string; username: string; sessionId?: string; cwd?: string; cols?: number; rows?: number; }; type BridgeSession = { client: ServerWebSocket; sessionId: string; unsubs: Array<() => void>; }; const sessions = new Map, BridgeSession>(); let idCounter = 0; function nextId(): string { return `pty_${Date.now()}_${++idCounter}`; } const sendOutput = (ws: ServerWebSocket, data: string) => { try { ws.send(JSON.stringify({ type: 'output', data })); } catch { // ws already closed } }; export const terminalWebsocket = { async open(ws: ServerWebSocket) { const { email, username } = ws.data; console.log(`[terminal] open: email=${email} username=${username}`); if (!isTerminalConnected()) { sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n'); return; } const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`; // Everything this bridge knows: which session, which folder the panel was opened on, and how big the // client's viewport is. The shell, its arguments and the home directory are the sidecar's — it is the // process that spawns them, and officer has no business reading the owner's SHELL and HOME to guess. const config: PtyInitConfig = { sessionId, cwd: ws.data.cwd, cols: ws.data.cols, rows: ws.data.rows }; // The sidecar emits one global stream, so each frame is filtered down to this session and relabelled. const relay = (event: 'pty:output' | 'pty:replay' | 'pty:exit', clientType: string) => on(event, (msg) => { if (msg.type !== event || msg.sessionId !== sessionId) return; try { ws.send(JSON.stringify({ type: clientType, data: 'data' in msg ? msg.data : undefined })); } catch { // ws already closed } }); const session: BridgeSession = { client: ws, sessionId, unsubs: [relay('pty:output', 'output'), relay('pty:replay', 'replay'), relay('pty:exit', 'exit')], }; sessions.set(ws, session); // Send init command to PTY sidecar try { await sendPtyCommandAsync({ type: 'pty:init', id: nextId(), sessionId, config }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to initialize terminal'; sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); for (const unsub of session.unsubs) unsub(); sessions.delete(ws); } }, message(ws: ServerWebSocket, raw: string | Buffer) { const session = sessions.get(ws); if (!session) return; try { const payload = typeof raw === 'string' ? raw : raw.toString(); const msg = JSON.parse(payload); switch (msg.type) { case 'input': sendPtyCommand({ type: 'pty:input', id: nextId(), sessionId: session.sessionId, data: msg.data ?? '' }); break; case 'resize': if (msg.cols > 0 && msg.rows > 0) { sendPtyCommand({ type: 'pty:resize', id: nextId(), sessionId: session.sessionId, cols: msg.cols, rows: msg.rows, }); } break; // There was a 'cwd' case here that typed `cd \r` into the user's shell. No frontend sends // that message — the browser composes its own `cd` (Terminal.tsx / CommandTerminalWrapper.tsx) — // so it was unreachable, and synthesizing keystrokes is not a thing a proxy should do. } } catch { // ignore malformed messages } }, close(ws: ServerWebSocket) { const session = sessions.get(ws); if (session) { for (const unsub of session.unsubs) unsub(); // Don't kill PTY — it can be reattached sessions.delete(ws); } }, drain() {}, }; export const broadcastPanelRefresh = (email: string) => { const msg = JSON.stringify({ type: 'panel-refresh' }); for (const [ws] of sessions) { if (ws.data.email === email) { try { ws.send(msg); } catch { /* ignore */ } } } };