perf: pre-compress gamedata JSON, tune MariaDB, fix avatar imager, docs
CI / check (push) Successful in 1m3s
CI / deploy (push) Successful in 1m12s

- 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
This commit is contained in:
openhands committed 2026-09-06 12:06:38 +02:00
1 parent 251738fdda
commit 9dc9d1fa4b
4 files changed
+260 -9

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
/**
* 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);
});