Operations in wizwar's shape: the determinism gate, backups, the rollup and the pulse

Every deploy first replays every production ledger with the engine about
to ship. The droplet gains a nightly rollup of counts and a nightly
backup to Spaces, a pulse script the pulse skill reads, rate limits on
opening rooms and taking seats, and eviction of idle rooms from memory
with reload from their ledgers on the next visit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-22 22:06:10 -04:00
co-authored by Claude Fable 5.1
parent 885dcf56e6
commit 9bd203ae60
13 changed files with 418 additions and 5 deletions
+18
View File
@@ -12,6 +12,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { RoomError, Rooms } from './rooms';
import { Store } from './store';
@@ -21,6 +22,21 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
const BODY_LIMIT = 16 * 1024;
const rooms = new Rooms(new Store(DATA_DIR));
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
const doors = new RateLimit(40, 60 * 60 * 1000);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
doors.prune();
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
}, 60 * 60 * 1000).unref();
/** The visitor's address as Caddy reports it, or the socket's when unproxied. */
function clientOf(req: IncomingMessage): string {
const forwarded = req.headers['x-forwarded-for'];
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim();
return first || req.socket.remoteAddress || '?';
}
function send(res: ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
@@ -59,6 +75,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
if (parts.length === 2 && req.method === 'POST') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many duels opened from here just now; try again later.', 429);
const body = await readBody(req);
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2));
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
@@ -73,6 +90,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
if (action === 'join') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
const seat = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}