Phase 18: internet realtime collaboration (GM-authoritative) + server

- Shared Zod wire protocol (src/lib/sync/messages.ts): hosted/joined/snapshot/
  mapImage/error; player-safe projections only (enemy HP masked on the GM before
  broadcast via src/lib/combat/playerProjection.ts).
- wsSync adapter (src/lib/sync/wsSync.ts) behind the existing SyncAdapter seam:
  GM hostSession + debounced snapshot broadcast (useSessionBroadcaster), player
  joinSession into an ephemeral playerSessionStore, exponential-backoff reconnect
  with seamless room resume (GM secret). localSync remains the offline default.
- buildSnapshot (src/lib/sync/snapshot.ts) reuses the same projection as the local
  /play view, guaranteeing parity. Player view refactored into shared PlayerBoards;
  /play?room=CODE = networked read-only mode.
- SessionControl in the header: Host (optional password) → shareable room code/link.
- Server (server/): Fastify + @fastify/websocket + @fastify/static, in-memory
  GM-authoritative rooms (crypto room id/secret hashed, optional join password,
  TTL sweep, players strictly read-only), origin allowlist, per-frame size cap +
  rate limit, Zod validation. esbuild bundle (server/build.mjs), tsconfig.server.json.
- Dockerfile (multi-stage) + deploy/ttrpg.compose.yml (own project on external
  'proxy' net, Traefik labels for ttrpg.briggen.dev, non-root, no-new-privileges).
  CSP connect-src now allows same-origin wss:.

Tests: protocol round-trip, room hub (auth/password/resume/TTL/read-only), enemy
masking, Fastify+ws integration (host→join→snapshot, player push rejected), and a
two-context Playwright realtime spec (separate config) — GM hosts, player device
sees live combat with masked enemy HP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:49:31 +02:00
parent fb459ad92c
commit a8cb7d65f4
31 changed files with 1304 additions and 118 deletions
+17
View File
@@ -0,0 +1,17 @@
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
// Bundle the server + its shared `@/lib/sync` imports into one ESM file.
// npm deps (fastify, zod, …) stay external and are installed at runtime.
await build({
entryPoints: ['server/src/index.ts'],
outfile: 'server/dist/index.js',
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
packages: 'external',
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" },
alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) },
});
console.log('server bundled → server/dist/index.js');
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ttrpg-server",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"fastify": "^5.8.5",
"zod": "^3.25.76"
}
}
+72
View File
@@ -0,0 +1,72 @@
import path from 'node:path';
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
export function buildServer() {
const app = Fastify({ bodyLimit: MAX_PAYLOAD });
const hub = new RoomHub();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket, req) => {
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.close(1008, 'origin');
return;
}
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed;
try { parsed = clientMessageSchema.safeParse(JSON.parse(raw.toString())); } catch { return; }
if (!parsed.success) return;
const m = parsed.data;
switch (m.t) {
case 'host': hub.host(sender, m.password, m.resume); break;
case 'join': hub.join(sender, m.joinCode, m.password); break;
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); break;
}
});
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
});
});
app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() }));
// Serve the built SPA with history fallback (so /play?room=... deep-links work).
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
return reply.sendFile('index.html');
});
return app;
}
// Start unless imported by a test.
if (process.env.NODE_ENV !== 'test') {
const app = buildServer();
app.listen({ port: PORT, host: '0.0.0.0' })
.then((addr) => console.log(`ttrpg server on ${addr} (static: ${STATIC_DIR})`))
.catch((e) => { console.error(e); process.exit(1); });
}
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment node
import { describe, it, expect, afterAll } from 'vitest';
import { WebSocket } from 'ws';
import type { AddressInfo } from 'node:net';
import { buildServer } from './index';
import type { ClientMessage, ServerMessage } from '@/lib/sync/messages';
const app = buildServer();
let base = '';
async function start(): Promise<void> {
await app.listen({ port: 0, host: '127.0.0.1' });
const addr = app.server.address() as AddressInfo;
base = `ws://127.0.0.1:${addr.port}/ws`;
}
afterAll(async () => { await app.close(); });
function open(): Promise<WebSocket> {
const ws = new WebSocket(base);
return new Promise((res, rej) => { ws.on('open', () => res(ws)); ws.on('error', rej); });
}
function send(ws: WebSocket, msg: ClientMessage): void { ws.send(JSON.stringify(msg)); }
function next(ws: WebSocket, t: ServerMessage['t']): Promise<ServerMessage> {
return new Promise((resolve) => {
const onMsg = (raw: Buffer | ArrayBuffer | Buffer[]) => {
const m = JSON.parse(raw.toString()) as ServerMessage;
if (m.t === t) { ws.off('message', onMsg); resolve(m); }
};
ws.on('message', onMsg);
});
}
const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
describe('realtime server (integration)', () => {
it('host → join → snapshot flow; players cannot push state', async () => {
await start();
const gm = await open();
send(gm, { t: 'host', campaignId: 'c' });
const hosted = await next(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
expect(hosted.joinCode).toBeTruthy();
const player = await open();
send(player, { t: 'join', joinCode: hosted.joinCode });
await next(player, 'joined');
send(gm, { t: 'state', gmSecret: hosted.gmSecret, snapshot: snap });
const got = await next(player, 'snapshot') as Extract<ServerMessage, { t: 'snapshot' }>;
expect(got.snapshot.campaignName).toBe('Live');
// A player pushing state is rejected.
send(player, { t: 'state', gmSecret: 'forged', snapshot: snap });
const err = await next(player, 'error') as Extract<ServerMessage, { t: 'error' }>;
expect(err.code).toBe('forbidden');
gm.close(); player.close();
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { RoomHub, type Sender } from './rooms';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
function fake(): Sender & { msgs: ServerMessage[] } {
const msgs: ServerMessage[] = [];
return { msgs, send: (m) => msgs.push(m) };
}
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
function lastOf<T extends ServerMessage['t']>(s: ReturnType<typeof fake>, t: T): Extract<ServerMessage, { t: T }> | undefined {
return [...s.msgs].reverse().find((m): m is Extract<ServerMessage, { t: T }> => m.t === t);
}
describe('RoomHub', () => {
it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => {
const hub = new RoomHub();
const gm = fake();
hub.host(gm);
const hosted = lastOf(gm, 'hosted')!;
expect(hosted).toBeTruthy();
const code = (hosted as Extract<ServerMessage, { t: 'hosted' }>).joinCode;
const secret = (hosted as Extract<ServerMessage, { t: 'hosted' }>).gmSecret;
const player = fake();
hub.join(player, code);
expect(lastOf(player, 'joined')).toBeTruthy();
hub.state(gm, secret, snap);
expect(lastOf(player, 'snapshot')).toMatchObject({ snapshot: { campaignName: 'C' } });
});
it('rejects state from a non-GM and from a wrong secret', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const player = fake();
const code = (lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>).joinCode;
hub.join(player, code);
hub.state(player, 'whatever', snap);
expect(lastOf(player, 'error')?.code).toBe('forbidden');
hub.state(gm, 'wrong-secret', snap);
expect(lastOf(gm, 'error')?.code).toBe('forbidden');
});
it('enforces an optional join password', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm, 'sesame');
const code = (lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>).joinCode;
const p1 = fake(); hub.join(p1, code, 'nope');
expect(lastOf(p1, 'error')?.code).toBe('bad-password');
const p2 = fake(); hub.join(p2, code, 'sesame');
expect(lastOf(p2, 'joined')).toBeTruthy();
});
it('reports no-room for a bad code, and resumes a room by GM secret', () => {
const hub = new RoomHub();
const stray = fake(); hub.join(stray, 'ZZZZZZ');
expect(lastOf(stray, 'error')?.code).toBe('no-room');
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const gm2 = fake(); hub.host(gm2, undefined, hosted.gmSecret);
const resumed = lastOf(gm2, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
expect(resumed.roomId).toBe(hosted.roomId);
expect(hub.roomCount()).toBe(1);
});
it('caches + serves images and evicts idle rooms on sweep', () => {
let t = 0;
const hub = new RoomHub(() => t);
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
hub.image(gm, hosted.gmSecret, 'map1', 'data:abc');
const player = fake(); hub.join(player, hosted.joinCode);
hub.requestImage(player, 'map1');
expect(lastOf(player, 'mapImage')).toMatchObject({ id: 'map1', dataUrl: 'data:abc' });
t += 7 * 60 * 60 * 1000; // past TTL
hub.sweep();
expect(hub.roomCount()).toBe(0);
});
});
+145
View File
@@ -0,0 +1,145 @@
import crypto from 'node:crypto';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
export interface Sender {
send: (msg: ServerMessage) => void;
}
interface Room {
roomId: string;
joinCode: string;
gmSecretHash: string;
passwordHash: string | null;
gm: Sender | null;
players: Set<Sender>;
snapshot: Snapshot | null;
images: Map<string, string>;
lastActivity: number;
}
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
function sha256(s: string): string {
return crypto.createHash('sha256').update(s).digest('hex');
}
function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b);
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
/**
* In-memory, GM-authoritative room registry. No persistence. The GM is the only
* writer; players are read-only and the hub enforces it. `now` is injectable for tests.
*/
export class RoomHub {
private rooms = new Map<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player' }>();
constructor(private now: () => number = () => Date.now()) {}
private mintJoinCode(): string {
for (let attempt = 0; attempt < 50; attempt++) {
let code = '';
const bytes = crypto.randomBytes(6);
for (let i = 0; i < 6; i++) code += JOIN_ALPHABET[bytes[i]! % JOIN_ALPHABET.length];
if (!this.byCode.has(code)) return code;
}
return crypto.randomBytes(8).toString('hex').toUpperCase().slice(0, 6);
}
host(socket: Sender, password?: string, resume?: string): void {
// Resume an existing room if the GM presents its secret (seamless reconnect).
if (resume) {
const hash = sha256(resume);
for (const room of this.rooms.values()) {
if (timingEqual(room.gmSecretHash, hash)) {
room.gm = socket;
room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'gm' });
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
return;
}
}
}
const roomId = crypto.randomUUID();
const joinCode = this.mintJoinCode();
const gmSecret = crypto.randomBytes(32).toString('base64url');
const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret),
passwordHash: password ? sha256(password) : null,
gm: socket, players: new Set(), snapshot: null, images: new Map(), lastActivity: this.now(),
};
this.rooms.set(roomId, room);
this.byCode.set(joinCode, roomId);
this.conns.set(socket, { roomId, role: 'gm' });
socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
}
join(socket: Sender, joinCode: string, password?: string): void {
const roomId = this.byCode.get(joinCode.trim().toUpperCase());
const room = roomId ? this.rooms.get(roomId) : undefined;
if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; }
if (room.passwordHash && (!password || !timingEqual(room.passwordHash, sha256(password)))) {
socket.send({ t: 'error', code: 'bad-password', message: 'Wrong session password.' });
return;
}
room.players.add(socket);
room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'player' });
socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
}
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
room.snapshot = snapshot;
room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'snapshot', snapshot });
}
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
room.images.set(id, dataUrl);
room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
}
requestImage(socket: Sender, id: string): void {
const conn = this.conns.get(socket);
const room = conn ? this.rooms.get(conn.roomId) : undefined;
const dataUrl = room?.images.get(id);
if (dataUrl) socket.send({ t: 'mapImage', id, dataUrl });
}
disconnect(socket: Sender): void {
const conn = this.conns.get(socket);
if (!conn) return;
const room = this.rooms.get(conn.roomId);
if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
room.players.delete(socket);
}
this.conns.delete(socket);
}
/** Evict rooms idle past the TTL. Call periodically. */
sweep(): void {
const cutoff = this.now() - ROOM_TTL_MS;
for (const [id, room] of this.rooms) {
if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); }
}
}
roomCount(): number { return this.rooms.size; }
private gmRoom(socket: Sender, gmSecret: string): Room | null {
const conn = this.conns.get(socket);
if (!conn || conn.role !== 'gm') return null;
const room = this.rooms.get(conn.roomId);
if (!room || room.gm !== socket || !timingEqual(room.gmSecretHash, sha256(gmSecret))) return null;
return room;
}
}