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:
uroma
2026-01-19 20:48:00 +00:00
Unverified
parent 9f35b68ea2
commit 64f1ffb484
5 changed files with 325 additions and 386 deletions

View File

@@ -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);
}