- scripts/compress-gamedata.mjs: pre-compress large gamedata JSON to .gz (gzip level 9, idempotent mtime check) served via nginx gzip_static (48 MB FurnitureData.json -> ~2.4 MB, ~0.3s -> ~0.005s per request) - package.json: add gamedata:compress script - fix(imaging): accept real Habbo figure strings in avatar route (allow optional second number per part, e.g. hd-180-1.ch-210-66) - README: document avatar imager container (avatar-imaging-pixinode, port 8082, /docker/Polaris-imager), nginx /imaging proxying, MariaDB tuning, gamedata pre-compression cron and caching layers
131 lines
3.4 KiB
JavaScript
131 lines
3.4 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|