From 4819cd2cabbc504e9c445149d993ea2a4aa4ba8b Mon Sep 17 00:00:00 2001 From: Heidi Date: Sat, 16 May 2026 11:18:15 +0100 Subject: [PATCH] fix --- .gitignore | 4 +- README.md | 18 +++-- ecosystem.config.cjs | 5 ++ public/light_fury_match.jpg | Bin 8480 -> 0 bytes server.cjs | 145 ++++++++++++++++++++++++------------ webhook.cjs | 49 +++++++++++- 6 files changed, 166 insertions(+), 55 deletions(-) delete mode 100755 public/light_fury_match.jpg diff --git a/.gitignore b/.gitignore index 6d97866..99df0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules dist +dist-next +dist-previous .env .env.local .DS_Store @@ -7,4 +9,4 @@ npm-debug.log* vite-dev*.log server-local*.log .local-storage/ -.claude/ \ No newline at end of file +.claude/ diff --git a/README.md b/README.md index 7783257..f600e04 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,12 @@ npm run build pm2 start ecosystem.config.cjs ``` -The production server runs on . It serves `/health` +The production server runs on . PM2 starts the web app in +cluster mode with two workers by default, waits for each worker to signal that it +is ready, and then reloads workers one at a time during deploys. Override the +worker count with `WEB_INSTANCES`. + +The server serves `/health` locally and only proxies the API routes used by the app: - `GET /api/tss/leaderboard/teams?limit=1..100` @@ -171,14 +176,17 @@ The default deploy flow is: ```sh git pull --ff-only -npm install --production=false --include=dev --include=optional -npm run build +npm ci --include=dev --include=optional +npm run build -- --outDir dist-next +# the webhook promotes dist-next to dist after carrying over old hashed assets pm2 reload tssbot-web --update-env ``` Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is -`tssbot-web`, so unrelated PM2 processes are left alone. The webhook exits after -24 hours so PM2 restarts it cleanly. +`tssbot-web`, so unrelated PM2 processes are left alone. The web server handles +`SIGINT` and `SIGTERM` by closing its listener and SQLite handles before exit, +which lets PM2 finish reloads without dropping active requests. The webhook +exits after 24 hours so PM2 restarts it cleanly. When webhook code changes are deployed, restart the webhook process once so PM2 loads the updated listener: diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index dd2b57c..a390fad 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -4,6 +4,11 @@ module.exports = { name: 'tssbot-web', script: 'server.cjs', cwd: __dirname, + exec_mode: 'cluster', + instances: process.env.WEB_INSTANCES || 2, + wait_ready: true, + listen_timeout: 10000, + kill_timeout: 10000, env: { NODE_ENV: 'production', PORT: process.env.PORT || 3010, diff --git a/public/light_fury_match.jpg b/public/light_fury_match.jpg deleted file mode 100755 index bb918e686aec7ba85fad1d34216bff0cfb01f9bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8480 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 { const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase() @@ -241,6 +242,7 @@ function ensureAnalyticsDb() { analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE)) analyticsDb.pragma('journal_mode = WAL') + analyticsDb.pragma('busy_timeout = 5000') analyticsDb.exec(` create table if not exists viewer_events ( id integer primary key autoincrement, @@ -331,6 +333,7 @@ function ensureUptimeDb() { uptimeDb = new Database(path.join(storageDir, UPTIME_DATABASE_FILE)) uptimeDb.pragma('journal_mode = WAL') + uptimeDb.pragma('busy_timeout = 5000') uptimeDb.exec(` create table if not exists uptime_snapshots ( id integer primary key autoincrement, @@ -433,12 +436,14 @@ async function uptimeHistory() { } } +let uptimeSamplerTimer = null + function startUptimeSampler() { takeUptimeSnapshot().catch((error) => { console.error('Initial uptime snapshot failed:', error) }) - setInterval(() => { + uptimeSamplerTimer = setInterval(() => { takeUptimeSnapshot().catch((error) => { console.error('Uptime snapshot failed:', error) }) @@ -926,15 +931,17 @@ function purgeOldAnalytics(db) { const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString() const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString() - db.prepare(` - delete from viewer_events - where occurred_at < ? - `).run(eventCutoff) + db.transaction(() => { + db.prepare(` + delete from viewer_events + where occurred_at < ? + `).run(eventCutoff) - db.prepare(` - delete from active_viewers - where last_seen_at < ? - `).run(activeCutoff) + db.prepare(` + delete from active_viewers + where last_seen_at < ? + `).run(activeCutoff) + })() } function recordViewerEvent(req, payload) { @@ -978,44 +985,48 @@ function recordViewerEvent(req, payload) { } const now = new Date().toISOString() - db.prepare(` - insert into viewer_events - (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, - referrer, user_agent, browser, os, device, screen, language, timezone, - country, region, city, latitude, longitude, consent, metadata) - values - (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, - @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, - @country, @region, @city, @latitude, @longitude, @consent, @metadata) - `).run({ ...event, occurred_at: now }) + const writeViewerEvent = db.transaction(() => { + db.prepare(` + insert into viewer_events + (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, + referrer, user_agent, browser, os, device, screen, language, timezone, + country, region, city, latitude, longitude, consent, metadata) + values + (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, + @country, @region, @city, @latitude, @longitude, @consent, @metadata) + `).run({ ...event, occurred_at: now }) - db.prepare(` - insert into active_viewers - (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, - referrer, user_agent, browser, os, device, screen, language, timezone, - country, region, city, latitude, longitude) - values - (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, - @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, - @country, @region, @city, @latitude, @longitude) - on conflict(session_id) do update set - last_seen_at = excluded.last_seen_at, - page_path = excluded.page_path, - page_title = excluded.page_title, - referrer = excluded.referrer, - user_agent = excluded.user_agent, - browser = excluded.browser, - os = excluded.os, - device = excluded.device, - screen = excluded.screen, - language = excluded.language, - timezone = excluded.timezone, - country = excluded.country, - region = excluded.region, - city = excluded.city, - latitude = excluded.latitude, - longitude = excluded.longitude - `).run({ ...event, now }) + db.prepare(` + insert into active_viewers + (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, + referrer, user_agent, browser, os, device, screen, language, timezone, + country, region, city, latitude, longitude) + values + (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, + @country, @region, @city, @latitude, @longitude) + on conflict(session_id) do update set + last_seen_at = excluded.last_seen_at, + page_path = excluded.page_path, + page_title = excluded.page_title, + referrer = excluded.referrer, + user_agent = excluded.user_agent, + browser = excluded.browser, + os = excluded.os, + device = excluded.device, + screen = excluded.screen, + language = excluded.language, + timezone = excluded.timezone, + country = excluded.country, + region = excluded.region, + city = excluded.city, + latitude = excluded.latitude, + longitude = excluded.longitude + `).run({ ...event, now }) + }) + + writeViewerEvent() } function deleteViewerData(payload) { @@ -1790,11 +1801,49 @@ const server = http.createServer((req, res) => { server.listen(PORT, '0.0.0.0', () => { console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`proxying API requests to ${API_UPSTREAM}`) - console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`) + if (RUN_BACKGROUND_JOBS) { + console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`) + } else { + console.log('uptime sampler disabled in this worker') + } console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`) console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`) if (!TURNSTILE_SECRET_KEY) { console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request') } - startUptimeSampler() + if (RUN_BACKGROUND_JOBS) startUptimeSampler() + process.send?.('ready') }) + +let shuttingDown = false + +function closeDatabase(db, name) { + if (!db) return + + try { + db.close() + } catch (error) { + console.error(`Failed to close ${name} database:`, error) + } +} + +function shutdown() { + if (shuttingDown) return + shuttingDown = true + + if (uptimeSamplerTimer) clearInterval(uptimeSamplerTimer) + + server.close(() => { + closeDatabase(uptimeDb, 'uptime') + closeDatabase(analyticsDb, 'analytics') + process.exit(0) + }) + + setTimeout(() => { + console.error('Graceful shutdown timed out') + process.exit(1) + }, 10000).unref() +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/webhook.cjs b/webhook.cjs index 8971bcb..2f6b4c5 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -44,6 +44,9 @@ const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web') .split(',') .map((target) => target.trim()) .filter(Boolean) +const DIST_DIR = path.join(__dirname, 'dist') +const NEXT_DIST_DIR = path.join(__dirname, 'dist-next') +const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous') const ALLOWED_REFS = new Set( (process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main') .split(',') @@ -198,6 +201,49 @@ async function ensureBuildDependencies() { } } +function copyMissingFiles(fromDir, toDir) { + if (!fs.existsSync(fromDir) || !fs.existsSync(toDir)) return + + for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) { + const source = path.join(fromDir, entry.name) + const target = path.join(toDir, entry.name) + + if (entry.isDirectory()) { + fs.mkdirSync(target, { recursive: true }) + copyMissingFiles(source, target) + continue + } + + if (!fs.existsSync(target)) { + fs.copyFileSync(source, target) + } + } +} + +function promoteBuiltDist() { + const previousAssetsDir = path.join(DIST_DIR, 'assets') + const nextAssetsDir = path.join(NEXT_DIST_DIR, 'assets') + let movedCurrentDist = false + + copyMissingFiles(previousAssetsDir, nextAssetsDir) + + fs.rmSync(PREVIOUS_DIST_DIR, { recursive: true, force: true }) + + try { + if (fs.existsSync(DIST_DIR)) { + fs.renameSync(DIST_DIR, PREVIOUS_DIST_DIR) + movedCurrentDist = true + } + + fs.renameSync(NEXT_DIST_DIR, DIST_DIR) + } catch (error) { + if (movedCurrentDist && !fs.existsSync(DIST_DIR) && fs.existsSync(PREVIOUS_DIST_DIR)) { + fs.renameSync(PREVIOUS_DIST_DIR, DIST_DIR) + } + throw error + } +} + function postDiscordWebhook(payload) { if (!DISCORD_WEBHOOK_URL) return Promise.resolve() @@ -360,7 +406,8 @@ async function deploy(push) { await run('git', ['pull', '--ff-only']) diff = await deployDiff(push) await ensureBuildDependencies() - await run('npm', ['run', 'build']) + await run('npm', ['run', 'build', '--', '--outDir', 'dist-next']) + promoteBuiltDist() for (const target of RESTART_TARGETS) { await run('pm2', ['reload', target, '--update-env'])