@vanhanit/pre-compress 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,9 +16,9 @@
16
16
 
17
17
  pre-compress writes a gzip (`.gz`) and a brotli (`.br`) version next to each file matched by the glob patterns, using
18
18
  the highest compression levels. A web server can then send the compressed file from disk instead of compressing it on
19
- every request, for example with `gzip_static` and `brotli_static` in nginx.
19
+ every request, see [Serve the compressed files](#serve-the-compressed-files).
20
20
 
21
- - Files that are already compressed (`.gz` and `.br`) are never compressed again.
21
+ - Files that are already compressed (`.gz`, `.br` and `.zst`) are never compressed again.
22
22
  - Compressed files are written atomically, so a server never sends a partially written file.
23
23
  - No dependencies, only Node.js built-ins.
24
24
  - Free to use, also in companies, under the [PolyForm Shield License](#license).
@@ -64,11 +64,12 @@ Usage: pre-compress [options]
64
64
  Compress static files to be served by a web server.
65
65
 
66
66
  Options:
67
- -f, --files <files...> the files to compress, supports glob patterns to match multiple files
68
- -d, --delete-larger delete compressed files that are not smaller than the source
69
- -s, --skip-existing don't compress if the compressed version is newer than the source
70
- -V, --version output the version number
71
- -h, --help display help for command
67
+ -f, --files <files...> the files to compress, supports glob patterns to match multiple files
68
+ -e, --encodings <encodings...> the encodings to write: gzip, brotli, zstd (default: gzip,brotli)
69
+ -d, --delete-larger delete compressed files that are not smaller than the source
70
+ -s, --skip-existing don't compress if the compressed version is newer than the source
71
+ -V, --version output the version number
72
+ -h, --help display help for command
72
73
  ```
73
74
 
74
75
  Quote the patterns so the shell passes them on unexpanded:
@@ -77,11 +78,80 @@ Quote the patterns so the shell passes them on unexpanded:
77
78
  pre-compress --delete-larger --skip-existing --files "dist/**/*.{html,css,js,svg}"
78
79
  ```
79
80
 
81
+ - `--encodings` selects the encodings to write. Add `zstd` to also write a zstandard (`.zst`) version, which recent
82
+ browsers support. The zstd API of Node.js is still experimental.
80
83
  - `--delete-larger` removes compressed versions that would not save any bytes, so the server sends the source instead.
81
84
  - `--skip-existing` only compresses files that changed since they were last compressed, which speeds up repeated runs.
82
85
 
83
86
  The command exits with code 1 when the arguments are invalid or a file cannot be compressed.
84
87
 
88
+ ## Serve the compressed files
89
+
90
+ The server must send the compressed file only to browsers that accept its encoding, with the type of the original file,
91
+ a `Content-Encoding` header and `Vary: Accept-Encoding` so caches keep one copy per encoding.
92
+
93
+ ### nginx
94
+
95
+ `gzip_static` is part of nginx and included in the official packages and Docker images. Brotli and zstd need the
96
+ third-party modules [ngx_brotli](https://github.com/google/ngx_brotli) and
97
+ [zstd-nginx-module](https://github.com/tokers/zstd-nginx-module), which many distributions package, for example as
98
+ `libnginx-mod-http-brotli-static` on Debian and Ubuntu.
99
+
100
+ ```nginx
101
+ location / {
102
+ gzip_static on;
103
+ brotli_static on; # ngx_brotli
104
+ zstd_static on; # zstd-nginx-module
105
+ gzip_vary on; # also adds Vary: Accept-Encoding for brotli and zstd
106
+ gzip_proxied any; # gzip_static skips requests from proxies and CDNs (with a Via header) by default
107
+ }
108
+ ```
109
+
110
+ Leave out the directives of modules you don't have. nginx doesn't weigh the client's preferences between the modules:
111
+ when a browser accepts several encodings, the first module that finds a file wins. Brotli files are usually the
112
+ smallest, so with nginx, the default gzip and brotli files are enough.
113
+
114
+ ### Caddy
115
+
116
+ Caddy supports all three encodings without plugins and picks the one the browser prefers, using the listed order when it
117
+ has no preference:
118
+
119
+ ```caddyfile
120
+ example.com {
121
+ root * /var/www
122
+ file_server {
123
+ precompressed br zstd gzip
124
+ }
125
+ }
126
+ ```
127
+
128
+ ## Benchmark
129
+
130
+ `npm run bench` serves files with a Node.js server, compressed on every request at the default levels servers use, or
131
+ pre-compressed by pre-compress, and measures them under load. These are the results for the minified JavaScript in
132
+ `node_modules` (5 files, 231 KiB) with 16 connections, on 4 cores of an Intel Xeon at 2.10 GHz and Node.js 22:
133
+
134
+ | Encoding | Compressed | Level | Size | Requests/s | p50 ms | p99 ms | Server CPU ms/request |
135
+ | -------- | -------------- | ----- | ------- | ---------- | ------ | ------ | --------------------- |
136
+ | identity | no | - | 231 KiB | 11908 | 1.07 | 4.80 | 0.051 |
137
+ | gzip | on request | 6 | 61 KiB | 2797 | 5.25 | 12.19 | 1.168 |
138
+ | gzip | pre-compressed | 9 | 61 KiB | 18058 | 0.76 | 2.90 | 0.033 |
139
+ | br | on request | 6 | 57 KiB | 1793 | 8.10 | 19.90 | 1.868 |
140
+ | br | pre-compressed | 11 | 53 KiB | 17892 | 0.76 | 2.93 | 0.035 |
141
+ | zstd | on request | 3 | 63 KiB | 3351 | 3.98 | 25.87 | 0.865 |
142
+ | zstd | pre-compressed | 19 | 56 KiB | 16565 | 0.80 | 3.02 | 0.036 |
143
+
144
+ Pre-compressed brotli files are 77% smaller than the originals and 7% smaller than brotli compressed on request, because
145
+ the highest quality is too slow to use on every request. The server answers 10 times more requests than when it
146
+ compresses with brotli, with 50 times less CPU per request, and even more than without compression, since it sends
147
+ fewer bytes. Compressing the files once took 0.2 seconds.
148
+
149
+ Pass options and your own files to measure them:
150
+
151
+ ```shell
152
+ npm run bench -- --duration 10 --connections 64 "dist/**/*.{html,css,js}"
153
+ ```
154
+
85
155
  ## Development
86
156
 
87
157
  ```shell
package/dist/args.js CHANGED
@@ -1,24 +1,33 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { ENCODING_NAMES } from "./compress.js";
2
3
  /**
3
4
  * Invalid command line arguments.
4
5
  */
5
6
  export class ArgumentError extends Error {
6
7
  name = "ArgumentError";
7
8
  }
9
+ const DEFAULT_ENCODINGS = ["gzip", "brotli"];
8
10
  const OPTIONS = {
9
11
  files: { type: "string", short: "f", multiple: true },
12
+ encodings: { type: "string", short: "e", multiple: true },
10
13
  "delete-larger": { type: "boolean", short: "d" },
11
14
  "skip-existing": { type: "boolean", short: "s" },
12
15
  version: { type: "boolean", short: "V" },
13
16
  help: { type: "boolean", short: "h" }
14
17
  };
15
- const HELP = [
16
- ["-f, --files <files...>", "the files to compress, supports glob patterns to match multiple files"],
17
- ["-d, --delete-larger", "delete compressed files that are not smaller than the source"],
18
- ["-s, --skip-existing", "don't compress if the compressed version is newer than the source"],
19
- ["-V, --version", "output the version number"],
20
- ["-h, --help", "display help for command"]
21
- ];
18
+ const DESCRIPTIONS = {
19
+ files: "the files to compress, supports glob patterns to match multiple files",
20
+ encodings: `the encodings to write: ${ENCODING_NAMES.join(", ")} (default: ${DEFAULT_ENCODINGS.join(",")})`,
21
+ "delete-larger": "delete compressed files that are not smaller than the source",
22
+ "skip-existing": "don't compress if the compressed version is newer than the source",
23
+ version: "output the version number",
24
+ help: "display help for command"
25
+ };
26
+ function flags(name) {
27
+ const option = OPTIONS[name];
28
+ const value = option.type === "string" ? ` <${name}...>` : "";
29
+ return `-${option.short}, --${name}${value}`;
30
+ }
22
31
  function parse(args) {
23
32
  try {
24
33
  return parseArgs({ args: [...args], options: OPTIONS, allowPositionals: true });
@@ -27,6 +36,15 @@ function parse(args) {
27
36
  throw new ArgumentError(error.message);
28
37
  }
29
38
  }
39
+ function parseEncodings(values) {
40
+ const names = new Set(values.flatMap((value) => value.split(",")));
41
+ for (const name of names) {
42
+ if (!ENCODING_NAMES.includes(name)) {
43
+ throw new ArgumentError(`unknown encoding '${name}', choose from ${ENCODING_NAMES.join(", ")}`);
44
+ }
45
+ }
46
+ return [...names];
47
+ }
30
48
  /**
31
49
  * Parses the command line arguments, without the node and script paths. Patterns may follow `-f` or be repeated.
32
50
  */
@@ -40,20 +58,24 @@ export function parseArguments(args) {
40
58
  }
41
59
  const patterns = [...(values.files ?? []), ...positionals];
42
60
  if (patterns.length === 0) {
43
- throw new ArgumentError("required option '-f, --files <files...>' not specified");
61
+ throw new ArgumentError(`required option '${flags("files")}' not specified`);
44
62
  }
45
63
  return {
46
64
  name: "compress",
47
65
  patterns,
48
- deleteLarger: values["delete-larger"] ?? false,
49
- skipExisting: values["skip-existing"] ?? false
66
+ options: {
67
+ encodings: values.encodings ? parseEncodings(values.encodings) : DEFAULT_ENCODINGS,
68
+ deleteLarger: values["delete-larger"] ?? false,
69
+ skipExisting: values["skip-existing"] ?? false
70
+ }
50
71
  };
51
72
  }
52
73
  /**
53
74
  * Returns the help text.
54
75
  */
55
76
  export function usage(name, description) {
56
- const width = Math.max(...HELP.map(([flags]) => flags.length));
57
- const options = HELP.map(([flags, text]) => ` ${flags.padEnd(width)} ${text}`);
77
+ const names = Object.keys(OPTIONS);
78
+ const width = Math.max(...names.map((option) => flags(option).length));
79
+ const options = names.map((option) => ` ${flags(option).padEnd(width)} ${DESCRIPTIONS[option]}`);
58
80
  return [`Usage: ${name} [options]`, "", description, "", "Options:", ...options].join("\n");
59
81
  }
package/dist/cli.js CHANGED
@@ -5,41 +5,29 @@ import { ArgumentError, parseArguments, usage } from "./args.js";
5
5
  import { compressFile, isCompressed } from "./compress.js";
6
6
  import { mapConcurrently } from "./concurrency.js";
7
7
  import { findFiles } from "./files.js";
8
- import { formatDuration, pluralize } from "./format.js";
9
8
  import { readPackageInfo } from "./package-info.js";
10
- function report({ file, size, outputs }, io) {
11
- for (const output of outputs) {
12
- if (output.status === "written") {
13
- io.log(`Compressed file ${styleText("yellow", file)} to ${styleText("green", output.file)}`);
14
- }
15
- else if (output.status === "skipped") {
16
- io.log(`Skipped file ${styleText("yellow", output.file)} because it is up to date`);
17
- }
18
- else {
19
- io.log(`Deleted file ${styleText("red", output.file)} because ${output.size} byte(s) is not smaller than ${size} byte(s)`);
20
- }
21
- }
22
- }
23
- async function compress(command, io) {
9
+ import { describeResult, summarize } from "./report.js";
10
+ const consoleIo = {
11
+ log: (message) => console.log(message),
12
+ error: (message) => console.error(message),
13
+ style: (color, text) => styleText(color, text)
14
+ };
15
+ async function compress({ patterns, options }, io) {
24
16
  const startTime = performance.now();
25
- const files = (await findFiles(command.patterns)).filter((file) => !isCompressed(file));
17
+ const files = (await findFiles(patterns)).filter((file) => !isCompressed(file));
26
18
  const results = await mapConcurrently(files, availableParallelism(), async (file) => {
27
- const result = await compressFile(file, command);
28
- report(result, io);
19
+ const result = await compressFile(file, options);
20
+ for (const line of describeResult(result, io.style)) {
21
+ io.log(line);
22
+ }
29
23
  return result;
30
24
  });
31
- const timeTakenMs = performance.now() - startTime;
32
- const outputs = results.flatMap((result) => result.outputs);
33
- const count = (status) => outputs.filter((output) => output.status === status).length;
34
- io.log(`Processed ${pluralize(files.length, "file")} in ${styleText("green", formatDuration(timeTakenMs))}: ` +
35
- `${styleText("green", String(count("written")))} written, ` +
36
- `${styleText("yellowBright", String(count("skipped")))} skipped, ` +
37
- `${styleText("red", String(count("deleted")))} deleted`);
25
+ io.log(summarize(results, performance.now() - startTime, io.style));
38
26
  }
39
27
  /**
40
28
  * Runs the command line interface with the arguments, without the node and script paths, and returns the exit code.
41
29
  */
42
- export async function run(args, io = console) {
30
+ export async function run(args, io = consoleIo) {
43
31
  const { command: name, version, description } = readPackageInfo();
44
32
  try {
45
33
  const command = parseArguments(args);
package/dist/compress.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { readFile, rm, stat } from "node:fs/promises";
2
2
  import { extname } from "node:path";
3
3
  import { promisify } from "node:util";
4
- import { brotliCompress, constants, gzip } from "node:zlib";
4
+ import { brotliCompress, constants, gzip, zstdCompress } from "node:zlib";
5
5
  import { replaceFile, statFile } from "./files.js";
6
6
  const gzipAsync = promisify(gzip);
7
7
  const brotliCompressAsync = promisify(brotliCompress);
8
- const ENCODINGS = [
9
- {
8
+ const zstdCompressAsync = promisify(zstdCompress);
9
+ const ENCODINGS = {
10
+ gzip: {
10
11
  extension: "gz",
11
12
  compress: (data) => gzipAsync(data, { level: constants.Z_BEST_COMPRESSION })
12
13
  },
13
- {
14
+ brotli: {
14
15
  extension: "br",
15
16
  compress: (data) => brotliCompressAsync(data, {
16
17
  params: {
@@ -19,26 +20,37 @@ const ENCODINGS = [
19
20
  [constants.BROTLI_PARAM_SIZE_HINT]: data.length
20
21
  }
21
22
  })
23
+ },
24
+ zstd: {
25
+ extension: "zst",
26
+ // Level 19 keeps the window at 8 MB or less, the most browsers must support for zstd (RFC 9659).
27
+ compress: (data) => zstdCompressAsync(data, {
28
+ params: { [constants.ZSTD_c_compressionLevel]: 19 },
29
+ pledgedSrcSize: data.length
30
+ })
22
31
  }
23
- ];
32
+ };
33
+ /** The names of the supported encodings. */
34
+ export const ENCODING_NAMES = Object.keys(ENCODINGS);
24
35
  /**
25
36
  * Whether the file has the extension of a compressed file this tool writes.
26
37
  */
27
38
  export function isCompressed(file) {
28
39
  const extension = extname(file).slice(1).toLowerCase();
29
- return ENCODINGS.some((encoding) => encoding.extension === extension);
40
+ return Object.values(ENCODINGS).some((encoding) => encoding.extension === extension);
30
41
  }
31
42
  async function isUpToDate(target, source) {
32
43
  const stats = await statFile(target);
33
44
  return stats !== undefined && stats.mtimeMs >= source.mtimeMs;
34
45
  }
35
46
  /**
36
- * Writes a compressed file next to the source for every encoding.
47
+ * Writes a compressed file next to the source for every selected encoding.
37
48
  */
38
49
  export async function compressFile(file, options) {
39
50
  const source = await stat(file);
40
51
  let data;
41
- const outputs = await Promise.all(ENCODINGS.map(async (encoding) => {
52
+ const outputs = await Promise.all(options.encodings.map(async (name) => {
53
+ const encoding = ENCODINGS[name];
42
54
  const target = `${file}.${encoding.extension}`;
43
55
  if (options.skipExisting && (await isUpToDate(target, source))) {
44
56
  return { file: target, status: "skipped" };
package/dist/main.js CHANGED
@@ -1,3 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { run } from "./cli.js";
2
+ import { configureThreadPool } from "./thread-pool.js";
3
+ // The CLI is loaded afterwards, so nothing can use the thread pool before it is sized.
4
+ configureThreadPool();
5
+ const { run } = await import("./cli.js");
3
6
  process.exitCode = await run(process.argv.slice(2));
package/dist/report.js ADDED
@@ -0,0 +1,30 @@
1
+ import { formatDuration, pluralize } from "./format.js";
2
+ function describeOutput(file, size, output, style) {
3
+ switch (output.status) {
4
+ case "written":
5
+ return `Compressed file ${style("yellow", file)} to ${style("green", output.file)}`;
6
+ case "skipped":
7
+ return `Skipped file ${style("yellow", output.file)} because it is up to date`;
8
+ case "deleted":
9
+ return `Deleted file ${style("red", output.file)} because ${output.size} byte(s) is not smaller than ${size} byte(s)`;
10
+ }
11
+ }
12
+ /**
13
+ * Returns a line for every compressed file written, skipped or deleted for a source file.
14
+ */
15
+ export function describeResult({ file, size, outputs }, style) {
16
+ return outputs.map((output) => describeOutput(file, size, output, style));
17
+ }
18
+ /**
19
+ * Returns a line counting what was done to the compressed files of all processed source files.
20
+ */
21
+ export function summarize(results, milliseconds, style) {
22
+ const counts = { written: 0, skipped: 0, deleted: 0 };
23
+ for (const output of results.flatMap((result) => result.outputs)) {
24
+ counts[output.status]++;
25
+ }
26
+ return (`Processed ${pluralize(results.length, "file")} in ${style("green", formatDuration(milliseconds))}: ` +
27
+ `${style("green", String(counts.written))} written, ` +
28
+ `${style("yellowBright", String(counts.skipped))} skipped, ` +
29
+ `${style("red", String(counts.deleted))} deleted`);
30
+ }
@@ -0,0 +1,9 @@
1
+ import { availableParallelism } from "node:os";
2
+ const DEFAULT_SIZE = 4;
3
+ /**
4
+ * Sizes libuv's thread pool, which runs the compression, to the available cores. Its default of 4 threads leaves the
5
+ * other cores idle. Takes effect only before the pool is first used, and keeps a size that is already set.
6
+ */
7
+ export function configureThreadPool(env = process.env, cores = availableParallelism()) {
8
+ env.UV_THREADPOOL_SIZE ??= String(Math.max(DEFAULT_SIZE, cores));
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanhanit/pre-compress",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Compress static files to be served by a web server.",
5
5
  "author": "Thomas Vanhaniemi",
6
6
  "type": "module",
@@ -58,6 +58,7 @@
58
58
  "lint": "eslint .",
59
59
  "format": "prettier --write .",
60
60
  "format:check": "prettier --check .",
61
+ "bench": "node bench/bench.ts",
61
62
  "check": "npm run lint && npm run format:check && npm run typecheck && npm test"
62
63
  }
63
64
  }