@medicomind/rolldown-compression 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # @medicomind/rolldown-compression
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`8c73f0c`](https://github.com/Medico-Mind/rolldown-compression/commit/8c73f0c725501fcf69ebbe3ea9a2d5264437d089) Thanks [@Mnwa](https://github.com/Mnwa)! - Update dependencies: TypeScript 7 toolchain, @types/node 26, zlib-rs 0.6.6.
8
+
9
+ ### Patch Changes
10
+
11
+ - [`798219c`](https://github.com/Medico-Mind/rolldown-compression/commit/798219c059dc6c514912bbaea3844e3c0d09b447) Thanks [@Mnwa](https://github.com/Mnwa)! - Elide lifetimes
12
+
13
+ - [`996537c`](https://github.com/Medico-Mind/rolldown-compression/commit/996537c2948681e323aa0d67fdc077e5e2887df2) Thanks [@Mnwa](https://github.com/Mnwa)! - rollback ts to 7
14
+
15
+ ## 0.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - [`5307247`](https://github.com/Medico-Mind/rolldown-compression/commit/5307247e332a479672b6f91bfa76953922bd695e) Thanks [@Mnwa](https://github.com/Mnwa)! - Initial release.
20
+
21
+ - Rolldown plugin compressing emitted assets with gzip, brotli and zstd.
22
+ - Native Rust compression core (napi-rs v3 + rayon): one batched FFI call per
23
+ build, parallel across files and algorithms, cost-aware scheduling,
24
+ off-main-thread execution.
25
+ - `compression()` / `defineAlgorithm()` API mirroring vite-plugin-compression2:
26
+ include/exclude filters, threshold, filename patterns, deleteOriginalAssets,
27
+ skipIfLargerOrEqual, concurrency, logLevel.
28
+ - Eager option validation, re-compression guard, watch-mode no-op with
29
+ `enableInWatchMode` opt-in.
30
+ - Prebuilt binaries for darwin-arm64/x64, linux-x64/arm64-gnu, linux-x64-musl,
31
+ win32-x64-msvc.
32
+
33
+ - [`66182be`](https://github.com/Medico-Mind/rolldown-compression/commit/66182be208983bbbcac805cf0bb1d2671f86fcd2) Thanks [@Mnwa](https://github.com/Mnwa)! - First release
34
+
35
+ ### Patch Changes
36
+
37
+ - [`0fdd849`](https://github.com/Medico-Mind/rolldown-compression/commit/0fdd849328e71701b1aedadb677a82e6e75640a8) Thanks [@Mnwa](https://github.com/Mnwa)! - Set repo
38
+
39
+ Release notes are generated by [changesets](https://github.com/changesets/changesets); new sections are added here automatically when a release PR is merged.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 medicomind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # @medicomind/rolldown-compression
2
+
3
+ [![CI](https://github.com/Medico-Mind/rolldown-compression/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Medico-Mind/rolldown-compression/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40medicomind%2Frolldown-compression?logo=npm)](https://www.npmjs.com/package/@medicomind/rolldown-compression)
5
+ [![npm downloads](https://img.shields.io/npm/dm/%40medicomind%2Frolldown-compression)](https://www.npmjs.com/package/@medicomind/rolldown-compression)
6
+ [![node](https://img.shields.io/node/v/%40medicomind%2Frolldown-compression)](https://www.npmjs.com/package/@medicomind/rolldown-compression)
7
+ [![license](https://img.shields.io/npm/l/%40medicomind%2Frolldown-compression)](./LICENSE)
8
+
9
+ Fast, native compression plugin for [Rolldown](https://rolldown.rs): compresses emitted assets with **gzip**, **brotli** and **zstd** at build time. The compression core is written in Rust (napi-rs + rayon) — one batched FFI call per build, fanned out across all CPU cores, without ever blocking the JS event loop.
10
+
11
+ API ergonomics mirror [`vite-plugin-compression2`](https://github.com/nonzzz/vite-plugin-compression); see [differences](#differences-from-vite-plugin-compression2).
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install -D @medicomind/rolldown-compression
17
+ ```
18
+
19
+ Prebuilt binaries are installed automatically — no Rust toolchain needed (see [platform support](#platform-support)).
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ // rolldown.config.ts
25
+ import { defineConfig } from 'rolldown'
26
+ import { compression } from '@medicomind/rolldown-compression'
27
+
28
+ export default defineConfig({
29
+ input: 'src/main.ts',
30
+ plugins: [
31
+ // gzip + brotli with defaults
32
+ compression(),
33
+ ],
34
+ })
35
+ ```
36
+
37
+ Full configuration:
38
+
39
+ ```ts
40
+ import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'
41
+
42
+ compression({
43
+ include: [/\.(js|mjs|css|html|svg|json|wasm)$/],
44
+ exclude: [/\.(png|jpe?g|webp|woff2?)$/],
45
+ threshold: 1024,
46
+ algorithms: [
47
+ 'gzip', // string shorthand with default level
48
+ defineAlgorithm('brotli', { quality: 11 }),
49
+ defineAlgorithm('zstd', { level: 19 }),
50
+ ],
51
+ filename: '[path][base].gz', // or (fileName, algorithm) => string
52
+ deleteOriginalAssets: false,
53
+ skipIfLargerOrEqual: true,
54
+ concurrency: 0, // 0 = number of logical CPUs
55
+ logLevel: 'info',
56
+ })
57
+ ```
58
+
59
+ ## Options
60
+
61
+ | option | type | default | description |
62
+ | --- | --- | --- | --- |
63
+ | `include` | `string \| RegExp \| Array<string \| RegExp>` | `/\.(html\|xml\|css\|json\|js\|mjs\|svg\|yaml\|yml\|toml\|txt\|wasm)$/` | Files to compress. Strings are [picomatch](https://github.com/micromatch/picomatch) globs, matched against bundle-relative file names ([`@rollup/pluginutils` `createFilter`](https://github.com/rollup/plugins/tree/master/packages/pluginutils#createfilter) semantics). |
64
+ | `exclude` | `string \| RegExp \| Array<string \| RegExp>` | — | Files to skip. **Wins over `include`.** |
65
+ | `threshold` | `number` | `0` | Minimum original size in bytes for a file to be compressed. |
66
+ | `algorithms` | `Array<AlgorithmName \| DefineAlgorithmResult>` | `['gzip', 'brotli']` | Algorithms to run. Aliases `gz`, `br`, `brotliCompress`, `zstandard` normalize to `gzip` / `brotli` / `zstd`. |
67
+ | `filename` | `string \| (fileName, algorithm) => string` | `'[path][base]' + ext` | Name of the emitted artifact. Tokens: `[path]` (directory incl. trailing `/`), `[base]`, `[name]`, `[ext]` (with dot), `[hash]` (8-char sha256 of the content). Default extensions: `.gz`, `.br`, `.zst`. |
68
+ | `deleteOriginalAssets` | `boolean` | `false` | Remove the original from the bundle after all algorithms processed it. Errors if `filename` resolves to the source name. |
69
+ | `skipIfLargerOrEqual` | `boolean` | `true` | Don't emit artifacts whose compressed size is `>=` the original. |
70
+ | `concurrency` | `number` | `0` | Native worker threads. `0` = number of logical CPUs. |
71
+ | `logLevel` | `'silent' \| 'error' \| 'warn' \| 'info'` | `'info'` | Plugin log verbosity; `info` prints a per-algorithm summary at build end. |
72
+ | `enableInWatchMode` | `boolean` | `false` | The plugin is a no-op in watch/dev mode unless enabled (see [watch mode](#watch--dev-mode)). |
73
+
74
+ All options are validated when `compression()` is called — invalid levels (e.g. brotli quality 12), unknown algorithm names or malformed filters throw immediately, not mid-build.
75
+
76
+ ### Per-algorithm options
77
+
78
+ | algorithm | option | range | default |
79
+ | --- | --- | --- | --- |
80
+ | `gzip` | `level` | 0–9 | 6 |
81
+ | `brotli` | `quality` | 0–11 | 11 |
82
+ | `brotli` | `windowBits` | 10–24 | 22 |
83
+ | `zstd` | `level` | 1–22 | 19 |
84
+
85
+ ```ts
86
+ defineAlgorithm('gzip', { level: 9 })
87
+ defineAlgorithm('brotli', { quality: 7, windowBits: 22 })
88
+ defineAlgorithm('zstd', { level: 12 })
89
+ ```
90
+
91
+ ## How it works
92
+
93
+ - The plugin hooks **`generateBundle`**, while all chunks and assets are still in memory — no filesystem round-trip. Eligible files (filter + threshold) are sent to the native module as **one batched FFI call per build**; results are emitted with `emitFile`.
94
+ - Compression runs on a rayon thread pool inside the native module (`AsyncTask`), parallel across files *and* algorithms, scheduled most-expensive-first so one large brotli file can't stretch the batch tail. The JS event loop keeps ticking throughout (covered by a test).
95
+ - Buffers cross the FFI boundary without base64/string round-trips, and the compression working set lives in native memory — a 500 MB asset does not pressure the JS heap.
96
+ - A failing task never aborts the batch: per-task errors are aggregated and fail the build with one message.
97
+ - Already-compressed artifacts (`.gz`, `.br`, `.zst` — ours or pre-existing) are never re-compressed, so chaining plugin instances can't produce `app.js.gz.br`.
98
+
99
+ Limitation: assets written to disk by other plugins in `writeBundle`/`closeBundle` (i.e. after `generateBundle`) are not seen by this plugin. This matches how `vite-plugin-compression2` handles the in-bundle pass; a `writeBundle` fallback was deliberately left out to keep the pipeline zero-copy and single-pass. File an issue if you have a concrete case.
100
+
101
+ ### Watch / dev mode
102
+
103
+ The plugin declares `apply: 'build'` (honored by Vite-style hosts such as `rolldown-vite`) **and** checks `this.meta.watchMode` at `generateBundle` time, making it a no-op under `rolldown --watch`. Set `enableInWatchMode: true` to compress in watch builds anyway.
104
+
105
+ ## Serving pre-compressed assets
106
+
107
+ nginx ([gzip_static](https://nginx.org/en/docs/http/ngx_http_gzip_static_module.html) / [brotli_static](https://github.com/google/ngx_brotli) / [zstd_static](https://github.com/tokers/zstd-nginx-module)):
108
+
109
+ ```nginx
110
+ location / {
111
+ gzip_static on; # serves foo.js.gz when the client accepts gzip
112
+ brotli_static on; # requires ngx_brotli
113
+ zstd_static on; # requires zstd-nginx-module
114
+ }
115
+ ```
116
+
117
+ Caddy:
118
+
119
+ ```caddyfile
120
+ example.com {
121
+ root * /srv/dist
122
+ file_server {
123
+ precompressed zstd br gzip
124
+ }
125
+ }
126
+ ```
127
+
128
+ ## Benchmark
129
+
130
+ `npm run bench` (or `node benchmark/index.mjs --quick`) generates a dist-shaped fixture set — 200 files / ~48 MB with a long-tail size distribution — and compresses it with the native core vs `node:zlib` driven at full parallelism via `Promise.all` (the reference plugin's best case). Both sides always use the same levels.
131
+
132
+ Results on an Apple M1 Pro (10 cores), Node 26, default `UV_THREADPOOL_SIZE`:
133
+
134
+ | scenario | output | native (rust) | node:zlib | speedup |
135
+ | --- | --- | --- | --- | --- |
136
+ | gzip+brotli (ref. defaults: 9/11) | 8.71 MB | 8.82s | 15.19s | **1.72x** |
137
+ | gzip (level 9) | 5.51 MB | 0.10s | 0.33s | **3.48x** |
138
+ | gzip (level 6) | 5.64 MB | 0.07s | 0.16s | **2.38x** |
139
+ | brotli (quality 11) | 3.21 MB | 8.16s | 14.96s | **1.83x** |
140
+ | zstd (level 19) | 3.28 MB | 2.19s | 6.91s | **3.15x** |
141
+
142
+ Reading these numbers honestly:
143
+
144
+ - **gzip** and **zstd** beat the ≥3x target: the Rust encoders ([zlib-rs](https://github.com/trifectatechfoundation/zlib-rs), ~2.4x faster per core than node's bundled zlib in our measurements; libzstd) are faster per core *and* use every core, while `node:zlib` is capped at `UV_THREADPOOL_SIZE` (default 4) threads.
145
+ - **brotli at quality 11** is the bound on the combined number: the Rust `brotli` crate is at per-core parity with node's C brotli (we measured a 1.01 single-thread ratio), so the achievable speedup is roughly `cores / UV_THREADPOOL_SIZE` — ~2x on 8 equal cores, more on bigger machines. No implementation can honestly do better without changing the algorithm or its level.
146
+ - The speedup grows with core count and shrinks if you raise `UV_THREADPOOL_SIZE` for the JS side — the benchmark prints both so runs are comparable.
147
+
148
+ ## Platform support
149
+
150
+ Prebuilt binaries are published for:
151
+
152
+ | platform | triple |
153
+ | --- | --- |
154
+ | macOS arm64 | `aarch64-apple-darwin` |
155
+ | macOS x64 | `x86_64-apple-darwin` |
156
+ | Linux x64 (glibc) | `x86_64-unknown-linux-gnu` |
157
+ | Linux arm64 (glibc) | `aarch64-unknown-linux-gnu` |
158
+ | Linux x64 (musl) | `x86_64-unknown-linux-musl` |
159
+ | Windows x64 | `x86_64-pc-windows-msvc` |
160
+
161
+ Node.js >= 18.
162
+
163
+ ## Differences from vite-plugin-compression2
164
+
165
+ - **Native speed**: compression runs in Rust on all cores, one FFI batch per build, instead of `node:zlib` calls through the libuv thread pool.
166
+ - **No custom JS algorithms**: `algorithms` accepts only the built-in `gzip` / `brotli` / `zstd` (function-form algorithms can't cross the FFI boundary). `defineAlgorithm` returns an opaque object, not a `[name, options]` tuple — treat it as such.
167
+ - **No tarball plugin**: out of scope.
168
+ - **gzip default level is 6** (zlib default), not 9 — measurably faster for a ~1% size difference. Pass `defineAlgorithm('gzip', { level: 9 })` to match the reference.
169
+ - **zstd everywhere**: zstd is compiled in, with no dependency on the Node runtime's zstd support (node >= 22.15).
170
+ - Extra options: `concurrency` (native thread cap) and `enableInWatchMode`.
171
+
172
+ ## Implementation decisions
173
+
174
+ - **Rolldown target**: developed and tested against `rolldown@1.1.x` (peer range `^1.0.0`), using the Rollup-compatible `generateBundle`/`emitFile` plugin API.
175
+ - **gzip backend**: `flate2` with the pure-Rust `zlib-rs` backend — as fast as or faster than zlib-ng in our runs, with no cmake/C toolchain requirement for contributors.
176
+ - **Publishing**: public npm (`--access public`), versioned with [changesets](https://github.com/changesets/changesets). PRs include a changeset (`npx changeset`); the Version workflow keeps a `chore: release` PR up to date, and merging it tags the release and runs the full napi build matrix before `napi prepublish` + `npm publish`. Run the Release workflow via `workflow_dispatch` for a dry-run that builds all platform artifacts without publishing.
177
+
178
+ ## Development
179
+
180
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full contributor guide (setup, tests, changesets, PR workflow).
181
+
182
+ ```sh
183
+ npm install # install JS deps
184
+ npm run build # release native build + TS bundle
185
+ npm test # vitest (unit + integration)
186
+ cargo test # Rust core tests
187
+ npm run bench # benchmark vs node:zlib
188
+ COMPRESSION_TEST_LARGE=1 npx vitest run __tests__/integration/large-file.test.ts # 150 MB asset test
189
+ npx changeset # add a changeset describing your change (required for releases)
190
+ ```
191
+
192
+ ## License
193
+
194
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,309 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let node_crypto = require("node:crypto");
25
+ let node_path = require("node:path");
26
+ node_path = __toESM(node_path);
27
+ let _rollup_pluginutils = require("@rollup/pluginutils");
28
+ let node_module = require("node:module");
29
+ //#region ts/options.ts
30
+ /**
31
+ * Option types, normalization and validation for the public API.
32
+ *
33
+ * Everything here is validated eagerly at `compression()` call time so that
34
+ * misconfiguration fails when the config is evaluated, not mid-build.
35
+ */
36
+ const DEFAULT_INCLUDE = /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|txt|wasm)$/;
37
+ const ALIASES = {
38
+ gzip: "gzip",
39
+ gz: "gzip",
40
+ brotli: "brotli",
41
+ br: "brotli",
42
+ brotliCompress: "brotli",
43
+ zstd: "zstd",
44
+ zstandard: "zstd"
45
+ };
46
+ const EXTENSIONS = {
47
+ gzip: ".gz",
48
+ brotli: ".br",
49
+ zstd: ".zst"
50
+ };
51
+ const DEFAULT_LEVELS = {
52
+ gzip: 6,
53
+ brotli: 11,
54
+ zstd: 19
55
+ };
56
+ const LEVEL_RANGES = {
57
+ gzip: [0, 9],
58
+ brotli: [0, 11],
59
+ zstd: [1, 22]
60
+ };
61
+ const LOG_LEVELS = [
62
+ "silent",
63
+ "error",
64
+ "warn",
65
+ "info"
66
+ ];
67
+ /** File extensions produced by this plugin; used by the re-compression guard. */
68
+ const COMPRESSED_EXTENSION_RE = /\.(gz|br|zst)$/i;
69
+ var OptionValidationError = class extends Error {
70
+ constructor(message) {
71
+ super(`[rolldown-compression] ${message}`);
72
+ this.name = "OptionValidationError";
73
+ }
74
+ };
75
+ function assertIntegerInRange(value, [min, max], label) {
76
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new OptionValidationError(`invalid ${label}: expected an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
77
+ }
78
+ /** Normalize an algorithm name, throwing on unknown input. */
79
+ function normalizeAlgorithmName(name) {
80
+ const canonical = ALIASES[name];
81
+ if (canonical === void 0) throw new OptionValidationError(`unknown algorithm ${JSON.stringify(name)}: expected one of ${Object.keys(ALIASES).join(", ")}`);
82
+ return canonical;
83
+ }
84
+ function defineAlgorithm(name, options = {}) {
85
+ const algorithm = normalizeAlgorithmName(name);
86
+ validateAlgorithmOptions(algorithm, options);
87
+ return Object.freeze({
88
+ algorithm,
89
+ options: Object.freeze({ ...options })
90
+ });
91
+ }
92
+ function validateAlgorithmOptions(algorithm, options) {
93
+ if (algorithm === "brotli") {
94
+ const { quality, windowBits } = options;
95
+ if (quality !== void 0) assertIntegerInRange(quality, LEVEL_RANGES.brotli, "brotli quality");
96
+ if (windowBits !== void 0) assertIntegerInRange(windowBits, [10, 24], "brotli windowBits");
97
+ return;
98
+ }
99
+ const { level } = options;
100
+ if (level !== void 0) assertIntegerInRange(level, LEVEL_RANGES[algorithm], `${algorithm} level`);
101
+ }
102
+ function resolveAlgorithm(entry) {
103
+ const { algorithm, options } = typeof entry === "string" ? {
104
+ algorithm: normalizeAlgorithmName(entry),
105
+ options: {}
106
+ } : validateDefined(entry);
107
+ const resolved = {
108
+ algorithm,
109
+ level: algorithm === "brotli" ? options.quality ?? DEFAULT_LEVELS.brotli : options.level ?? DEFAULT_LEVELS[algorithm],
110
+ extension: EXTENSIONS[algorithm]
111
+ };
112
+ if (algorithm === "brotli") {
113
+ const { windowBits } = options;
114
+ if (windowBits !== void 0) resolved.windowBits = windowBits;
115
+ }
116
+ return resolved;
117
+ }
118
+ function validateDefined(entry) {
119
+ if (entry === null || typeof entry !== "object" || typeof entry.algorithm !== "string" || entry.options === null || typeof entry.options !== "object") throw new OptionValidationError(`invalid algorithms entry ${JSON.stringify(entry)}: expected an algorithm name or a defineAlgorithm() result`);
120
+ const algorithm = normalizeAlgorithmName(entry.algorithm);
121
+ validateAlgorithmOptions(algorithm, entry.options);
122
+ return {
123
+ algorithm,
124
+ options: entry.options
125
+ };
126
+ }
127
+ /** Validate and normalize user options. Throws at `compression()` call time. */
128
+ function resolveOptions(options = {}) {
129
+ if (options === null || typeof options !== "object") throw new OptionValidationError("options must be an object");
130
+ const { include = DEFAULT_INCLUDE, exclude, threshold = 0, algorithms = ["gzip", "brotli"], filename, deleteOriginalAssets = false, skipIfLargerOrEqual = true, concurrency = 0, logLevel = "info", enableInWatchMode = false } = options;
131
+ if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0) throw new OptionValidationError(`invalid threshold: expected a non-negative number, got ${JSON.stringify(threshold)}`);
132
+ if (!Number.isInteger(concurrency) || concurrency < 0) throw new OptionValidationError(`invalid concurrency: expected a non-negative integer, got ${JSON.stringify(concurrency)}`);
133
+ if (!LOG_LEVELS.includes(logLevel)) throw new OptionValidationError(`invalid logLevel ${JSON.stringify(logLevel)}: expected one of ${LOG_LEVELS.join(", ")}`);
134
+ if (filename !== void 0 && typeof filename !== "string" && typeof filename !== "function") throw new OptionValidationError("invalid filename: expected a string pattern or a function");
135
+ if (!Array.isArray(algorithms) || algorithms.length === 0) throw new OptionValidationError("invalid algorithms: expected a non-empty array");
136
+ return {
137
+ filter: (0, _rollup_pluginutils.createFilter)(include, exclude, { resolve: false }),
138
+ threshold,
139
+ algorithms: algorithms.map(resolveAlgorithm),
140
+ filename,
141
+ deleteOriginalAssets: Boolean(deleteOriginalAssets),
142
+ skipIfLargerOrEqual: Boolean(skipIfLargerOrEqual),
143
+ concurrency,
144
+ logLevel,
145
+ enableInWatchMode: Boolean(enableInWatchMode)
146
+ };
147
+ }
148
+ /**
149
+ * Resolve the emitted file name for a compressed artifact.
150
+ *
151
+ * Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`.
152
+ */
153
+ function resolveOutputFileName(filename, fileName, algorithm, source) {
154
+ if (typeof filename === "function") return filename(fileName, algorithm.algorithm);
155
+ const pattern = filename ?? `[path][base]${algorithm.extension}`;
156
+ const dir = node_path.default.posix.dirname(fileName);
157
+ const base = node_path.default.posix.basename(fileName);
158
+ const ext = node_path.default.posix.extname(fileName);
159
+ return pattern.replaceAll("[path]", dir === "." ? "" : `${dir}/`).replaceAll("[base]", base).replaceAll("[name]", base.slice(0, base.length - ext.length)).replaceAll("[ext]", ext).replaceAll("[hash]", () => (0, node_crypto.createHash)("sha256").update(source).digest("hex").slice(0, 8));
160
+ }
161
+ /**
162
+ * Compress a batch of buffers in the native rayon thread pool, off the JS
163
+ * main thread. `tasks[i]` describes how to compress `buffers[i]`.
164
+ */
165
+ const compressBuffers = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../index.js").compressBuffers;
166
+ //#endregion
167
+ //#region ts/plugin.ts
168
+ const PLUGIN_NAME = "rolldown-compression";
169
+ const LOG_PRIORITY = {
170
+ silent: 0,
171
+ error: 1,
172
+ warn: 2,
173
+ info: 3
174
+ };
175
+ function createLogger(level) {
176
+ const priority = LOG_PRIORITY[level];
177
+ const prefix = `[${PLUGIN_NAME}]`;
178
+ return {
179
+ info: (message) => {
180
+ if (priority >= LOG_PRIORITY.info) console.info(`${prefix} ${message}`);
181
+ },
182
+ warn: (message) => {
183
+ if (priority >= LOG_PRIORITY.warn) console.warn(`${prefix} ${message}`);
184
+ },
185
+ error: (message) => {
186
+ if (priority >= LOG_PRIORITY.error) console.error(`${prefix} ${message}`);
187
+ }
188
+ };
189
+ }
190
+ function toBuffer(source) {
191
+ return typeof source === "string" ? Buffer.from(source, "utf8") : Buffer.from(source);
192
+ }
193
+ function formatBytes(bytes) {
194
+ if (bytes < 1024) return `${bytes} B`;
195
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
196
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
197
+ }
198
+ function createCompressionPlugin(options) {
199
+ const logger = createLogger(options.logLevel);
200
+ const emittedNames = /* @__PURE__ */ new Set();
201
+ return Object.assign({
202
+ name: PLUGIN_NAME,
203
+ async generateBundle(_outputOptions, bundle) {
204
+ if (this.meta?.watchMode && !options.enableInWatchMode) {
205
+ logger.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
206
+ return;
207
+ }
208
+ const startedAt = performance.now();
209
+ const pending = [];
210
+ const processedSources = /* @__PURE__ */ new Set();
211
+ for (const [fileName, output] of Object.entries(bundle)) {
212
+ if (!options.filter(fileName)) continue;
213
+ if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
214
+ const buffer = toBuffer(output.type === "chunk" ? output.code : output.source);
215
+ if (buffer.byteLength < options.threshold) continue;
216
+ for (const algorithm of options.algorithms) {
217
+ const outputFileName = resolveOutputFileName(options.filename, fileName, algorithm, buffer);
218
+ if (outputFileName === fileName) this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] the filename option resolved "${fileName}" (${algorithm.algorithm}) to the same name as the source asset; refusing to overwrite it`));
219
+ pending.push({
220
+ task: {
221
+ fileName,
222
+ algorithm: algorithm.algorithm,
223
+ level: algorithm.level,
224
+ windowBits: algorithm.windowBits
225
+ },
226
+ buffer,
227
+ sourceFileName: fileName,
228
+ outputFileName
229
+ });
230
+ }
231
+ processedSources.add(fileName);
232
+ }
233
+ if (pending.length === 0) return;
234
+ const results = await compressBuffers(pending.map((artifact) => artifact.task), pending.map((artifact) => artifact.buffer), {
235
+ concurrency: options.concurrency,
236
+ skipIfLargerOrEqual: options.skipIfLargerOrEqual
237
+ });
238
+ const failures = [];
239
+ const emittedBySource = /* @__PURE__ */ new Map();
240
+ const stats = /* @__PURE__ */ new Map();
241
+ for (const [index, result] of results.entries()) {
242
+ const artifact = pending[index];
243
+ if (artifact === void 0) continue;
244
+ if (result.error !== void 0 && result.error !== null) {
245
+ failures.push(`${result.fileName} (${result.algorithm}): ${result.error}`);
246
+ continue;
247
+ }
248
+ if (result.skipped) {
249
+ logger.info(`skipped ${artifact.outputFileName}: ${result.algorithm} output would not be smaller than the original`);
250
+ continue;
251
+ }
252
+ this.emitFile({
253
+ type: "asset",
254
+ fileName: artifact.outputFileName,
255
+ source: result.data
256
+ });
257
+ emittedNames.add(artifact.outputFileName);
258
+ emittedBySource.set(artifact.sourceFileName, (emittedBySource.get(artifact.sourceFileName) ?? 0) + 1);
259
+ const stat = stats.get(result.algorithm) ?? {
260
+ count: 0,
261
+ originalBytes: 0,
262
+ outputBytes: 0
263
+ };
264
+ stat.count += 1;
265
+ stat.originalBytes += result.originalSize;
266
+ stat.outputBytes += result.compressedSize;
267
+ stats.set(result.algorithm, stat);
268
+ }
269
+ if (failures.length > 0) this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${failures.length} compression task(s) failed:\n${failures.join("\n")}`));
270
+ if (options.deleteOriginalAssets) for (const fileName of processedSources) {
271
+ if ((emittedBySource.get(fileName) ?? 0) === 0) logger.warn(`deleteOriginalAssets removed "${fileName}" even though no compressed variant was emitted for it`);
272
+ delete bundle[fileName];
273
+ }
274
+ const elapsedMs = performance.now() - startedAt;
275
+ const summary = [...stats.entries()].map(([algorithm, stat]) => `${algorithm}: ${stat.count} file(s), ${formatBytes(stat.originalBytes)} -> ${formatBytes(stat.outputBytes)}`).join("; ");
276
+ if (summary.length > 0) {
277
+ const totalSaved = [...stats.values()].reduce((sum, stat) => sum + (stat.originalBytes - stat.outputBytes), 0);
278
+ logger.info(`${summary}; saved ${formatBytes(totalSaved)} in ${(elapsedMs / 1e3).toFixed(2)}s`);
279
+ }
280
+ }
281
+ }, { apply: "build" });
282
+ }
283
+ //#endregion
284
+ //#region ts/index.ts
285
+ /**
286
+ * Create the compression plugin.
287
+ *
288
+ * Options are validated eagerly: invalid levels, algorithm names, thresholds
289
+ * etc. throw here rather than during the build.
290
+ *
291
+ * @example
292
+ * import { defineConfig } from 'rolldown'
293
+ * import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'
294
+ *
295
+ * export default defineConfig({
296
+ * plugins: [
297
+ * compression({
298
+ * threshold: 1024,
299
+ * algorithms: ['gzip', defineAlgorithm('brotli', { quality: 11 })],
300
+ * }),
301
+ * ],
302
+ * })
303
+ */
304
+ function compression(options) {
305
+ return createCompressionPlugin(resolveOptions(options));
306
+ }
307
+ //#endregion
308
+ exports.compression = compression;
309
+ exports.defineAlgorithm = defineAlgorithm;
@@ -0,0 +1,112 @@
1
+ import { Plugin } from "rolldown";
2
+ //#region ts/options.d.ts
3
+ /** Accepted algorithm names, including aliases. */
4
+ type AlgorithmName = 'gzip' | 'gz' | 'brotli' | 'br' | 'brotliCompress' | 'zstd' | 'zstandard';
5
+ /** Canonical algorithm names after alias normalization. */
6
+ type CanonicalAlgorithm = 'gzip' | 'brotli' | 'zstd';
7
+ /** Gzip options. `level`: 0-9, default 6. */
8
+ interface GzipOptions {
9
+ level?: number;
10
+ }
11
+ /** Brotli options. `quality`: 0-11, default 11. `windowBits`: 10-24, default 22. */
12
+ interface BrotliOptions {
13
+ quality?: number;
14
+ windowBits?: number;
15
+ }
16
+ /** Zstandard options. `level`: 1-22, default 19. */
17
+ interface ZstdOptions {
18
+ level?: number;
19
+ }
20
+ /** A normalized `(algorithm, options)` pair produced by {@link defineAlgorithm}. */
21
+ interface DefineAlgorithmResult {
22
+ readonly algorithm: CanonicalAlgorithm;
23
+ readonly options: GzipOptions | BrotliOptions | ZstdOptions;
24
+ }
25
+ /** Pattern or callback used to derive compressed artifact names. */
26
+ type FilenameOption = string | ((fileName: string, algorithm: CanonicalAlgorithm) => string);
27
+ type LogLevel = 'silent' | 'error' | 'warn' | 'info';
28
+ /** Options accepted by {@link compression}. */
29
+ interface CompressionOptions {
30
+ /**
31
+ * Files to compress. Strings are picomatch globs, RegExps are tested
32
+ * against the output file name.
33
+ * @default /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|txt|wasm)$/
34
+ */
35
+ include?: string | RegExp | Array<string | RegExp>;
36
+ /** Files to exclude. Takes precedence over `include`. */
37
+ exclude?: string | RegExp | Array<string | RegExp>;
38
+ /**
39
+ * Minimum size of the original asset, in bytes, for it to be compressed.
40
+ * @default 0
41
+ */
42
+ threshold?: number;
43
+ /**
44
+ * Algorithms to run, as names or {@link defineAlgorithm} results.
45
+ * @default ['gzip', 'brotli']
46
+ */
47
+ algorithms?: Array<AlgorithmName | DefineAlgorithmResult>;
48
+ /**
49
+ * Name of the emitted artifact. Tokens: `[path]` (directory, with trailing
50
+ * slash), `[base]` (file name with extension), `[name]`, `[ext]` (with
51
+ * leading dot), `[hash]` (8-char content hash). Function form receives the
52
+ * original file name and the canonical algorithm name.
53
+ * @default '[path][base]' + per-algorithm extension (.gz / .br / .zst)
54
+ */
55
+ filename?: FilenameOption;
56
+ /**
57
+ * Remove the original asset from the bundle once all algorithms have
58
+ * processed it.
59
+ * @default false
60
+ */
61
+ deleteOriginalAssets?: boolean;
62
+ /**
63
+ * Do not emit artifacts whose compressed size is >= the original size.
64
+ * @default true
65
+ */
66
+ skipIfLargerOrEqual?: boolean;
67
+ /**
68
+ * Native worker threads used for compression. `0` = number of logical CPUs.
69
+ * @default 0
70
+ */
71
+ concurrency?: number;
72
+ /** @default 'info' */
73
+ logLevel?: LogLevel;
74
+ /**
75
+ * The plugin is a no-op in watch/dev mode unless this is set to `true`.
76
+ * @default false
77
+ */
78
+ enableInWatchMode?: boolean;
79
+ }
80
+ /**
81
+ * Pair an algorithm with its options, validating both eagerly.
82
+ *
83
+ * @example
84
+ * compression({ algorithms: [defineAlgorithm('brotli', { quality: 9 })] })
85
+ */
86
+ declare function defineAlgorithm(name: 'gzip' | 'gz', options?: GzipOptions): DefineAlgorithmResult;
87
+ declare function defineAlgorithm(name: 'brotli' | 'br' | 'brotliCompress', options?: BrotliOptions): DefineAlgorithmResult;
88
+ declare function defineAlgorithm(name: 'zstd' | 'zstandard', options?: ZstdOptions): DefineAlgorithmResult;
89
+ //#endregion
90
+ //#region ts/index.d.ts
91
+ /**
92
+ * Create the compression plugin.
93
+ *
94
+ * Options are validated eagerly: invalid levels, algorithm names, thresholds
95
+ * etc. throw here rather than during the build.
96
+ *
97
+ * @example
98
+ * import { defineConfig } from 'rolldown'
99
+ * import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'
100
+ *
101
+ * export default defineConfig({
102
+ * plugins: [
103
+ * compression({
104
+ * threshold: 1024,
105
+ * algorithms: ['gzip', defineAlgorithm('brotli', { quality: 11 })],
106
+ * }),
107
+ * ],
108
+ * })
109
+ */
110
+ declare function compression(options?: CompressionOptions): Plugin;
111
+ //#endregion
112
+ export { type AlgorithmName, type BrotliOptions, type CanonicalAlgorithm, type CompressionOptions, type DefineAlgorithmResult, type FilenameOption, type GzipOptions, type LogLevel, type ZstdOptions, compression, defineAlgorithm };