✨ 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
@@ -35,7 +35,10 @@ jobs:
|
||||
# served read-only at
|
||||
# https://pages.e1nsnull.de/<owner>/<repo>/<tag>/coverage/. Wipe only
|
||||
# this tag's `coverage/`, so sibling docs/landing trees and older
|
||||
# tags survive; pruning stale tags is a manual chore.
|
||||
# tags survive; pruning stale tags is a manual chore. The
|
||||
# precompress pass emits `.br` / `.gz` / `.zst` sidecars next to
|
||||
# every text asset, so `static-web-server` can serve the precompressed
|
||||
# variant and keep the original as fallback.
|
||||
- name: Publish coverage to the pages server
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
env:
|
||||
@@ -47,6 +50,7 @@ jobs:
|
||||
rm -rf "${DEST}"
|
||||
mkdir -p "${DEST}"
|
||||
cp -R coverage/. "${DEST}/"
|
||||
node --strip-types scripts/precompress.ts "${DEST}"
|
||||
echo "Coverage: https://pages.e1nsnull.de/${REPO}/${TAG}/coverage/"
|
||||
# Fast, offline packaging gate. `attw` stays in `publish` (it needs
|
||||
# a pack + full resolution matrix); `publint` packs too but is cheap
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
"import/no-nodejs-modules": "off",
|
||||
"eslint/no-magic-numbers": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/**/*.ts"],
|
||||
"rules": {
|
||||
"import/no-nodejs-modules": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["dist", "node_modules", "coverage"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"entry": ["scripts/*.ts"],
|
||||
"ignoreDependencies": ["@runwisp/pubv"]
|
||||
}
|
||||
+2
-2
@@ -42,12 +42,12 @@
|
||||
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell",
|
||||
"check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}",
|
||||
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
|
||||
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src}",
|
||||
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
|
||||
"check:tsc": "tsc",
|
||||
"clean": "rm -rf dist coverage",
|
||||
"fix": "npm run fix:oxlint && npm run fix:oxfmt",
|
||||
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
|
||||
"fix:oxlint": "oxlint --fix src",
|
||||
"fix:oxlint": "oxlint --fix src scripts",
|
||||
"create:branch": "./scripts/branch.sh",
|
||||
"create:release": "./scripts/release.sh",
|
||||
"maintain": "npm run maintain:knip; npm run maintain:outdated",
|
||||
|
||||
@@ -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));
|
||||
+1
-1
@@ -8,5 +8,5 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
Reference in new issue
Block a user