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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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); });
|
||||
}
|
||||
Reference in New Issue
Block a user