export interface TerminalSession { sessionId: string; cols: number; rows: number; capabilities?: { input?: TerminalInputCapability; }; } export interface TerminalInputCapability { preferred?: 'ws' | 'http'; transports?: Array<'ws' | 'http'>; ws?: { path: string; v?: number; enc?: string; }; } export interface TerminalStreamEvent { type: 'connected' | 'data' | 'exit' | 'reconnecting'; data?: string; exitCode?: number; signal?: number | null; attempt?: number; maxAttempts?: number; } export interface CreateTerminalOptions { cwd: string; cols?: number; rows?: number; } export interface ConnectStreamOptions { maxRetries?: number; initialRetryDelay?: number; maxRetryDelay?: number; connectionTimeout?: number; } type TerminalInputControlMessage = { t: string; s?: string; c?: string; f?: boolean; v?: number; }; const CONTROL_TAG_JSON = 0x01; const WS_READY_STATE_OPEN = 1; const DEFAULT_TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws'; const WS_SEND_WAIT_MS = 1200; const WS_RECONNECT_INITIAL_DELAY_MS = 1000; const WS_RECONNECT_MAX_DELAY_MS = 30000; const WS_RECONNECT_JITTER_MS = 250; const WS_KEEPALIVE_INTERVAL_MS = 20000; const WS_CONNECT_TIMEOUT_MS = 5000; const GLOBAL_TERMINAL_INPUT_STATE_KEY = '__openchamberTerminalInputWsState'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const normalizeWebSocketPath = (pathValue: string): string => { if (/^wss?:\/\//i.test(pathValue)) { return pathValue; } if (/^https?:\/\//i.test(pathValue)) { const url = new URL(pathValue); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; return url.toString(); } if (typeof window === 'undefined') { return ''; } const normalizedPath = pathValue.startsWith('/') ? pathValue : `/${pathValue}`; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}${normalizedPath}`; }; const encodeControlFrame = (payload: TerminalInputControlMessage): Uint8Array => { const jsonBytes = textEncoder.encode(JSON.stringify(payload)); const bytes = new Uint8Array(jsonBytes.length + 1); bytes[0] = CONTROL_TAG_JSON; bytes.set(jsonBytes, 1); return bytes; }; const isWsInputSupported = (capability: TerminalInputCapability | null): boolean => { if (!capability) return false; const transports = capability.transports ?? []; const supportsTransport = transports.includes('ws') || capability.preferred === 'ws'; return supportsTransport && typeof capability.ws?.path === 'string' && capability.ws.path.length > 0; }; class TerminalInputWsManager { private socket: WebSocket | null = null; private socketUrl = ''; private boundSessionId: string | null = null; private openPromise: Promise | null = null; private reconnectTimeout: ReturnType | null = null; private reconnectAttempt = 0; private keepaliveInterval: ReturnType | null = null; private closed = false; configure(socketUrl: string): void { if (!socketUrl) return; if (this.socketUrl === socketUrl) { this.closed = false; if (this.isConnectedOrConnecting()) { return; } this.ensureConnected(); return; } this.socketUrl = socketUrl; this.closed = false; this.resetConnection(); this.ensureConnected(); } async sendInput(sessionId: string, data: string): Promise { if (!sessionId || !data || this.closed || !this.socketUrl) { return false; } const socket = await this.getOpenSocket(WS_SEND_WAIT_MS); if (!socket || socket.readyState !== WS_READY_STATE_OPEN) { return false; } try { if (this.boundSessionId !== sessionId) { socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 1 })); this.boundSessionId = sessionId; } socket.send(data); return true; } catch { this.handleSocketFailure(); return false; } } unbindSession(sessionId: string): void { if (!sessionId) return; if (this.boundSessionId === sessionId) { this.boundSessionId = null; } } close(): void { this.closed = true; this.clearReconnectTimeout(); this.resetConnection(); this.socketUrl = ''; } prime(): void { if (this.closed || !this.socketUrl) { return; } if (this.isConnectedOrConnecting()) { return; } this.ensureConnected(); } isConnectedOrConnecting(socketUrl?: string): boolean { if (this.closed) { return false; } if (socketUrl && this.socketUrl !== socketUrl) { return false; } if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return true; } return this.openPromise !== null; } private sendControl(payload: TerminalInputControlMessage): boolean { if (!this.socket || this.socket.readyState !== WS_READY_STATE_OPEN) { return false; } try { this.socket.send(encodeControlFrame(payload)); return true; } catch { this.handleSocketFailure(); return false; } } private startKeepalive(): void { this.stopKeepalive(); this.keepaliveInterval = setInterval(() => { if (this.closed) { return; } this.sendControl({ t: 'p', v: 1 }); }, WS_KEEPALIVE_INTERVAL_MS); } private stopKeepalive(): void { if (!this.keepaliveInterval) { return; } clearInterval(this.keepaliveInterval); this.keepaliveInterval = null; } private scheduleReconnect(): void { if (this.closed || !this.socketUrl || this.reconnectTimeout) { return; } const baseDelay = Math.min( WS_RECONNECT_INITIAL_DELAY_MS * Math.pow(2, this.reconnectAttempt), WS_RECONNECT_MAX_DELAY_MS ); const jitter = Math.floor(Math.random() * WS_RECONNECT_JITTER_MS); const delay = baseDelay + jitter; this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; this.reconnectAttempt += 1; this.ensureConnected(); }, delay); } private clearReconnectTimeout(): void { if (!this.reconnectTimeout) { return; } clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } private async getOpenSocket(waitMs: number): Promise { if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) { return this.socket; } this.ensureConnected(); if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) { return this.socket; } const opened = await Promise.race([ this.openPromise ?? Promise.resolve(null), new Promise((resolve) => { setTimeout(() => resolve(null), waitMs); }), ]); if (opened && opened.readyState === WS_READY_STATE_OPEN) { return opened; } if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) { return this.socket; } return null; } private ensureConnected(): void { if (this.closed || !this.socketUrl) { return; } if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; } if (this.openPromise) { return; } this.clearReconnectTimeout(); this.openPromise = new Promise((resolve) => { let settled = false; let connectTimeout: ReturnType | null = null; const settle = (value: WebSocket | null) => { if (settled) { return; } settled = true; if (connectTimeout) { clearTimeout(connectTimeout); connectTimeout = null; } this.openPromise = null; resolve(value); }; try { const socket = new WebSocket(this.socketUrl); socket.binaryType = 'arraybuffer'; socket.onopen = () => { this.socket = socket; this.reconnectAttempt = 0; this.startKeepalive(); settle(socket); }; socket.onmessage = (event) => { void this.handleSocketMessage(event.data); }; socket.onclose = () => { if (this.socket === socket) { this.socket = null; this.boundSessionId = null; this.stopKeepalive(); if (!this.closed) { this.scheduleReconnect(); } } settle(null); }; this.socket = socket; connectTimeout = setTimeout(() => { if (socket.readyState === WebSocket.CONNECTING) { socket.close(); settle(null); } }, WS_CONNECT_TIMEOUT_MS); } catch { settle(null); if (!this.closed) { this.scheduleReconnect(); } } }); } private async handleSocketMessage(messageData: unknown): Promise { const bytes = await this.asUint8Array(messageData); if (!bytes || bytes.length < 2) { return; } if (bytes[0] !== CONTROL_TAG_JSON) { return; } try { const payload = JSON.parse(textDecoder.decode(bytes.subarray(1))) as TerminalInputControlMessage; if (payload.t === 'po') { return; } if (payload.t === 'e') { if (payload.c === 'NOT_BOUND' || payload.c === 'SESSION_NOT_FOUND') { this.boundSessionId = null; } if (payload.f === true) { this.handleSocketFailure(); } } } catch { this.handleSocketFailure(); } } private async asUint8Array(messageData: unknown): Promise { if (messageData instanceof ArrayBuffer) { return new Uint8Array(messageData); } if (messageData instanceof Uint8Array) { return messageData; } if (typeof Blob !== 'undefined' && messageData instanceof Blob) { const buffer = await messageData.arrayBuffer(); return new Uint8Array(buffer); } return null; } private handleSocketFailure(): void { this.boundSessionId = null; this.resetConnection(); this.scheduleReconnect(); } private resetConnection(): void { this.openPromise = null; this.stopKeepalive(); if (this.socket) { const socket = this.socket; this.socket = null; socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null; if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { socket.close(); } } this.boundSessionId = null; } } type TerminalInputWsGlobalState = { capability: TerminalInputCapability | null; manager: TerminalInputWsManager | null; }; const getTerminalInputWsGlobalState = (): TerminalInputWsGlobalState => { const globalScope = globalThis as typeof globalThis & { [GLOBAL_TERMINAL_INPUT_STATE_KEY]?: TerminalInputWsGlobalState; }; if (!globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY]) { globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY] = { capability: null, manager: null, }; } return globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY]; }; const applyTerminalInputCapability = (capability: TerminalInputCapability | undefined): void => { const globalState = getTerminalInputWsGlobalState(); globalState.capability = capability ?? null; if (!isWsInputSupported(globalState.capability)) { globalState.manager?.close(); globalState.manager = null; return; } const wsPath = globalState.capability?.ws?.path; if (!wsPath) { return; } const socketUrl = normalizeWebSocketPath(wsPath); if (!socketUrl) { return; } if (!globalState.manager) { globalState.manager = new TerminalInputWsManager(); } globalState.manager.configure(socketUrl); }; const sendTerminalInputHttp = async (sessionId: string, data: string): Promise => { const response = await fetch(`/api/terminal/${sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: data, }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to send input' })); throw new Error(error.error || 'Failed to send terminal input'); } }; export async function createTerminalSession( options: CreateTerminalOptions ): Promise { const response = await fetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cwd: options.cwd, cols: options.cols || 80, rows: options.rows || 24, }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to create terminal' })); throw new Error(error.error || 'Failed to create terminal session'); } const session = await response.json() as TerminalSession; applyTerminalInputCapability(session.capabilities?.input); return session; } export function connectTerminalStream( sessionId: string, onEvent: (event: TerminalStreamEvent) => void, onError?: (error: Error, fatal?: boolean) => void, options: ConnectStreamOptions = {} ): () => void { const { maxRetries = 3, initialRetryDelay = 1000, maxRetryDelay = 8000, connectionTimeout = 10000, } = options; let eventSource: EventSource | null = null; let retryCount = 0; let retryTimeout: ReturnType | null = null; let connectionTimeoutId: ReturnType | null = null; let isClosed = false; let hasDispatchedOpen = false; let terminalExited = false; const clearTimeouts = () => { if (retryTimeout) { clearTimeout(retryTimeout); retryTimeout = null; } if (connectionTimeoutId) { clearTimeout(connectionTimeoutId); connectionTimeoutId = null; } }; const cleanup = () => { isClosed = true; clearTimeouts(); if (eventSource) { eventSource.close(); eventSource = null; } }; const connect = () => { if (isClosed || terminalExited) { return; } if (eventSource && eventSource.readyState !== EventSource.CLOSED) { console.warn('Attempted to create duplicate EventSource, skipping'); return; } hasDispatchedOpen = false; eventSource = new EventSource(`/api/terminal/${sessionId}/stream`); connectionTimeoutId = setTimeout(() => { if (!hasDispatchedOpen && eventSource?.readyState !== EventSource.OPEN) { console.error('Terminal connection timeout'); eventSource?.close(); handleError(new Error('Connection timeout'), false); } }, connectionTimeout); eventSource.onopen = () => { if (hasDispatchedOpen) { return; } hasDispatchedOpen = true; retryCount = 0; clearTimeouts(); onEvent({ type: 'connected' }); }; eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) as TerminalStreamEvent; if (data.type === 'exit') { getTerminalInputWsGlobalState().manager?.unbindSession(sessionId); terminalExited = true; cleanup(); } onEvent(data); } catch (error) { console.error('Failed to parse terminal event:', error); onError?.(error as Error, false); } }; eventSource.onerror = (error) => { console.error('Terminal stream error:', error, 'readyState:', eventSource?.readyState); clearTimeouts(); const isFatalError = terminalExited || eventSource?.readyState === EventSource.CLOSED; eventSource?.close(); eventSource = null; if (!terminalExited) { handleError(new Error('Terminal stream connection error'), isFatalError); } }; }; const handleError = (error: Error, isFatal: boolean) => { if (isClosed || terminalExited) { return; } if (retryCount < maxRetries && !isFatal) { retryCount++; const delay = Math.min(initialRetryDelay * Math.pow(2, retryCount - 1), maxRetryDelay); console.log(`Reconnecting to terminal stream (attempt ${retryCount}/${maxRetries}) in ${delay}ms`); onEvent({ type: 'reconnecting', attempt: retryCount, maxAttempts: maxRetries, }); retryTimeout = setTimeout(() => { if (!isClosed && !terminalExited) { connect(); } }, delay); } else { console.error(`Terminal connection failed after ${retryCount} attempts`); onError?.(error, true); cleanup(); } }; connect(); return cleanup; } export async function sendTerminalInput( sessionId: string, data: string ): Promise { const globalState = getTerminalInputWsGlobalState(); if (globalState.manager && await globalState.manager.sendInput(sessionId, data)) { return; } await sendTerminalInputHttp(sessionId, data); } export async function resizeTerminal( sessionId: string, cols: number, rows: number ): Promise { const response = await fetch(`/api/terminal/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to resize terminal' })); throw new Error(error.error || 'Failed to resize terminal'); } } export async function closeTerminal(sessionId: string): Promise { getTerminalInputWsGlobalState().manager?.unbindSession(sessionId); const response = await fetch(`/api/terminal/${sessionId}`, { method: 'DELETE', }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to close terminal' })); throw new Error(error.error || 'Failed to close terminal'); } } export async function restartTerminalSession( currentSessionId: string, options: { cwd: string; cols?: number; rows?: number } ): Promise { getTerminalInputWsGlobalState().manager?.unbindSession(currentSessionId); const response = await fetch(`/api/terminal/${currentSessionId}/restart`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cwd: options.cwd, cols: options.cols ?? 80, rows: options.rows ?? 24, }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to restart terminal' })); throw new Error(error.error || 'Failed to restart terminal'); } const session = await response.json() as TerminalSession; applyTerminalInputCapability(session.capabilities?.input); return session; } export async function forceKillTerminal(options: { sessionId?: string; cwd?: string; }): Promise { const response = await fetch('/api/terminal/force-kill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to force kill terminal' })); throw new Error(error.error || 'Failed to force kill terminal'); } if (options.sessionId) { getTerminalInputWsGlobalState().manager?.unbindSession(options.sessionId); } } export function disposeTerminalInputTransport(): void { const globalState = getTerminalInputWsGlobalState(); globalState.manager?.close(); globalState.manager = null; globalState.capability = null; } export function primeTerminalInputTransport(): void { const globalState = getTerminalInputWsGlobalState(); if (globalState.capability && !isWsInputSupported(globalState.capability)) { return; } const wsPath = globalState.capability?.ws?.path ?? DEFAULT_TERMINAL_INPUT_WS_PATH; const socketUrl = normalizeWebSocketPath(wsPath); if (!socketUrl) { return; } if (!globalState.manager) { globalState.manager = new TerminalInputWsManager(); } if (globalState.manager.isConnectedOrConnecting(socketUrl)) { return; } globalState.manager.configure(socketUrl); globalState.manager.prime(); } const hotModule = (import.meta as ImportMeta & { hot?: { dispose: (callback: () => void) => void; }; }).hot; if (hotModule) { hotModule.dispose(() => { disposeTerminalInputTransport(); }); }