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(); 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); }); }