fix
This commit is contained in:
+3
-1
@@ -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/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'])
|
||||||
|
|||||||
Reference in New Issue
Block a user