import path from 'node:path'; import { existsSync, readFileSync } 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 { startScheduler } from './ingest/scheduler'; import { ModelEngine } from './model'; import { buildPreview, buildTeamProfile } from './preview'; import { buildScoreboard, snapshotCheck } from './scoreboard'; import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult } from './db/db'; import { inPlayProbs } from '../../src/lib/model/inplay'; import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push'; import type { EspnSummary } from './ingest/espnNormalize'; import { loadStatic } from './preview'; import { canonicalTeam } from '../../src/lib/teams'; 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); } }); db(); // open + migrate SQLite before anything reads it const state = new TournamentState(STATIC_DIR); // Restore persisted results over the seed — the in-memory snapshot forgets // them on restart once ESPN's scoreboard window has moved past a match day. let restored = 0; for (const r of allFixtureResults()) { if (r.status === 'scheduled') continue; if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++; } if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`); const model = new ModelEngine(STATIC_DIR); model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament) model.recompute(state.allFixtures()); const clients = new Set(); const snapshotMessage = (): string => JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage); const predictionsMessage = (): string => JSON.stringify({ t: 'predictions', model: model.current() } satisfies ServerMessage); const sendAll = (msg: string): void => { for (const ws of clients) { try { ws.send(msg); } catch { /* closed */ } } }; /** Re-push the snapshot, and predictions too if the finished-result set moved. */ const notifier = new PushNotifier(); const broadcast = (): void => { sendAll(snapshotMessage()); if (model.recompute(state.allFixtures())) sendAll(predictionsMessage()); try { notifier.tick(state.allFixtures()); } catch { /* never block a broadcast */ } }; // Record the in-play win probability per game state while matches run — // the momentum curve on match pages. Lambdas are the frozen pre-match // expectation (predictions only recompute when the finished set changes). const recordInplayTick = (): void => { const preds = new Map(model.current().matches.map((m) => [m.num, m])); for (const f of state.allFixtures()) { if (f.status !== 'live' || f.homeScore == null || f.awayScore == null) continue; const pred = preds.get(f.num); if (!pred) continue; const minute = f.minute ?? 0; const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore); recordInplay(f.num, { minute, home_score: f.homeScore, away_score: f.awayScore, p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4), }); } }; // ---- REST: snapshot + predictions (initial load + a no-WS fallback) ---- app.get('/api/snapshot', async () => state.snapshot()); app.get('/api/predictions', async () => model.current()); app.get('/api/sources/health', async () => ({ sources: healthAll() })); // Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input) app.get('/api/odds', async () => ({ odds: latestOddsAll() })); // In-play win-probability history — the momentum curve on match pages. app.get('/api/inplay/:num', async (req) => { const num = Number((req.params as { num: string }).num); return { points: Number.isFinite(num) ? inplayHistory(num) : [] }; }); // Web Push (feature-flagged on VAPID env): key discovery + subscriptions. app.get('/api/push/key', async (_req, reply) => pushEnabled ? { key: vapidPublicKey } : reply.code(404).send({ error: 'push disabled' }), ); app.post('/api/push/subscribe', async (req, reply) => { if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' }); const b = (req.body ?? {}) as { subscription?: { endpoint?: string }; team?: string }; if (!b.subscription?.endpoint || !b.team) return reply.code(400).send({ error: 'subscription + team required' }); pushSubscribe(b.subscription as { endpoint: string }, b.team); return { ok: true }; }); app.post('/api/push/unsubscribe', async (req, reply) => { if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' }); const b = (req.body ?? {}) as { endpoint?: string }; if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' }); pushUnsubscribe(b.endpoint); return { ok: true }; }); // Rich match data captured from FotMob/FIFA/Open-Meteo, projected for the // match center: oriented shots with xG, momentum, POTM, official info, weather. app.get('/api/match-rich/:num', async (req, reply) => { const num = Number((req.params as { num: string }).num); if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' }); interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string } interface FmDetails { homeTeamId?: number | null; shots?: FmShot[]; momentum?: { main?: { data?: { minute: number; value: number }[] } } | null; matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null; } const fm = getMatchProvider(num, 'fotmob'); const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info'); const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather'); const homeId = fm?.data.homeTeamId ?? null; const shots = (fm?.data.shots ?? []).map((s) => ({ isHome: homeId != null ? s.teamId === homeId : null, player: s.playerName ?? '', x: s.x ?? null, y: s.y ?? null, min: s.min ?? null, xg: s.expectedGoals ?? null, xgot: s.expectedGoalsOnTarget ?? null, type: s.eventType ?? '', situation: s.situation ?? '', })); const xg = shots.reduce( (acc, s) => { if (s.isHome === true) acc.home += s.xg ?? 0; else if (s.isHome === false) acc.away += s.xg ?? 0; return acc; }, { home: 0, away: 0 }, ); const potmRaw = fm?.data.matchFacts?.playerOfTheMatch; return { shots, xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null, momentum: fm?.data.momentum?.main?.data ?? [], potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null, info: fifaInfo ? { attendance: fifaInfo.data.attendance, stadium: fifaInfo.data.stadium, referee: fifaInfo.data.officials?.find((o) => o.TypeLocalized?.[0]?.Description === 'Referee')?.Name?.[0]?.Description ?? null, } : null, weather: weather ? weather.data : null, }; }); // Golden Boot: per-player goals aggregated from structured ESPN scoring // plays across every started match (own goals + shootout kicks excluded). app.get('/api/goldenboot', async () => { const tally = new Map(); for (const f of state.allFixtures()) { if (f.status === 'scheduled') continue; const ext = getMatchExt(f.num); if (!ext) continue; const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name])); for (const e of ext.data.events) { if (!e.scoring || e.shootout || !e.scorer) continue; if (/own goal/i.test(e.type)) continue; const team = (e.teamId && teamName.get(e.teamId)) ?? ''; const key = `${e.scorer}|${team}`; const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff }; cur.goals += 1; if (f.kickoff > cur.latest) cur.latest = f.kickoff; tally.set(key, cur); } } const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25); return { players }; }); app.get('/api/scoreboard', async () => buildScoreboard(state)); // The data lake, quantified — counters for the /data page. app.get('/api/data/stats', async () => { const safe = (fn: () => T): T | null => { try { return fn(); } catch { return null; } }; return { lake: safe(() => loadStatic('lakestats.json')), archive: safe(() => { const r = loadStatic<{ matchesProcessed: number; asOf: string }>('ratings.json'); const h = loadStatic>('h2h.json'); return { results: r.matchesProcessed, asOf: r.asOf, h2hPairings: Object.keys(h).length }; }), fingerprints: safe(() => Object.keys(loadStatic<{ teams: Record }>('fingerprints.json').teams).length), squadValues: safe(() => Object.keys(loadStatic<{ teams: Record }>('squadvalues.json').teams).length), db: dbCounts(), sources: healthAll(), }; }); app.get('/api/odds/:num', async (req, reply) => { const num = Number((req.params as { num: string }).num); if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' }); return { history: oddsFor(num) }; }); app.get('/api/preview/:num', async (req, reply) => { const num = Number((req.params as { num: string }).num); const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null; return preview ?? reply.code(404).send({ error: 'no such fixture' }); }); app.get('/api/team/:name', async (req, reply) => { const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name)); const profile = buildTeamProfile(name, state, model); return profile ?? reply.code(404).send({ error: 'no such team' }); }); 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' }); saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status); snapshotCheck(state, model); recordInplayTick(); 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()); socket.send(predictionsMessage()); } 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); }); }); }); // ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ---- const stopIngest = startScheduler(state, () => broadcast(), () => { const taken = snapshotCheck(state, model); if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`); try { recordInplayTick(); } catch { /* non-fatal */ } }); app.addHook('onClose', async () => stopIngest()); // ---- nightly Team-DC refit (backtest-validated): learn from this tournament ---- const refitTimer = setInterval(() => { if (model.refitTeamDc(state.allFixtures())) broadcast(); }, 24 * 60 * 60 * 1000); // The boot refit ran before ingestion restored finished matches — run once // more after the boot sweep settles so a restart keeps tournament context. const refitOnce = setTimeout(() => { if (model.refitTeamDc(state.allFixtures())) broadcast(); }, 5 * 60_000); app.addHook('onClose', async () => { clearInterval(refitTimer); clearTimeout(refitOnce); }); // ---- static SPA (prod) with history fallback + per-route OG meta ---- if (existsSync(STATIC_DIR)) { void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); // Shared links unfurl with the model's numbers: swap and inject // og: meta for /match/:num and /team/:name before serving the SPA shell. const indexHtml = readFileSync(`${STATIC_DIR}/index.html`, 'utf8'); const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"'); const withMeta = (title: string, desc: string): string => indexHtml.replace( /<title>[^<]*<\/title>/, `<title>${esc(title)}\n \n \n `, ); const ogFor = (url: string): string | null => { try { const mMatch = /^\/match\/(\d+)(?:\?|$)/.exec(url); if (mMatch) { const f = state.allFixtures().find((x) => x.num === Number(mMatch[1])); if (!f) return null; const pred = model.current().matches.find((m) => m.num === f.num); const score = f.homeScore != null && f.status !== 'scheduled' ? ` ${f.homeScore}–${f.awayScore}` : ''; const odds = pred ? ` · Model: ${Math.round(pred.probs.home * 100)}/${Math.round(pred.probs.draw * 100)}/${Math.round(pred.probs.away * 100)}` : ''; return withMeta( `${f.home.label}${score || ' vs'} ${f.away.label} — Cup26`, `World Cup 2026 · ${f.venue}${odds} (model probabilities, not betting advice)`, ); } const mTeam = /^\/team\/([^/?]+)(?:\?|$)/.exec(url); if (mTeam) { const team = decodeURIComponent(mTeam[1]!); const odds = model.current().odds.find((o) => o.team === team); if (!odds) return null; return withMeta( `${team} — Cup26`, `World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`, ); } } catch { /* fall through to the plain shell */ } return null; }; app.setNotFoundHandler((req, reply) => { const url = req.raw.url ?? '/'; if (url.startsWith('/ws')) return reply.code(426).send('Upgrade Required'); if (url.startsWith('/api')) return reply.code(404).send({ error: 'not found' }); const html = ogFor(url); if (html) return reply.type('text/html; charset=utf-8').send(html); 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); }); }