// Courtside server entry point. One process: HTTP pages + WebSocket fan-out + SQLite. import { createServer } from 'node:http'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { WebSocketServer } from 'ws'; import { Store } from './store.js'; import { Registry } from './state.js'; import { Auth } from './auth.js'; import { buildRouter } from './routes.js'; import { HttpError, send, serveStatic } from './http.js'; import { publicState } from './public-state.js'; const here = dirname(fileURLToPath(import.meta.url)); const env = process.env; const PORT = Number(env.PORT ?? 3000); const HOST = env.HOST ?? '0.0.0.0'; const DB_PATH = env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite'); const store = new Store(DB_PATH); const registry = new Registry(store); const auth = new Auth({ password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET, trustCfAccess: env.TRUST_CF_ACCESS === 'true' }); const router = buildRouter({ registry, auth, store }); const staticRoot = join(here, 'public'); const server = createServer(async (req, res) => { const url = new URL(req.url, 'http://x'); try { if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length), url.searchParams.has('v')); const m = router.match(req.method, url.pathname); if (!m) throw new HttpError(404, 'Not found'); await m.handler(req, res, m.params); } catch (e) { const status = e instanceof HttpError ? e.status : 500; if (status === 500) console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`, e); if (!res.headersSent) send.html(res, `${status}

${status}

${escape(e.message)}

Home

`, status); else res.end(); } }); // ---------- WebSocket: /ws/ pushes the public state on every change ---------- const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 }); server.on('upgrade', (req, socket, head) => { const m = req.url.match(/^\/ws\/([a-z0-9-]+)\/?$/); const item = m && registry.get(m[1]); if (!item) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, ws => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', () => { /* clients never send; ignore */ }); const unsub = registry.subscribe(m[1], state => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(state)); }); ws.on('close', unsub); ws.send(JSON.stringify(publicState(item))); }); }); const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); } }, 30000); // ---------- ETA refresh: queue ETAs drift as time passes even with no results ---------- const etaTimer = setInterval(() => { for (const [slug, { t }] of registry.items) if (t.phase === 'live' && registry.subscribers.get(slug)?.size) registry.notify(slug); }, 60000); server.listen(PORT, HOST, () => console.log(`Courtside listening on http://${HOST}:${PORT} db=${DB_PATH} tournaments=${registry.items.size}`)); for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { console.log(`\n${sig}: shutting down`); clearInterval(heartbeat); clearInterval(etaTimer); for (const ws of wss.clients) ws.close(); server.close(() => { store.close(); process.exit(0); }); setTimeout(() => process.exit(0), 3000).unref(); }); function escape(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }