diff --git a/README.md b/README.md
index 2afa7f2..3497eee 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,12 @@ API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120
```
+On startup, the web server preloads the critical public snapshots before
+signalling PM2 `ready`: team leaderboard, player leaderboard, home teams, and
+recent games. `/health` includes a `public_data` block with the latest preload
+status. A same-origin `POST /api/cache/prewarm` refreshes those snapshots on
+demand.
+
## Reverse proxy / Cloudflare
The server only honours `CF-Connecting-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`,
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8801076..5c6f1ce 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1074,28 +1074,11 @@ function GatedAppContent() {
}
function AppContent() {
- const bootData = window.__TSS_BOOT_DATA__ || {}
const [route, setRoute] = useState(() => parseRoute())
- const [leaderboard, setLeaderboard] = useState(() => (
- bootData.leaderboard
- ? { status: 'ready', data: bootData.leaderboard, error: null }
- : { status: 'idle', data: null, error: null }
- ))
- const [playerLeaderboard, setPlayerLeaderboard] = useState(() => (
- bootData.playerLeaderboard
- ? { status: 'ready', data: bootData.playerLeaderboard, error: null }
- : { status: 'idle', data: null, error: null }
- ))
- const [homeTeams, setHomeTeams] = useState(() => (
- bootData.homeTeams
- ? { status: 'ready', data: bootData.homeTeams, error: null }
- : { status: 'idle', data: null, error: null }
- ))
- const [live, setLive] = useState(() => (
- bootData.live
- ? { status: 'ready', data: bootData.live, error: null, updatedAt: Date.now() }
- : { status: 'idle', data: null, error: null, updatedAt: 0 }
- ))
+ const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
+ const [playerLeaderboard, setPlayerLeaderboard] = useState({ status: 'idle', data: null, error: null })
+ const [homeTeams, setHomeTeams] = useState({ status: 'idle', data: null, error: null })
+ const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 })
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index f4199eb..5671800 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -2,53 +2,4 @@ import { createRoot } from 'react-dom/client'
import './styles.css'
import App from './App.jsx'
-const root = document.getElementById('root')
-
-async function fetchBootJson(path) {
- const response = await fetch(path, { headers: { Accept: 'application/json' } })
- if (!response.ok) throw new Error(`Boot request failed with ${response.status}`)
- return response.json()
-}
-
-async function preloadBootData() {
- if (!root?.dataset.tssFallback) return
-
- const pathname = window.location.pathname
- const timeout = new Promise((_, reject) => {
- window.setTimeout(() => reject(new Error('Boot preload timed out')), 1500)
- })
-
- const load = async () => {
- if (pathname === '/') {
- const [homeTeams, live] = await Promise.all([
- fetchBootJson('/data/home-teams.json'),
- fetchBootJson('/data/recent-games.json'),
- ])
- return { homeTeams, live }
- }
-
- if (pathname === '/teams') {
- return { leaderboard: await fetchBootJson('/data/leaderboard-teams.json') }
- }
-
- if (pathname === '/players') {
- return { playerLeaderboard: await fetchBootJson('/data/leaderboard-players.json') }
- }
-
- if (pathname === '/battle-logs' || pathname === '/live') {
- const [live, leaderboard] = await Promise.all([
- fetchBootJson('/data/recent-games.json'),
- fetchBootJson('/data/leaderboard-teams.json'),
- ])
- return { live, leaderboard }
- }
-
- return null
- }
-
- window.__TSS_BOOT_DATA__ = await Promise.race([load(), timeout]).catch(() => null)
-}
-
-preloadBootData().finally(() => {
- createRoot(root).render()
-})
+createRoot(document.getElementById('root')).render()
diff --git a/server.cjs b/server.cjs
index 133f308..abee758 100644
--- a/server.cjs
+++ b/server.cjs
@@ -212,6 +212,7 @@ let uptimeDb = null
let analyticsDb = null
let latestUptimeSnapshot = null
let publicDataPrewarmTimer = null
+let publicDataStartupStatus = { ready: false, checked_at: null, results: [] }
function sendJson(res, status, body, headers = {}) {
send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers })
@@ -464,13 +465,14 @@ function refreshPublicData(filePath, target) {
function publicDataPrewarmTargets() {
return [
- '/api/tss/leaderboard/teams?limit=100',
- '/api/tss/leaderboard/players?limit=100',
- '/api/tss/leaderboard/teams?limit=4',
- '/api/tss/games/recent?limit=50',
- ].map((requestPath) => {
+ { name: 'team leaderboard', path: '/api/tss/leaderboard/teams?limit=100' },
+ { name: 'player leaderboard', path: '/api/tss/leaderboard/players?limit=100' },
+ { name: 'home teams', path: '/api/tss/leaderboard/teams?limit=4' },
+ { name: 'recent games', path: '/api/tss/games/recent?limit=50' },
+ ].map(({ name, path: requestPath }) => {
const requestUrl = new URL(requestPath, 'http://localhost')
return {
+ name,
requestUrl,
target: new URL(requestPath, API_UPSTREAM),
filePath: publicDataCachePathForUrl(requestUrl),
@@ -478,14 +480,60 @@ function publicDataPrewarmTargets() {
}).filter((entry) => entry.filePath)
}
-function prewarmPublicDataCache() {
+async function prewarmPublicDataCache({ force = false, log = false } = {}) {
const jobs = []
- for (const { filePath, target } of publicDataPrewarmTargets()) {
+ for (const { name, filePath, target } of publicDataPrewarmTargets()) {
const current = cachedPublicData(filePath)
- if (current?.fresh) continue
- jobs.push(fillPublicData(filePath, target, PUBLIC_DATA_COLD_TIMEOUT_MS))
+ if (current?.fresh && !force) {
+ jobs.push(Promise.resolve({ name, filePath, ok: true, cached: true, bytes: fs.statSync(filePath).size }))
+ continue
+ }
+
+ const startedAt = Date.now()
+ jobs.push(
+ fillPublicData(filePath, target, PUBLIC_DATA_COLD_TIMEOUT_MS)
+ .then((ok) => ({
+ name,
+ filePath,
+ ok,
+ cached: false,
+ ms: Date.now() - startedAt,
+ bytes: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
+ }))
+ .catch((error) => ({
+ name,
+ filePath,
+ ok: false,
+ cached: false,
+ ms: Date.now() - startedAt,
+ error: error.message,
+ bytes: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
+ })),
+ )
}
- return Promise.allSettled(jobs)
+ const results = await Promise.all(jobs)
+ publicDataStartupStatus = {
+ ready: results.every((result) => result.ok || result.bytes > 0),
+ checked_at: new Date().toISOString(),
+ results: results.map((result) => ({
+ name: result.name,
+ ok: Boolean(result.ok || result.bytes > 0),
+ cached: Boolean(result.cached),
+ bytes: result.bytes || 0,
+ ms: result.ms || 0,
+ error: result.error || '',
+ })),
+ }
+
+ if (log) {
+ for (const result of publicDataStartupStatus.results) {
+ const state = result.ok ? (result.cached ? 'cached' : 'warmed') : 'failed'
+ const detail = result.error ? ` (${result.error})` : ''
+ console.log(`public data ${state}: ${result.name} ${result.bytes} bytes in ${result.ms}ms${detail}`)
+ }
+ }
+
+ return publicDataStartupStatus
}
function startPublicDataPrewarmer() {
@@ -2281,171 +2329,6 @@ function routeStructuredData(origin, seo, canonicalUrl) {
])
}
-function readPublicDataSnapshot(relativePath) {
- try {
- const filePath = path.resolve(PUBLIC_DATA_CACHE_DIR, relativePath)
- const relativeToCache = path.relative(PUBLIC_DATA_CACHE_DIR, filePath)
- if (relativeToCache.startsWith('..') || path.isAbsolute(relativeToCache)) return null
- const stat = fs.statSync(filePath)
- if (!stat.isFile() || Date.now() - stat.mtimeMs > PUBLIC_DATA_CACHE_STALE_MS) return null
- return JSON.parse(fs.readFileSync(filePath, 'utf8'))
- } catch {
- return null
- }
-}
-
-function fallbackNumber(value) {
- return new Intl.NumberFormat('en-GB').format(Number(value || 0))
-}
-
-function fallbackDate(timestamp) {
- if (!timestamp) return 'Unknown time'
- return new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(Number(timestamp) * 1000))
-}
-
-function fallbackShell(title, meta, body) {
- return `
-
-
-
-
${escapeHtml(title)}
-
${escapeHtml(meta)}
-
- ${body}
-
-
- `
-}
-
-function playersFallbackHtml() {
- const players = readPublicDataSnapshot('leaderboard-players.json')?.players || []
- if (!players.length) return ''
-
- const rows = players.slice(0, 100).map((player, index) => `
-
- #${index + 1}
-
-
${escapeHtml(player.nick || player.uid)}
-
${escapeHtml(player.uid)} · last seen ${escapeHtml(fallbackDate(player.last_seen))}
-
- ${fallbackNumber(player.score)}
- ${fallbackNumber(player.total_battles)}
- ${fallbackNumber(player.total_kills)}
- ${fallbackNumber(player.assists)}
- ${Number(player.win_rate || 0).toFixed(1)}%
- ${Number(player.kdr || 0).toFixed(2)}
- ${fallbackNumber(player.teams_seen)}
-
- `).join('')
-
- return fallbackShell(
- 'Player Leaderboard',
- `${players.length} players returned`,
- `
-
-
-
-
Rank
Player
Score
Battles
Kills
Assists
WR
KDR
Teams
-
- ${rows}
-
-
-
`,
- )
-}
-
-function teamsFallbackHtml() {
- const teams = readPublicDataSnapshot('leaderboard-teams.json')?.teams || []
- if (!teams.length) return ''
-
- const rows = teams.slice(0, 100).map((team, index) => {
- const name = team.name || ''
- return `
-
- #${index + 1}
- ${escapeHtml(name)}
- ${fallbackNumber(team.player_count)} players
- ${fallbackNumber(team.total_battles)} battles
- ${Number(team.win_rate || 0).toFixed(1)}% WR
- ${fallbackNumber(team.points?.total_points || team.total_kills)}
-
- `
- }).join('')
-
- return fallbackShell(
- 'Team Leaderboard',
- `${teams.length} teams returned`,
- `${rows}
`,
- )
-}
-
-function battleLogsFallbackHtml() {
- const matches = readPublicDataSnapshot('recent-games.json')?.matches || []
- if (!matches.length) return ''
-
- const rows = matches.slice(0, 50).map((match) => {
- const winner = match.winning_team || match.team_name || ''
- const loser = match.losing_team || ''
- return `
-
-
-
${escapeHtml(match.map_name || 'Unknown map')}
-
${escapeHtml(fallbackDate(match.timestamp))} · ${escapeHtml(match.session_id)}
-
-
- ${escapeHtml(winner)}
- vs
- ${escapeHtml(loser)}
-
- ${fallbackNumber(match.player_count)}v${fallbackNumber(match.player_count)}
-
- `
- }).join('')
-
- return fallbackShell(
- 'Battle Logs',
- `${matches.length} battles returned`,
- `${rows}
`,
- )
-}
-
-function homeFallbackHtml() {
- const teams = readPublicDataSnapshot('home-teams.json')?.teams || []
- const matches = readPublicDataSnapshot('recent-games.json')?.matches || []
-
- return `
-
-
-
-
BorisBot got nothin on THIS
-
Toothless' TSS Bot
-
Powered by Spectra. TSS analytics.
-
-
-
-
Cached now
-
-
${fallbackNumber(teams.length)} featured teams · ${fallbackNumber(matches.length)} recent games
- ${teams.slice(0, 4).map((team) => `
${escapeHtml(team.name || '')}`).join('')}
-
-
-
-
- `
-}
-
-function routeFallbackHtml(pathname) {
- if (pathname === '/players') return playersFallbackHtml()
- if (pathname === '/teams') return teamsFallbackHtml()
- if (pathname === '/battle-logs' || pathname === '/live') return battleLogsFallbackHtml()
- if (pathname === '/') return homeFallbackHtml()
- return ''
-}
-
function htmlWithSeo(req, data) {
const origin = pagePublicOrigin(req)
let pathname = '/'
@@ -2458,14 +2341,7 @@ function htmlWithSeo(req, data) {
const seo = routeSeo(pathname)
const canonicalUrl = `${origin}${seo.path}`
- const fallback = routeFallbackHtml(pathname)
-
- const rootHtml = fallback
- ? `${fallback}
`
- : ''
-
return data.toString('utf8')
- .replace('', rootHtml)
.replace(/\s+integrity=(["'])sha(?:256|384|512)-[^"']+\1/g, '')
.replaceAll('__PUBLIC_ORIGIN__', origin)
.replaceAll('__SEO_TITLE__', escapeHtml(seo.title))
@@ -2854,7 +2730,7 @@ const server = http.createServer((req, res) => {
}
if (req.url === '/health') {
- sendJson(res, 200, { ok: true })
+ sendJson(res, 200, { ok: true, public_data: publicDataStartupStatus })
return
}
@@ -2869,6 +2745,21 @@ const server = http.createServer((req, res) => {
return
}
+ if (req.method === 'POST' && req.url === '/api/cache/prewarm') {
+ if (!isSameOriginRequest(req)) {
+ sendJson(res, 403, { error: 'Cache prewarm is restricted to this site' })
+ return
+ }
+ if (isRateLimited(req)) {
+ sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
+ return
+ }
+ prewarmPublicDataCache({ force: true, log: true })
+ .then((status) => sendJson(res, status.ready ? 200 : 207, status))
+ .catch((error) => sendJson(res, 500, { error: 'Cache prewarm failed', detail: error.message }))
+ return
+ }
+
if (req.method === 'GET' && req.url === '/api/viewers') {
if (isRateLimited(req)) {
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
@@ -3030,7 +2921,7 @@ server.listen(PORT, '0.0.0.0', async () => {
if (RUN_BACKGROUND_JOBS) {
startUptimeSampler()
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
- await prewarmPublicDataCache()
+ await prewarmPublicDataCache({ log: true })
startPublicDataPrewarmer()
}
process.send?.('ready')