The room counts rounds played and sends the count in its view; a page sends it back with each move, and the server refuses a move whose count is behind (a slow connection, a second click, a stale tab), or that has no count at all (an old page, told to reload). Before, such a move was taken as the move for the next round. Found in Dip's acceptance run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
321 lines
15 KiB
TypeScript
321 lines
15 KiB
TypeScript
// The front door for games between people. Plain HTTP for actions, a
|
|
// websocket only to say "something changed, fetch the view again".
|
|
//
|
|
// POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices
|
|
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
|
// POST /api/rooms/:id/join {name} take the next seat
|
|
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
|
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
|
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
|
// POST /api/rooms/:id/turn {token, input} this seat's move
|
|
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
|
|
// {name, text} or from the gallery, signed, when the host allows it
|
|
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
|
|
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
|
|
// POST /api/rooms/:id/rematch {token} a finished table opens the next one
|
|
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
|
|
// POST /api/transfer {phrase} claim one
|
|
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
|
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
|
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
|
// POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies
|
|
// GET /api/tally what the ledgers add up to, for the hall
|
|
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
|
// a seat in the Peanut Gallery, counted for the table
|
|
//
|
|
// Caddy serves the static site and proxies /api and /ws here.
|
|
|
|
import * as Sentry from '@sentry/node';
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import { dirname } from 'node:path';
|
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
import { game } from '../../src/lib/game';
|
|
import { RateLimit } from './ratelimit';
|
|
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
|
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
|
import { Store } from './store';
|
|
import { Tallies } from './tally';
|
|
|
|
const PORT = Number(process.env.PORT ?? '__PORT__');
|
|
const HOST = process.env.HOST ?? '127.0.0.1';
|
|
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
|
|
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$/, '');
|
|
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
|
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
|
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
|
const BODY_LIMIT = 16 * 1024;
|
|
|
|
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
|
if (process.env.SENTRY_DSN) {
|
|
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: 'production', tracesSampleRate: 0 });
|
|
}
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error(reason);
|
|
Sentry.captureException(reason);
|
|
});
|
|
|
|
const store = new Store(DATA_DIR);
|
|
const rooms = new Rooms(game, store, { name: KEEPER, zone: KEEPER_TZ });
|
|
/** Reports live beside the room ledgers, not among them. */
|
|
const reports = new Reports(dirname(DATA_DIR));
|
|
const tallies = new Tallies(game, store);
|
|
/** 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);
|
|
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
|
const voices = new RateLimit(240, 60 * 60 * 1000);
|
|
/** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */
|
|
const desk = new RateLimit(6, 60 * 60 * 1000);
|
|
/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */
|
|
const galleryVoices = new RateLimit(30, 10 * 60 * 1000);
|
|
/** Calls to the keeper: a real person's phone rings for each. */
|
|
const bells = new RateLimit(3, 60 * 60 * 1000);
|
|
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
|
const claims = new RateLimit(10, 10 * 60 * 1000);
|
|
const MAX_AUDIENCE = 30;
|
|
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
|
setInterval(() => {
|
|
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
|
doors.prune();
|
|
voices.prune();
|
|
desk.prune();
|
|
galleryVoices.prune();
|
|
bells.prune();
|
|
claims.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 {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
res.end(status === 204 ? undefined : JSON.stringify(body));
|
|
}
|
|
|
|
function readBytes(req: IncomingMessage, limit: number, tooBig: string): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const declared = Number(req.headers['content-length'] ?? 0);
|
|
if (declared > limit) {
|
|
reject(new RoomError(tooBig, 413));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
let size = 0;
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (chunk: Buffer) => {
|
|
size += chunk.length;
|
|
if (size > limit) {
|
|
reject(new RoomError(tooBig, 413));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
const bytes = await readBytes(req, BODY_LIMIT, 'That is more than a move needs.');
|
|
if (bytes.length === 0) return {};
|
|
try {
|
|
return JSON.parse(bytes.toString('utf8')) as Record<string, unknown>;
|
|
} catch {
|
|
throw new RoomError('The request was not JSON.');
|
|
}
|
|
}
|
|
|
|
/** A report, an answer, or a call to the table is worth waking the keeper for; the desk's own replies are not. */
|
|
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
|
Sentry.captureMessage(title, { level: 'error', ...detail });
|
|
}
|
|
|
|
/** The seats a browser claims, kept to the ones its tokens prove. */
|
|
function provenSeats(raw: unknown): { roomId: string; seat: string }[] {
|
|
const claims = Array.isArray(raw) ? raw.slice(0, 50) : [];
|
|
const proven: { roomId: string; seat: string }[] = [];
|
|
for (const claim of claims) {
|
|
if (typeof claim !== 'object' || claim === null) continue;
|
|
const c = claim as Record<string, unknown>;
|
|
try {
|
|
const room = rooms.get(String(c.roomId ?? ''));
|
|
proven.push({ roomId: room.id, seat: rooms.seatOf(room, typeof c.token === 'string' ? c.token : undefined).id });
|
|
} catch {
|
|
// A seat this browser cannot prove is not its business.
|
|
}
|
|
}
|
|
return proven;
|
|
}
|
|
|
|
const DESK_BUSY = 'The desk has plenty from here for now; more in an hour.';
|
|
|
|
async function handleReports(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
|
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
|
if (parts[2] === 'mine' && parts.length === 3) {
|
|
const body = await readBody(req);
|
|
return send(res, 200, { reports: reports.mine(provenSeats(body.seats)) });
|
|
}
|
|
const report = reports.find(parts[2] ?? '');
|
|
if (!report) throw new RoomError('No such report.', 404);
|
|
if (parts[3] === 'image') {
|
|
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
|
reports.attachImage(report, await readBytes(req, IMAGE_MAX_BYTES, 'A picture of 2.5 MB at most.'));
|
|
return send(res, 204, {});
|
|
}
|
|
if (parts[3] === 'answer') {
|
|
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
|
const body = await readBody(req);
|
|
const room = rooms.get(String(body.roomId ?? report.roomId));
|
|
const seat = rooms.seatOf(room, typeof body.token === 'string' ? body.token : undefined);
|
|
if (room.id !== report.roomId) throw new RoomError('That report is not yours to answer.', 403);
|
|
reports.answer(report, seat, body.text);
|
|
ringBell(`${seat.name} answers on report ${report.id} (${room.id}): ${String(body.text ?? '').slice(0, 100)}`, {
|
|
fingerprint: ['report-answer', report.id, new Date().toISOString()],
|
|
tags: { room: room.id, report: report.id, player: seat.name },
|
|
extra: { text: body.text, link: `${PUBLIC_URL}/join/${room.id}` }
|
|
});
|
|
return send(res, 200, { ok: true });
|
|
}
|
|
throw new RoomError('Not here.', 404);
|
|
}
|
|
|
|
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
|
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
|
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
|
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
|
|
const body = await readBody(req);
|
|
return send(res, 200, rooms.claim(body.phrase));
|
|
}
|
|
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 rooms opened from here just now; try again later.', 429);
|
|
const body = await readBody(req);
|
|
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats), body.options);
|
|
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
|
|
}
|
|
|
|
const room = rooms.get(parts[2] ?? '');
|
|
const action = parts[3];
|
|
if (!action && req.method === 'GET') {
|
|
const token = url.searchParams.get('token');
|
|
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
|
const body = await readBody(req);
|
|
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
|
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, token: minted } = rooms.join(room, String(body.name ?? ''));
|
|
return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
|
|
}
|
|
if (action === 'bot') {
|
|
rooms.addBot(room, token);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'unseat') {
|
|
rooms.unseat(room, token, body.seat);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'begin') {
|
|
rooms.begin(room, token);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'turn') {
|
|
rooms.submit(room, token, body.input, body.resolved);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'say') {
|
|
if (!token) {
|
|
if (!galleryVoices.allow(clientOf(req))) throw new RoomError('The gallery has said plenty from here for now.', 429);
|
|
rooms.sayFromGallery(room, body.name, body.text);
|
|
return send(res, 200, rooms.view(room, SPECTATOR));
|
|
}
|
|
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
|
rooms.say(room, token, body.text);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'gallery') {
|
|
rooms.setGalleryTalk(room, token, body.on === true);
|
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
|
}
|
|
if (action === 'challenge') {
|
|
if (!bells.allow(clientOf(req))) throw new RoomError('The keeper has been called enough from here for one hour.', 429);
|
|
const seat = rooms.seatOf(room, token);
|
|
rooms.callKeeper(room, token, KEEPER);
|
|
const link = `${PUBLIC_URL}/join/${room.id}`;
|
|
// One issue per table, so each call rings once, with the door in the message.
|
|
ringBell(`${seat.name} challenges ${KEEPER} to a game — ${link}`, {
|
|
fingerprint: ['challenge', room.id],
|
|
tags: { room: room.id, challenger: seat.name },
|
|
extra: { link, players: room.seats.map((s) => s.name).join(', ') }
|
|
});
|
|
return send(res, 200, rooms.view(room, seat.id));
|
|
}
|
|
if (action === 'rematch') {
|
|
const next = rooms.rematch(room, token);
|
|
return send(res, next.created ? 201 : 200, { roomId: next.roomId, seat: next.seat?.id ?? null, token: next.token, created: next.created });
|
|
}
|
|
if (action === 'transfer') {
|
|
return send(res, 200, rooms.transfer(room, token));
|
|
}
|
|
if (action === 'report') {
|
|
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
|
const seat = token ? rooms.seatOf(room, token) : null;
|
|
const turn = room.state ? game.turn(room.state) : null;
|
|
const report = reports.file(room, seat, turn, body.happened, body.expected);
|
|
ringBell(`Report from ${report.player} in ${room.id}: ${report.happened.slice(0, 100)}`, {
|
|
fingerprint: ['report', report.id],
|
|
tags: { room: room.id, report: report.id, player: report.player },
|
|
extra: { happened: report.happened, expected: report.expected, turn: report.turn, seq: report.seq, link: `${PUBLIC_URL}/join/${room.id}` }
|
|
});
|
|
return send(res, 201, { id: report.id });
|
|
}
|
|
throw new RoomError('Not here.', 404);
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
handle(req, res).catch((err: unknown) => {
|
|
if (err instanceof RoomError) return send(res, err.status, { error: err.message });
|
|
console.error(err);
|
|
Sentry.captureException(err, { extra: { url: req.url, method: req.method } });
|
|
send(res, 500, { error: 'Something went wrong on the server.' });
|
|
});
|
|
});
|
|
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
server.on('upgrade', (req, socket, head) => {
|
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
if (url.pathname !== '/ws') return socket.destroy();
|
|
let seatId: string;
|
|
let room: ReturnType<typeof rooms.get>;
|
|
try {
|
|
room = rooms.get(url.searchParams.get('room') ?? '');
|
|
const token = url.searchParams.get('token');
|
|
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
|
|
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
|
|
} catch {
|
|
return socket.destroy();
|
|
}
|
|
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
|
const onChange = (changed: ReturnType<typeof rooms.get>) => {
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
|
|
};
|
|
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
|
ws.on('close', unsubscribe);
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
|
});
|