perf(static): serve the hashed assets from Brotli copies written at build time

Every file under assets/ was compressed again on each download, at the low
level a request path can afford. They carry their content hash in the name
and never change, so the client build now writes a Brotli copy beside each
compressible one (postbuild, scripts/precompress.mjs) and production points
a browser that accepts Brotli at that copy before express.static serves it.

Measured on a production build of 5.1.2, fetching the 652 precached files
the way a service worker install does: 16.3 MB -> 13.9 MB on the wire and
about 1.0 s -> 0.2 s of server CPU per install. A client without Brotli, a
range request and a build without copies are served exactly as before.
This commit is contained in:
John Jahn
2026-11-10 15:03:28 -05:00
committed by Maurice
parent a9d11ccb2e
commit 04b1fb72bb
9 changed files with 314 additions and 2 deletions
+60
View File
@@ -0,0 +1,60 @@
/*
* The work behind scripts/precompress.mjs: a Brotli copy (<file>.br) beside every
* compressible build file under dist/assets.
*
* The server compresses a build file on every download otherwise, at the low level
* a request path can afford. A file under assets/ carries its content hash in its
* name and never changes, so it is compressed once here, at the level a build can
* afford, and the server hands that copy out as it is
* (server/src/nest/platform/precompressed-static.ts).
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync, constants } from 'node:zlib';
/** What the server's own compression would take; fonts, images and video are compressed formats already. */
const COMPRESSIBLE = /\.(?:m?js|css|svg|json|wasm|html|webmanifest)$/i;
/** The server leaves anything below 1 kB uncompressed too: the headers cost more than the saving. */
export const MIN_BYTES = 1024;
/** Quality 11 takes about ten times as long per byte as 9, so the largest chunks get 9. */
const BIG_BYTES = 1024 * 1024;
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else yield full;
}
}
/**
* Writes the .br copies under `dir` and returns what it did. A copy that would not be
* smaller than its file is not written, and a directory that does not exist is no work.
*/
export function precompress(dir) {
const result = { files: 0, before: 0, after: 0 };
let files;
try {
files = [...walk(dir)];
} catch (err) {
if (err.code === 'ENOENT') return result;
throw err;
}
for (const file of files) {
if (!COMPRESSIBLE.test(file)) continue;
const input = readFileSync(file);
if (input.length < MIN_BYTES) continue;
const output = brotliCompressSync(input, {
params: {
[constants.BROTLI_PARAM_QUALITY]: input.length > BIG_BYTES ? 9 : 11,
[constants.BROTLI_PARAM_SIZE_HINT]: input.length,
},
});
if (output.length >= input.length) continue;
writeFileSync(`${file}.br`, output);
result.files += 1;
result.before += input.length;
result.after += output.length;
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/*
* postbuild: writes a Brotli copy beside every compressible file under dist/assets,
* which the server hands to a browser that accepts Brotli instead of compressing
* the file again on each download. scripts/lib/precompress.mjs holds the work.
*/
import { fileURLToPath } from 'node:url';
import { precompress } from './lib/precompress.mjs';
const { files, before, after } = precompress(fileURLToPath(new URL('../dist/assets', import.meta.url)));
const mb = (bytes) => (bytes / 1048576).toFixed(1);
console.log(`precompress: ${files} files, ${mb(before)} MB -> ${mb(after)} MB (brotli)`);