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