feat: T14 server.ts — wiring (noServer/handleUpgrade, M4/M5/L3/L5)
Express static (public/+build/); WebSocketServer noServer + maxPayload (L5); upgrade gate: path!=wsPath->destroy, bad origin->401, else handleUpgrade (L3); connection waits attach, handleAttach in try/catch -> exit(-1)+close on spawn fail (M4); message routes input/resize; close->detach (never kill); SIGINT/TERM ->shutdown; reapIdle timer; safeSend guards readyState (M5). tsc clean.
This commit is contained in:
264
src/server.ts
Normal file
264
src/server.ts
Normal file
@@ -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<void> } {
|
||||||
|
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<void> {
|
||||||
|
return new Promise<void>((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<string, string | undefined>)
|
||||||
|
startServer(cfg)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user