import { spawn, type IPty } from '@skitee3000/bun-pty' import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config' interface TerminalSession { id: string pty: IPty outputBuffer: string[] maxBufferSize: number clients: Set createdAt: Date } // Store active terminal sessions by ID (persistent across reconnections) const sessions = new Map() // Map WebSocket to sessionId const wsToSession = new Map() function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession { let session = sessions.get(sessionId) if (!session) { console.log(`[Terminal] Creating new session: ${sessionId}`) const pty = spawn(SHELL, SHELL_ARGS, { name: 'xterm-256color', cols: 80, rows: 24, cwd: WORKING_DIR }) session = { id: sessionId, pty, outputBuffer: [], maxBufferSize: MAX_BUFFER_LINES, clients: new Set(), createdAt: new Date() } // Capture output to buffer and send to clients pty.onData((data: string) => { session!.outputBuffer.push(data) if (session!.outputBuffer.length > session!.maxBufferSize) { session!.outputBuffer.shift() } for (const ws of session!.clients) { try { ws.send(JSON.stringify({ type: 'output', data })) } catch { // Client disconnected } } }) // Handle PTY exit pty.onExit(({ exitCode }) => { console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`) for (const ws of session!.clients) { try { ws.send(JSON.stringify({ type: 'exit', data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n` })) } catch { /* ignore */ } } sessions.delete(sessionId) }) sessions.set(sessionId, session) console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`) } return session } export function startTerminalServer() { const server = Bun.serve({ port: PORT_TERMINAL, fetch(req, server) { const url = new URL(req.url) // Health check with session info if (url.pathname === '/health') { const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({ id, clients: s.clients.size, pid: s.pty.pid, bufferSize: s.outputBuffer.length, createdAt: s.createdAt.toISOString() })) return Response.json({ status: 'ok', sessions: sessionsInfo, cwd: WORKING_DIR }) } // List active sessions if (url.pathname === '/sessions') { const list = Array.from(sessions.keys()) return Response.json({ sessions: list }) } // Check if this is a WebSocket upgrade request const upgradeHeader = req.headers.get('upgrade') console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) if (upgradeHeader?.toLowerCase() === 'websocket') { const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID const success = server.upgrade(req, { data: { sessionId } }) console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`) if (success) { return undefined } return new Response('WebSocket upgrade failed', { status: 400 }) } return new Response( 'Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session= - Connect to session', { status: 200 } ) }, websocket: { open(ws) { const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID console.log(`[Terminal] Client connecting to session: ${sessionId}`) try { const session = getOrCreateSession(sessionId) session.clients.add(ws) wsToSession.set(ws, sessionId) // Send connection info ws.send(JSON.stringify({ type: 'connected', sessionId: session.id, isNew: session.outputBuffer.length === 0 })) // Replay buffer if there's history if (session.outputBuffer.length > 0) { console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`) ws.send(JSON.stringify({ type: 'replay', data: session.outputBuffer.join('') })) } console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`) } catch (e: any) { console.error('[Terminal] Error:', e) ws.send(JSON.stringify({ type: 'error', message: e.message })) } }, message(ws, message) { try { const msg = JSON.parse(message as string) const sessionId = wsToSession.get(ws) if (!sessionId) return const session = sessions.get(sessionId) if (!session) return if (msg.type === 'input') { session.pty.write(msg.data) } else if (msg.type === 'resize' && msg.cols && msg.rows) { session.pty.resize(msg.cols, msg.rows) console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`) } } catch (e: any) { console.error('[Terminal] Error:', e) } }, close(ws) { const sessionId = wsToSession.get(ws) if (sessionId) { const session = sessions.get(sessionId) if (session) { session.clients.delete(ws) console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`) // Don't kill PTY - session persists } wsToSession.delete(ws) } } } }) console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`) return server }