feat: implement HTTP polling to bypass WebSocket issue
The WebSocket closes immediately with code 1006 due to nginx/proxy issues. This implementation completely bypasses WebSocket for terminal output by using HTTP polling instead. Architecture: - Server buffers PTY output in memory (outputBuffer array) - Frontend polls every 100ms via GET /terminals/:id/output?since=N - Output entries have monotonically increasing index - Old output is automatically cleaned up after 5 minutes - Commands sent via HTTP POST (already implemented) Changes: - terminal-service.js: Added bufferOutput(), getTerminalOutput() - terminal.js: Added startPolling(), stopPolling(), sendTerminalResize() - server.js: Added GET /terminals/:id/output and POST /terminals/:id/resize - No WebSocket needed for output display (keeps legacy compatibility) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Terminal Service - Manages PTY processes and WebSocket connections
|
||||
* Terminal Service - Manages PTY processes with HTTP polling for output
|
||||
* This version uses HTTP polling instead of WebSocket to bypass nginx issues
|
||||
*/
|
||||
|
||||
const { spawn } = require('node-pty');
|
||||
@@ -9,14 +10,16 @@ const path = require('path');
|
||||
|
||||
class TerminalService {
|
||||
constructor() {
|
||||
this.terminals = new Map(); // terminalId -> { pty, ws, sessionId, workingDir, mode, createdAt }
|
||||
this.terminals = new Map(); // terminalId -> { pty, sessionId, workingDir, mode, createdAt, outputBuffer, lastPollIndex }
|
||||
this.wsServer = null;
|
||||
this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl');
|
||||
this.pingInterval = null;
|
||||
this.maxBufferSize = 10000; // Maximum buffer size per terminal
|
||||
this.maxOutputAge = 300000; // 5 minutes - max age for output in buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket server for terminal I/O
|
||||
* Setup WebSocket server (kept for compatibility, but not used for terminal I/O)
|
||||
*/
|
||||
createServer(httpServer) {
|
||||
this.wsServer = new Server({ noServer: true });
|
||||
@@ -46,7 +49,7 @@ class TerminalService {
|
||||
// Setup ping interval to keep connections alive
|
||||
this.setupPingInterval();
|
||||
|
||||
console.log('[TerminalService] WebSocket server initialized');
|
||||
console.log('[TerminalService] WebSocket server initialized (legacy, polling preferred)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,24 +98,29 @@ class TerminalService {
|
||||
|
||||
console.log(`[TerminalService] PTY spawned with PID: ${pty.pid}, shell: ${shell}`);
|
||||
|
||||
// Store terminal info
|
||||
// Store terminal info with output buffer for polling
|
||||
const terminal = {
|
||||
id: terminalId,
|
||||
pty,
|
||||
ws: null,
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode, // 'session', 'shell', or 'mixed'
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString()
|
||||
lastActivity: new Date().toISOString(),
|
||||
outputBuffer: [], // Array of { index, timestamp, data, type }
|
||||
outputIndex: 0, // Monotonically increasing index
|
||||
lastPollTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.terminals.set(terminalId, terminal);
|
||||
|
||||
// Buffer PTY output for polling
|
||||
this.bufferOutput(terminalId, pty);
|
||||
|
||||
// Log terminal creation
|
||||
this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`);
|
||||
|
||||
console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir}`);
|
||||
console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir} with HTTP polling enabled`);
|
||||
|
||||
return { success: true, terminalId, terminal };
|
||||
} catch (error) {
|
||||
@@ -125,7 +133,58 @@ class TerminalService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket connection for terminal I/O
|
||||
* Buffer PTY output for HTTP polling
|
||||
*/
|
||||
bufferOutput(terminalId, pty) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
if (!terminal) return;
|
||||
|
||||
// Handle PTY output - buffer it for polling
|
||||
pty.onData((data) => {
|
||||
try {
|
||||
console.log(`[TerminalService] PTY data from ${terminalId}: ${data.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}`);
|
||||
|
||||
// Add to buffer with index
|
||||
terminal.outputBuffer.push({
|
||||
index: terminal.outputIndex++,
|
||||
timestamp: Date.now(),
|
||||
type: 'data',
|
||||
data: data
|
||||
});
|
||||
|
||||
// Trim buffer if too large
|
||||
if (terminal.outputBuffer.length > this.maxBufferSize) {
|
||||
const removedCount = terminal.outputBuffer.length - this.maxBufferSize;
|
||||
terminal.outputBuffer = terminal.outputBuffer.slice(removedCount);
|
||||
console.log(`[TerminalService] Trimmed buffer for ${terminalId}, removed ${removedCount} old entries`);
|
||||
}
|
||||
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] ERROR buffering PTY data:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle PTY exit
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[TerminalService] PTY EXIT for ${terminalId}: exitCode=${exitCode}, signal=${signal}`);
|
||||
this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`);
|
||||
|
||||
// Add exit message to buffer
|
||||
terminal.outputBuffer.push({
|
||||
index: terminal.outputIndex++,
|
||||
timestamp: Date.now(),
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal
|
||||
});
|
||||
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket connection for terminal I/O (legacy, kept for compatibility)
|
||||
*/
|
||||
handleConnection(terminalId, ws) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
@@ -136,10 +195,9 @@ class TerminalService {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.ws = ws;
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`);
|
||||
console.log(`[TerminalService] WebSocket connected for terminal ${terminalId} (legacy mode)`);
|
||||
console.log(`[TerminalService] Terminal info - workingDir: ${terminal.workingDir}, mode: ${terminal.mode}`);
|
||||
|
||||
// Handle incoming messages from client (user input)
|
||||
@@ -154,11 +212,6 @@ class TerminalService {
|
||||
console.log(`[TerminalService] Writing to PTY: "${message.data.replace(/\n/g, '\\n')}"`);
|
||||
terminal.pty.write(message.data);
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
// Log commands (basic detection of Enter key)
|
||||
if (message.data === '\r') {
|
||||
// Could extract and log command here
|
||||
}
|
||||
} else if (message.type === 'resize') {
|
||||
// Handle terminal resize
|
||||
console.log(`[TerminalService] Resize to ${message.cols}x${message.rows}`);
|
||||
@@ -185,60 +238,16 @@ class TerminalService {
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
});
|
||||
|
||||
// Handle PTY output - send to client
|
||||
terminal.pty.onData((data) => {
|
||||
try {
|
||||
console.log(`[TerminalService] PTY data from ${terminalId}: ${data.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}`);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'data',
|
||||
data: data
|
||||
}));
|
||||
} else {
|
||||
console.log(`[TerminalService] Cannot send PTY data - WebSocket not open (state: ${ws.readyState})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] ERROR sending PTY data to client:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle PTY exit
|
||||
terminal.pty.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[TerminalService] PTY EXIT for ${terminalId}: exitCode=${exitCode}, signal=${signal}`);
|
||||
this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`);
|
||||
|
||||
try {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal
|
||||
}));
|
||||
ws.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error sending exit message:`, error);
|
||||
}
|
||||
|
||||
this.terminals.delete(terminalId);
|
||||
});
|
||||
|
||||
// Handle WebSocket close
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId} - code: ${code}, reason: ${reason || 'none'}`);
|
||||
|
||||
// Don't kill PTY immediately - allow reconnection
|
||||
// PTY will be killed after timeout or explicit close
|
||||
// Don't kill PTY - it continues with HTTP polling
|
||||
});
|
||||
|
||||
// Handle WebSocket error
|
||||
ws.on('error', (error) => {
|
||||
console.error(`[TerminalService] ✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`);
|
||||
console.error(`[TerminalService] Error:`, error);
|
||||
console.error(`[TerminalService] Message: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error(`[TerminalService] Stack: ${error.stack}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial welcome message
|
||||
@@ -246,30 +255,81 @@ class TerminalService {
|
||||
type: 'ready',
|
||||
terminalId,
|
||||
workingDir: terminal.workingDir,
|
||||
mode: terminal.mode
|
||||
mode: terminal.mode,
|
||||
polling: true // Indicate polling is available
|
||||
});
|
||||
console.log(`[TerminalService] Sending ready message to ${terminalId}: ${readyMessage}`);
|
||||
|
||||
try {
|
||||
ws.send(readyMessage);
|
||||
console.log(`[TerminalService] Ready message sent successfully`);
|
||||
|
||||
// Send a ping immediately after ready to ensure connection stays alive
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.ping();
|
||||
console.log(`[TerminalService] Sent ping after ready message`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error sending ping after ready:`, error);
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error sending ready message:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal output via HTTP polling (replaces WebSocket for output)
|
||||
*/
|
||||
getTerminalOutput(terminalId, sinceIndex = 0) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get output entries since the specified index
|
||||
const newOutput = terminal.outputBuffer.filter(entry => entry.index > sinceIndex);
|
||||
|
||||
// Clean up old output based on age
|
||||
const now = Date.now();
|
||||
terminal.outputBuffer = terminal.outputBuffer.filter(entry => {
|
||||
// Keep recent output and output that hasn't been polled yet
|
||||
return (now - entry.timestamp) < this.maxOutputAge || entry.index > sinceIndex;
|
||||
});
|
||||
|
||||
terminal.lastPollTime = new Date().toISOString();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: newOutput,
|
||||
currentIndex: terminal.outputIndex - 1,
|
||||
hasMore: newOutput.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error getting terminal output:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize terminal via HTTP
|
||||
*/
|
||||
resizeTerminal(terminalId, cols, rows) {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
if (!terminal.pty) {
|
||||
return { success: false, error: 'PTY not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.pty.resize(cols, rows);
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
console.log(`[TerminalService] Resized terminal ${terminalId} to ${cols}x${rows}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error resizing terminal ${terminalId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach terminal to a Claude Code session
|
||||
* Pipes session stdout/stderr to PTY
|
||||
@@ -326,13 +386,13 @@ class TerminalService {
|
||||
terminal.mode = mode;
|
||||
terminal.lastActivity = new Date().toISOString();
|
||||
|
||||
// Notify client of mode change
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.send(JSON.stringify({
|
||||
type: 'modeChanged',
|
||||
mode
|
||||
}));
|
||||
}
|
||||
// Add mode change to buffer so polling clients see it
|
||||
terminal.outputBuffer.push({
|
||||
index: terminal.outputIndex++,
|
||||
timestamp: Date.now(),
|
||||
type: 'modeChanged',
|
||||
mode
|
||||
});
|
||||
|
||||
return { success: true, mode };
|
||||
}
|
||||
@@ -381,11 +441,6 @@ class TerminalService {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.close(1000, 'Terminal closed');
|
||||
}
|
||||
|
||||
this.terminals.delete(terminalId);
|
||||
|
||||
this.logCommand(terminalId, null, 'Terminal closed');
|
||||
@@ -482,9 +537,6 @@ class TerminalService {
|
||||
if (terminal.pty) {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
||||
terminal.ws.close(1000, 'Server shutting down');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user