import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; /** * Emit precompressed `.br` / `.gz` / `.zst` sidecars next to every text asset * under the given directories, keeping the originals. The Gitea pages service * (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the sidecar * matching `Accept-Encoding` and falls back to the original for the rest. * * Usage: node --strip-types scripts/precompress.ts [...] * * Why sidecars rather than per-request compression: development/ci.md ยง Coverage * serving. */ /** * Only extensions worth compressing. Images, fonts and archives are already * compressed, so a sidecar would only make them bigger. */ const TEXT_EXTENSIONS: ReadonlySet = new Set([ ".css", ".htm", ".html", ".info", ".js", ".json", ".map", ".md", ".mjs", ".svg", ".txt", ".xml", ".yaml", ".yml", ]); const GZIP_LEVEL = 9; const ZSTD_LEVEL = 19; const INITIAL_COUNT = 0; /** `process.argv` is `[node, script, ...args]`; drop the first two entries. */ const ARGV_PREFIX_LENGTH = 2; const FAILURE_EXIT_CODE = 1; interface Encoder { readonly suffix: string; readonly encode: (input: Buffer) => Buffer; } const ENCODERS: readonly Encoder[] = [ { suffix: ".br", encode: (input) => zlib.brotliCompressSync(input, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, }, }), }, { suffix: ".gz", encode: (input) => zlib.gzipSync(input, { level: GZIP_LEVEL }), }, { suffix: ".zst", encode: (input) => zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL, }, }), }, ]; interface Totals { assets: number; sidecars: number; savedBytes: number; } /** Depth-first list of every regular file under `directory`, recursively. */ const listFiles = (directory: string): string[] => { const files: string[] = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { files.push(...listFiles(entryPath)); } else if (entry.isFile()) { files.push(entryPath); } } return files; }; const writeSidecar = ( target: string, input: Buffer, encoder: Encoder, ): number => { const compressed = encoder.encode(input); if (compressed.byteLength < input.byteLength) { fs.writeFileSync(target, compressed); return input.byteLength - compressed.byteLength; } // Drop a stale sidecar: it would still win at the server. fs.rmSync(target, { force: true }); return INITIAL_COUNT; }; const precompress = (file: string, totals: Totals): void => { if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) { return; } totals.assets += 1; const input = fs.readFileSync(file); for (const encoder of ENCODERS) { const saved = writeSidecar(`${file}${encoder.suffix}`, input, encoder); if (saved > INITIAL_COUNT) { totals.sidecars += 1; totals.savedBytes += saved; } } }; const main = (directories: readonly string[]): void => { if (directories.length === INITIAL_COUNT) { process.stderr.write("usage: precompress.ts [...]\n"); process.exitCode = FAILURE_EXIT_CODE; return; } const totals: Totals = { assets: INITIAL_COUNT, sidecars: INITIAL_COUNT, savedBytes: INITIAL_COUNT, }; for (const directory of directories) { for (const file of listFiles(directory)) { precompress(file, totals); } } process.stdout.write( `precompressed ${totals.assets} text assets into ${totals.sidecars} sidecars (saved ${totals.savedBytes} bytes)\n`, ); }; main(process.argv.slice(ARGV_PREFIX_LENGTH));