380 lines
9.7 KiB
JavaScript
380 lines
9.7 KiB
JavaScript
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 = parseInt(process.env.PORT || '3002', 10);
|
|
const PORT_FILE = '.terminal-server-port';
|
|
|
|
// CORS middleware
|
|
app.use((req, res, next) => {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
|
|
if (req.method === 'OPTIONS') {
|
|
return res.status(204).send();
|
|
}
|
|
next();
|
|
});
|
|
|
|
// 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.removeListener('data', dataHandler);
|
|
session.pty.removeListener('exit', 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');
|
|
});
|
|
});
|
|
|
|
async function findAvailablePort(startPort) {
|
|
const net = await import('net');
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.listen(startPort, () => {
|
|
const port = server.address().port;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.on('error', () => {
|
|
resolve(findAvailablePort(startPort + 1));
|
|
});
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
const fs = await import('fs');
|
|
const availablePort = await findAvailablePort(PORT);
|
|
|
|
server.listen(availablePort, () => {
|
|
console.log(`Terminal server running on http://localhost:${availablePort}`);
|
|
fs.writeFileSync(PORT_FILE, String(availablePort));
|
|
});
|
|
|
|
process.on('exit', () => {
|
|
try {
|
|
fs.unlinkSync(PORT_FILE);
|
|
} catch {}
|
|
});
|
|
})();
|