/** * public/main.ts — esbuild entry point for the browser frontend (T11). * * Responsibilities: * - xterm.js Terminal + FitAddon initialization, mounted to #term * - WebSocket client with scheme following page protocol (M6) * - Session persistence via localStorage (sessionId) * - Reconnection with exponential backoff (1s/2s/4s…cap 30s) * - Resize handling with 100ms debounce * - Mobile key bar mounting via mountKeybar (T10) * * No console.log — status messages are written as ANSI color sequences to the terminal. */ // CSS side-effect import processed by esbuild (→ public/build/main.css). // tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic. // The import is intentional and required per PLAN §1 / ARCHITECTURE §5. // @ts-ignore import '@xterm/xterm/css/xterm.css' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { mountKeybar } from './keybar.js' import type { ClientMessage, ServerMessage } from '../src/types.js' /* ─── ANSI color helpers ─────────────────────────────────────────── */ const RESET = '\x1b[0m' const BOLD = '\x1b[1m' const DIM = '\x1b[2m' const YELLOW = '\x1b[33m' const RED = '\x1b[31m' const GREEN = '\x1b[32m' const CYAN = '\x1b[36m' function statusLine(msg: string): string { return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n` } /* ─── Message construction helper ───────────────────────────────── */ function buildMessage(msg: ClientMessage): string { return JSON.stringify(msg) } /* ─── Session ID persistence ─────────────────────────────────────── */ const SESSION_KEY = 'web-terminal:sessionId' function loadSessionId(): string | null { try { return localStorage.getItem(SESSION_KEY) } catch { return null } } function saveSessionId(id: string): void { try { localStorage.setItem(SESSION_KEY, id) } catch { // localStorage unavailable — proceed without persistence } } /* ─── WS URL (M6: scheme follows page protocol) ─────────────────── */ function buildWsUrl(): string { const scheme = location.protocol === 'https:' ? 'wss' : 'ws' return `${scheme}://${location.host}/term` } /* ─── Terminal initialization ────────────────────────────────────── */ const term = new Terminal({ scrollback: 5000, fontFamily: 'Menlo, Consolas, monospace', theme: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8', }, }) const fitAddon = new FitAddon() term.loadAddon(fitAddon) const termEl = document.getElementById('term') if (!termEl) { throw new Error('#term element not found in DOM') } term.open(termEl) // fit() must only run when the container has real dimensions (display:none → NaN) function safefit(): { cols: number; rows: number } | null { try { fitAddon.fit() const cols = term.cols const rows = term.rows if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) { return null } return { cols, rows } } catch { return null } } // Initial fit after open — element should have real dimensions by now const initialDims = safefit() /* ─── WebSocket + reconnect state machine ────────────────────────── */ let ws: WebSocket | null = null let reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 ms let reconnectTimer: ReturnType | null = null let isConnecting = false /** Resize debounce state */ let resizeTimer: ReturnType | null = null let lastSentCols = initialDims?.cols ?? 0 let lastSentRows = initialDims?.rows ?? 0 function sendResize(cols: number, rows: number): void { if (ws === null || ws.readyState !== WebSocket.OPEN) return if (cols === lastSentCols && rows === lastSentRows) return lastSentCols = cols lastSentRows = rows ws.send(buildMessage({ type: 'resize', cols, rows })) } function scheduleResize(): void { if (resizeTimer !== null) clearTimeout(resizeTimer) resizeTimer = setTimeout(() => { resizeTimer = null const dims = safefit() if (dims === null) return sendResize(dims.cols, dims.rows) }, 100) } function connect(): void { if (isConnecting) return isConnecting = true const sessionId = loadSessionId() const url = buildWsUrl() term.write(statusLine(`${YELLOW}Connecting…${RESET}`)) const socket = new WebSocket(url) ws = socket socket.addEventListener('open', () => { reconnectDelay = 1000 // reset backoff on successful open term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) // First frame must be attach socket.send(buildMessage({ type: 'attach', sessionId })) }) socket.addEventListener('message', (event: MessageEvent) => { let msg: ServerMessage try { msg = JSON.parse(event.data) as ServerMessage } catch { // Unparseable frame — ignore return } handleServerMessage(msg, socket) }) socket.addEventListener('close', () => { isConnecting = false ws = null scheduleReconnect() }) socket.addEventListener('error', () => { // 'error' is always followed by 'close'; close handler drives reconnect isConnecting = false }) } function handleServerMessage(msg: ServerMessage, socket: WebSocket): void { switch (msg.type) { case 'attached': { saveSessionId(msg.sessionId) // Send current terminal dimensions after attach const dims = safefit() if (dims !== null) { sendResize(dims.cols, dims.rows) } break } case 'output': { term.write(msg.data) break } case 'exit': { const reason = msg.reason ? ` (${msg.reason})` : '' term.write( statusLine( `${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` + `\r\n${DIM}Press Enter to reconnect…${RESET}`, ), ) // Enter → start a new session (clear old sessionId so server spawns fresh) const onEnterReconnect = term.onData((data) => { if (data === '\r') { onEnterReconnect.dispose() // Clear stored session so next attach creates a new one try { localStorage.removeItem(SESSION_KEY) } catch { // ignore } socket.close() connect() } }) break } } } function scheduleReconnect(): void { if (reconnectTimer !== null) return const delay = reconnectDelay reconnectDelay = Math.min(reconnectDelay * 2, 30_000) term.write( statusLine( `${YELLOW}Disconnected — reconnecting in ${(delay / 1000).toFixed(0)}s…${RESET}`, ), ) reconnectTimer = setTimeout(() => { reconnectTimer = null connect() }, delay) } /* ─── Keyboard input ─────────────────────────────────────────────── */ term.onData((data) => { if (ws === null || ws.readyState !== WebSocket.OPEN) return ws.send(buildMessage({ type: 'input', data })) }) /* ─── Resize observers ───────────────────────────────────────────── */ window.addEventListener('resize', scheduleResize) const resizeObserver = new ResizeObserver(() => { scheduleResize() }) resizeObserver.observe(termEl) /* ─── Mobile key bar ─────────────────────────────────────────────── */ mountKeybar((data) => { if (ws === null || ws.readyState !== WebSocket.OPEN) return ws.send(buildMessage({ type: 'input', data })) }) /* ─── Initial connection ─────────────────────────────────────────── */ connect()