fix(nitro): normalise uploaded bundles so meta.image matches the texture
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 39s
CI / tests-unit (push) Successful in 2m3s
CI / tests-integration (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m45s
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 39s
CI / tests-unit (push) Successful in 2m3s
CI / tests-integration (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m45s
An uploaded .nitro was written to disk byte-for-byte, so a bundle from a third-party tool that ships a WebP member while still pointing spritesheet.meta.image at a .png was accepted and stored as-is. The client resolves the spritesheet through that pointer, so the result was a file that validates fine and then renders nothing. Re-write the bundle through createNitroBundle on import, which labels the member from the actual bytes and repairs the pointer. No texture is re-encoded, so the bytes stay identical, and the member keeps the base name it arrived with so `chair*2` colour variants that share the `chair` library are not renamed.
This commit is contained in:
1 parent
17de94d984
commit
306e209e29
4 files changed
+36
-153
No files matched your search
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in new issue
Block a user