treesap
Version:
AI Agent Framework
405 lines (336 loc) • 11.9 kB
text/typescript
import { WebSocketServer, WebSocket } from 'ws';
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { TerminalService, type TerminalSession } from './terminal.js';
import type { IncomingMessage } from 'http';
import type { Server } from 'http';
export interface WebSocketClient {
id: string;
ws: WebSocket;
sessionId?: string;
terminalId?: string;
lastPing: Date;
}
export interface WebSocketMessage {
type: 'join' | 'leave' | 'input' | 'resize' | 'ping' | 'pong';
sessionId?: string;
terminalId?: string;
data?: string;
cols?: number;
rows?: number;
timestamp?: number;
}
export class WebSocketTerminalService {
private static wss: WebSocketServer | null = null;
private static clients = new Map<string, WebSocketClient>();
private static sessionClients = new Map<string, Set<string>>(); // sessionId -> Set<clientId>
static initialize(server: Server) {
this.wss = new WebSocketServer({
server,
path: '/terminal/ws'
});
this.wss.on('connection', (ws: WebSocket, request: IncomingMessage) => {
const clientId = uuidv4();
const client: WebSocketClient = {
id: clientId,
ws,
lastPing: new Date()
};
this.clients.set(clientId, client);
console.log(`WebSocket client connected: ${clientId}`);
// Set up message handler
ws.on('message', (data: Buffer) => {
this.handleMessage(clientId, data);
});
// Set up disconnect handler
ws.on('close', () => {
this.handleDisconnect(clientId);
});
// Set up error handler
ws.on('error', (error) => {
console.error(`WebSocket error for client ${clientId}:`, error);
this.handleDisconnect(clientId);
});
// Send initial connection success
this.sendToClient(clientId, {
type: 'pong',
timestamp: Date.now()
});
// Set up ping/pong for connection health
this.setupPingPong(clientId);
});
console.log('WebSocket server initialized for terminal connections');
}
private static setupPingPong(clientId: string) {
const client = this.clients.get(clientId);
if (!client) return;
const pingInterval = setInterval(() => {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.ping();
client.lastPing = new Date();
} else {
clearInterval(pingInterval);
}
}, 30000); // Ping every 30 seconds
client.ws.on('pong', () => {
client.lastPing = new Date();
});
client.ws.on('close', () => {
clearInterval(pingInterval);
});
}
private static handleMessage(clientId: string, data: Buffer) {
try {
const message: WebSocketMessage = JSON.parse(data.toString());
const client = this.clients.get(clientId);
if (!client) {
console.error(`Client ${clientId} not found`);
return;
}
switch (message.type) {
case 'join':
this.handleJoin(clientId, message);
break;
case 'leave':
this.handleLeave(clientId, message);
break;
case 'input':
this.handleInput(clientId, message);
break;
case 'resize':
this.handleResize(clientId, message);
break;
case 'ping':
this.sendToClient(clientId, {
type: 'pong',
timestamp: Date.now()
});
break;
default:
console.warn(`Unknown message type: ${message.type}`);
}
} catch (error) {
console.error(`Error parsing WebSocket message from ${clientId}:`, error);
}
}
private static handleJoin(clientId: string, message: WebSocketMessage) {
const client = this.clients.get(clientId);
if (!client || !message.sessionId) return;
console.log(`Client ${clientId} joining session ${message.sessionId}`);
// Remove client from any existing session
if (client.sessionId) {
this.removeClientFromSession(clientId, client.sessionId);
}
// Get or create terminal session
let session = TerminalService.getSession(message.sessionId);
if (!session) {
console.log(`Creating new terminal session: ${message.sessionId}`);
session = TerminalService.createSession(message.sessionId);
}
// Update client info
client.sessionId = message.sessionId;
client.terminalId = message.terminalId;
// Add client to session tracking
this.addClientToSession(clientId, message.sessionId);
// Set up output listener for this session (if not already set up)
this.setupSessionOutputListener(message.sessionId);
// Send connection confirmation
this.sendToClient(clientId, {
type: 'connected',
sessionId: message.sessionId,
timestamp: Date.now()
});
// Notify all clients in this session about client count
this.broadcastClientCount(message.sessionId);
}
private static handleLeave(clientId: string, message: WebSocketMessage) {
const client = this.clients.get(clientId);
if (!client) return;
console.log(`Client ${clientId} leaving session ${client.sessionId}`);
if (client.sessionId) {
this.removeClientFromSession(clientId, client.sessionId);
this.broadcastClientCount(client.sessionId);
}
client.sessionId = undefined;
client.terminalId = undefined;
}
private static handleInput(clientId: string, message: WebSocketMessage) {
const client = this.clients.get(clientId);
if (!client || !message.sessionId || message.data === undefined) return;;
// Get the terminal session
const session = TerminalService.getSession(message.sessionId);
if (!session) {
console.error(`Session ${message.sessionId} not found`);
this.sendToClient(clientId, {
type: 'error',
data: 'Terminal session not found',
timestamp: Date.now()
});
return;
}
// Send input directly to PTY (raw input like key presses)
try {
session.lastActivity = new Date();
session.process.write(message.data);
} catch (error) {
console.error(`Failed to send input to session ${message.sessionId}:`, error);
this.sendToClient(clientId, {
type: 'error',
data: 'Failed to send input to terminal',
timestamp: Date.now()
});
}
}
private static handleResize(clientId: string, message: WebSocketMessage) {
const client = this.clients.get(clientId);
if (!client || !message.sessionId || message.cols === undefined || message.rows === undefined) return;
// Get the terminal session
const session = TerminalService.getSession(message.sessionId);
if (!session) {
console.error(`Session ${message.sessionId} not found for resize`);
return;
}
// Resize the PTY
try {
console.log(`Resizing terminal session ${message.sessionId} to ${message.cols}x${message.rows}`);
session.process.resize(message.cols, message.rows);
session.lastActivity = new Date();
// Update session dimensions
session.cols = message.cols;
session.rows = message.rows;
} catch (error) {
console.error(`Failed to resize session ${message.sessionId}:`, error);
}
}
private static handleDisconnect(clientId: string) {
const client = this.clients.get(clientId);
console.log(`WebSocket client disconnected: ${clientId}`);
if (client?.sessionId) {
this.removeClientFromSession(clientId, client.sessionId);
this.broadcastClientCount(client.sessionId);
}
this.clients.delete(clientId);
}
private static addClientToSession(clientId: string, sessionId: string) {
if (!this.sessionClients.has(sessionId)) {
this.sessionClients.set(sessionId, new Set());
}
this.sessionClients.get(sessionId)!.add(clientId);
}
private static removeClientFromSession(clientId: string, sessionId: string) {
const clientSet = this.sessionClients.get(sessionId);
if (clientSet) {
clientSet.delete(clientId);
if (clientSet.size === 0) {
this.sessionClients.delete(sessionId);
// Clean up output listener if no clients are connected
this.cleanupSessionOutputListener(sessionId);
}
}
}
private static setupSessionOutputListener(sessionId: string) {
const session = TerminalService.getSession(sessionId);
if (!session) return;
// Check if listener already exists
if (session.eventEmitter.listenerCount('output') > 0) {
return; // Listener already set up
}
const handleOutput = (data: any) => {
this.broadcastToSession(sessionId, data);
};
session.eventEmitter.on('output', handleOutput);
console.log(`Set up output listener for session ${sessionId}`);
}
private static cleanupSessionOutputListener(sessionId: string) {
const session = TerminalService.getSession(sessionId);
if (!session) return;
session.eventEmitter.removeAllListeners('output');
console.log(`Cleaned up output listener for session ${sessionId}`);
}
private static broadcastToSession(sessionId: string, data: any) {
const clientIds = this.sessionClients.get(sessionId);
if (!clientIds) return;
const message = {
...data,
timestamp: Date.now()
};
for (const clientId of clientIds) {
this.sendToClient(clientId, message);
}
}
private static broadcastClientCount(sessionId: string) {
const clientIds = this.sessionClients.get(sessionId);
const count = clientIds ? clientIds.size : 0;
if (clientIds) {
for (const clientId of clientIds) {
this.sendToClient(clientId, {
type: 'clients_count',
count,
timestamp: Date.now()
});
}
}
}
private static sendToClient(clientId: string, message: any) {
const client = this.clients.get(clientId);
if (!client || client.ws.readyState !== WebSocket.OPEN) {
return;
}
try {
client.ws.send(JSON.stringify(message));
} catch (error) {
console.error(`Error sending message to client ${clientId}:`, error);
this.handleDisconnect(clientId);
}
}
// API methods for external control
static getSessionClients(sessionId: string): string[] {
const clientSet = this.sessionClients.get(sessionId);
return clientSet ? Array.from(clientSet) : [];
}
static sendCommandToSession(sessionId: string, command: string): boolean {
// Send command to terminal
const success = TerminalService.executeCommand(sessionId, command);
if (success) {
// The output will be automatically broadcast to all connected clients
// via the output listener
console.log(`Command sent to session ${sessionId}: ${command.substring(0, 50)}...`);
}
return success;
}
static getActiveSessions(): Array<{ sessionId: string; clientCount: number }> {
return Array.from(this.sessionClients.entries()).map(([sessionId, clients]) => ({
sessionId,
clientCount: clients.size
}));
}
static getConnectedClients(): number {
return this.clients.size;
}
static closeSession(sessionId: string) {
const clientIds = this.sessionClients.get(sessionId);
if (clientIds) {
// Notify all clients that the session is closing
for (const clientId of clientIds) {
this.sendToClient(clientId, {
type: 'session_closed',
sessionId,
timestamp: Date.now()
});
}
// Clean up client tracking
this.sessionClients.delete(sessionId);
}
// Clean up the terminal session
TerminalService.destroySession(sessionId);
}
static cleanup() {
if (this.wss) {
console.log('Closing WebSocket server...');
this.wss.close();
this.wss = null;
}
this.clients.clear();
this.sessionClients.clear();
}
}