This commit is contained in:
Theo Browne
2026-02-22 00:22:42 -08:00
parent 1ab21883fb
commit a25097cd4a

221
server.ts
View File

@@ -1,4 +1,5 @@
import type { ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
import { timingSafeEqual } from "node:crypto";
import indexHtml from "./index.html"; import indexHtml from "./index.html";
import historyHtml from "./history.html"; import historyHtml from "./history.html";
import { getRounds, getAllRounds } from "./db.ts"; import { getRounds, getAllRounds } from "./db.ts";
@@ -50,9 +51,96 @@ const gameState: GameState = {
isPaused: false, isPaused: false,
}; };
// ── Guardrails ──────────────────────────────────────────────────────────────
type WsData = { ip: string };
const WINDOW_MS = 60_000;
const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(process.env.WS_UPGRADE_LIMIT_PER_MIN, 20);
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(process.env.HISTORY_LIMIT_PER_MIN, 120);
const ADMIN_LIMIT_PER_MIN = parsePositiveInt(process.env.ADMIN_LIMIT_PER_MIN, 10);
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000);
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
const MAX_HISTORY_PAGE = parsePositiveInt(process.env.MAX_HISTORY_PAGE, 100_000);
const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50);
const HISTORY_CACHE_TTL_MS = parsePositiveInt(process.env.HISTORY_CACHE_TTL_MS, 5_000);
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(process.env.MAX_HISTORY_CACHE_KEYS, 500);
const requestWindows = new Map<string, number[]>();
const wsByIp = new Map<string, number>();
const historyCache = new Map<string, { body: string; expiresAt: number }>();
let lastRateWindowSweep = 0;
let lastHistoryCacheSweep = 0;
function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getClientIp(req: Request, server: Bun.Server<WsData>): string {
return server.requestIP(req)?.address ?? "unknown";
}
function isRateLimited(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
if (now - lastRateWindowSweep >= windowMs) {
for (const [bucketKey, timestamps] of requestWindows) {
const recent = timestamps.filter((timestamp) => now - timestamp <= windowMs);
if (recent.length === 0) {
requestWindows.delete(bucketKey);
} else {
requestWindows.set(bucketKey, recent);
}
}
lastRateWindowSweep = now;
}
const existing = requestWindows.get(key) ?? [];
const recent = existing.filter((timestamp) => now - timestamp <= windowMs);
if (recent.length >= limit) {
requestWindows.set(key, recent);
return true;
}
recent.push(now);
requestWindows.set(key, recent);
return false;
}
function secureCompare(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}
function isAdminAuthorized(req: Request, url: URL): boolean {
const expected = process.env.ADMIN_SECRET;
if (!expected) return false;
const provided = req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
if (!provided) return false;
return secureCompare(provided, expected);
}
function decrementIpConnection(ip: string) {
const current = wsByIp.get(ip) ?? 0;
if (current <= 1) {
wsByIp.delete(ip);
return;
}
wsByIp.set(ip, current - 1);
}
function setHistoryCache(key: string, body: string, expiresAt: number) {
if (historyCache.size >= MAX_HISTORY_CACHE_KEYS) {
const firstKey = historyCache.keys().next().value;
if (firstKey) historyCache.delete(firstKey);
}
historyCache.set(key, { body, expiresAt });
}
// ── WebSocket clients ─────────────────────────────────────────────────────── // ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<unknown>>(); const clients = new Set<ServerWebSocket<WsData>>();
function broadcast() { function broadcast() {
const msg = JSON.stringify({ const msg = JSON.stringify({
@@ -70,7 +158,7 @@ function broadcast() {
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
const server = Bun.serve({ const server = Bun.serve<WsData>({
port, port,
routes: { routes: {
"/": indexHtml, "/": indexHtml,
@@ -78,47 +166,118 @@ const server = Bun.serve({
}, },
fetch(req, server) { fetch(req, server) {
const url = new URL(req.url); const url = new URL(req.url);
const ip = getClientIp(req, server);
if (url.pathname.startsWith("/assets/")) { if (url.pathname.startsWith("/assets/")) {
const path = `./public${url.pathname}`; const path = `./public${url.pathname}`;
const file = Bun.file(path); const file = Bun.file(path);
return new Response(file); return new Response(file, {
} headers: {
if (url.pathname === "/api/pause") { "Cache-Control": "public, max-age=604800, immutable",
const secret = url.searchParams.get("secret"); "X-Content-Type-Options": "nosniff",
if (process.env.ADMIN_SECRET && secret === process.env.ADMIN_SECRET) { },
gameState.isPaused = true;
broadcast();
return new Response("Paused", { status: 200 });
}
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname === "/api/resume") {
const secret = url.searchParams.get("secret");
if (process.env.ADMIN_SECRET && secret === process.env.ADMIN_SECRET) {
gameState.isPaused = false;
broadcast();
return new Response("Resumed", { status: 200 });
}
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname === "/api/history") {
const page = parseInt(url.searchParams.get("page") || "1", 10);
return new Response(JSON.stringify(getRounds(page)), {
headers: { "Content-Type": "application/json" }
}); });
} }
if (url.pathname === "/healthz") {
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
if (!isAdminAuthorized(req, url)) {
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname === "/api/pause") {
gameState.isPaused = true;
} else {
gameState.isPaused = false;
}
broadcast();
return new Response(url.pathname === "/api/pause" ? "Paused" : "Resumed", {
status: 200,
});
}
if (url.pathname === "/api/history") {
if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
const rawPage = parseInt(url.searchParams.get("page") || "1", 10);
const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10);
const page = Number.isFinite(rawPage) ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE) : 1;
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10;
const cacheKey = `${page}:${limit}`;
const now = Date.now();
if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) {
for (const [key, value] of historyCache) {
if (value.expiresAt <= now) historyCache.delete(key);
}
lastHistoryCacheSweep = now;
}
const cached = historyCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return new Response(cached.body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=5, stale-while-revalidate=30",
"X-Content-Type-Options": "nosniff",
},
});
}
const body = JSON.stringify(getRounds(page, limit));
setHistoryCache(cacheKey, body, now + HISTORY_CACHE_TTL_MS);
return new Response(body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=5, stale-while-revalidate=30",
"X-Content-Type-Options": "nosniff",
},
});
}
if (url.pathname === "/ws") { if (url.pathname === "/ws") {
const upgraded = server.upgrade(req); if (req.method !== "GET") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET" },
});
}
if (isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
if (clients.size >= MAX_WS_GLOBAL) {
return new Response("Service Unavailable", { status: 503 });
}
const existingForIp = wsByIp.get(ip) ?? 0;
if (existingForIp >= MAX_WS_PER_IP) {
return new Response("Too Many Requests", { status: 429 });
}
const upgraded = server.upgrade(req, { data: { ip } });
if (!upgraded) { if (!upgraded) {
return new Response("WebSocket upgrade failed", { status: 400 }); return new Response("WebSocket upgrade failed", { status: 400 });
} }
return undefined; return undefined;
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
}, },
websocket: { websocket: {
data: {} as WsData,
open(ws) { open(ws) {
clients.add(ws); clients.add(ws);
wsByIp.set(ws.data.ip, (wsByIp.get(ws.data.ip) ?? 0) + 1);
broadcast(); broadcast();
}, },
message(_ws, _message) { message(_ws, _message) {
@@ -126,6 +285,7 @@ const server = Bun.serve({
}, },
close(ws) { close(ws) {
clients.delete(ws); clients.delete(ws);
decrementIpConnection(ws.data.ip);
broadcast(); broadcast();
}, },
}, },
@@ -133,6 +293,13 @@ const server = Bun.serve({
hmr: true, hmr: true,
console: true, console: true,
}, },
error(error) {
log("ERROR", "server", "Unhandled fetch/websocket error", {
message: error.message,
stack: error.stack,
});
return new Response("Internal Server Error", { status: 500 });
},
}); });
console.log(`\n🎮 Qwipslop Web — http://localhost:${server.port}`); console.log(`\n🎮 Qwipslop Web — http://localhost:${server.port}`);