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,15 +1,18 @@
/**
* Terminal Manager - Frontend for xterm.js terminals
* Terminal Manager - Frontend for xterm.js terminals with HTTP polling
* Uses HTTP polling to bypass WebSocket/SSL issues
*/
class TerminalManager {
constructor() {
this.terminals = new Map(); // terminalId -> { terminal, ws, fitAddon, container, mode, ready }
this.terminals = new Map(); // terminalId -> { terminal, fitAddon, container, mode, polling, currentOutputIndex }
this.activeTerminalId = null;
this.xtermLoaded = false;
this.terminalsContainer = null;
this.terminalTabsContainer = null;
this.debugMessages = [];
this.pollingInterval = 100; // ms between polls
this.pollingTimers = new Map(); // terminalId -> timer
// Bind methods
this.createTerminal = this.createTerminal.bind(this);
@@ -39,11 +42,12 @@ class TerminalManager {
if (debugContent) {
const colorMap = {
'INIT': '#4a9eff',
'WS': '#a78bfa',
'HTTP': '#a78bfa',
'CMD': '#51cf66',
'ERROR': '#ff6b6b',
'READY': '#ffd43b',
'PTY': '#ffa94d'
'PTY': '#ffa94d',
'POLL': '#ff922b'
};
const color = colorMap[category] || '#e0e0e0';
@@ -262,17 +266,14 @@ class TerminalManager {
this.debugLog('INIT', `Creating terminal UI...`);
await this.createTerminalUI(terminalId, selectedDir, mode);
// Initialize xterm.js FIRST (before connecting WebSocket)
// This ensures this.terminals map has the entry ready
// Initialize xterm.js
this.debugLog('INIT', `Initializing xterm.js...`);
await this.initializeXTerm(terminalId);
this.debugLog('INIT', `xterm.js initialized, terminal should be in map now`);
this.debugLog('INIT', `xterm.js initialized`);
// NOW connect WebSocket (terminal entry exists in map)
// This waits for the 'ready' message from backend
this.debugLog('INIT', `Connecting WebSocket...`);
await this.connectTerminal(terminalId);
this.debugLog('INIT', `WebSocket connected and ready`);
// Start HTTP polling for output (NO WebSocket needed!)
this.debugLog('POLL', `Starting HTTP polling for ${terminalId}...`);
this.startPolling(terminalId);
// Switch to new terminal
this.debugLog('INIT', `Switching to terminal ${terminalId}...`);
@@ -282,9 +283,7 @@ class TerminalManager {
// Handle terminal type specific initialization
if (selectedTerminalType === 'claude-cli') {
this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`);
// TEMPORARY: Test with simple echo command first
// TODO: Change back to 'claude --dangerously-skip-permissions\n' after testing
await this.launchCommand(terminalId, 'echo "WebSocket test"\n');
await this.launchCommand(terminalId, 'claude --dangerously-skip-permissions\n');
if (!silent) {
showToast('Claude Code CLI terminal created', 'success');
@@ -641,22 +640,13 @@ class TerminalManager {
* Launch Claude CLI in terminal with optional session
*/
async launchClaudeCLI(terminalId, sessionId, source = 'web') {
const terminal = this.terminals.get(terminalId);
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
console.error('[TerminalManager] Terminal not ready for CLI launch');
return;
}
// Construct command
const command = sessionId
? `claude --session ${sessionId}\n`
: 'claude\n';
// Send command to terminal
terminal.ws.send(JSON.stringify({
type: 'input',
data: command
}));
// Send command to terminal via HTTP
await this.sendTerminalInput(terminalId, command);
// Update mode to session
await this.setMode(terminalId, 'session');
@@ -675,87 +665,96 @@ class TerminalManager {
}
// Store session ID and source in terminal data
if (sessionId) {
terminal.sessionId = sessionId;
terminal.sessionSource = source;
const terminal = this.terminals.get(terminalId);
if (terminal) {
if (sessionId) {
terminal.sessionId = sessionId;
terminal.sessionSource = source;
}
}
}
/**
* Launch a command in the terminal
* Uses HTTP POST as workaround for WebSocket send issue
* Launch a command in the terminal via HTTP
*/
async launchCommand(terminalId, command) {
this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`);
// Wait for terminal to be ready (max 5 seconds)
this.debugLog('CMD', `Waiting for terminal ${terminalId} to be ready...`);
const ready = await this.waitForTerminalReady(terminalId, 5000);
if (!ready) {
this.debugLog('ERROR', `Terminal ${terminalId} NOT ready (timeout after 5s)`);
showToast('Terminal not ready. Please try again.', 'error');
return;
}
this.debugLog('CMD', `Terminal ${terminalId} is ready!`);
// Use HTTP POST instead of WebSocket send (bypasses proxy issue)
this.debugLog('CMD', `Sending command via HTTP POST: ${command.trim()}`);
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: command })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const result = await res.json();
if (result.success) {
this.debugLog('CMD', `Command sent to terminal ${terminalId} via HTTP: ${command.trim()}`);
} else {
this.debugLog('ERROR', `Failed to send command: ${result.error}`);
showToast(`Failed to send command: ${result.error}`, 'error');
}
} catch (error) {
this.debugLog('ERROR', `HTTP POST failed:`, { error: error.message });
showToast(`Failed to send command: ${error.message}`, 'error');
}
await this.sendTerminalInput(terminalId, command);
}
/**
* Wait for terminal to be ready
* Start HTTP polling for terminal output
*/
async waitForTerminalReady(terminalId, timeout = 5000) {
const startTime = Date.now();
startPolling(terminalId) {
const terminal = this.terminals.get(terminalId);
if (!terminal) return;
return new Promise((resolve) => {
const checkReady = () => {
const terminal = this.terminals.get(terminalId);
terminal.currentOutputIndex = 0;
terminal.polling = true;
if (terminal && terminal.ready) {
console.log(`[TerminalManager] Terminal ${terminalId} is ready`);
resolve(true);
return;
const poll = async () => {
const term = this.terminals.get(terminalId);
if (!term || !term.polling) {
this.debugLog('POLL', `Stopped polling for ${terminalId}`);
return;
}
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/output?since=${term.currentOutputIndex}`);
const data = await res.json();
if (data.success && data.output && data.output.length > 0) {
this.debugLog('POLL', `Received ${data.output.length} output entries for ${terminalId}`);
// Write output to xterm.js
for (const entry of data.output) {
if (entry.type === 'data') {
if (term.terminal) {
term.terminal.write(entry.data);
}
} else if (entry.type === 'exit') {
this.debugLog('PTY', `Terminal exited: ${entry.exitCode || entry.signal}`);
showToast(`Terminal exited: ${entry.exitCode || 'terminated'}`, 'info');
} else if (entry.type === 'modeChanged') {
this.debugLog('INIT', `Mode changed to ${entry.mode}`);
this.updateModeDisplay(terminalId, entry.mode);
}
}
// Update current index
term.currentOutputIndex = data.currentIndex;
}
} catch (error) {
this.debugLog('ERROR', `Polling error for ${terminalId}:`, { error: error.message });
}
if (Date.now() - startTime > timeout) {
console.error(`[TerminalManager] Terminal ${terminalId} ready timeout`);
resolve(false);
return;
}
// Schedule next poll
if (term.polling) {
this.pollingTimers.set(terminalId, setTimeout(poll, this.pollingInterval));
}
};
// Check again in 100ms
setTimeout(checkReady, 100);
};
// Start polling
poll();
this.debugLog('POLL', `Started polling for ${terminalId} at ${this.pollingInterval}ms interval`);
}
checkReady();
});
/**
* Stop HTTP polling for terminal
*/
stopPolling(terminalId) {
const timer = this.pollingTimers.get(terminalId);
if (timer) {
clearTimeout(timer);
this.pollingTimers.delete(terminalId);
}
const terminal = this.terminals.get(terminalId);
if (terminal) {
terminal.polling = false;
}
this.debugLog('POLL', `Stopped polling for ${terminalId}`);
}
/**
@@ -878,163 +877,6 @@ class TerminalManager {
});
}
/**
* Connect terminal WebSocket
* Waits for 'ready' message from backend before resolving
*/
async connectTerminal(terminalId) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/claude/api/terminals/${terminalId}/ws`;
this.debugLog('WS', `Connecting to WebSocket for terminal ${terminalId}`, { url: wsUrl });
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
this.debugLog('WS', `✅ WebSocket OPENED for terminal ${terminalId}`);
this.debugLog('WS', `Checking if terminal exists in map...`);
// Store WebSocket in terminal entry
// NOTE: This assumes initializeXTerm() has already been called
// and this.terminals has the entry
const terminal = this.terminals.get(terminalId);
if (terminal) {
this.debugLog('WS', `✅ Terminal found in map, storing WebSocket`);
terminal.ws = ws;
terminal.ready = false; // Will be set to true when 'ready' message received
this.debugLog('WS', `✅ WebSocket stored in terminal map, waiting for 'ready' message`);
} else {
this.debugLog('ERROR', `❌ CRITICAL: Terminal ${terminalId} not found in map!`);
this.debugLog('ERROR', `Available terminals:`, {
count: this.terminals.size,
ids: Array.from(this.terminals.keys())
});
reject(new Error(`Terminal ${terminalId} not initialized`));
ws.close();
return;
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.debugLog('WS', `Message received: type="${message.type}"`, message);
this.handleTerminalMessage(terminalId, message);
// If this is the ready message, resolve the promise
if (message.type === 'ready') {
this.debugLog('READY', `✅ Ready message received for ${terminalId}, PTY initialized`, message);
const terminal = this.terminals.get(terminalId);
if (terminal) {
terminal.ready = true;
}
resolve();
}
} catch (error) {
this.debugLog('ERROR', `Failed to parse WebSocket message`, { error: error.message, data: event.data });
}
};
ws.onerror = (error) => {
this.debugLog('ERROR', `✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`, {
type: error.type || 'unknown',
message: error.message || 'No error message',
error: error
});
this.debugLog('ERROR', `Check browser console (F12) for full error details`);
reject(error);
};
ws.onclose = (event) => {
const closeReasons = {
1000: 'Normal Closure',
1001: 'Endpoint Going Away',
1002: 'Protocol Error',
1003: 'Unsupported Data',
1004: '(Reserved)',
1005: 'No Status Received',
1006: 'Abnormal Closure (connection dropped)',
1007: 'Invalid frame payload data',
1008: 'Policy Violation',
1009: 'Message Too Big',
1010: 'Mandatory Extension',
1011: 'Internal Server Error',
1015: 'TLS Handshake'
};
const closeReason = closeReasons[event.code] || `Unknown (${event.code})`;
this.debugLog('ERROR', `🔌 WebSocket CLOSED for terminal ${terminalId}`, {
code: event.code,
reasonName: closeReason,
reason: event.reason || 'None provided',
wasClean: event.wasClean,
timestamp: new Date().toISOString()
});
if (event.code === 1006) {
this.debugLog('ERROR', `💡 Code 1006 means connection was dropped abnormally - check server logs`);
this.debugLog('ERROR', `💡 Common causes: server crash, network issue, or timeout`);
}
// Show terminals currently in map for debugging
this.debugLog('ERROR', `Current terminals in map:`, {
count: this.terminals.size,
ids: Array.from(this.terminals.keys())
});
};
} catch (error) {
this.debugLog('ERROR', `Exception connecting WebSocket`, error);
reject(error);
}
});
}
/**
* Handle terminal message from WebSocket
*/
handleTerminalMessage(terminalId, message) {
this.debugLog('WS', `Handling message: type="${message.type}"`, message);
const terminal = this.terminals.get(terminalId);
if (!terminal) {
this.debugLog('ERROR', `Cannot handle message - terminal ${terminalId} not found in map`, { terminalsInMap: Array.from(this.terminals.keys()) });
return;
}
switch (message.type) {
case 'ready':
this.debugLog('READY', `PTY initialized for ${terminalId}`);
break;
case 'data':
// Write to xterm.js
if (terminal.terminal) {
this.debugLog('PTY', `Writing data to xterm.js (${message.data.length} chars)`);
terminal.terminal.write(message.data);
} else {
this.debugLog('ERROR', `No xterm.js instance for terminal ${terminalId}`);
}
break;
case 'exit':
this.debugLog('PTY', `Terminal exited: ${message.exitCode || signal}`);
showToast(`Terminal exited: ${message.exitCode || 'terminated'}`, 'info');
break;
case 'modeChanged':
this.debugLog('INIT', `Mode changed to ${message.mode}`);
this.updateModeDisplay(terminalId, message.mode);
break;
default:
this.debugLog('WS', `Unknown message type: ${message.type}`);
}
}
/**
* Initialize xterm.js instance for terminal
*/
@@ -1101,70 +943,86 @@ class TerminalManager {
// Auto-focus terminal when created
setTimeout(() => terminal.focus(), 100);
// Handle user input
// Handle user input - send via HTTP POST
terminal.onData((data) => {
this.sendTerminalInput(terminalId, data);
});
// Handle resize
// Handle resize - send via HTTP POST
terminal.onResize(({ cols, rows }) => {
this.sendTerminalResize(terminalId, cols, rows);
});
// Store terminal instance in map
// This MUST happen before connectTerminal() is called
this.terminals.set(terminalId, {
terminal,
fitAddon,
ws: null, // Will be set by connectTerminal()
container,
mode: 'mixed',
ready: false // Will be set to true when 'ready' message received
polling: false,
currentOutputIndex: 0
});
this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}, terminals now has`, { terminalIds: Array.from(this.terminals.keys()) });
this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}`);
return terminal;
}
/**
* Send input to terminal
* Send input to terminal via HTTP POST
*/
sendTerminalInput(terminalId, data) {
const terminal = this.terminals.get(terminalId);
async sendTerminalInput(terminalId, data) {
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data })
});
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
return;
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const result = await res.json();
if (!result.success) {
this.debugLog('ERROR', `Failed to send input: ${result.error}`);
}
} catch (error) {
this.debugLog('ERROR', `Error sending input:`, { error: error.message });
}
terminal.ws.send(JSON.stringify({
type: 'input',
data
}));
}
/**
* Send terminal resize
* Send terminal resize via HTTP POST
*/
sendTerminalResize(terminalId, cols, rows) {
const terminal = this.terminals.get(terminalId);
async sendTerminalResize(terminalId, cols, rows) {
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
});
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
return;
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const result = await res.json();
if (!result.success) {
this.debugLog('ERROR', `Failed to resize terminal: ${result.error}`);
}
} catch (error) {
this.debugLog('ERROR', `Error resizing terminal:`, { error: error.message });
}
terminal.ws.send(JSON.stringify({
type: 'resize',
cols,
rows
}));
}
/**
* Switch to specific terminal
*/
async switchToTerminal(terminalId) {
this.debugLog('INIT', `switchToTerminal called for ${terminalId}, checking if terminal in map...`);
this.debugLog('INIT', `switchToTerminal called for ${terminalId}`);
// Hide all containers and remove active class
document.querySelectorAll('.terminal-container').forEach(c => {
@@ -1183,30 +1041,16 @@ class TerminalManager {
container.classList.add('active');
container.style.display = 'flex';
// Initialize xterm.js if not already done
if (!this.terminals.has(terminalId)) {
this.debugLog('INIT', `Terminal ${terminalId} NOT in map, calling initializeXTerm...`);
await this.initializeXTerm(terminalId);
} else {
this.debugLog('INIT', `Terminal ${terminalId} already in map, skipping initialization`);
}
// Fit terminal - DISABLED to avoid WebSocket closure issue
// Fit terminal
const terminal = this.terminals.get(terminalId);
if (terminal && terminal.fitAddon) {
this.debugLog('INIT', `Skipping fitAddon.fit() for ${terminalId} to avoid WebSocket closure`);
// TODO: Investigate why fitAddon.fit() causes WebSocket to close
// setTimeout(() => {
// this.debugLog('INIT', `Executing fitAddon.fit() for ${terminalId}`);
// try {
// terminal.fitAddon.fit();
// this.debugLog('INIT', `fitAddon.fit() completed for ${terminalId}`);
// } catch (error) {
// this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message });
// }
// }, 100);
} else {
this.debugLog('INIT', `No fitAddon for terminal ${terminalId} (terminal=${!!terminal}, fitAddon=${!!terminal?.fitAddon})`);
setTimeout(() => {
try {
terminal.fitAddon.fit();
} catch (error) {
this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message });
}
}, 100);
}
}
@@ -1225,6 +1069,9 @@ class TerminalManager {
*/
async closeTerminal(terminalId) {
try {
// Stop polling
this.stopPolling(terminalId);
// Close via API
await fetch(`/claude/api/terminals/${terminalId}`, {
method: 'DELETE'