mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-18 00:59:38 +00:00
Adds frontend/scripts/precompress.js, a Node script that walks assets/static/build/ and emits .gz (zlib level 9) and .zst (zlib level 19) siblings for every JS, CSS, JSON, SVG, font, source map, and small text asset. Already-encoded binaries (woff2, png, jpg, ...) and no-savings files (smaller than 1 KiB or compressing to >95% of source) are skipped. Every emitted sibling is round-tripped through gunzipSync / zstdDecompressSync so a corrupt sibling never reaches disk. Wired as the npm `postbuild` hook so `npm run build` produces siblings, and as `prewatch` (`--clean`) so `npm run watch` removes any stale siblings from a prior production build before webpack's first emit. The built-in node:zlib zstd API requires Node 22.15 or later; engines constraint bumped accordingly and frontend/tests/README.md aligned.
269 lines
7.3 KiB
JavaScript
269 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
|
|
Copyright (c) 2018 - 2026 PhotoPrism UG. All rights reserved.
|
|
|
|
Pre-compresses bundled frontend assets (JS, CSS, fonts, JSON, SVG, …) into
|
|
`.gz` and `.zst` siblings so that the Go static handler in
|
|
`internal/server/routes_static.go` can serve them verbatim and skip the
|
|
runtime compression middleware on the hot static-asset path.
|
|
|
|
Skipped extensions are formats that are already compressed (woff2, webp, …)
|
|
or binary blobs where compression adds CPU without meaningful savings.
|
|
|
|
Run automatically as the npm `postbuild` hook for `npm run build`, so that
|
|
`make build-js` produces precompressed siblings without a separate step.
|
|
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const zlib = require("zlib");
|
|
const process = require("process");
|
|
|
|
// COMPRESSIBLE_EXTENSIONS lists file suffixes whose contents are worth
|
|
// precompressing. Anything not on this list is left alone — including
|
|
// already-compressed binaries (woff2, png, jpg, …) and out-of-scope formats
|
|
// (mp3, mp4, zip, gz, zst).
|
|
const COMPRESSIBLE_EXTENSIONS = new Set([
|
|
".js",
|
|
".mjs",
|
|
".cjs",
|
|
".css",
|
|
".html",
|
|
".htm",
|
|
".xml",
|
|
".svg",
|
|
".json",
|
|
".map",
|
|
".txt",
|
|
".webmanifest",
|
|
".eot",
|
|
".ttf",
|
|
".woff",
|
|
]);
|
|
|
|
// MIN_BYTES skips tiny files where the encoder framing overhead would
|
|
// outweigh any saving and where the per-request CPU cost of runtime
|
|
// compression is already negligible.
|
|
const MIN_BYTES = 1024;
|
|
|
|
// MIN_RATIO ensures we only keep a precompressed sibling when it's at least
|
|
// ~5% smaller than the source. Files that don't compress meaningfully
|
|
// (already encoded, near-random) are not worth a separate disk read.
|
|
const MIN_RATIO = 0.95;
|
|
|
|
const DEFAULT_TARGET = path.join(__dirname, "..", "..", "assets", "static", "build");
|
|
|
|
// `--clean` removes any precompressed siblings under the target directory
|
|
// without producing new ones. Used by the watch script so stale bundles
|
|
// from a previous `make build-js` don't get served while webpack rebuilds
|
|
// identity assets in development.
|
|
const args = process.argv.slice(2);
|
|
let cleanOnly = false;
|
|
const positional = [];
|
|
for (const arg of args) {
|
|
if (arg === "--clean") {
|
|
cleanOnly = true;
|
|
} else if (arg === "--") {
|
|
// pass-through separator, ignore
|
|
} else if (arg.startsWith("-")) {
|
|
console.error(`[precompress] error: unknown flag ${arg}`);
|
|
process.exit(2);
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
const targetDir = positional[0] || DEFAULT_TARGET;
|
|
|
|
if (cleanOnly) {
|
|
const removed = cleanSiblings(targetDir);
|
|
console.log(`[precompress] removed ${removed} stale sibling(s) under ${path.relative(process.cwd(), targetDir) || "."}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Hard-fail on Node runtimes without built-in zstd support so the build
|
|
// produces both encodings as required by the static-handler contract.
|
|
if (typeof zlib.zstdCompressSync !== "function") {
|
|
console.error(
|
|
`[precompress] error: this Node.js (${process.version}) lacks built-in zstd support. ` +
|
|
"Upgrade to Node 22.15+ or 24.x — see frontend/package.json engines."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const ZSTD_LEVEL = 19;
|
|
const GZIP_LEVEL = zlib.constants.Z_BEST_COMPRESSION; // level 9
|
|
|
|
let scanned = 0;
|
|
let compressed = 0;
|
|
let skippedSmall = 0;
|
|
let skippedRatio = 0;
|
|
let skippedExt = 0;
|
|
let savedGzip = 0;
|
|
let savedZstd = 0;
|
|
|
|
walk(targetDir);
|
|
|
|
console.log(
|
|
`[precompress] processed ${compressed}/${scanned} files in ${path.relative(process.cwd(), targetDir) || "."}: ` +
|
|
`gzip saved ${formatBytes(savedGzip)}, zstd saved ${formatBytes(savedZstd)} ` +
|
|
`(skipped: ext=${skippedExt}, small=${skippedSmall}, ratio=${skippedRatio})`
|
|
);
|
|
|
|
function walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") {
|
|
console.error(`[precompress] warning: target directory ${dir} does not exist; nothing to do.`);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
if (entry.name.endsWith(".gz") || entry.name.endsWith(".zst") || entry.name.endsWith(".br")) {
|
|
// Already a precompressed sibling from a previous build; skip it
|
|
// so we don't recursively encode siblings.
|
|
continue;
|
|
}
|
|
scanned += 1;
|
|
|
|
const ext = path.extname(entry.name).toLowerCase();
|
|
if (!COMPRESSIBLE_EXTENSIONS.has(ext)) {
|
|
skippedExt += 1;
|
|
continue;
|
|
}
|
|
|
|
let buf;
|
|
try {
|
|
buf = fs.readFileSync(full);
|
|
} catch (err) {
|
|
console.error(`[precompress] error: failed to read ${full}: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (buf.length < MIN_BYTES) {
|
|
skippedSmall += 1;
|
|
continue;
|
|
}
|
|
|
|
const gz = zlib.gzipSync(buf, { level: GZIP_LEVEL });
|
|
const zs = zlib.zstdCompressSync(buf, {
|
|
params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
|
|
});
|
|
|
|
// Round-trip the encoded bytes so a corrupt sibling never reaches disk.
|
|
if (!zlib.gunzipSync(gz).equals(buf)) {
|
|
console.error(`[precompress] error: gzip round-trip mismatch for ${full}`);
|
|
process.exit(1);
|
|
}
|
|
if (!zlib.zstdDecompressSync(zs).equals(buf)) {
|
|
console.error(`[precompress] error: zstd round-trip mismatch for ${full}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (gz.length / buf.length > MIN_RATIO && zs.length / buf.length > MIN_RATIO) {
|
|
// Compression doesn't actually shrink this file (e.g. tightly packed
|
|
// assets); leave it as identity-only. Remove any stale siblings from
|
|
// a previous build.
|
|
removeStale(full);
|
|
skippedRatio += 1;
|
|
continue;
|
|
}
|
|
|
|
if (gz.length / buf.length <= MIN_RATIO) {
|
|
writeSibling(full + ".gz", gz);
|
|
savedGzip += buf.length - gz.length;
|
|
} else {
|
|
removeIfExists(full + ".gz");
|
|
}
|
|
if (zs.length / buf.length <= MIN_RATIO) {
|
|
writeSibling(full + ".zst", zs);
|
|
savedZstd += buf.length - zs.length;
|
|
} else {
|
|
removeIfExists(full + ".zst");
|
|
}
|
|
|
|
compressed += 1;
|
|
}
|
|
}
|
|
|
|
function writeSibling(target, contents) {
|
|
fs.writeFileSync(target, contents, { mode: 0o644 });
|
|
}
|
|
|
|
function removeStale(originalPath) {
|
|
removeIfExists(originalPath + ".gz");
|
|
removeIfExists(originalPath + ".zst");
|
|
}
|
|
|
|
function removeIfExists(p) {
|
|
try {
|
|
fs.unlinkSync(p);
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function cleanSiblings(dir) {
|
|
let count = 0;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") {
|
|
return 0;
|
|
}
|
|
throw err;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
count += cleanSiblings(full);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
if (entry.name.endsWith(".gz") || entry.name.endsWith(".zst")) {
|
|
try {
|
|
fs.unlinkSync(full);
|
|
count += 1;
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (n < 1024) {
|
|
return `${n} B`;
|
|
}
|
|
const units = ["KiB", "MiB", "GiB"];
|
|
let value = n / 1024;
|
|
let unit = units[0];
|
|
for (let i = 1; i < units.length && value >= 1024; i += 1) {
|
|
value /= 1024;
|
|
unit = units[i];
|
|
}
|
|
return `${value.toFixed(1)} ${unit}`;
|
|
}
|