diff --git a/frontend/package.json b/frontend/package.json index 20acde7ad..65c326b39 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/scripts/precompress.js b/frontend/scripts/precompress.js new file mode 100644 index 000000000..03bd899cf --- /dev/null +++ b/frontend/scripts/precompress.js @@ -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}`; +} diff --git a/frontend/tests/README.md b/frontend/tests/README.md index 78bd00e44..6b537cd91 100644 --- a/frontend/tests/README.md +++ b/frontend/tests/README.md @@ -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`.