Files
web-terminal/public/main.ts
Yaojia Wang 326d3472dd feat: W1 frontend — index.html, style.css, keybar, main.ts (esbuild entry)
- T8 public/index.html      — #term/#keybar, loads ./build/main.js (type=module)
- T9 public/style.css       — fullscreen term, keybar fixed bottom, hidden >768px
- T10 public/keybar.ts      — key→byte map + mountKeybar (13 tests)
- T11 public/main.ts        — xterm+fit, WS client (wss on https/M6), reconnect
                              backoff, resize debounce, mountKeybar wiring

Verified by orchestrator: backend+frontend tsc clean; full suite 141 tests;
npm run build:web bundles main.ts -> public/build/main.js (430kb).
Note: main.ts uses one @ts-ignore for the CSS import (minor; future: *.css d.ts).
2026-06-16 18:36:32 +02:00

275 lines
8.1 KiB
TypeScript

/**
* 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<typeof setTimeout> | null = null
let isConnecting = false
/** Resize debounce state */
let resizeTimer: ReturnType<typeof setTimeout> | 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<string>) => {
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()