/** * useTerminal hook * * Manages a WebSocket connection to the /terminal namespace and a PTY terminal session. * Follows the same patterns as useVoiceInput and useWebSocket. * * Protocol (from terminal.gateway.ts): * 1. Connect with auth token in handshake * 2. Emit terminal:create → 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 interface CreateSessionOptions { name?: string; cols?: number; rows?: number; cwd?: string; } export interface TerminalSession { sessionId: string; name: string; cols: number; rows: number; } export interface TerminalExitEvent { sessionId: string; exitCode: number; signal?: string; } export interface UseTerminalOptions { /** Authentication token for WebSocket handshake */ token: string; /** Callback fired when terminal output is received */ onOutput?: (sessionId: string, data: string) => void; /** Callback fired when a terminal session exits */ onExit?: (event: TerminalExitEvent) => void; /** Callback fired on terminal errors */ onError?: (message: string) => void; } export interface UseTerminalReturn { /** Whether the WebSocket is connected */ isConnected: boolean; /** The current terminal session ID, or null if no session is active */ sessionId: string | null; /** Create a new PTY session */ createSession: (options?: CreateSessionOptions) => void; /** Send input data to the terminal */ sendInput: (data: string) => void; /** Resize the terminal PTY */ resize: (cols: number, rows: number) => void; /** Close the current PTY session */ closeSession: () => void; /** Connection error message, if any */ connectionError: string | null; } // ========================================== // 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 a real PTY terminal session over WebSocket. * * @param options - Configuration including auth token and event callbacks * @returns Terminal state and control functions */ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { const { token, onOutput, onExit, onError } = options; const [isConnected, setIsConnected] = useState(false); const [sessionId, setSessionId] = useState(null); const [connectionError, setConnectionError] = useState(null); const socketRef = useRef(null); const sessionIdRef = useRef(null); // Keep callbacks in refs to avoid stale closures without causing reconnects const onOutputRef = useRef(onOutput); const onExitRef = useRef(onExit); const onErrorRef = useRef(onError); useEffect(() => { onOutputRef.current = onOutput; }, [onOutput]); useEffect(() => { onExitRef.current = onExit; }, [onExit]); useEffect(() => { onErrorRef.current = onError; }, [onError]); // Connect to the /terminal namespace 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); setSessionId(null); sessionIdRef.current = null; }; const handleConnectError = (error: Error): void => { setConnectionError(error.message || "Terminal connection failed"); setIsConnected(false); }; const handleTerminalCreated = (payload: TerminalCreatedPayload): void => { setSessionId(payload.sessionId); sessionIdRef.current = payload.sessionId; }; const handleTerminalOutput = (payload: TerminalOutputPayload): void => { onOutputRef.current?.(payload.sessionId, payload.data); }; const handleTerminalExit = (payload: TerminalExitPayload): void => { onExitRef.current?.(payload); setSessionId(null); sessionIdRef.current = null; }; const handleTerminalError = (payload: TerminalErrorPayload): void => { onErrorRef.current?.(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 active session before disconnecting const currentSessionId = sessionIdRef.current; if (currentSessionId) { socket.emit("terminal:close", { sessionId: currentSessionId }); } socket.disconnect(); socketRef.current = null; }; }, [token]); 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 sendInput = useCallback((data: string): void => { const socket = socketRef.current; const currentSessionId = sessionIdRef.current; if (!socket?.connected || !currentSessionId) { return; } socket.emit("terminal:input", { sessionId: currentSessionId, data }); }, []); const resize = useCallback((cols: number, rows: number): void => { const socket = socketRef.current; const currentSessionId = sessionIdRef.current; if (!socket?.connected || !currentSessionId) { return; } socket.emit("terminal:resize", { sessionId: currentSessionId, cols, rows }); }, []); const closeSession = useCallback((): void => { const socket = socketRef.current; const currentSessionId = sessionIdRef.current; if (!socket?.connected || !currentSessionId) { return; } socket.emit("terminal:close", { sessionId: currentSessionId }); setSessionId(null); sessionIdRef.current = null; }, []); return { isConnected, sessionId, createSession, sendInput, resize, closeSession, connectionError, }; }