Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process Node server with SQLite via node:sqlite and a WebSocket live board, organizer desk, QR landing page that follows the tournament phase, Dockerfile and compose with cloudflared, and documentation for deploying and running a day. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// 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));
|
||||
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, `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${status}</title><body style="font-family:sans-serif;padding:40px"><h1>${status}</h1><p>${escape(e.message)}</p><p><a href="/">Home</a></p>`, status);
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- WebSocket: /ws/<slug> 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])); }
|
||||
Reference in New Issue
Block a user