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,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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
Reference in New Issue
Block a user