feat(web): integrate xterm.js with WebSocket terminal backend (#518)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
2026-02-26 02:55:53 +00:00
committed by jason.woltje
parent 8128eb7fbe
commit 417c6ab49c
9 changed files with 1694 additions and 100 deletions

View File

@@ -0,0 +1,294 @@
/**
* 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<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const sessionIdRef = useRef<string | null>(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<string, unknown> = {};
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,
};
}