/** * useTerminalSessions hook * * Manages multiple PTY terminal sessions over a single WebSocket connection * to the /terminal namespace. Supports creating, closing, renaming, and switching * between sessions, with per-session output callback multiplexing. * * Protocol (from terminal.gateway.ts): * 1. Connect with auth token in handshake * 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows } * 3. Emit terminal:input { sessionId, data } to send keystrokes * 4. Receive terminal:output { sessionId, data } for stdout/stderr * 5. Emit terminal:resize { sessionId, cols, rows } on window resize * 6. Emit terminal:close { sessionId } to terminate the PTY * 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit * 8. Receive terminal:error { message } on errors */ import { useEffect, useRef, useState, useCallback } from "react"; import type { Socket } from "socket.io-client"; import { io } from "socket.io-client"; import { API_BASE_URL } from "@/lib/config"; // ========================================== // Types // ========================================== export type SessionStatus = "active" | "exited"; export interface SessionInfo { /** Session identifier returned by the server */ sessionId: string; /** Human-readable tab label */ name: string; /** Whether the PTY process is still running */ status: SessionStatus; /** Exit code, populated when status === 'exited' */ exitCode?: number; } export interface CreateSessionOptions { /** Optional label for the new session */ name?: string; /** Terminal columns */ cols?: number; /** Terminal rows */ rows?: number; /** Working directory */ cwd?: string; } export interface UseTerminalSessionsOptions { /** Authentication token for WebSocket handshake */ token: string; } export interface UseTerminalSessionsReturn { /** Map of sessionId → SessionInfo */ sessions: Map; /** Currently active (visible) session id, or null if none */ activeSessionId: string | null; /** Whether the WebSocket is connected */ isConnected: boolean; /** Connection error message, if any */ connectionError: string | null; /** Create a new PTY session */ createSession: (options?: CreateSessionOptions) => void; /** Close an existing PTY session */ closeSession: (sessionId: string) => void; /** Rename a session (local label only, not persisted to server) */ renameSession: (sessionId: string, name: string) => void; /** Switch the visible session */ setActiveSession: (sessionId: string) => void; /** Send keyboard input to a session */ sendInput: (sessionId: string, data: string) => void; /** Notify the server of a terminal resize */ resize: (sessionId: string, cols: number, rows: number) => void; /** * Register a callback that receives output data for a specific session. * Returns an unsubscribe function — call it during cleanup. */ registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; } // ========================================== // Payload shapes matching terminal.dto.ts // ========================================== interface TerminalCreatedPayload { sessionId: string; name: string; cols: number; rows: number; } interface TerminalOutputPayload { sessionId: string; data: string; } interface TerminalExitPayload { sessionId: string; exitCode: number; signal?: string; } interface TerminalErrorPayload { message: string; } // ========================================== // Security validation // ========================================== function validateWebSocketSecurity(url: string): void { const isProduction = process.env.NODE_ENV === "production"; const isSecure = url.startsWith("https://") || url.startsWith("wss://"); if (isProduction && !isSecure) { console.warn( "[Security Warning] Terminal WebSocket using insecure protocol (ws://). " + "Authentication tokens may be exposed. Use wss:// in production." ); } } // ========================================== // Hook // ========================================== /** * Hook for managing multiple PTY terminal sessions over a single WebSocket connection. * * @param options - Configuration including auth token * @returns Multi-session terminal state and control functions */ export function useTerminalSessions( options: UseTerminalSessionsOptions ): UseTerminalSessionsReturn { const { token } = options; const socketRef = useRef(null); // Per-session output callback registry; keyed by sessionId const outputCallbacksRef = useRef void>>>(new Map()); const [sessions, setSessions] = useState>(new Map()); const [activeSessionId, setActiveSessionIdState] = useState(null); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); // ========================================== // Auto-select first available session when active becomes null // ========================================== useEffect(() => { if (activeSessionId === null && sessions.size > 0) { const firstId = sessions.keys().next().value; if (firstId !== undefined) { setActiveSessionIdState(firstId); } } }, [activeSessionId, sessions]); // ========================================== // WebSocket connection // ========================================== useEffect(() => { if (!token) { return; } const wsUrl = API_BASE_URL; validateWebSocketSecurity(wsUrl); setConnectionError(null); const socket = io(`${wsUrl}/terminal`, { auth: { token }, transports: ["websocket", "polling"], }); socketRef.current = socket; const handleConnect = (): void => { setIsConnected(true); setConnectionError(null); }; const handleDisconnect = (): void => { setIsConnected(false); // Sessions remain in the Map but are no longer interactive setSessions((prev) => { const next = new Map(prev); for (const [id, info] of next) { if (info.status === "active") { next.set(id, { ...info, status: "exited" }); } } return next; }); }; const handleConnectError = (error: Error): void => { setConnectionError(error.message || "Terminal connection failed"); setIsConnected(false); }; const handleTerminalCreated = (payload: TerminalCreatedPayload): void => { setSessions((prev) => { const next = new Map(prev); next.set(payload.sessionId, { sessionId: payload.sessionId, name: payload.name, status: "active", }); return next; }); // Set as active session if none is currently active setActiveSessionIdState((prev) => prev ?? payload.sessionId); }; const handleTerminalOutput = (payload: TerminalOutputPayload): void => { const callbacks = outputCallbacksRef.current.get(payload.sessionId); if (callbacks) { for (const cb of callbacks) { cb(payload.data); } } }; const handleTerminalExit = (payload: TerminalExitPayload): void => { setSessions((prev) => { const next = new Map(prev); const session = next.get(payload.sessionId); if (session) { next.set(payload.sessionId, { ...session, status: "exited", exitCode: payload.exitCode, }); } return next; }); }; const handleTerminalError = (payload: TerminalErrorPayload): void => { console.error("[Terminal] Error:", payload.message); }; socket.on("connect", handleConnect); socket.on("disconnect", handleDisconnect); socket.on("connect_error", handleConnectError); socket.on("terminal:created", handleTerminalCreated); socket.on("terminal:output", handleTerminalOutput); socket.on("terminal:exit", handleTerminalExit); socket.on("terminal:error", handleTerminalError); return (): void => { socket.off("connect", handleConnect); socket.off("disconnect", handleDisconnect); socket.off("connect_error", handleConnectError); socket.off("terminal:created", handleTerminalCreated); socket.off("terminal:output", handleTerminalOutput); socket.off("terminal:exit", handleTerminalExit); socket.off("terminal:error", handleTerminalError); // Close all active sessions before disconnecting const currentSessions = sessions; for (const [id, info] of currentSessions) { if (info.status === "active") { socket.emit("terminal:close", { sessionId: id }); } } socket.disconnect(); socketRef.current = null; }; // Intentional: token is the only dep that should trigger reconnection }, [token]); // ========================================== // Control functions // ========================================== const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => { const socket = socketRef.current; if (!socket?.connected) { return; } const payload: Record = {}; if (createOptions.name !== undefined) payload.name = createOptions.name; if (createOptions.cols !== undefined) payload.cols = createOptions.cols; if (createOptions.rows !== undefined) payload.rows = createOptions.rows; if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd; socket.emit("terminal:create", payload); }, []); const closeSession = useCallback((sessionId: string): void => { const socket = socketRef.current; if (socket?.connected) { socket.emit("terminal:close", { sessionId }); } setSessions((prev) => { const next = new Map(prev); next.delete(sessionId); return next; }); // If closing the active session, activeSessionId becomes null // and the auto-select useEffect will pick the first remaining session setActiveSessionIdState((prev) => (prev === sessionId ? null : prev)); }, []); const renameSession = useCallback((sessionId: string, name: string): void => { setSessions((prev) => { const next = new Map(prev); const session = next.get(sessionId); if (session) { next.set(sessionId, { ...session, name }); } return next; }); }, []); const setActiveSession = useCallback((sessionId: string): void => { setActiveSessionIdState(sessionId); }, []); const sendInput = useCallback((sessionId: string, data: string): void => { const socket = socketRef.current; if (!socket?.connected) { return; } socket.emit("terminal:input", { sessionId, data }); }, []); const resize = useCallback((sessionId: string, cols: number, rows: number): void => { const socket = socketRef.current; if (!socket?.connected) { return; } socket.emit("terminal:resize", { sessionId, cols, rows }); }, []); const registerOutputCallback = useCallback( (sessionId: string, cb: (data: string) => void): (() => void) => { const registry = outputCallbacksRef.current; if (!registry.has(sessionId)) { registry.set(sessionId, new Set()); } // Safe: we just ensured the key exists const callbackSet = registry.get(sessionId); if (callbackSet) { callbackSet.add(cb); } return (): void => { registry.get(sessionId)?.delete(cb); }; }, [] ); return { sessions, activeSessionId, isConnected, connectionError, createSession, closeSession, renameSession, setActiveSession, sendInput, resize, registerOutputCallback, }; }