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:
@@ -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); });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user