/** * Pre-compress large gamedata JSON files to .gz so nginx `gzip_static` * serves them via sendfile instead of re-compressing up to 48 MB on every * request (see /etc/nginx/nginx.conf: `gzip_static on`). * * Idempotent: a file is only re-compressed when its source mtime is newer * than the existing .gz (e.g. after a Studio catalog export rewrites it). * * Usage: node scripts/compress-gamedata.mjs * Env: GAMEDATA_ROOT (default /var/www/Gamedata) * GAMEDATA_GZIP_MIN_BYTES (default 1048576) */ import { createReadStream, createWriteStream, promises as fs } from "node:fs"; import { cpus } from "node:os"; import { join, relative } from "node:path"; import { pipeline } from "node:stream/promises"; import { createGzip } from "node:zlib"; const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata"; const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024); const GZIP_LEVEL = 9; const TARGET_DIRS = ["config", "bundled/config"]; const CONCURRENCY = Math.max(1, Math.min(4, cpus().length)); function isCompressible(name) { return ( name.endsWith(".json") && !name.endsWith(".gz") && !name.endsWith(".min.json") ); } async function collectCandidates() { const files = []; for (const dir of TARGET_DIRS) { const root = join(GAMEDATA_ROOT, dir); let entries; try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !isCompressible(entry.name)) continue; const full = join(root, entry.name); const stat = await fs.stat(full); if (stat.size < MIN_BYTES) continue; files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs }); } } return files; } async function needsCompression({ src, mtimeMs }) { const gz = `${src}.gz`; try { const stat = await fs.stat(gz); return stat.mtimeMs < mtimeMs; } catch { return true; } } async function compressOne({ src }) { const gz = `${src}.gz`; const tmp = `${gz}.tmp-${process.pid}`; await pipeline( createReadStream(src), createGzip({ level: GZIP_LEVEL }), createWriteStream(tmp), ); await fs.rename(tmp, gz); try { await fs.chmod(gz, 0o644); } catch { // Best-effort; ownership is up to the caller. } return gz; } async function main() { const candidates = await collectCandidates(); const work = []; const skipped = []; for (const candidate of candidates) { if (await needsCompression(candidate)) { work.push(candidate); } else { skipped.push(candidate); } } const results = []; let idx = 0; async function worker() { while (idx < work.length) { const item = work[idx++]; try { const gz = await compressOne(item); const stat = await fs.stat(gz); results.push( `compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`, ); } catch (err) { results.push( `FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`, ); } } } const workers = Array.from( { length: Math.min(CONCURRENCY, work.length) }, () => worker(), ); await Promise.all(workers); console.log( `gamedata: ${work.length} to compress, ${skipped.length} up to date`, ); for (const line of results) console.log(`gamedata: ${line}`); } main().catch((err) => { console.error( `gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); });