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:
2026-09-03 20:31:46 +00:00
co-authored by Claude Fable 5.1
commit 5ade592384
39 changed files with 3103 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
// A very small router and helpers over node:http. No framework so the deploy has no
// dependency surface beyond `ws` and `qrcode`.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
export class Router {
constructor() { this.routes = []; }
add(method, pattern, handler) {
const keys = [];
const re = new RegExp('^' + pattern.replace(/\//g, '\\/').replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^\\/]+)'; }) + '\\/?$');
this.routes.push({ method, re, keys, handler });
return this;
}
get(p, h) { return this.add('GET', p, h); }
post(p, h) { return this.add('POST', p, h); }
match(method, path) {
for (const r of this.routes) {
if (r.method !== method) continue;
const m = path.match(r.re);
if (m) return { handler: r.handler, params: Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])) };
}
return null;
}
}
export class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
export async function readBody(req, limit = 64 * 1024) {
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > limit) throw new HttpError(413, 'Body too large'); chunks.push(c); }
const raw = Buffer.concat(chunks).toString('utf8');
const ct = req.headers['content-type'] ?? '';
if (ct.includes('application/json')) return raw ? JSON.parse(raw) : {};
if (ct.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw));
return raw;
}
export function parseCookies(req) {
return Object.fromEntries((req.headers.cookie ?? '').split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf('='); return [s.slice(0, i), decodeURIComponent(s.slice(i + 1))]; }));
}
export const send = {
html(res, body, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(body); },
json(res, obj, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(JSON.stringify(obj)); },
redirect(res, to, headers = {}) { res.writeHead(303, { location: to, ...headers }); res.end(); },
text(res, body, status = 200) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); res.end(body); },
};
const MIME = { '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.json': 'application/json' };
export async function serveStatic(res, root, urlPath) {
const p = normalize(join(root, urlPath));
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
try {
const s = await stat(p);
if (!s.isFile()) throw new HttpError(404, 'Not found');
const body = await readFile(p);
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': 'public, max-age=300' });
res.end(body);
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
}
/** Signed cookie values: `payload.signature`. */
export function sign(value, secret) { return `${value}.${createHmac('sha256', secret).update(value).digest('base64url')}`; }
export function verify(signed, secret) {
if (!signed || !signed.includes('.')) return null;
const i = signed.lastIndexOf('.');
const value = signed.slice(0, i), sig = signed.slice(i + 1);
const expected = createHmac('sha256', secret).update(value).digest('base64url');
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
return value;
}
export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));