This commit is contained in:
2026-05-16 11:18:15 +01:00
parent f094cb8dae
commit 4819cd2cab
6 changed files with 166 additions and 55 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
node_modules node_modules
dist dist
dist-next
dist-previous
.env .env
.env.local .env.local
.DS_Store .DS_Store
@@ -7,4 +9,4 @@ npm-debug.log*
vite-dev*.log vite-dev*.log
server-local*.log server-local*.log
.local-storage/ .local-storage/
.claude/ .claude/
+13 -5
View File
@@ -34,7 +34,12 @@ npm run build
pm2 start ecosystem.config.cjs pm2 start ecosystem.config.cjs
``` ```
The production server runs on <http://localhost:3010>. It serves `/health` The production server runs on <http://localhost:3010>. 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: locally and only proxies the API routes used by the app:
- `GET /api/tss/leaderboard/teams?limit=1..100` - `GET /api/tss/leaderboard/teams?limit=1..100`
@@ -171,14 +176,17 @@ The default deploy flow is:
```sh ```sh
git pull --ff-only git pull --ff-only
npm install --production=false --include=dev --include=optional npm ci --include=dev --include=optional
npm run build 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 pm2 reload tssbot-web --update-env
``` ```
Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is 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 `tssbot-web`, so unrelated PM2 processes are left alone. The web server handles
24 hours so PM2 restarts it cleanly. `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 When webhook code changes are deployed, restart the webhook process once so PM2
loads the updated listener: loads the updated listener:
+5
View File
@@ -4,6 +4,11 @@ module.exports = {
name: 'tssbot-web', name: 'tssbot-web',
script: 'server.cjs', script: 'server.cjs',
cwd: __dirname, cwd: __dirname,
exec_mode: 'cluster',
instances: process.env.WEB_INSTANCES || 2,
wait_ready: true,
listen_timeout: 10000,
kill_timeout: 10000,
env: { env: {
NODE_ENV: 'production', NODE_ENV: 'production',
PORT: process.env.PORT || 3010, PORT: process.env.PORT || 3010,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

+97 -48
View File
@@ -59,6 +59,7 @@ const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200 const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000 const MAX_RATE_LIMIT_KEYS = 1000
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024 const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
const RUN_BACKGROUND_JOBS = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0'
const TRUST_PROXY = (() => { const TRUST_PROXY = (() => {
const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase() 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 = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE))
analyticsDb.pragma('journal_mode = WAL') analyticsDb.pragma('journal_mode = WAL')
analyticsDb.pragma('busy_timeout = 5000')
analyticsDb.exec(` analyticsDb.exec(`
create table if not exists viewer_events ( create table if not exists viewer_events (
id integer primary key autoincrement, id integer primary key autoincrement,
@@ -331,6 +333,7 @@ function ensureUptimeDb() {
uptimeDb = new Database(path.join(storageDir, UPTIME_DATABASE_FILE)) uptimeDb = new Database(path.join(storageDir, UPTIME_DATABASE_FILE))
uptimeDb.pragma('journal_mode = WAL') uptimeDb.pragma('journal_mode = WAL')
uptimeDb.pragma('busy_timeout = 5000')
uptimeDb.exec(` uptimeDb.exec(`
create table if not exists uptime_snapshots ( create table if not exists uptime_snapshots (
id integer primary key autoincrement, id integer primary key autoincrement,
@@ -433,12 +436,14 @@ async function uptimeHistory() {
} }
} }
let uptimeSamplerTimer = null
function startUptimeSampler() { function startUptimeSampler() {
takeUptimeSnapshot().catch((error) => { takeUptimeSnapshot().catch((error) => {
console.error('Initial uptime snapshot failed:', error) console.error('Initial uptime snapshot failed:', error)
}) })
setInterval(() => { uptimeSamplerTimer = setInterval(() => {
takeUptimeSnapshot().catch((error) => { takeUptimeSnapshot().catch((error) => {
console.error('Uptime snapshot failed:', 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 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() const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
db.prepare(` db.transaction(() => {
delete from viewer_events db.prepare(`
where occurred_at < ? delete from viewer_events
`).run(eventCutoff) where occurred_at < ?
`).run(eventCutoff)
db.prepare(` db.prepare(`
delete from active_viewers delete from active_viewers
where last_seen_at < ? where last_seen_at < ?
`).run(activeCutoff) `).run(activeCutoff)
})()
} }
function recordViewerEvent(req, payload) { function recordViewerEvent(req, payload) {
@@ -978,44 +985,48 @@ function recordViewerEvent(req, payload) {
} }
const now = new Date().toISOString() const now = new Date().toISOString()
db.prepare(` const writeViewerEvent = db.transaction(() => {
insert into viewer_events db.prepare(`
(occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, insert into viewer_events
referrer, user_agent, browser, os, device, screen, language, timezone, (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
country, region, city, latitude, longitude, consent, metadata) referrer, user_agent, browser, os, device, screen, language, timezone,
values country, region, city, latitude, longitude, consent, metadata)
(@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, values
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
@country, @region, @city, @latitude, @longitude, @consent, @metadata) @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone,
`).run({ ...event, occurred_at: now }) @country, @region, @city, @latitude, @longitude, @consent, @metadata)
`).run({ ...event, occurred_at: now })
db.prepare(` db.prepare(`
insert into active_viewers insert into active_viewers
(session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, (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, referrer, user_agent, browser, os, device, screen, language, timezone,
country, region, city, latitude, longitude) country, region, city, latitude, longitude)
values values
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone,
@country, @region, @city, @latitude, @longitude) @country, @region, @city, @latitude, @longitude)
on conflict(session_id) do update set on conflict(session_id) do update set
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
page_path = excluded.page_path, page_path = excluded.page_path,
page_title = excluded.page_title, page_title = excluded.page_title,
referrer = excluded.referrer, referrer = excluded.referrer,
user_agent = excluded.user_agent, user_agent = excluded.user_agent,
browser = excluded.browser, browser = excluded.browser,
os = excluded.os, os = excluded.os,
device = excluded.device, device = excluded.device,
screen = excluded.screen, screen = excluded.screen,
language = excluded.language, language = excluded.language,
timezone = excluded.timezone, timezone = excluded.timezone,
country = excluded.country, country = excluded.country,
region = excluded.region, region = excluded.region,
city = excluded.city, city = excluded.city,
latitude = excluded.latitude, latitude = excluded.latitude,
longitude = excluded.longitude longitude = excluded.longitude
`).run({ ...event, now }) `).run({ ...event, now })
})
writeViewerEvent()
} }
function deleteViewerData(payload) { function deleteViewerData(payload) {
@@ -1790,11 +1801,49 @@ const server = http.createServer((req, res) => {
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`tssbot-web serving http://localhost:${PORT}`)
console.log(`proxying API requests to ${API_UPSTREAM}`) 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 uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`) console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
if (!TURNSTILE_SECRET_KEY) { if (!TURNSTILE_SECRET_KEY) {
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request') 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)
+48 -1
View File
@@ -44,6 +44,9 @@ const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web')
.split(',') .split(',')
.map((target) => target.trim()) .map((target) => target.trim())
.filter(Boolean) .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( const ALLOWED_REFS = new Set(
(process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main') (process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main')
.split(',') .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) { function postDiscordWebhook(payload) {
if (!DISCORD_WEBHOOK_URL) return Promise.resolve() if (!DISCORD_WEBHOOK_URL) return Promise.resolve()
@@ -360,7 +406,8 @@ async function deploy(push) {
await run('git', ['pull', '--ff-only']) await run('git', ['pull', '--ff-only'])
diff = await deployDiff(push) diff = await deployDiff(push)
await ensureBuildDependencies() await ensureBuildDependencies()
await run('npm', ['run', 'build']) await run('npm', ['run', 'build', '--', '--outDir', 'dist-next'])
promoteBuiltDist()
for (const target of RESTART_TARGETS) { for (const target of RESTART_TARGETS) {
await run('pm2', ['reload', target, '--update-env']) await run('pm2', ['reload', target, '--update-env'])