// 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]));