defuss-brotli 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aron Homberg
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,328 @@
1
+ # `defuss-brotli`
2
+
3
+ A split pure-Rust Brotli package for Node.js + browsers.
4
+
5
+ - **Small browser decoder bundle** - import only the decompressor, skip the encoder WASM entirely
6
+ - **Separate heavier encoder bundle** - used server-side or at build time
7
+ - **Single NPM package** with split exports
8
+ - **Pure-Rust WebAssembly** - no native addons
9
+ - **Typed TypeScript API** with full JSDoc
10
+ - **Vitest coverage** in Node and browser
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ bun add defuss-brotli
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ### Compress
21
+
22
+ ```ts
23
+ import { init, compressText } from "defuss-brotli/compressor";
24
+
25
+ await init();
26
+
27
+ const compressed = compressText("Hello, Brotli!", { quality: 6 });
28
+ ```
29
+
30
+ ### Decompress
31
+
32
+ ```ts
33
+ import { init, decompressText } from "defuss-brotli/decompressor";
34
+
35
+ await init();
36
+
37
+ const text = decompressText(compressed);
38
+ // → "Hello, Brotli!"
39
+ ```
40
+
41
+ ## API
42
+
43
+ ### `compress` vs `compressText`
44
+
45
+ | Function | Input | Encoding | When to use |
46
+ |---|---|---|---|
47
+ | `compress(bytes, opts)` | `Uint8Array` | None - bytes pass through as-is | Binary data, files, pre-encoded buffers |
48
+ | `compressText(text, opts)` | `string` | `TextEncoder` (UTF-8) applied internally | JSON, HTML, Markdown, any JS string |
49
+
50
+ **Why `TextEncoder`?** JS strings are UTF-16 internally, but Brotli operates on
51
+ raw bytes. `TextEncoder` converts to canonical UTF-8 - the standard encoding for
52
+ web text. `compressText` handles this so you don't have to.
53
+
54
+ ### `decompress` vs `decompressText`
55
+
56
+ | Function | Output | Decoding | When to use |
57
+ |---|---|---|---|
58
+ | `decompress(bytes, opts)` | `Uint8Array` | None - raw bytes returned | Binary data, custom decoding |
59
+ | `decompressText(bytes, opts)` | `string` | `TextDecoder` (UTF-8) applied internally | Text that was compressed with `compressText` |
60
+
61
+ **Why `TextDecoder`?** Reverses the `TextEncoder` step. If the bytes are not
62
+ valid UTF-8, replacement characters (U+FFFD) are inserted rather than throwing.
63
+
64
+ ### Compression Options
65
+
66
+ ```ts
67
+ interface BrotliCompressOptions {
68
+ quality?: number; // 0–11, default 6
69
+ lgwin?: number; // 10–24, default 22
70
+ }
71
+ ```
72
+
73
+ - **`quality`** - 0 is fastest/largest, 11 is slowest/smallest. 5–7 is the sweet spot for on-the-fly web serving.
74
+ - **`lgwin`** - Sliding window size exponent. Larger windows find more matches at the cost of memory. 22 covers most web-text repetition patterns.
75
+
76
+ ### Decompression Options
77
+
78
+ ```ts
79
+ interface BrotliDecompressOptions {
80
+ maxOutputSize?: number; // default 64 MiB (67_108_864)
81
+ }
82
+ ```
83
+
84
+ Hard cap on decompressed output to prevent **decompression bombs**. Raise explicitly if your payloads legitimately exceed 64 MiB.
85
+
86
+ ## Assumptions, Guarantees, Limits
87
+
88
+ ### Assumptions
89
+
90
+ - Compressed input was produced by a **valid Brotli encoder** (this library, Node.js `zlib`, nginx, etc.).
91
+ - Text payloads compressed with `compressText` are valid **UTF-8** when decompressed.
92
+
93
+ ### Guarantees
94
+
95
+ - `compressText(s)` → `decompressText(...)` is a **perfect round-trip** for any JS string.
96
+ - `compress(b)` → `decompress(...)` is a **perfect round-trip** for any `Uint8Array`.
97
+ - `init()` is **idempotent** - calling it multiple times returns the same Promise and does not reload WASM.
98
+ - Importing only `defuss-brotli/decompressor` **never loads** the encoder WASM binary.
99
+
100
+ ### Limits
101
+
102
+ - `quality` must be an integer in **0–11** (Brotli spec hard limit).
103
+ - `lgwin` must be an integer in **10–24** (Brotli spec hard limit).
104
+ - `maxOutputSize` defaults to **64 MiB** - the WASM module rejects output exceeding this.
105
+ - Browser WASM memory is capped at **~2 GB** on 64-bit platforms (practical upper bound for very large payloads).
106
+
107
+ ## Why the Split Exports?
108
+
109
+ The encoder and decoder are built from **two separate Rust Wasm crates**:
110
+
111
+ | Export | Rust crate | WASM size | Bundle (JS + WASM) | Typical use |
112
+ |---|---|---|---|---|
113
+ | `defuss-brotli/compressor` | `brotli` | 984 KB | 1.3 MB | Server-side, build pipelines |
114
+ | `defuss-brotli/decompressor` | `brotli-decompressor` | 208 KB | 276 KB | Browser clients |
115
+
116
+ Browser apps that only decompress pre-compressed data import just the decompressor, keeping the bundle small.
117
+
118
+ ## Benchmarks
119
+
120
+ Measured with `vitest bench` on the payloads shown. Numbers are **ops/sec** (higher is better).
121
+
122
+ Run benchmarks yourself:
123
+
124
+ ```bash
125
+ bun run bench # Node.js
126
+ bun run bench:browser # Chromium via Playwright
127
+ ```
128
+
129
+ ### Node.js (Bun)
130
+
131
+ **Text Compression – `compressText`** (ops/sec, higher is better):
132
+
133
+ | Payload | Quality 1 | Quality 6 | Quality 11 |
134
+ |---|---|---|---|
135
+ | Short JSON (83 B) | – | 33,773 | – |
136
+ | Markdown (2 KB) | 16,612 | 10,098 | 327 |
137
+ | HTML (8 KB) | 5,010 | 2,473 | 19 |
138
+
139
+ **Binary Compression – `compress`** (ops/sec):
140
+
141
+ | Payload | Quality 1 | Quality 6 | Quality 11 |
142
+ |---|---|---|---|
143
+ | Pseudorandom (512 B) | – | 25,331 | – |
144
+ | SVG (4.9 MB) | 8.6 | 4.2 | 0.15 |
145
+ | PNG (2.1 MB) | 39.2 | 81.4 | 0.21 |
146
+
147
+ PNG compresses faster than SVG at q6 because already-compressed data triggers
148
+ Brotli's fast-path fallback earlier, producing less work per byte.
149
+
150
+ **Text Decompression – `decompressText`** (ops/sec):
151
+
152
+ | Payload | ops/sec |
153
+ |---|---|
154
+ | Short JSON (83 B) | 143,102 |
155
+ | Markdown (2 KB) | 46,341 |
156
+ | HTML (8 KB) | 34,720 |
157
+
158
+ **Binary Decompression – `decompress`** (ops/sec):
159
+
160
+ | Payload | ops/sec |
161
+ |---|---|
162
+ | Pseudorandom (512 B) | 111,424 |
163
+ | SVG (4.9 MB) | 34.5 |
164
+ | PNG (2.1 MB) | 654 |
165
+
166
+ ### Browser (Chromium)
167
+
168
+ **Text Compression – `compressText`** (ops/sec, higher is better):
169
+
170
+ | Payload | Quality 1 | Quality 6 | Quality 11 |
171
+ |---|---|---|---|
172
+ | Short JSON (83 B) | – | 36,264 | – |
173
+ | Markdown (2 KB) | 36,002 | 12,932 | 408 |
174
+ | HTML (8 KB) | 8,222 | 2,535 | 19 |
175
+
176
+ **Binary Compression – `compress`** (ops/sec):
177
+
178
+ | Payload | Quality 1 | Quality 6 | Quality 11 |
179
+ |---|---|---|---|
180
+ | Pseudorandom (512 B) | – | 29,712 | – |
181
+ | SVG (4.9 MB) | 15.9 | 4.8 | 0.15 |
182
+
183
+ **Text Decompression – `decompressText`** (ops/sec):
184
+
185
+ | Payload | ops/sec |
186
+ |---|---|
187
+ | Short JSON (83 B) | 157,906 |
188
+ | Markdown (2 KB) | 51,946 |
189
+ | HTML (8 KB) | 35,990 |
190
+
191
+ **Binary Decompression – `decompress`** (ops/sec):
192
+
193
+ | Payload | ops/sec |
194
+ |---|---|
195
+ | Pseudorandom (512 B) | 122,072 |
196
+ | SVG (4.9 MB) | 39.7 |
197
+ | PNG (2.1 MB) | 841 |
198
+
199
+ ## Examples
200
+
201
+ Run the quality comparison example to see compression ratios at different quality levels:
202
+
203
+ ```bash
204
+ bun run example
205
+ ```
206
+
207
+ Sample output:
208
+
209
+ ```
210
+ ════════════════════════════════════════════════════════════════════════
211
+ Short JSON (~83 B) (83 B input)
212
+ ════════════════════════════════════════════════════════════════════════
213
+ Quality │ Output │ Ratio │ Compress ms │ Decompress ms
214
+ ─────── │ ────────── │ ─────── │ ─────────── │ ─────────────
215
+ 1 │ 87 B │ 104.8% │ 1.22 │ 0.39
216
+ 4 │ 83 B │ 100.0% │ 0.38 │ 0.39
217
+ 6 │ 80 B │ 96.4% │ 1.41 │ 0.22
218
+ 9 │ 80 B │ 96.4% │ 2.14 │ 0.19
219
+ 11 │ 85 B │ 102.4% │ 3.44 │ 0.21
220
+
221
+ ════════════════════════════════════════════════════════════════════════
222
+ Pseudorandom binary (512 B) (512 B input)
223
+ ════════════════════════════════════════════════════════════════════════
224
+ Quality │ Output │ Ratio │ Compress ms │ Decompress ms
225
+ ─────── │ ────────── │ ─────── │ ─────────── │ ─────────────
226
+ 1 │ 286 B │ 55.9% │ 0.08 │ 0.88
227
+ 4 │ 278 B │ 54.3% │ 0.27 │ 0.07
228
+ 6 │ 270 B │ 52.7% │ 0.34 │ 0.09
229
+ 9 │ 259 B │ 50.6% │ 0.65 │ 0.05
230
+ 11 │ 239 B │ 46.7% │ 3.16 │ 0.39
231
+
232
+ ════════════════════════════════════════════════════════════════════════
233
+ SVG (4.9 MB) (4.9 MB input)
234
+ ════════════════════════════════════════════════════════════════════════
235
+ Quality │ Output │ Ratio │ Compress ms │ Decompress ms
236
+ ─────── │ ────────── │ ─────── │ ─────────── │ ─────────────
237
+ 1 │ 2.8 MB │ 57.0% │ 67.01 │ 70.33
238
+ 4 │ 1.9 MB │ 38.0% │ 85.98 │ 41.85
239
+ 6 │ 1.8 MB │ 37.4% │ 203.16 │ 42.70
240
+ 9 │ 1.8 MB │ 36.8% │ 506.93 │ 41.66
241
+ 11 │ 1.7 MB │ 34.5% │ 5108.92 │ 47.78
242
+
243
+ ════════════════════════════════════════════════════════════════════════
244
+ PNG (2.1 MB) (2.1 MB input)
245
+ ════════════════════════════════════════════════════════════════════════
246
+ Quality │ Output │ Ratio │ Compress ms │ Decompress ms
247
+ ─────── │ ────────── │ ─────── │ ─────────── │ ─────────────
248
+ 1 │ 2.1 MB │ 100.1% │ 16.59 │ 11.37
249
+ 4 │ 2.1 MB │ 100.0% │ 6.00 │ 10.96
250
+ 6 │ 2.1 MB │ 100.0% │ 13.40 │ 11.11
251
+ 9 │ 2.0 MB │ 99.0% │ 192.97 │ 23.06
252
+ 11 │ 2.0 MB │ 96.2% │ 3101.38 │ 30.33
253
+
254
+ ════════════════════════════════════════════════════════════════════════
255
+ All round-trips passed
256
+ ════════════════════════════════════════════════════════════════════════
257
+ ```
258
+
259
+ **Key takeaways:**
260
+
261
+ - **SVG** (text-based) compresses well: 57% → 34.5% ratio as quality increases, at the cost of much longer compression times at q11.
262
+ - **PNG** (already compressed) barely shrinks: ~100% ratio at q1–q6, only 96% at q11 — Brotli can't improve on an already-compressed binary format.
263
+ - **Short text** can actually *grow* at low quality levels (104.8% at q1) — the Brotli header overhead exceeds the savings on tiny payloads.
264
+
265
+ ## Defaults
266
+
267
+ For typical web text (JSON, Markdown, HTML, CSS, JS):
268
+
269
+ - `quality = 6`
270
+ - `lgwin = 22`
271
+
272
+ That is a sane point on the speed/ratio curve. Crank `quality` higher if you
273
+ need smaller output and can afford more CPU time.
274
+
275
+ ## Build
276
+
277
+ ### Prerequisites
278
+
279
+ - [Bun](https://bun.sh/) >= 1.0
280
+ - Rust toolchain + `wasm32-unknown-unknown` target
281
+ - `wasm-pack`
282
+ - `wasm-opt` (optional, from [binaryen](https://github.com/WebAssembly/binaryen) — produces smaller WASM)
283
+
284
+ Install everything automatically (cross-platform — installs Rust/wasm-pack if missing):
285
+
286
+ ```bash
287
+ make setup
288
+ # or
289
+ bun run setup
290
+ ```
291
+
292
+ Build everything:
293
+
294
+ ```bash
295
+ bun install
296
+ bun run build
297
+ ```
298
+
299
+ What happens during build:
300
+
301
+ 1. `wasm-pack` builds the compressor crate
302
+ 2. `wasm-pack` builds the decompressor crate
303
+ 3. `wasm-opt -Oz` runs if available
304
+ 4. `bun build` bundles `src/compressor.ts` and `src/decompressor.ts` into `dist/`
305
+ 5. `tsc` generates declaration files
306
+
307
+ ## Test and Benchmark
308
+
309
+ Node:
310
+
311
+ ```bash
312
+ bun test
313
+ bun example
314
+ bun test:e2e
315
+
316
+ bun bench
317
+ bun bench:browser
318
+ ```
319
+
320
+ Browser:
321
+
322
+ ```bash
323
+ bun run test:browser
324
+ ```
325
+
326
+ ## License
327
+
328
+ MIT
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Options for Brotli compression.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * compress(data, { quality: 11, lgwin: 22 }); // smallest output, slowest
7
+ * compress(data, { quality: 1 }); // fastest, larger output
8
+ * ```
9
+ */
10
+ export interface BrotliCompressOptions {
11
+ /**
12
+ * Brotli compression quality level (0–11).
13
+ *
14
+ * - **0** — fastest compression, largest output
15
+ * - **5–7** — good balance for web text (HTML/CSS/JS/JSON)
16
+ * - **11** — smallest output, slowest compression
17
+ *
18
+ * @default 6
19
+ */
20
+ quality?: number;
21
+ /**
22
+ * Sliding window size exponent (10–24).
23
+ *
24
+ * Larger windows let the encoder find longer matches at the cost of more
25
+ * memory. **22** is a strong default for typical web payloads.
26
+ *
27
+ * @default 22
28
+ */
29
+ lgwin?: number;
30
+ }
31
+ /** Default compression quality. Good balance of speed and ratio for web text. */
32
+ export declare const DEFAULT_QUALITY = 6;
33
+ /** Default sliding window exponent. Covers most web-text repetition patterns. */
34
+ export declare const DEFAULT_LGWIN = 22;
35
+ /**
36
+ * Throws if `init()` has not been called yet.
37
+ * Used internally before every compress call.
38
+ */
39
+ export declare function assertReady(): void;
40
+ /**
41
+ * Validates and applies defaults for compression options.
42
+ *
43
+ * @throws If `quality` is not an integer in 0–11 or `lgwin` is not in 10–24.
44
+ */
45
+ export declare function normalizeOptions(options?: BrotliCompressOptions): Required<BrotliCompressOptions>;
46
+ /**
47
+ * Load the Brotli compressor WASM module. Must be called (and awaited) once
48
+ * before any `compress` / `compressText` call.
49
+ *
50
+ * Idempotent — calling `init()` multiple times returns the same Promise and
51
+ * does not reload the WASM binary.
52
+ *
53
+ * Works identically in Node.js and browsers.
54
+ */
55
+ export declare function init(): Promise<void>;
56
+ /**
57
+ * Non-throwing readiness check.
58
+ *
59
+ * @returns `true` after `init()` has resolved, `false` otherwise.
60
+ */
61
+ export declare function isReady(): boolean;
62
+ /**
63
+ * Compress raw bytes with Brotli.
64
+ *
65
+ * Use this when you already have a `Uint8Array` (binary data, pre-encoded
66
+ * text, HTTP response bodies, etc.). No encoding is performed — what you
67
+ * pass in is exactly what gets compressed.
68
+ *
69
+ * For compressing JS strings directly, see {@link compressText}.
70
+ *
71
+ * @param input Raw bytes to compress.
72
+ * @param options Compression tuning — see {@link BrotliCompressOptions}.
73
+ * @returns A new `Uint8Array` containing the Brotli-compressed output.
74
+ *
75
+ * @throws If `init()` was not called first.
76
+ * @throws If options are out of range.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * await init();
81
+ * const compressed = compress(myFileBytes, { quality: 11 });
82
+ * ```
83
+ */
84
+ export declare function compress(input: Uint8Array, options?: BrotliCompressOptions): Uint8Array;
85
+ /**
86
+ * Compress a JS string with Brotli.
87
+ *
88
+ * Internally encodes the string to UTF-8 via `TextEncoder` and then
89
+ * delegates to {@link compress}. This is the right choice when your input is
90
+ * a plain string (JSON, HTML, Markdown, etc.) and you don't want to deal
91
+ * with encoding yourself.
92
+ *
93
+ * To decompress back to a string, use `decompressText()` from the
94
+ * decompressor module.
95
+ *
96
+ * @param text The string to compress.
97
+ * @param options Compression tuning — see {@link BrotliCompressOptions}.
98
+ * @returns A new `Uint8Array` containing the Brotli-compressed output.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * await init();
103
+ * const compressed = compressText('{"hello":"world"}', { quality: 6 });
104
+ * ```
105
+ */
106
+ export declare function compressText(text: string, options?: BrotliCompressOptions): Uint8Array;