Files
cup26/server/src/index.ts
T
NilsBriggen 9a31e9f4db Phase 1: live dashboard — fixtures, scores, group tables, bracket
- Shared domain model (types, team metadata/flags/aliases, FIFA-tiebreak standings)
- buildFixtures.ts: openfootball 2026 → normalized fixtures.json (104 matches,
  ISO-UTC kickoffs, pretty knockout placeholders)
- Fastify live layer: seed load, football-data.org poller + SofaScore enhancement
  (feature-flagged, fallback), dynamic live-window polling, WS snapshot push,
  REST /api/snapshot, dev /api/dev/score injection
- Client: tournament store (REST + WS + static-file fallback), MatchCard,
  TeamLabel, GroupTable, Live/Groups/Bracket pages, live connection indicator
- Verified: WS pushes a dev-injected result; standings recompute live in-browser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:04:31 +02:00

117 lines
4.7 KiB
TypeScript

import path from 'node:path';
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startLiveLoop } from './liveLoop';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const MAX_WS = Number(process.env.MAX_WS) || 200;
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ trustProxy: true, bodyLimit: 256 * 1024 });
// Tolerate body-less requests that still set content-type: application/json.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const state = new TournamentState(STATIC_DIR);
const clients = new Set<WebSocket>();
const snapshotMessage = (): string =>
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
const broadcast = (): void => {
const msg = snapshotMessage();
for (const ws of clients) {
try { ws.send(msg); } catch { /* closed */ }
}
};
// ---- REST: current snapshot (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot());
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
if (!IS_PROD) {
app.post('/api/dev/score', async (req, reply) => {
const b = (req.body ?? {}) as {
num?: number; homeScore?: number; awayScore?: number; status?: MatchStatus; minute?: number;
};
if (typeof b.num !== 'number') return reply.code(400).send({ error: 'num required' });
const status: MatchStatus = b.status ?? 'finished';
const f = state.applyScore(
b.num,
b.homeScore ?? null,
b.awayScore ?? null,
status,
b.minute ?? null,
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
broadcast();
return { ok: true, fixture: f };
});
}
// ---- WebSocket: push the snapshot on connect and on every change ----
void app.register(fastifyWebsocket);
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket: WebSocket, req: FastifyRequest) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.close(1008, 'origin');
return;
}
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
clients.add(socket);
try { socket.send(snapshotMessage()); } catch { /* closed */ }
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('close', () => { clearInterval(heartbeat); clients.delete(socket); });
socket.on('error', () => { clearInterval(heartbeat); clients.delete(socket); });
});
});
// ---- live polling loop (no-op unless a source is configured) ----
const stopLive = startLiveLoop(state, () => broadcast());
app.addHook('onClose', async () => stopLive());
// ---- static SPA (prod) with history fallback ----
if (existsSync(STATIC_DIR)) {
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');
if (req.raw.url?.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
return reply.sendFile('index.html');
});
}
return app;
}
if (process.env.NODE_ENV !== 'test') {
const app = buildServer();
app.listen({ port: PORT, host: '0.0.0.0' })
.then((addr) => console.log(`cup26 server on ${addr} (static: ${existsSync(STATIC_DIR) ? STATIC_DIR : 'dev — vite serves UI'})`))
.catch((e) => { console.error(e); process.exit(1); });
}