Files
waving-hands/server/src/index.ts
T

536 lines
27 KiB
TypeScript

// The front door for duels between people. Plain HTTP for actions, a
// websocket only to say "something changed, fetch the view again".
//
// POST /api/rooms {name, size?} create a duel and take seat A
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} the host seats a bot, before the duel begins
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
// 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} from the gallery, when the host has let it talk
// POST /api/rooms/:id/gallery {token, on} the host lets the gallery talk, or hushes it
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another wizard's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a wizard's seat to a bot for the rest of the duel
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
// POST /api/seats {seats: [{roomId, token}], forget?: [roomId]} bank this browser's seats in a cookie the server
// sets, and get back every seat the cookie holds; a browser whose storage was
// cleared gets its seats from the cookie, which clearing storage leaves alone
// POST /api/seats/find {name, roomId?} the seats a name holds at duels not yet over: those last used from this
// address are re-seated and returned; the rest only counted
// 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
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
// 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
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
//
// Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/waving-hands-reseat.sh sends it):
//
// GET /keeper/seats?name= every table not yet over where a person of that name sits, for a player who lost the codes too
// GET /keeper/rooms/:id who sits at the table, and when each wizard last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { WebSocketServer, WebSocket } from 'ws';
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';
import { dirname, join } from 'node:path';
const PORT = Number(process.env.PORT ?? 8788);
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
/** 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';
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/waving-hands/keeper.key';
const BODY_LIMIT = 16 * 1024;
/** The seats a browser holds, banked in a cookie: the browser keeps it for the server alone, and sends it to the API only. */
const SEATS_COOKIE = 'seats';
/** As long as a browser will keep a cookie. */
const SEATS_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
/** The newest seats a cookie carries; the rest fall off the end. */
const SEATS_COOKIE_KEEP = 60;
// 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 rooms = new Rooms(new Store(DATA_DIR), { 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(new Store(DATA_DIR));
const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, '');
/** 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);
/** Searches for lost seats: each reads every duel written to lately, and a person loses their seats rarely. */
const searches = new RateLimit(5, 10 * 60 * 1000);
const MAX_AUDIENCE = 30;
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
const PING_MS = Number(process.env.PING_MS ?? 30_000);
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();
searches.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): Promise<Buffer> {
return new Promise((resolve, reject) => {
const declared = Number(req.headers['content-length'] ?? 0);
if (declared > limit) {
reject(new RoomError('A picture of 2.5 MB at most.', 413));
req.destroy();
return;
}
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > limit) {
reject(new RoomError('A picture of 2.5 MB at most.', 413));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
/** A report or an answer 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 });
}
/** Addresses go on the ledgers as salted hashes. The salt is made once, beside the ledgers, and never leaves the host. */
const SALT_FILE = join(dirname(DATA_DIR), 'addr.salt');
function addrSalt(): string {
try {
const salt = readFileSync(SALT_FILE, 'utf8').trim();
if (salt) return salt;
} catch {
// No salt yet: this is the first start.
}
const salt = randomBytes(32).toString('base64url');
writeFileSync(SALT_FILE, salt + '\n', { mode: 0o600 });
return salt;
}
const salt = addrSalt();
/** The visitor's address as the ledgers note it: a hash a reader of the ledger cannot turn back into the address. */
function addrOf(req: IncomingMessage): string {
return createHmac('sha256', salt).update(clientOf(req)).digest('hex').slice(0, 16);
}
/**
* Whether the request was sent by this site's own pages. A page elsewhere could have a visitor's browser post here
* on its behalf; the browser names that page's origin, and the request is refused. The cookie is not sent across
* sites in any case, so what such a page could do is re-key a visitor's own seats under a name, to no end but nuisance.
*/
function fromThisSite(req: IncomingMessage): boolean {
const origin = req.headers.origin;
if (!origin) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
}
}
/** The seats the browser's cookie banks, as room and token; a cookie this server never wrote reads as none. */
function bankedSeats(req: IncomingMessage): { roomId: string; token: string }[] {
const header = req.headers.cookie ?? '';
const pair = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${SEATS_COOKIE}=`));
if (!pair) return [];
return pair
.slice(SEATS_COOKIE.length + 1)
.split(':')
.map((entry) => entry.split('.'))
.filter((e): e is [string, string] => e.length === 2 && /^[A-Z0-9]{4}$/.test(e[0]) && /^[A-Za-z0-9_-]{1,64}$/.test(e[1]))
.map(([roomId, token]) => ({ roomId, token }));
}
/** The cookie written for the seats given, newest last; sent only to the API, only over https where there is any, never to a script. */
function bankSeats(req: IncomingMessage, res: ServerResponse, seats: { roomId: string; token: string }[]): void {
const value = seats.slice(-SEATS_COOKIE_KEEP).map((s) => `${s.roomId}.${s.token}`).join(':');
const secure = req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
res.setHeader('set-cookie', `${SEATS_COOKIE}=${value}; Path=/api; Max-Age=${SEATS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
}
/** The seats a browser claims, from its cookie and its own storage, kept to the ones their tokens still open, each room once. */
function provenBank(req: IncomingMessage, claimed: unknown, forget: unknown): { roomId: string; seat: string; token: string }[] {
const dropped = new Set(Array.isArray(forget) ? forget.slice(0, 50).map((r) => String(r).toUpperCase()) : []);
const own = Array.isArray(claimed) ? claimed.slice(0, SEATS_COOKIE_KEEP) : [];
const byRoom = new Map<string, string>();
// The browser's own copy is the fresher where the two differ, so it is taken last.
for (const c of [...bankedSeats(req), ...own]) {
if (typeof c !== 'object' || c === null) continue;
const { roomId, token } = c as Record<string, unknown>;
if (typeof roomId !== 'string' || typeof token !== 'string' || !token) continue;
const id = roomId.toUpperCase();
if (!dropped.has(id)) byRoom.set(id, token);
}
const proven: { roomId: string; seat: string; token: string }[] = [];
for (const [roomId, token] of byRoom) {
try {
const room = rooms.get(roomId);
proven.push({ roomId: room.id, seat: rooms.seatOf(room, token).id, token });
} catch {
// A seat whose token no longer opens is not banked again.
}
}
return proven;
}
/** 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;
}
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('The desk has plenty from here for now; more in an hour.', 429);
reports.attachImage(report, await readBytes(req, IMAGE_MAX_BYTES));
return send(res, 204, {});
}
if (parts[3] === 'answer') {
if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 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);
}
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new RoomError('That is more than a turn needs.', 413));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (chunks.length === 0) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new RoomError('The request was not JSON.'));
}
});
req.on('error', reject);
});
}
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
function keeperKeyGiven(req: IncomingMessage): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
/**
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
* forwarded is turned away besides, and so is one without the keeper's key.
*/
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[], url: URL): Promise<void> {
if (req.headers['x-forwarded-for'] || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
if (parts[1] === 'seats' && parts.length === 2 && req.method === 'GET') return send(res, 200, { tables: rooms.tablesForKeeper(url.searchParams.get('name') ?? '') });
if (parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
const room = rooms.get(parts[2] ?? '');
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
const { seat, token } = rooms.reseat(room, body.seat);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
rooms.keeperSays(room, KEEPER, body.text);
return send(res, 204, {});
}
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] === 'keeper') return handleKeeper(req, res, parts, url);
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] === 'seats' && req.method === 'POST') {
if (!fromThisSite(req) || !String(req.headers['content-type'] ?? '').startsWith('application/json')) throw new RoomError('Not from here.', 403);
const body = await readBody(req);
if (parts.length === 2) {
const seats = provenBank(req, body.seats, body.forget);
bankSeats(req, res, seats);
return send(res, 200, { seats });
}
if (parts[2] === 'find' && parts.length === 3) {
if (!searches.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429);
const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined);
// The seats given back are banked at once, so the cookie and the browser agree.
if (found.seated.length) bankSeats(req, res, provenBank(req, found.seated, []));
return send(res, 200, found);
}
throw new RoomError('Not here.', 404);
}
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, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2));
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));
rooms.seen(room, token, addrOf(req));
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);
if (typeof body.token === 'string' && body.token) rooms.seen(room, body.token, addrOf(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, 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, typeof body.token === 'string' ? body.token : undefined);
const seat = rooms.seatOf(room, body.token as string);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'unseat') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.unseat(room, token, body.seat);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'begin') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.begin(room, token);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'turn') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.submit(room, token, body.input, body.resolved);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429);
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
const seat = token ? rooms.seatOf(room, token) : null;
const report = reports.file(room, seat, 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 });
}
if (action === 'say') {
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
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') {
const token = typeof body.token === 'string' ? body.token : undefined;
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 token = typeof body.token === 'string' ? body.token : undefined;
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 duel — ${link}`, {
fingerprint: ['challenge', room.id],
tags: { room: room.id, challenger: seat.name },
extra: { link, wizards: room.seats.map((s) => s.name).join(', ') }
});
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'rematch') {
const next = rooms.rematch(room, typeof body.token === 'string' ? body.token : undefined);
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, typeof body.token === 'string' ? body.token : undefined));
}
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, typeof body.token === 'string' ? body.token : undefined, body.seat));
}
if (action === 'standin') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).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.' });
});
});
// The socket only ever speaks to the browser; anything the browser sends is ignored, so it may send almost nothing.
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 });
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<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<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);
answered.set(ws, true);
ws.on('pong', () => answered.set(ws, true));
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate());
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
});
/**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds.
*/
const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => {
for (const ws of wss.clients) {
if (!answered.get(ws)) {
ws.terminate();
continue;
}
answered.set(ws, false);
ws.ping();
}
}, PING_MS).unref();
server.listen(PORT, HOST, () => {
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});