/** * Terminal server that manages ttyd instances for tmux sessions. * * Runs alongside Next.js. Spawns a ttyd process per session on demand, * each on a unique port. The dashboard embeds ttyd via iframe. * * ttyd handles all the hard parts: xterm.js, WebSocket, ANSI rendering, * cursor positioning, resize, input — battle-tested and correct. * * TODO: Add authentication middleware to verify: * - User is authenticated * - User owns the requested session * - Rate limiting for terminal access */ import { spawn, type ChildProcess } from "node:child_process"; import { createServer, request } from "node:http"; import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js"; /** Cached full path to tmux binary */ const TMUX = findTmux(); console.log(`[Terminal] Using tmux: ${TMUX}`); interface TtydInstance { sessionId: string; port: number; process: ChildProcess; } const instances = new Map(); const availablePorts = new Set(); // Pool of recycled ports let nextPort = 7800; // Start ttyd instances from port 7800 const MAX_PORT = 7900; // Prevent unbounded port allocation /** * Check if ttyd is ready to accept connections by making a test request. * Returns a promise that resolves when ttyd is ready or rejects after timeout. * Properly cancels pending timeouts and requests to prevent memory leaks. */ function waitForTtyd(port: number, sessionId: string, timeoutMs = 3000): Promise { const startTime = Date.now(); let timeoutId: NodeJS.Timeout | null = null; let pendingReq: ReturnType | null = null; let settled = false; return new Promise((resolve, reject) => { const cleanup = () => { settled = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } if (pendingReq) { pendingReq.destroy(); pendingReq = null; } }; const checkReady = () => { if (settled) return; if (Date.now() - startTime > timeoutMs) { cleanup(); reject(new Error(`ttyd did not become ready within ${timeoutMs}ms`)); return; } const req = request( { hostname: "localhost", port, path: `/${sessionId}/`, method: "GET", timeout: 500, }, (_res) => { // Any response (even 404) means ttyd is listening cleanup(); resolve(); }, ); pendingReq = req; req.on("timeout", () => { if (settled) return; req.destroy(); pendingReq = null; // Schedule retry but track the timeout ID timeoutId = setTimeout(checkReady, 100); }); req.on("error", () => { if (settled) return; pendingReq = null; // Connection refused or other error - ttyd not ready yet, retry timeoutId = setTimeout(checkReady, 100); }); req.end(); }; checkReady(); }); } /** * Spawn or reuse a ttyd instance for a tmux session. * * @param sessionId - User-facing session ID (used for base-path and URL) * @param tmuxSessionName - Actual tmux session name (may be hash-prefixed) */ function getOrSpawnTtyd(sessionId: string, tmuxSessionName: string): TtydInstance { const existing = instances.get(sessionId); if (existing) return existing; // Allocate port: reuse from pool if available, otherwise increment let port: number; if (availablePorts.size > 0) { // Reuse a recycled port port = availablePorts.values().next().value as number; availablePorts.delete(port); } else { // Allocate new port if (nextPort >= MAX_PORT) { throw new Error(`Port exhaustion: reached maximum of ${MAX_PORT - 7800} terminal instances`); } port = nextPort++; } console.log(`[Terminal] Spawning ttyd for ${tmuxSessionName} on port ${port}`); // Enable mouse mode for scrollback support const mouseProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "mouse", "on"]); mouseProc.on("error", (err) => { console.error(`[Terminal] Failed to set mouse mode for ${tmuxSessionName}:`, err.message); }); // Hide the green status bar for cleaner appearance const statusProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "status", "off"]); statusProc.on("error", (err) => { console.error(`[Terminal] Failed to hide status bar for ${tmuxSessionName}:`, err.message); }); // Use user-facing sessionId for base-path (matches URL the dashboard uses) // Use tmuxSessionName for tmux attach (may be hash-prefixed) const proc = spawn( "ttyd", [ "--writable", "--port", String(port), "--base-path", `/${sessionId}`, TMUX, "attach-session", "-t", tmuxSessionName, ], { stdio: ["ignore", "pipe", "pipe"], }, ); proc.stdout?.on("data", (data: Buffer) => { console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`); }); proc.stderr?.on("data", (data: Buffer) => { console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`); }); // Use once() for cleanup handlers to prevent race condition when both exit and error fire proc.once("exit", (code) => { console.log(`[Terminal] ttyd ${sessionId} exited with code ${code}`); // Only delete if this is still the current instance (prevents race with error handler) const current = instances.get(sessionId); if (current?.process === proc) { instances.delete(sessionId); // Only recycle port on clean exit (code 0), not on errors // Failed ttyd processes may leave ports in TIME_WAIT state if (code === 0) { availablePorts.add(port); } } }); proc.once("error", (err) => { console.error(`[Terminal] ttyd ${sessionId} error:`, err.message); // Only delete if this is still the current instance (prevents race with exit handler) const current = instances.get(sessionId); if (current?.process === proc) { instances.delete(sessionId); // Don't recycle port on error - may still be in use or TIME_WAIT } // Kill any running process try { proc.kill(); } catch { // Ignore kill errors if process already dead } }); const instance: TtydInstance = { sessionId, port, process: proc }; instances.set(sessionId, instance); return instance; } // Simple HTTP API for the dashboard to request terminal URLs const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); // CORS for dashboard - allow requests from the same host as the dashboard // TODO: Replace with proper session-based authentication const origin = req.headers.origin; if (origin && origin !== "null") { // Extract hostname from origin and compare with request host try { const originUrl = new URL(origin); const requestHost = req.headers.host; // Allow if origin hostname matches request host (supports remote deployments) if (requestHost && originUrl.hostname === requestHost.split(":")[0]) { res.setHeader("Access-Control-Allow-Origin", origin); } } catch { // Invalid origin URL, don't set CORS header } } else { // Allow null origin (file:// or local HTML files) res.setHeader("Access-Control-Allow-Origin", "*"); } res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } // GET /terminal?session=ao-1 → returns { url, port } if (url.pathname === "/terminal") { const sessionId = url.searchParams.get("session"); if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing session parameter" })); return; } // Validate session ID to prevent path traversal and injection if (!validateSessionId(sessionId)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid session ID" })); return; } // Resolve tmux session name: try exact match first, then suffix match // (hash-prefixed sessions like "8474d6f29887-ao-15" are accessed by user-facing ID "ao-15") const tmuxSessionId = resolveTmuxSession(sessionId, TMUX); if (!tmuxSessionId) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Session not found" })); return; } // Spawn ttyd and wait for it to be ready (catch port exhaustion and startup failures) try { const instance = getOrSpawnTtyd(sessionId, tmuxSessionId); await waitForTtyd(instance.port, sessionId); // Use the request host to construct the terminal URL (supports remote access) const host = req.headers.host ?? "localhost"; const protocol = req.headers["x-forwarded-proto"] ?? "http"; res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ url: `${protocol}://${host.split(":")[0]}:${instance.port}/${sessionId}/`, port: instance.port, sessionId, }), ); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); console.error(`[Terminal] Failed to start terminal for ${sessionId}:`, errorMsg); res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Failed to start terminal" })); } return; } // GET /health if (url.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ instances: Object.fromEntries( [...instances.entries()].map(([id, inst]) => [id, { port: inst.port }]), ), }), ); return; } res.writeHead(404); res.end("Not found"); }); const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10); server.listen(PORT, () => { console.log(`[Terminal] Server listening on port ${PORT}`); }); // Graceful shutdown — kill all ttyd instances function shutdown(signal: string) { console.log(`[Terminal] Received ${signal}, shutting down...`); for (const [, instance] of instances) { instance.process.kill(); } server.close(() => { console.log("[Terminal] Server closed"); process.exit(0); }); // Force exit after 5s if graceful shutdown hangs // Use unref() so this timer doesn't prevent process exit if server closes quickly const forceExitTimer = setTimeout(() => { console.error("[Terminal] Forced shutdown after timeout"); process.exit(1); }, 5000); forceExitTimer.unref(); } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM"));