mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
5c6976355b
commit
da169dc669
3 changed files with 296 additions and 25 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
269
frontend/scripts/precompress.js
Normal file
269
frontend/scripts/precompress.js
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue