diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b2a00a6 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,264 @@ +/** + * src/server.ts (T14) — HTTP + WebSocket wiring layer. + * + * Responsibilities (ARCHITECTURE §3.6): + * 1. Express static hosting of public/ (including public/build/ esbuild output). + * 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade. + * 3. HTTP 'upgrade' event (single entry point): + * - Wrong path → socket.destroy() + * - Origin not allowed → 401 + destroy() + * - Passes → wss.handleUpgrade → wss.emit('connection') + * 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached' + * catch (spawn failure, M4) → send exit(-1) + close (only this connection). + * 5. WS 'message': parse → ok:false discard+log; route input/resize. + * 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2). + * 7. SIGINT/SIGTERM → manager.shutdown() + http server close. + * + * M5: every ws.send guarded by readyState === WS_OPEN. + * L3: noServer:true — no double upgrade handling. + * L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options. + * No hardcoded strings: wsPath comes from config (invariant 8). + * + * Style: immutable data, explicit error handling, no silent swallowing. + */ + +import { createServer } from 'node:http' +import { URL } from 'node:url' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import express from 'express' +import { WebSocketServer } from 'ws' +import type { WebSocket as WsWebSocket } from 'ws' +import type { IncomingMessage } from 'node:http' + +import { loadConfig } from './config.js' +import { parseClientMessage, serialize } from './protocol.js' +import { isOriginAllowed } from './http/origin.js' +import { createSessionManager } from './session/manager.js' +import { detachWs, writeInput, resize } from './session/session.js' +import type { Config } from './types.js' +import { WS_OPEN } from './types.js' + +// ── defaults ────────────────────────────────────────────────────────────────── + +const DEFAULT_COLS = 80 +const DEFAULT_ROWS = 24 + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/** Derive __dirname equivalent in ESM. */ +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +/** Guard a ws.send so we never call send on a non-OPEN socket (M5). */ +function safeSend(ws: WsWebSocket, data: string): void { + if (ws.readyState === WS_OPEN) { + ws.send(data) + } +} + +// ── startServer ─────────────────────────────────────────────────────────────── + +/** + * Start the HTTP + WS server. + * + * Returns an object with a `close()` method that gracefully shuts down + * both the HTTP server and the session manager. + */ +export function startServer(cfg: Config): { close(): Promise } { + const manager = createSessionManager(cfg) + + // ── Express static hosting ──────────────────────────────────────────────── + const app = express() + + // Serve the entire public/ directory (including public/build/ esbuild output). + // Note: `npm run build:web` must be run before `npm start` to populate public/build/. + const publicDir = path.join(__dirname, '..', 'public') + app.use(express.static(publicDir)) + + // ── HTTP server ─────────────────────────────────────────────────────────── + const httpServer = createServer(app) + + // ── WebSocket server (noServer: true — L3) ──────────────────────────────── + // noServer prevents the wss from auto-attaching its own 'upgrade' listener, + // which would double-handle the socket before our Origin check runs. + const wss = new WebSocketServer({ + noServer: true, + maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size + }) + + // ── Idle reaper ─────────────────────────────────────────────────────────── + const REAP_INTERVAL_MS = 60_000 // check every minute + const reapTimer = setInterval(() => { + manager.reapIdle(Date.now()) + }, REAP_INTERVAL_MS) + // Don't let this timer prevent the process from exiting. + reapTimer.unref() + + // ── HTTP upgrade (single entry point — ARCHITECTURE §3.6, L3) ──────────── + httpServer.on('upgrade', (req: IncomingMessage, socket, head) => { + // Parse the path. Use a dummy base so URL() accepts a path-only string. + const { pathname } = new URL(req.url ?? '/', 'http://localhost') + + // 1. Wrong path → destroy + if (pathname !== cfg.wsPath) { + socket.destroy() + return + } + + // 2. Origin check (CSWSH defence, ARCHITECTURE §3.3 / TECH_DOC §7) + const origin = req.headers['origin'] as string | undefined + if (!isOriginAllowed(origin, cfg.allowedOrigins)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + + // 3. Hand off to wss — it completes the handshake and emits 'connection'. + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) + }) + + // ── WS connection handler ───────────────────────────────────────────────── + wss.on('connection', (ws: WsWebSocket) => { + // State: session id bound after the first 'attach' frame. + let boundSessionId: string | null = null + + // Per-connection message handler. + const onMessage = (raw: Buffer | string): void => { + const text = typeof raw === 'string' ? raw : raw.toString('utf8') + const result = parseClientMessage(text) + + if (!result.ok) { + // Discard invalid frames; log without noisy stack traces (avoid noise per spec). + console.error('[server] invalid client message discarded:', result.error) + return + } + + const msg = result.message + + // ── First frame must be 'attach' ────────────────────────────────────── + if (boundSessionId === null) { + if (msg.type !== 'attach') { + // Protocol violation: first message was not attach. Ignore and wait. + console.error('[server] expected attach as first message, got:', msg.type) + return + } + + const dims = { + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + } + + let session + try { + session = manager.handleAttach(ws, msg.sessionId, dims, Date.now()) + } catch (err: unknown) { + // M4: spawn failure — send exit(-1) and close only this connection. + const reason = + err instanceof Error ? err.message : String(err) + safeSend(ws, serialize({ type: 'exit', code: -1, reason })) + ws.close() + return + } + + boundSessionId = session.meta.id + + // Confirm the attach to the client. + safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id })) + return + } + + // ── Subsequent frames: route input / resize ─────────────────────────── + const session = manager.get(boundSessionId) + if (session === undefined) { + // Session was reaped between messages — nothing to route to. + return + } + + if (msg.type === 'input') { + writeInput(session, msg.data) + } else if (msg.type === 'resize') { + resize(session, msg.cols, msg.rows) + } else if (msg.type === 'attach') { + // Re-attach after first bind is not expected; log and ignore. + console.error('[server] unexpected re-attach on established connection') + } + } + + ws.on('message', onMessage) + + // ── WS close ───────────────────────────────────────────────────────────── + ws.on('close', () => { + if (boundSessionId === null) return + + const session = manager.get(boundSessionId) + if (session === undefined) return + + // Invariant #2: never kill the PTY on WS close — detach only. + detachWs(session, Date.now()) + }) + + // ── WS error ───────────────────────────────────────────────────────────── + ws.on('error', (err: Error) => { + // Log and let the 'close' event (which always follows 'error') handle cleanup. + console.error('[server] ws error:', err.message) + }) + }) + + // ── Graceful shutdown helper ────────────────────────────────────────────── + function doShutdown(): void { + clearInterval(reapTimer) + manager.shutdown() + } + + // ── Signal handlers (SIGINT / SIGTERM) ──────────────────────────────────── + const onSignal = (): void => { + console.error('[server] shutdown signal received — cleaning up') + doShutdown() + httpServer.close(() => process.exit(0)) + } + + process.on('SIGINT', onSignal) + process.on('SIGTERM', onSignal) + + // ── uncaughtException: log and exit for truly unexpected errors ─────────── + // (spawn failures / send failures are handled above and must NOT reach here) + process.on('uncaughtException', (err: Error) => { + console.error('[server] uncaughtException — exiting:', err) + doShutdown() + process.exit(1) + }) + + // ── Start listening ─────────────────────────────────────────────────────── + httpServer.listen(cfg.port, cfg.bindHost, () => { + console.error( + `[server] listening on http://${cfg.bindHost}:${cfg.port} WS → ${cfg.wsPath}`, + ) + console.error('[server] note: run `npm run build:web` before connecting a browser') + }) + + // ── Return handle for tests / orchestrator ──────────────────────────────── + return { + close(): Promise { + return new Promise((resolve) => { + doShutdown() + httpServer.close(() => resolve()) + }) + }, + } +} + +// ── Entrypoint (npm start → tsx src/server.ts) ─────────────────────────────── +// Detect whether this module is the main entry point in both CJS and ESM. +const isMain = + // ESM: import.meta.url matches the invoked file URL + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) + +if (isMain) { + const cfg = loadConfig(process.env as Record) + startServer(cfg) +}