Replace each card's "+ start claude here" with a row of three brand-logo launcher buttons (logo + caption, touch-friendly, tooltips): - Claude (coral burst) → new terminal tab running `claude` - Codex (OpenAI mark) → new terminal tab running `codex` - VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default `code`) on the host via execFile (no shell); path validated as an existing absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added. Active-session highlight (user request): when a project has a running Claude session (Claude-hook status != unknown — plain shells/codex stay unknown), the Claude logo gets a coral ring + tint, with a count badge when more than one. New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts. onOpenProject gains an optional cmd; makeProjectCard exported for tests. Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher row (3 buttons, claude/codex commands), and the active-Claude highlight + badge. Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3 logos render with brand colours, and POST /open-in-editor actually launched VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
530 lines
22 KiB
TypeScript
530 lines
22 KiB
TypeScript
/**
|
|
* 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 net from 'node:net'
|
|
import { URL } from 'node:url'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import express from 'express'
|
|
import type { Response } 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 { parseHookEvent } from './http/hook.js'
|
|
import { listSessions } from './http/history.js'
|
|
import { buildProjects } from './http/projects.js'
|
|
import { openInEditor } from './http/editor.js'
|
|
import { createSessionManager } from './session/manager.js'
|
|
import { detachWs, writeInput, setClientDims } 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
|
|
|
|
// Width of the leaky-bucket window for per-connection WS rate limiting (10).
|
|
const RATE_WINDOW_MS = 1000
|
|
|
|
// Max chars of a user-controlled string written to a log line (M2 log injection).
|
|
const LOG_FIELD_MAX = 200
|
|
|
|
/** The decision JSON a PermissionRequest command hook writes to stdout. */
|
|
function permDecision(behavior: 'allow' | 'deny'): unknown {
|
|
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
|
|
}
|
|
|
|
/**
|
|
* True for loopback peers (hooks always run on the host). Accepts the whole
|
|
* 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
|
|
* address behind a local proxy / alternate loopback alias still passes.
|
|
*/
|
|
function isLoopback(ip: string): boolean {
|
|
if (ip === '::1') return true
|
|
// Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
|
|
const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
|
|
if (net.isIPv4(v4)) {
|
|
return v4.startsWith('127.')
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Strip control chars and truncate a user-controlled value before logging (M2). */
|
|
function sanitizeForLog(value: unknown): string {
|
|
return String(value)
|
|
.slice(0, LOG_FIELD_MAX)
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[\x00-\x1f]/g, '?')
|
|
}
|
|
|
|
// ── 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)
|
|
|
|
// H3: held PermissionRequest responses, keyed by sessionId. The express `res`
|
|
// is parked here until the client approves/rejects (or it times out).
|
|
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>()
|
|
|
|
function resolvePending(sessionId: string, decision: unknown): void {
|
|
const p = pendingApprovals.get(sessionId)
|
|
if (p === undefined) return
|
|
clearTimeout(p.timer)
|
|
pendingApprovals.delete(sessionId)
|
|
p.res.json(decision)
|
|
}
|
|
|
|
// ── Express static hosting ────────────────────────────────────────────────
|
|
const app = express()
|
|
|
|
// ── Security headers (Sec H2) ─────────────────────────────────────────────
|
|
// Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
|
|
// 'unsafe-inline' on style-src is required because xterm.js injects inline
|
|
// styles for the terminal viewport (verified: dropping it breaks rendering).
|
|
app.use((_req, res, next) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
res.setHeader('X-Frame-Options', 'DENY')
|
|
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
|
|
)
|
|
next()
|
|
})
|
|
|
|
// 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))
|
|
|
|
// ── Claude Code history (O2) — list past sessions for the resume browser ──
|
|
// SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
|
|
// first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
|
|
// any LAN device. That is consistent with the app's threat model — this whole
|
|
// app hands a full shell to anyone who can reach the port (no auth, LAN-only,
|
|
// never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
|
|
app.get('/sessions', async (_req, res) => {
|
|
res.json(await listSessions(50))
|
|
})
|
|
|
|
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
|
|
// Any LAN device opening the app fetches this to show the host's sessions as tabs.
|
|
app.get('/live-sessions', (_req, res) => {
|
|
res.json(manager.list())
|
|
})
|
|
|
|
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
|
|
app.get('/projects', async (_req, res) => {
|
|
try {
|
|
res.json(await buildProjects(cfg, manager.list()))
|
|
} catch (err) {
|
|
console.error('[server] /projects failed:', err instanceof Error ? err.message : String(err))
|
|
res.json([])
|
|
}
|
|
})
|
|
|
|
// Preview (v0.4 manage page) — the tail of a session's scrollback so the grid
|
|
// can render a live read-only thumbnail of its current screen. No client/attach.
|
|
app.get('/live-sessions/:id/preview', (req, res) => {
|
|
const session = manager.get(req.params.id)
|
|
if (session === undefined) {
|
|
res.status(404).end()
|
|
return
|
|
}
|
|
res.json({
|
|
id: session.meta.id,
|
|
cols: session.pty.cols,
|
|
rows: session.pty.rows,
|
|
data: session.buffer.tail(cfg.previewBytes),
|
|
})
|
|
})
|
|
|
|
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
|
|
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
|
|
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
|
|
// Origin whitelist; reject missing/foreign Origin with 403.
|
|
function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
|
|
const origin = req.headers['origin'] as string | undefined
|
|
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
|
|
res.status(403).end()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
|
|
app.delete('/live-sessions', (req, res) => {
|
|
if (!requireAllowedOrigin(req, res)) return
|
|
const onlyDetached = req.query['detached'] === '1'
|
|
let killed = 0
|
|
for (const s of manager.list()) {
|
|
if (onlyDetached && s.clientCount > 0) continue
|
|
if (manager.killById(s.id)) killed += 1
|
|
}
|
|
res.json({ killed })
|
|
})
|
|
|
|
// Kill one session by id — manage page per-row action.
|
|
app.delete('/live-sessions/:id', (req, res) => {
|
|
if (!requireAllowedOrigin(req, res)) return
|
|
const ok = manager.killById(req.params.id)
|
|
res.status(ok ? 204 : 404).end()
|
|
})
|
|
|
|
// Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
|
|
// logo). State-changing (spawns a GUI process), so it carries the same Origin
|
|
// guard as the DELETE routes. The path is validated + passed via execFile (no
|
|
// shell) in openInEditor.
|
|
app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
|
|
if (!requireAllowedOrigin(req, res)) return
|
|
const body = (req.body ?? {}) as Record<string, unknown>
|
|
const result = await openInEditor(cfg, body['path'])
|
|
if (result.ok) {
|
|
res.status(204).end()
|
|
return
|
|
}
|
|
res.status(result.status).json({ error: result.error })
|
|
})
|
|
|
|
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
|
// Hooks running inside a spawned shell POST status here (loopback only — the
|
|
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
|
app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
|
|
if (!isLoopback(req.socket.remoteAddress ?? '')) {
|
|
res.status(403).end()
|
|
return
|
|
}
|
|
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
|
|
if (ev === null) {
|
|
res.status(400).end()
|
|
return
|
|
}
|
|
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
|
|
res.status(204).end()
|
|
})
|
|
|
|
// H3: PermissionRequest hook — held until the client approves/rejects. The
|
|
// hook's curl writes our response (the decision JSON) to stdout for Claude.
|
|
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
|
|
if (!isLoopback(req.socket.remoteAddress ?? '')) {
|
|
res.status(403).end()
|
|
return
|
|
}
|
|
const sessionId = req.header('x-webterm-session')
|
|
const body = (req.body ?? {}) as Record<string, unknown>
|
|
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
|
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
|
|
|
|
// Nobody watching this tab (no session / no clients) → let Claude prompt itself.
|
|
if (sessionId === undefined || session === undefined || session.clients.size === 0) {
|
|
res.json({})
|
|
return
|
|
}
|
|
|
|
resolvePending(sessionId, {}) // clear any stale hold for this session
|
|
const timer = setTimeout(() => {
|
|
pendingApprovals.delete(sessionId)
|
|
res.json({}) // timeout → fall back to Claude's interactive prompt
|
|
manager.handleHookEvent(sessionId, 'idle')
|
|
}, cfg.permTimeoutMs)
|
|
pendingApprovals.set(sessionId, { res, timer })
|
|
|
|
// Show the approve/reject affordance on the client.
|
|
manager.handleHookEvent(sessionId, 'waiting', tool, true)
|
|
})
|
|
|
|
// ── 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 reapTimer = setInterval(() => {
|
|
manager.reapIdle(Date.now())
|
|
}, cfg.reapIntervalMs)
|
|
// 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 leaky-bucket rate limit (Sec M3). maxPayload caps frame
|
|
// SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
|
|
// CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
|
|
// legitimate burst); the warn log is throttled to once per window.
|
|
let windowStart = Date.now()
|
|
let msgsThisWindow = 0
|
|
let droppedThisWindow = 0
|
|
let warnedThisWindow = false
|
|
|
|
// Per-connection message handler.
|
|
const onMessage = (raw: Buffer | string): void => {
|
|
// ── Rate limit (leaky bucket over a 1s window) ────────────────────────
|
|
const now = Date.now()
|
|
if (now - windowStart >= RATE_WINDOW_MS) {
|
|
windowStart = now
|
|
msgsThisWindow = 0
|
|
droppedThisWindow = 0
|
|
warnedThisWindow = false
|
|
}
|
|
msgsThisWindow += 1
|
|
if (msgsThisWindow > cfg.maxMsgsPerSec) {
|
|
droppedThisWindow += 1
|
|
if (!warnedThisWindow) {
|
|
warnedThisWindow = true
|
|
console.error(
|
|
`[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
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:', sanitizeForLog(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(), msg.cwd)
|
|
} 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') {
|
|
// Latest-writer-wins: this device's dims drive the shared PTY size.
|
|
setClientDims(session, ws, msg.cols, msg.rows)
|
|
} else if (msg.type === 'approve') {
|
|
// H3: resolve the held PermissionRequest with allow.
|
|
resolvePending(boundSessionId, permDecision('allow'))
|
|
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
|
|
} else if (msg.type === 'reject') {
|
|
resolvePending(boundSessionId, permDecision('deny'))
|
|
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
|
|
} 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) {
|
|
// Session already gone — make sure no held approval dangles.
|
|
resolvePending(boundSessionId, {})
|
|
return
|
|
}
|
|
|
|
// Invariant #2: never kill the PTY on WS close — detach THIS client only
|
|
// (the PTY stays alive for other devices and for reconnect).
|
|
detachWs(session, ws, Date.now())
|
|
|
|
// H3: only release a held approval when the LAST viewer leaves. With
|
|
// multi-device sharing (v0.4), another device may still be looking at the
|
|
// same pending approval — closing one mirror must not cancel it for them.
|
|
if (session.clients.size === 0) {
|
|
resolvePending(boundSessionId, {})
|
|
}
|
|
})
|
|
|
|
// ── 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))
|
|
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
|
|
}
|
|
|
|
// ── uncaughtException: log and exit for truly unexpected errors ───────────
|
|
// (spawn failures / send failures are handled above and must NOT reach here)
|
|
const onUncaught = (err: Error): void => {
|
|
console.error('[server] uncaughtException — exiting:', err)
|
|
doShutdown()
|
|
process.exit(1)
|
|
}
|
|
|
|
process.on('SIGINT', onSignal)
|
|
process.on('SIGTERM', onSignal)
|
|
process.on('uncaughtException', onUncaught)
|
|
|
|
// Remove the process-level listeners this server registered, so repeated
|
|
// startServer()/close() cycles (tests, library use) don't leak handlers.
|
|
function removeProcessListeners(): void {
|
|
process.off('SIGINT', onSignal)
|
|
process.off('SIGTERM', onSignal)
|
|
process.off('uncaughtException', onUncaught)
|
|
}
|
|
|
|
// ── 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) => {
|
|
removeProcessListeners()
|
|
doShutdown()
|
|
httpServer.close(() => resolve())
|
|
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the
|
|
// close callback can fire instead of waiting for the keep-alive timeout.
|
|
httpServer.closeIdleConnections()
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
}
|