✨ Emit precompressed sidecars for served assets
Add scripts/precompress.ts, a dependency-free Node 26 tool that writes .br/.gz/.zst sidecars next to every text asset and keeps the originals, so static-web-server can serve the variant matching Accept-Encoding and fall back to the original. The coverage publish step runs it on the copied report. Wire scripts/*.ts into the toolchain: type-check (tsconfig include), lint and fix (oxlint paths), knip entry (CI invokes the file rather than importing it), and a narrow .oxlintrc override allowing node:* imports in Node CLI scripts.
This commit is contained in:
1 parent
224afa9afb
commit
78bec7a5eb
6 files changed
+159
-4
No files matched your search
@@ -0,0 +1,144 @@
|
||||
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 <dir> [<dir>...]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Only extensions worth compressing. Images, fonts and archives are already
|
||||
* compressed, so a sidecar would only make them bigger.
|
||||
*/
|
||||
const TEXT_EXTENSIONS: ReadonlySet<string> = 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 <dir> [<dir>...]\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));
|
||||
Reference in new issue
Block a user