import express from 'express'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3002; // Middleware app.use(express.json({ limit: '50mb' })); app.use(express.text({ type: '*/*' })); // Terminal sessions storage const terminalSessions = new Map(); let sessionIdCounter = 1; // Get shell based on platform function getShell() { if (process.platform === 'win32') { // Use PowerShell on Windows return 'powershell.exe'; } return process.env.SHELL || '/bin/bash'; } // Create terminal session async function createTerminalSession(cwd, cols = 80, rows = 24) { const pty = await import('node-pty'); const shell = getShell(); // Validate working directory let workingDir = cwd || os.homedir(); try { const fs = await import('fs'); if (!fs.existsSync(workingDir)) { workingDir = os.homedir(); } } catch { workingDir = os.homedir(); } const ptyProcess = pty.spawn(shell, [], { name: 'xterm-256color', cols, rows, cwd: workingDir, env: process.env, }); return ptyProcess; } // Generate session ID function generateSessionId() { return `term-${Date.now()}-${sessionIdCounter++}`; } // API: Create terminal session app.post('/api/terminal/create', async (req, res) => { try { const { cwd, cols, rows } = req.body; const sessionId = generateSessionId(); const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24); const session = { id: sessionId, pty: ptyProcess, cwd: ptyProcess.cwd, cols: cols || 80, rows: rows || 24, createdAt: Date.now(), }; terminalSessions.set(sessionId, session); // Handle PTY data ptyProcess.onData((data) => { // Data will be sent via SSE or WebSocket }); ptyProcess.onExit(({ exitCode, signal }) => { console.log(`Terminal exited: session=${sessionId} code=${exitCode} signal=${signal}`); terminalSessions.delete(sessionId); }); res.json({ sessionId, cols: session.cols, rows: session.rows, capabilities: { input: { preferred: 'http', transports: ['ws', 'http'], ws: { path: '/api/terminal/input-ws', }, }, }, }); } catch (error) { console.error('Failed to create terminal:', error); res.status(500).json({ error: error.message }); } }); // SSE: Terminal output stream app.get('/api/terminal/:sessionId/stream', (req, res) => { const { sessionId } = req.params; const session = terminalSessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Terminal session not found' }); } // Set SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const clientId = Date.now(); const dataHandler = (data) => { res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`); }; const exitHandler = ({ exitCode, signal }) => { res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`); res.end(); }; session.pty.onData(dataHandler); session.pty.onExit(exitHandler); // Send initial connection event res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`); // Handle client disconnect req.on('close', () => { session.pty.offData(dataHandler); session.pty.offExit(exitHandler); console.log(`Client disconnected: session=${sessionId} client=${clientId}`); }); }); // HTTP: Send terminal input app.post('/api/terminal/:sessionId/input', (req, res) => { const { sessionId } = req.params; const session = terminalSessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Terminal session not found' }); } try { const data = req.body; session.pty.write(data); res.status(204).send(); } catch (error) { console.error('Failed to write to terminal:', error); res.status(500).json({ error: 'Failed to write to terminal' }); } }); // HTTP: Resize terminal app.post('/api/terminal/:sessionId/resize', (req, res) => { const { sessionId } = req.params; const session = terminalSessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Terminal session not found' }); } try { const { cols, rows } = req.body; session.pty.resize(cols, rows); session.cols = cols; session.rows = rows; res.status(204).send(); } catch (error) { console.error('Failed to resize terminal:', error); res.status(500).json({ error: 'Failed to resize terminal' }); } }); // HTTP: Close terminal session app.delete('/api/terminal/:sessionId', (req, res) => { const { sessionId } = req.params; const session = terminalSessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Terminal session not found' }); } try { session.pty.kill(); terminalSessions.delete(sessionId); res.status(204).send(); } catch (error) { console.error('Failed to close terminal:', error); res.status(500).json({ error: 'Failed to close terminal' }); } }); // Restart terminal session app.post('/api/terminal/:sessionId/restart', async (req, res) => { const { sessionId } = req.params; const session = terminalSessions.get(sessionId); if (!session) { return res.status(404).json({ error: 'Terminal session not found' }); } try { const { cwd, cols, rows } = req.body; // Kill existing session session.pty.kill(); terminalSessions.delete(sessionId); // Create new session const newSessionId = generateSessionId(); const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24); const newSession = { id: newSessionId, pty: ptyProcess, cwd: ptyProcess.cwd, cols: cols || 80, rows: rows || 24, createdAt: Date.now(), }; terminalSessions.set(newSessionId, newSession); ptyProcess.onExit(({ exitCode, signal }) => { terminalSessions.delete(newSessionId); }); res.json({ sessionId: newSessionId, cols: newSession.cols, rows: newSession.rows, capabilities: { input: { preferred: 'http', transports: ['ws', 'http'], ws: { path: '/api/terminal/input-ws', }, }, }, }); } catch (error) { console.error('Failed to restart terminal:', error); res.status(500).json({ error: 'Failed to restart terminal' }); } }); // Force kill terminal app.post('/api/terminal/force-kill', (req, res) => { const { sessionId, cwd } = req.body; let killedCount = 0; if (sessionId) { const session = terminalSessions.get(sessionId); if (session) { session.pty.kill(); terminalSessions.delete(sessionId); killedCount = 1; } } else if (cwd) { for (const [id, session] of terminalSessions) { if (session.cwd === cwd) { session.pty.kill(); terminalSessions.delete(id); killedCount++; } } } res.json({ killed: killedCount }); }); // WebSocket for terminal input const server = createServer(app); const wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' }); wss.on('connection', (ws, req) => { console.log('WebSocket connected for terminal input'); let boundSessionId = null; ws.on('message', (message) => { try { const data = new Uint8Array(message); // Control frame: JSON if (data[0] === 0x01) { const jsonStr = new TextDecoder().decode(data.slice(1)); const payload = JSON.parse(jsonStr); // Bind session if (payload.t === 'b' && payload.s) { boundSessionId = payload.s; console.log(`WebSocket bound to session: ${boundSessionId}`); } // Ping if (payload.t === 'p') { ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))])); } return; } // Regular input data const text = new TextDecoder().decode(message); if (boundSessionId) { const session = terminalSessions.get(boundSessionId); if (session) { session.pty.write(text); } } } catch (error) { console.error('WebSocket message error:', error); } }); ws.on('close', () => { console.log('WebSocket disconnected'); }); }); server.listen(PORT, () => { console.log(`Terminal server running on http://localhost:${PORT}`); });