Frontend: Pre-compress static bundle assets at build time #5552

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.
This commit is contained in:
Michael Mayer 2026-05-03 13:43:44 +00:00
parent 5c6976355b
commit da169dc669
3 changed files with 296 additions and 25 deletions

View file

@ -10,6 +10,7 @@
"build": "webpack --node-env=production",
"build-analyze": "webpack --node-env=analyze",
"build-dev": "webpack --node-env=development",
"postbuild": "node scripts/precompress.js",
"debug": "webpack --stats-error-details",
"dep-list": "npx npm-check-updates",
"fmt": "eslint --cache --fix src/ *.js eslint.config.mjs",
@ -27,6 +28,7 @@
"security:scan": "npm run -s security:scan-installs && npm run -s security:scan-xss",
"security:scan-installs": "sh -lc 'set -e; MATCHES=\"$(rg -n --hidden --glob !**/.git/** -S \"npm (ci|install|update)\" ./Makefile ./package.json 2>/dev/null || true)\"; if [ -z \"$MATCHES\" ]; then echo \"No npm install/update/ci commands found in frontend/\"; exit 0; fi; VIOLATIONS=\"$(printf %s \"$MATCHES\" | rg -v -e \"ignore-scripts\" -e \"install( .*|) -g npm\" -e \"update( .*|) -g npm\" -e \":[0-9]+:\\s*#\" -e \"install-npm\" || true)\"; if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: npm install/update/ci without --ignore-scripts (exceptions excluded)\"; printf %s\\n \"$VIOLATIONS\"; exit 1; fi; echo \"OK: All frontend installs/updates use --ignore-scripts or are allowed exceptions.\"'",
"security:scan-xss": "sh -lc 'set -e; if rg -n --glob \"src/**\" -S \"v-html=\\\"\" src >/dev/null; then echo \"ERROR: v-html usage detected; prefer v-sanitize or $util.sanitizeHtml()\"; rg -n --glob \"src/**\" -S \"v-html=\\\"\" src; exit 1; fi; SINKS=\"$(rg -n --glob \"src/**\" -e \"\\.innerHTML\\s*=\" -e \"\\.outerHTML\\s*=\" -e \"insertAdjacentHTML\\(\" -e \"document\\.write\\(\" -e \"document\\.writeln\\(\" src || true)\"; VIOLATIONS=\"$(printf %s \"$SINKS\" | rg -v -e \"security-reviewed\" -e \"\\.innerHTML\\s*=\\s*[\\\"\\x27]{2}\" || true)\"; if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: review required for DOM XSS sinks; prefer textContent or approved sanitization paths\"; printf %s\\n \"$VIOLATIONS\"; exit 1; fi; echo \"OK: No unreviewed v-html or dangerous DOM HTML sinks detected.\"'",
"prewatch": "node scripts/precompress.js --clean",
"watch": "cross-env BUILD_ENV=development NODE_ENV=production webpack --watch"
},
"browserslist": [
@ -134,7 +136,7 @@
"workbox-webpack-plugin": "^7.4.0"
},
"engines": {
"node": ">= 18.0.0",
"node": ">= 22.15.0",
"npm": ">= 9.0.0",
"yarn": "please use npm"
},

View file

@ -0,0 +1,269 @@
#!/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}`;
}

View file

@ -1,6 +1,6 @@
## Frontend Tests & Linting
**Last Updated:** February 22, 2026
**Last Updated:** May 3, 2026
### Purpose
@ -59,29 +59,29 @@ When evaluating frontend tooling changes, test at least one CE run plus Plus and
Current frontend tool versions are defined in `frontend/package.json` unless stated otherwise.
| Tool | Version |
|:-------------------------------------|:------------|
| Node.js engine | `>= 18.0.0` |
| npm engine | `>= 9.0.0` |
| Vitest | `^3.2.4` |
| `@vitest/ui` | `^3.2.4` |
| `@vitest/coverage-v8` | `^3.2.4` |
| `@vitejs/plugin-vue` | `^6.0.4` |
| `@vue/test-utils` | `^2.4.6` |
| JSDOM | `^26.1.0` |
| Playwright (Vitest browser provider) | `^1.58.2` |
| ESLint | `^9.39.2` |
| `@eslint/js` | `^9.33.0` |
| `@eslint/eslintrc` | `^3.3.3` |
| `eslint-config-prettier` | `^10.1.8` |
| `eslint-plugin-import` | `^2.32.0` |
| `eslint-plugin-node` | `^11.1.0` |
| `eslint-plugin-prettier` | `^5.5.5` |
| `eslint-plugin-vue` | `^10.7.0` |
| `eslint-plugin-vuetify` | `^2.5.3` |
| `eslint-webpack-plugin` | `^5.0.2` |
| Prettier | `^3.8.1` |
| TestCafe CLI (dev environment) | `3.7.4` |
| Tool | Version |
|:-------------------------------------|:-------------|
| Node.js engine | `>= 22.15.0` |
| npm engine | `>= 9.0.0` |
| Vitest | `^3.2.4` |
| `@vitest/ui` | `^3.2.4` |
| `@vitest/coverage-v8` | `^3.2.4` |
| `@vitejs/plugin-vue` | `^6.0.4` |
| `@vue/test-utils` | `^2.4.6` |
| JSDOM | `^26.1.0` |
| Playwright (Vitest browser provider) | `^1.58.2` |
| ESLint | `^9.39.2` |
| `@eslint/js` | `^9.33.0` |
| `@eslint/eslintrc` | `^3.3.3` |
| `eslint-config-prettier` | `^10.1.8` |
| `eslint-plugin-import` | `^2.32.0` |
| `eslint-plugin-node` | `^11.1.0` |
| `eslint-plugin-prettier` | `^5.5.5` |
| `eslint-plugin-vue` | `^10.7.0` |
| `eslint-plugin-vuetify` | `^2.5.3` |
| `eslint-webpack-plugin` | `^5.0.2` |
| Prettier | `^3.8.1` |
| TestCafe CLI (dev environment) | `3.7.4` |
Note: TestCafe is available in the development environment but is currently not pinned as a direct dependency in `frontend/package.json`. Verify with `testcafe --version`.