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