zstd-stream 1.0.0 → 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
@@ -1,285 +1,273 @@
1
- <div align="center">
2
-
3
- # zstd-stream
4
-
5
- **High-performance Zstandard compression for Node.js and browsers with zero external dependencies**
6
-
7
- [![npm version](https://img.shields.io/npm/v/zstd-stream.svg)](https://www.npmjs.com/package/zstd-stream)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
- [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-brightgreen.svg)](https://www.typescriptlang.org/)
10
-
11
- </div>
12
-
13
- ---
14
-
15
- ## Features
16
-
17
- - 🚀 **Universal compatibility** - Works seamlessly in Node.js 18+ and modern browsers
18
- - 📦 **Zero external assets** - All WebAssembly code bundled internally, works out of the box
19
- - 🌊 **True streaming support** - Handle multi-GB files with minimal memory footprint
20
- - ⚡ **Backpressure handling** - Efficient memory management prevents overflow
21
- - 🎯 **Client-side optimization** - Reduce server load by compressing/decompressing in the browser
22
- - 🔧 **ESM-first** - Modern ECMAScript modules with full TypeScript support
23
- - 📊 **Progress tracking** - Monitor compression/decompression in real-time
24
-
25
- Built on the latest Zstandard v2 compression algorithm, `zstd-stream` is one of the only packages that embeds all WASM code internally, eliminating asset management headaches and enabling true plug-and-play compression.
26
-
27
- ---
28
-
29
- ## Installation
30
-
31
- ```bash
32
- npm install zstd-stream
33
- ```
34
-
35
- ---
36
-
37
- ## Usage Examples
38
-
39
- ### Basic Text Compression
40
-
41
- **⚠️ Note:** This method loads all data into memory. For large files (>100MB), use streaming instead.
42
-
43
- ```typescript
44
- import { compress, decompress } from "zstd-stream";
45
-
46
- // Compress text
47
- const text = "Hello, world!";
48
- const input = new TextEncoder().encode(text);
49
- const compressed = await compress(input, { level: 3 });
50
-
51
- // Decompress
52
- const decompressed = await decompress(compressed);
53
- const output = new TextDecoder().decode(decompressed);
54
-
55
- console.log(output); // "Hello, world!"
56
- ```
57
-
58
- ### Streaming Large Files (Recommended)
59
-
60
- **✅ Use this for large files** - Processes data in chunks with constant memory usage.
61
-
62
- ```typescript
63
- import { compressStream, decompressStream } from "zstd-stream";
64
-
65
- // Compress a stream
66
- const textStream = new ReadableStream({
67
- start(controller) {
68
- controller.enqueue(new TextEncoder().encode("Large text data..."));
69
- controller.close();
70
- },
71
- });
72
-
73
- const compressedStream = await compressStream(textStream, { level: 5 });
74
-
75
- // Decompress a stream
76
- const decompressedStream = await decompressStream(compressedStream);
77
-
78
- // Read the result
79
- const reader = decompressedStream.getReader();
80
- let result = "";
81
-
82
- while (true) {
83
- const { done, value } = await reader.read();
84
- if (done) break;
85
- result += new TextDecoder().decode(value);
86
- }
87
-
88
- console.log(result);
89
- ```
90
-
91
- ### Browser: Download Compressed File with StreamSaver.js
92
-
93
- **⚠️ Performance Note:** Handle compression via Web Workers for browser deployment to avoid degrading the application's performance!
94
-
95
- ```typescript
96
- import { compressStream } from "zstd-stream";
97
- import streamSaver from "streamsaver";
98
-
99
- // User selects a file
100
- const file = document.querySelector('input[type="file"]').files[0];
101
- const fileStream = file.stream();
102
-
103
- // Compress the file
104
- const compressed = await compressStream(fileStream, {
105
- level: 9,
106
- onProgress: (bytes) => {
107
- console.log(`Compressed: ${(bytes / 1024 / 1024).toFixed(2)} MB`);
108
- },
109
- });
110
-
111
- // Save to disk as .zst
112
- const fileWriteStream = streamSaver.createWriteStream(`${file.name}.zst`);
113
- const writer = fileWriteStream.getWriter();
114
-
115
- const reader = compressed.getReader();
116
- while (true) {
117
- const { done, value } = await reader.read();
118
- if (done) break;
119
- await writer.write(value);
120
- }
121
-
122
- await writer.close();
123
- ```
124
-
125
- ### Stream File to Server via HTTP
126
-
127
- ```typescript
128
- import { compressStream } from "zstd-stream";
129
-
130
- // Get file from user input
131
- const file = document.querySelector('input[type="file"]').files[0];
132
- const fileStream = file.stream();
133
-
134
- // Compress and upload
135
- const compressed = await compressStream(fileStream, { level: 6 });
136
-
137
- const response = await fetch("https://api.example.com/upload", {
138
- method: "POST",
139
- headers: {
140
- "Content-Type": "application/zstd",
141
- "Content-Encoding": "zstd",
142
- },
143
- body: compressed,
144
- duplex: "half", // Required for streaming request bodies
145
- });
146
-
147
- if (response.ok) {
148
- console.log("Upload complete!");
149
- }
150
- ```
151
-
152
- ---
153
-
154
- ## API Reference
155
-
156
- ### `compress(input, options?)`
157
-
158
- Compress data in one operation. Best for small files.
159
-
160
- **Parameters:**
161
-
162
- - `input: Uint8Array` - Data to compress
163
- - `options?: CompressOptions`
164
- - `level?: number` - Compression level (1-19, default: 3)
165
- - `onProgress?: (bytesWritten: number) => void` - Progress callback
166
-
167
- **Returns:** `Promise<Uint8Array>`
168
-
169
- ```typescript
170
- const compressed = await compress(data, { level: 9 });
171
- ```
172
-
173
- ### `compressStream(input, options?)`
174
-
175
- Compress a readable stream. Best for large files.
176
-
177
- **Parameters:**
178
-
179
- - `input: ReadableStream<Uint8Array>` - Stream to compress
180
- - `options?: CompressOptions`
181
- - `level?: number` - Compression level (1-19, default: 3)
182
- - `onProgress?: (bytesWritten: number) => void` - Progress callback
183
-
184
- **Returns:** `Promise<ReadableStream<Uint8Array>>`
185
-
186
- ```typescript
187
- const compressed = await compressStream(fileStream, { level: 5 });
188
- ```
189
-
190
- ### `decompress(input, options?)`
191
-
192
- Decompress data in one operation.
193
-
194
- **Parameters:**
195
-
196
- - `input: Uint8Array` - Compressed data
197
- - `options?: DecompressOptions`
198
- - `onProgress?: (bytesWritten: number) => void` - Progress callback
199
-
200
- **Returns:** `Promise<Uint8Array>`
201
-
202
- ```typescript
203
- const decompressed = await decompress(compressed);
204
- ```
205
-
206
- ### `decompressStream(input, options?)`
207
-
208
- Decompress a readable stream with backpressure support.
209
-
210
- **Parameters:**
211
-
212
- - `input: ReadableStream<Uint8Array>` - Compressed stream
213
- - `options?: DecompressOptions`
214
- - `onProgress?: (bytesWritten: number) => void` - Progress callback
215
-
216
- **Returns:** `Promise<ReadableStream<Uint8Array>>`
217
-
218
- ```typescript
219
- const decompressed = await decompressStream(compressedStream);
220
- ```
221
-
222
- ### `initialize()`
223
-
224
- Pre-initialize the WASM module (optional). Call during app startup to avoid initialization delay on first use.
225
-
226
- **Returns:** `Promise<void>`
227
-
228
- ```typescript
229
- await initialize();
230
- ```
231
-
232
- ---
233
-
234
- ## Compression Levels
235
-
236
- Levels 1-19 are supported. Higher levels provide diminishing returns.
237
-
238
- | Level | Speed | Ratio | Use Case |
239
- | ----- | --------- | -------- | ---------------------------------- |
240
- | 1-3 | Fast | Lower | Real-time, network streaming |
241
- | 3-7 | Medium | Balanced | General purpose (recommended) |
242
- | 8-15 | Slow | Better | File storage, archival |
243
- | 16-19 | Very slow | Maximum | One-time compression, cold storage |
244
-
245
- **Default level:** 3 (optimal balance of speed and compression)
246
-
247
- ---
248
-
249
- ## Browser Compatibility
250
-
251
- - Chrome/Edge 80+
252
- - Firefox 113+
253
- - Safari 16.4+
254
- - Node.js 18+
255
-
256
- Requires WebAssembly and ES2022 support.
257
-
258
- ---
259
-
260
- ## TypeScript
261
-
262
- Full type definitions included:
263
-
264
- ```typescript
265
- import type { CompressOptions, DecompressOptions } from "zstd-stream";
266
-
267
- const options: CompressOptions = {
268
- level: 9,
269
- onProgress: (bytes) => console.log(`Progress: ${bytes}`),
270
- };
271
- ```
272
-
273
- ---
274
-
275
- ## License
276
-
277
- MIT
278
-
279
- ---
280
-
281
- ## Credits
282
-
283
- Built with [Zstandard](https://github.com/facebook/zstd) by Meta, compiled using [Emscripten SDK](https://emscripten.org/).
284
-
285
- WebAssembly module embedded internally for zero-dependency deployment.
1
+ <div align="center">
2
+
3
+ # zstd-stream
4
+
5
+ **Streaming Zstandard compression for Node.js and browsers zero external dependencies, bounded memory.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/zstd-stream.svg)](https://www.npmjs.com/package/zstd-stream)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-brightgreen.svg)](https://www.typescriptlang.org/)
10
+ [![Browser Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://crellincreative.github.io/zstd-stream/)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ `zstd-stream` compresses and decompresses [Zstandard](https://github.com/facebook/zstd)
17
+ data through the Web Streams API. It streams files larger than available RAM in
18
+ **constant memory**, propagates **backpressure** end to end, and ships the
19
+ WebAssembly build **embedded** no `.wasm` asset to host or configure.
20
+
21
+ - 🌊 **Streaming first** process multi-GB data in fixed ~128 KB buffers
22
+ - **Backpressure built in** a slow writer automatically throttles the reader, so memory never runs away
23
+ - 📦 **Zero external assets** WASM is bundled in; `npm install` and import
24
+ - 🌍 **Universal** — the same code runs in Node.js 18+ and modern browsers
25
+ - 🔧 **ESM + TypeScript** full type definitions included
26
+ - 📊 **Progress callbacks** — observe bytes processed in real time
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install zstd-stream
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick start
39
+
40
+ Everything operates on `ReadableStream<Uint8Array>` — the same stream type
41
+ returned by `fetch`, `Blob.stream()`, `File.stream()`, and Node's
42
+ `Readable.toWeb()`.
43
+
44
+ ```typescript
45
+ import { compressStream, decompressStream } from "zstd-stream";
46
+
47
+ // Compress any byte stream...
48
+ const compressed = await compressStream(source, { level: 3 });
49
+
50
+ // ...and decompress it back.
51
+ const restored = await decompressStream(compressed);
52
+
53
+ // Collect a stream into bytes (or use .text() for strings):
54
+ const bytes = new Uint8Array(await new Response(restored).arrayBuffer());
55
+ ```
56
+
57
+ Prefer streaming wherever the data is large or arrives incrementally. For small,
58
+ fully in-memory buffers there are one-shot [`compress`](#compressinput-options) /
59
+ [`decompress`](#decompressinput-options) helpers.
60
+
61
+ ---
62
+
63
+ ## Recipes
64
+
65
+ ### Compress a file and upload it
66
+
67
+ Pipe the compressed stream straight into a `fetch` request body — nothing is
68
+ buffered in full.
69
+
70
+ ```typescript
71
+ import { compressStream } from "zstd-stream";
72
+
73
+ const file = document.querySelector<HTMLInputElement>("input[type=file]")!.files![0];
74
+ const compressed = await compressStream(file.stream(), { level: 6 });
75
+
76
+ await fetch("/upload", {
77
+ method: "POST",
78
+ headers: { "Content-Encoding": "zstd" },
79
+ body: compressed,
80
+ duplex: "half", // required when streaming a request body
81
+ });
82
+ ```
83
+
84
+ ### Download and decompress
85
+
86
+ ```typescript
87
+ import { decompressStream } from "zstd-stream";
88
+
89
+ const res = await fetch("/data.zst");
90
+ const decompressed = await decompressStream(res.body!);
91
+
92
+ const text = await new Response(decompressed).text();
93
+ ```
94
+
95
+ ### Save a compressed file to disk (browser)
96
+
97
+ `pipeTo` drives the whole transfer and applies backpressure for you.
98
+
99
+ ```typescript
100
+ import { compressStream } from "zstd-stream";
101
+ import streamSaver from "streamsaver";
102
+
103
+ const compressed = await compressStream(file.stream(), {
104
+ level: 9,
105
+ onProgress: (bytes) => console.log(`${(bytes / 1e6).toFixed(1)} MB written`),
106
+ });
107
+
108
+ await compressed.pipeTo(streamSaver.createWriteStream(`${file.name}.zst`));
109
+ ```
110
+
111
+ > **Tip:** compression is CPU-intensive. In the browser, run it inside a Web
112
+ > Worker to keep the UI responsive.
113
+
114
+ ### Compress a file on disk (Node.js)
115
+
116
+ ```typescript
117
+ import { createReadStream, createWriteStream } from "node:fs";
118
+ import { Readable, Writable } from "node:stream";
119
+ import { compressStream } from "zstd-stream";
120
+
121
+ const source = Readable.toWeb(createReadStream("big.log")) as ReadableStream<Uint8Array>;
122
+ const compressed = await compressStream(source, { level: 6 });
123
+
124
+ await compressed.pipeTo(Writable.toWeb(createWriteStream("big.log.zst")));
125
+ ```
126
+
127
+ ### Small, in-memory data
128
+
129
+ When you already hold the whole payload, skip the streams:
130
+
131
+ ```typescript
132
+ import { compress, decompress } from "zstd-stream";
133
+
134
+ const data = new TextEncoder().encode("Hello, world!");
135
+ const compressed = await compress(data, { level: 3 });
136
+ const restored = await decompress(compressed);
137
+
138
+ console.log(new TextDecoder().decode(restored)); // "Hello, world!"
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Memory & backpressure
144
+
145
+ The streaming API is built to handle data far larger than available RAM:
146
+
147
+ - **Bounded memory** — data flows through fixed ~128 KB working buffers and is
148
+ emitted one slice at a time. A compressed chunk that expands to gigabytes is
149
+ never decoded into a single allocation.
150
+ - **Backpressure** — output is produced lazily, one slice per read, so a slow
151
+ consumer (disk, network) throttles reads from the source. A fast producer
152
+ cannot outrun a slow consumer and pile up in memory.
153
+ - **Tunable buffering** — `highWaterMark` (bytes) sets how much output may queue
154
+ before reads pause. Larger values trade memory for throughput.
155
+
156
+ The one-shot `compress` / `decompress` helpers, by contrast, hold the entire
157
+ input and output in memory — use them only for small data.
158
+
159
+ ---
160
+
161
+ ## API reference
162
+
163
+ All functions are async and lazily initialize the WASM module on first use.
164
+
165
+ ### `compressStream(input, options?)`
166
+
167
+ Compress a stream. The primary API.
168
+
169
+ - `input: ReadableStream<Uint8Array>`
170
+ - `options?: CompressOptions`
171
+ - `level?: number` — compression level 1–19 (default: `3`)
172
+ - `onProgress?: (bytesWritten: number) => void` — cumulative compressed bytes
173
+ - `highWaterMark?: number` — output bytes buffered before backpressure pauses the source (default: 1 MiB)
174
+
175
+ **Returns:** `Promise<ReadableStream<Uint8Array>>`
176
+
177
+ ### `decompressStream(input, options?)`
178
+
179
+ Decompress a stream.
180
+
181
+ - `input: ReadableStream<Uint8Array>`
182
+ - `options?: DecompressOptions`
183
+ - `onProgress?: (bytesWritten: number) => void` — cumulative decompressed bytes
184
+ - `highWaterMark?: number` — output bytes buffered before backpressure pauses the source (default: 1 MiB)
185
+
186
+ **Returns:** `Promise<ReadableStream<Uint8Array>>`
187
+
188
+ ### `compress(input, options?)`
189
+
190
+ One-shot compression of an in-memory buffer. Holds all data in memory.
191
+
192
+ - `input: Uint8Array`
193
+ - `options?: CompressOptions` — `level`, `onProgress` (as above)
194
+
195
+ **Returns:** `Promise<Uint8Array>`
196
+
197
+ ### `decompress(input, options?)`
198
+
199
+ One-shot decompression of an in-memory buffer. Holds all data in memory.
200
+
201
+ - `input: Uint8Array`
202
+ - `options?: DecompressOptions` — `onProgress` (as above)
203
+
204
+ **Returns:** `Promise<Uint8Array>`
205
+
206
+ ### `initialize()`
207
+
208
+ Optional. Pre-loads the WASM module so the first compress/decompress call has no
209
+ startup cost. Safe to call multiple times.
210
+
211
+ **Returns:** `Promise<void>`
212
+
213
+ ```typescript
214
+ import { initialize } from "zstd-stream";
215
+
216
+ await initialize(); // e.g. during app startup
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Compression levels
222
+
223
+ Levels **1–19** are supported (default: `3`). Higher levels compress more but
224
+ cost more time and memory; "ultra" levels 20–22 are intentionally excluded
225
+ because, in streaming mode, they force every decompressor to allocate a 128 MB
226
+ window — even for tiny inputs.
227
+
228
+ Out-of-range values are clamped to the nearest valid level (with a
229
+ `console.warn`) rather than throwing — e.g. `level: 22` runs as `19`.
230
+
231
+ | Level | Speed | Ratio | Typical use |
232
+ | ----- | --------- | -------- | ----------------------------- |
233
+ | 1–3 | Fast | Lower | Real-time, network streaming |
234
+ | 4–7 | Balanced | Good | General purpose (recommended) |
235
+ | 8–15 | Slow | Better | File storage, archival |
236
+ | 16–19 | Very slow | Maximum | One-time / cold storage |
237
+
238
+ ---
239
+
240
+ ## TypeScript
241
+
242
+ ```typescript
243
+ import type { CompressOptions, DecompressOptions } from "zstd-stream";
244
+
245
+ const options: CompressOptions = {
246
+ level: 9,
247
+ highWaterMark: 4 * 1024 * 1024,
248
+ onProgress: (bytes) => console.log(`Progress: ${bytes}`),
249
+ };
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Compatibility
255
+
256
+ | Environment | Minimum |
257
+ | ----------- | ------- |
258
+ | Node.js | 18 |
259
+ | Chrome/Edge | 80 |
260
+ | Firefox | 113 |
261
+ | Safari | 16.4 |
262
+
263
+ Requires WebAssembly and ES2022 support.
264
+
265
+ ---
266
+
267
+ ## License
268
+
269
+ MIT
270
+
271
+ Built with [Zstandard](https://github.com/facebook/zstd) by Meta, compiled with
272
+ the [Emscripten SDK](https://emscripten.org/) and embedded for zero-dependency
273
+ deployment.
@@ -5,23 +5,76 @@ declare abstract class BaseProcessor {
5
5
  destroy(): void;
6
6
  protected abstract cleanup(): void;
7
7
  }
8
+ /**
9
+ * Incremental Zstandard compressor.
10
+ *
11
+ * The engine works on two fixed WASM buffers: an input buffer sized to
12
+ * `_cStreamInSize()` and an output buffer sized to `_cStreamOutSize()` (which
13
+ * zstd guarantees is large enough to flush one full block). Feeding no more
14
+ * than `inputBufferSize` bytes per call keeps every call's output within a
15
+ * single output buffer, so memory never scales with the total stream length.
16
+ */
8
17
  export declare class ZstdCompressor extends BaseProcessor {
9
- private level;
10
18
  private ctx;
11
- private buffer;
12
- private bufferSize;
19
+ private inBuffer;
20
+ private outBuffer;
21
+ private outBufferSize;
22
+ inputBufferSize: number;
23
+ private readonly level;
13
24
  constructor(level: number);
14
25
  init(): Promise<void>;
26
+ /**
27
+ * Compress one input slice (`length` must be <= `inputBufferSize`). The
28
+ * slice is fully consumed; returns the bytes produced this call, which may
29
+ * be empty when the engine is still buffering toward a block boundary.
30
+ */
31
+ compressStep(data: Uint8Array, offset: number, length: number): Uint8Array;
32
+ /**
33
+ * Emit one output buffer of the closing frame. `done` is true once the frame
34
+ * has been fully flushed (i.e. the engine returned less than a full buffer).
35
+ */
36
+ compressFinish(): {
37
+ output: Uint8Array;
38
+ done: boolean;
39
+ };
40
+ /** Single-shot compression of an in-memory buffer. */
15
41
  process(data: Uint8Array, isLast: boolean): Uint8Array;
42
+ /** Run one `_compressStream` call and copy out any produced bytes. */
43
+ private run;
44
+ private requireModule;
16
45
  protected cleanup(): void;
17
46
  }
47
+ /**
48
+ * Incremental Zstandard decompressor.
49
+ *
50
+ * Input is loaded into a fixed WASM buffer one `inputBufferSize`-sized slice at
51
+ * a time; `decompressStep()` then drains it one output buffer at a time. A
52
+ * single compressed slice can expand to far more than memory, so the caller is
53
+ * expected to enqueue each produced slice before requesting the next one.
54
+ */
18
55
  export declare class ZstdDecompressor extends BaseProcessor {
19
56
  private ctx;
20
- private buffer;
21
- private bufferSize;
57
+ private inBuffer;
58
+ private outBuffer;
59
+ private outBufferSize;
60
+ inputBufferSize: number;
61
+ private inLen;
62
+ private inPos;
22
63
  init(): Promise<void>;
64
+ /** Load one input slice (`length` must be <= `inputBufferSize`) for decoding. */
65
+ setInput(data: Uint8Array, offset: number, length: number): void;
66
+ /** True while the resident input slice still has bytes to decode. */
67
+ hasInput(): boolean;
68
+ /**
69
+ * Decode one output buffer's worth of data from the resident input slice.
70
+ * Returns the produced bytes (possibly empty). Advances the internal input
71
+ * position; throws if the engine can neither consume nor produce (corrupt
72
+ * input).
73
+ */
74
+ decompressStep(): Uint8Array;
75
+ /** Single-shot decompression of an in-memory buffer. */
23
76
  process(data: Uint8Array): Uint8Array;
24
- private concat;
77
+ private requireModule;
25
78
  protected cleanup(): void;
26
79
  }
27
80
  export {};
@@ -1,4 +1,44 @@
1
1
  import { getModule } from "./loader.js";
2
+ const EMPTY = new Uint8Array(0);
3
+ // ZSTD operation modes for the streaming wrapper.
4
+ const ZSTD_E_CONTINUE = 0;
5
+ const ZSTD_E_END = 2;
6
+ // Supported compression levels. 20-22 ("ultra") are excluded: in streaming mode
7
+ // they pin the window to 128 MB, forcing every decompressor to allocate 128 MB
8
+ // even for tiny files, and cost hundreds of MB to compress.
9
+ const MIN_LEVEL = 1;
10
+ const MAX_LEVEL = 19;
11
+ const DEFAULT_LEVEL = 3;
12
+ /**
13
+ * Coerce a requested level into the supported 1-19 range, warning (rather than
14
+ * throwing) so a misconfigured caller still gets a working stream.
15
+ */
16
+ function normalizeLevel(level) {
17
+ if (!Number.isFinite(level)) {
18
+ console.warn(`zstd-stream: invalid compression level ${level}; using default ${DEFAULT_LEVEL}.`);
19
+ return DEFAULT_LEVEL;
20
+ }
21
+ const valid = Math.min(MAX_LEVEL, Math.max(MIN_LEVEL, Math.round(level)));
22
+ if (valid !== level) {
23
+ console.warn(`zstd-stream: compression level ${level} is outside ${MIN_LEVEL}-${MAX_LEVEL}; using ${valid}.`);
24
+ }
25
+ return valid;
26
+ }
27
+ /** Join an array of chunks into a single contiguous Uint8Array. */
28
+ function concat(chunks) {
29
+ if (chunks.length === 0)
30
+ return EMPTY;
31
+ if (chunks.length === 1)
32
+ return chunks[0];
33
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
34
+ const result = new Uint8Array(total);
35
+ let offset = 0;
36
+ for (const chunk of chunks) {
37
+ result.set(chunk, offset);
38
+ offset += chunk.length;
39
+ }
40
+ return result;
41
+ }
2
42
  class BaseProcessor {
3
43
  destroyed = false;
4
44
  module = null;
@@ -9,147 +49,221 @@ class BaseProcessor {
9
49
  }
10
50
  }
11
51
  }
52
+ /**
53
+ * Incremental Zstandard compressor.
54
+ *
55
+ * The engine works on two fixed WASM buffers: an input buffer sized to
56
+ * `_cStreamInSize()` and an output buffer sized to `_cStreamOutSize()` (which
57
+ * zstd guarantees is large enough to flush one full block). Feeding no more
58
+ * than `inputBufferSize` bytes per call keeps every call's output within a
59
+ * single output buffer, so memory never scales with the total stream length.
60
+ */
12
61
  export class ZstdCompressor extends BaseProcessor {
13
- level;
14
62
  ctx = 0;
15
- buffer = 0;
16
- bufferSize = 0;
63
+ inBuffer = 0;
64
+ outBuffer = 0;
65
+ outBufferSize = 0;
66
+ inputBufferSize = 0;
67
+ level;
17
68
  constructor(level) {
18
69
  super();
19
- this.level = level;
20
- if (level < 1 || level > 19) {
21
- throw new Error("Compression level must be between 1 and 19");
22
- }
70
+ this.level = normalizeLevel(level);
23
71
  }
24
72
  async init() {
25
- this.module = getModule();
73
+ const m = (this.module = getModule());
74
+ this.ctx = m._createCCtx();
75
+ if (!this.ctx || m._initCStream(this.ctx, this.level) !== 0) {
76
+ this.cleanup();
77
+ throw new Error("Failed to create compression context");
78
+ }
79
+ this.outBufferSize = m._cStreamOutSize();
80
+ this.inputBufferSize = m._cStreamInSize();
81
+ this.outBuffer = m._malloc(this.outBufferSize);
82
+ this.inBuffer = m._malloc(this.inputBufferSize);
83
+ if (!this.outBuffer || !this.inBuffer) {
84
+ this.cleanup();
85
+ throw new Error("Failed to allocate compression buffers");
86
+ }
87
+ }
88
+ /**
89
+ * Compress one input slice (`length` must be <= `inputBufferSize`). The
90
+ * slice is fully consumed; returns the bytes produced this call, which may
91
+ * be empty when the engine is still buffering toward a block boundary.
92
+ */
93
+ compressStep(data, offset, length) {
94
+ const m = this.requireModule();
95
+ m.HEAPU8.set(data.subarray(offset, offset + length), this.inBuffer);
96
+ return this.run(length, ZSTD_E_CONTINUE);
26
97
  }
98
+ /**
99
+ * Emit one output buffer of the closing frame. `done` is true once the frame
100
+ * has been fully flushed (i.e. the engine returned less than a full buffer).
101
+ */
102
+ compressFinish() {
103
+ this.requireModule();
104
+ const output = this.run(0, ZSTD_E_END);
105
+ return { output, done: output.length < this.outBufferSize };
106
+ }
107
+ /** Single-shot compression of an in-memory buffer. */
27
108
  process(data, isLast) {
109
+ this.requireModule();
110
+ if (!data.length && !isLast)
111
+ return EMPTY;
112
+ const chunks = [];
113
+ let pos = 0;
114
+ while (pos < data.length) {
115
+ const n = Math.min(this.inputBufferSize, data.length - pos);
116
+ const out = this.compressStep(data, pos, n);
117
+ if (out.length)
118
+ chunks.push(out);
119
+ pos += n;
120
+ }
121
+ if (isLast) {
122
+ let result;
123
+ do {
124
+ result = this.compressFinish();
125
+ if (result.output.length)
126
+ chunks.push(result.output);
127
+ } while (!result.done);
128
+ }
129
+ return concat(chunks);
130
+ }
131
+ /** Run one `_compressStream` call and copy out any produced bytes. */
132
+ run(length, mode) {
133
+ const m = this.module;
134
+ const result = Number(m._compressStream(this.ctx, this.outBuffer, this.outBufferSize, this.inBuffer, length, mode));
135
+ if (result < 0) {
136
+ throw new Error(`Compression failed: ${m._getErrorName(-result)}`);
137
+ }
138
+ return result === 0
139
+ ? EMPTY
140
+ : new Uint8Array(m.HEAPU8.subarray(this.outBuffer, this.outBuffer + result));
141
+ }
142
+ requireModule() {
28
143
  if (!this.module)
29
144
  throw new Error("Not initialized");
30
145
  if (this.destroyed)
31
146
  throw new Error("Destroyed");
32
- if (!this.ctx) {
33
- this.ctx = this.module._createCCtx();
34
- if (!this.ctx || this.module._initCStream(this.ctx, this.level) !== 0) {
35
- this.cleanup();
36
- throw new Error("Failed to create compression context");
37
- }
38
- }
39
- if (!data.length && !isLast)
40
- return new Uint8Array(0);
41
- const srcPtr = this.module._malloc(data.length);
42
- if (!srcPtr)
43
- throw new Error("Failed to allocate source buffer");
44
- this.module.HEAPU8.set(data, srcPtr);
45
- try {
46
- if (!this.buffer) {
47
- this.bufferSize = this.module._cStreamOutSize();
48
- this.buffer = this.module._malloc(this.bufferSize);
49
- if (!this.buffer)
50
- throw new Error("Failed to allocate output buffer");
51
- }
52
- const result = Number(this.module._compressStream(this.ctx, this.buffer, this.bufferSize, srcPtr, data.length, isLast ? 2 : 0));
53
- if (result < 0) {
54
- throw new Error(`Compression failed: ${this.module._getErrorName(-result)}`);
55
- }
56
- return result === 0
57
- ? new Uint8Array(0)
58
- : new Uint8Array(this.module.HEAPU8.subarray(this.buffer, this.buffer + result));
59
- }
60
- finally {
61
- this.module._free(srcPtr);
62
- }
147
+ return this.module;
63
148
  }
64
149
  cleanup() {
65
150
  if (this.module) {
66
151
  if (this.ctx)
67
152
  this.module._freeCCtx(this.ctx);
68
- if (this.buffer)
69
- this.module._free(this.buffer);
153
+ if (this.outBuffer)
154
+ this.module._free(this.outBuffer);
155
+ if (this.inBuffer)
156
+ this.module._free(this.inBuffer);
70
157
  this.ctx = 0;
71
- this.buffer = 0;
158
+ this.outBuffer = 0;
159
+ this.inBuffer = 0;
72
160
  }
73
161
  }
74
162
  }
163
+ /**
164
+ * Incremental Zstandard decompressor.
165
+ *
166
+ * Input is loaded into a fixed WASM buffer one `inputBufferSize`-sized slice at
167
+ * a time; `decompressStep()` then drains it one output buffer at a time. A
168
+ * single compressed slice can expand to far more than memory, so the caller is
169
+ * expected to enqueue each produced slice before requesting the next one.
170
+ */
75
171
  export class ZstdDecompressor extends BaseProcessor {
76
172
  ctx = 0;
77
- buffer = 0;
78
- bufferSize = 0;
173
+ inBuffer = 0;
174
+ outBuffer = 0;
175
+ outBufferSize = 0;
176
+ inputBufferSize = 0;
177
+ // Position within the slice currently resident in the WASM input buffer.
178
+ inLen = 0;
179
+ inPos = 0;
79
180
  async init() {
80
- this.module = getModule();
81
- }
82
- process(data) {
83
- if (!this.module)
84
- throw new Error("Not initialized");
85
- if (this.destroyed)
86
- throw new Error("Destroyed");
87
- if (!this.ctx) {
88
- this.ctx = this.module._createDCtx();
89
- if (!this.ctx || this.module._initDStream(this.ctx) === 0) {
90
- this.cleanup();
91
- throw new Error("Failed to create decompression context");
92
- }
181
+ const m = (this.module = getModule());
182
+ this.ctx = m._createDCtx();
183
+ if (!this.ctx || m._initDStream(this.ctx) === 0) {
184
+ this.cleanup();
185
+ throw new Error("Failed to create decompression context");
93
186
  }
94
- if (!data.length)
95
- return new Uint8Array(0);
96
- if (!this.buffer) {
97
- this.bufferSize = this.module._dStreamOutSize();
98
- this.buffer = this.module._malloc(this.bufferSize);
99
- if (!this.buffer)
100
- throw new Error("Failed to allocate output buffer");
187
+ this.outBufferSize = m._dStreamOutSize();
188
+ this.inputBufferSize = m._dStreamInSize();
189
+ this.outBuffer = m._malloc(this.outBufferSize);
190
+ this.inBuffer = m._malloc(this.inputBufferSize);
191
+ if (!this.outBuffer || !this.inBuffer) {
192
+ this.cleanup();
193
+ throw new Error("Failed to allocate decompression buffers");
101
194
  }
102
- const srcPtr = this.module._malloc(data.length);
103
- if (!srcPtr)
104
- throw new Error("Failed to allocate source buffer");
105
- this.module.HEAPU8.set(data, srcPtr);
106
- try {
107
- let srcPos = 0;
108
- const chunks = [];
109
- while (srcPos < data.length) {
110
- const result = this.module._decompressStream(this.ctx, this.buffer, this.bufferSize, srcPtr + srcPos, data.length - srcPos);
111
- if (result < 0) {
112
- const errorCode = Number(BigInt(result) & 0x7fffffffffffffffn);
113
- throw new Error(`Decompression failed: ${this.module._getErrorName(errorCode)}`);
114
- }
115
- const consumed = Number(BigInt(result) >> 32n);
116
- const outputSize = Number(BigInt(result) & 0xffffffffn);
117
- if (consumed === 0 && outputSize === 0) {
118
- throw new Error("Decompression stalled");
119
- }
120
- if (outputSize > 0) {
121
- chunks.push(new Uint8Array(this.module.HEAPU8.subarray(this.buffer, this.buffer + outputSize)));
122
- }
123
- srcPos += consumed;
124
- }
125
- return this.concat(chunks);
195
+ }
196
+ /** Load one input slice (`length` must be <= `inputBufferSize`) for decoding. */
197
+ setInput(data, offset, length) {
198
+ const m = this.requireModule();
199
+ m.HEAPU8.set(data.subarray(offset, offset + length), this.inBuffer);
200
+ this.inLen = length;
201
+ this.inPos = 0;
202
+ }
203
+ /** True while the resident input slice still has bytes to decode. */
204
+ hasInput() {
205
+ return this.inPos < this.inLen;
206
+ }
207
+ /**
208
+ * Decode one output buffer's worth of data from the resident input slice.
209
+ * Returns the produced bytes (possibly empty). Advances the internal input
210
+ * position; throws if the engine can neither consume nor produce (corrupt
211
+ * input).
212
+ */
213
+ decompressStep() {
214
+ const m = this.requireModule();
215
+ const result = m._decompressStream(this.ctx, this.outBuffer, this.outBufferSize, this.inBuffer + this.inPos, this.inLen - this.inPos);
216
+ if (result < 0) {
217
+ const errorCode = Number(BigInt(result) & 0x7fffffffffffffffn);
218
+ throw new Error(`Decompression failed: ${m._getErrorName(errorCode)}`);
126
219
  }
127
- finally {
128
- this.module._free(srcPtr);
220
+ const consumed = Number(BigInt(result) >> 32n);
221
+ const outputSize = Number(BigInt(result) & 0xffffffffn);
222
+ if (consumed === 0 && outputSize === 0) {
223
+ throw new Error("Decompression stalled");
129
224
  }
225
+ this.inPos += consumed;
226
+ return outputSize === 0
227
+ ? EMPTY
228
+ : new Uint8Array(m.HEAPU8.subarray(this.outBuffer, this.outBuffer + outputSize));
130
229
  }
131
- concat(chunks) {
132
- if (chunks.length === 0)
133
- return new Uint8Array(0);
134
- if (chunks.length === 1)
135
- return chunks[0];
136
- const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
137
- const result = new Uint8Array(total);
138
- let offset = 0;
139
- for (const chunk of chunks) {
140
- result.set(chunk, offset);
141
- offset += chunk.length;
230
+ /** Single-shot decompression of an in-memory buffer. */
231
+ process(data) {
232
+ this.requireModule();
233
+ if (!data.length)
234
+ return EMPTY;
235
+ const chunks = [];
236
+ let pos = 0;
237
+ while (pos < data.length) {
238
+ const n = Math.min(this.inputBufferSize, data.length - pos);
239
+ this.setInput(data, pos, n);
240
+ while (this.hasInput()) {
241
+ const out = this.decompressStep();
242
+ if (out.length)
243
+ chunks.push(out);
244
+ }
245
+ pos += n;
142
246
  }
143
- return result;
247
+ return concat(chunks);
248
+ }
249
+ requireModule() {
250
+ if (!this.module)
251
+ throw new Error("Not initialized");
252
+ if (this.destroyed)
253
+ throw new Error("Destroyed");
254
+ return this.module;
144
255
  }
145
256
  cleanup() {
146
257
  if (this.module) {
147
258
  if (this.ctx)
148
259
  this.module._freeDCtx(this.ctx);
149
- if (this.buffer)
150
- this.module._free(this.buffer);
260
+ if (this.outBuffer)
261
+ this.module._free(this.outBuffer);
262
+ if (this.inBuffer)
263
+ this.module._free(this.inBuffer);
151
264
  this.ctx = 0;
152
- this.buffer = 0;
265
+ this.outBuffer = 0;
266
+ this.inBuffer = 0;
153
267
  }
154
268
  }
155
269
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  export interface CompressOptions {
2
2
  level?: number;
3
3
  onProgress?: (bytesWritten: number) => void;
4
+ /**
5
+ * Max bytes of output buffered before backpressure pauses reading the
6
+ * source. Larger values trade memory for throughput. Default: 1 MiB.
7
+ */
8
+ highWaterMark?: number;
4
9
  }
5
10
  export interface DecompressOptions {
6
11
  onProgress?: (bytesWritten: number) => void;
12
+ /** See {@link CompressOptions.highWaterMark}. Default: 1 MiB. */
13
+ highWaterMark?: number;
7
14
  }
8
15
  export declare function compress(input: Uint8Array, options?: CompressOptions): Promise<Uint8Array>;
9
16
  export declare function compressStream(input: ReadableStream<Uint8Array>, options?: CompressOptions): Promise<ReadableStream<Uint8Array>>;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ZstdCompressor, ZstdDecompressor } from "./compressor.js";
2
2
  import { initZstd } from "./loader.js";
3
+ const DEFAULT_HIGH_WATER_MARK = 1024 * 1024;
3
4
  let initialized = false;
4
5
  async function ensureInit() {
5
6
  if (!initialized) {
@@ -24,62 +25,71 @@ export async function compress(input, options) {
24
25
  }
25
26
  export async function compressStream(input, options) {
26
27
  await ensureInit();
27
- const { level = 3, onProgress } = options || {};
28
+ const { level = 3, onProgress, highWaterMark = DEFAULT_HIGH_WATER_MARK } = options || {};
28
29
  const reader = input.getReader();
29
- let compressor = null;
30
+ const compressor = new ZstdCompressor(level);
31
+ await compressor.init();
32
+ let chunk = null; // current source chunk
33
+ let chunkPos = 0; // bytes of `chunk` already fed
34
+ let finishing = false; // source drained, flushing the frame
30
35
  let totalWritten = 0;
31
- let inputDone = false;
36
+ const release = () => {
37
+ reader.releaseLock();
38
+ compressor.destroy();
39
+ };
32
40
  return new ReadableStream({
33
- async start() {
34
- compressor = new ZstdCompressor(level);
35
- await compressor.init();
36
- },
41
+ // Produce exactly one output slice per pull so the returned stream's
42
+ // queue (and thus how far we read ahead of the consumer) stays bounded.
37
43
  async pull(controller) {
38
44
  try {
39
- if (inputDone)
40
- return;
41
- let loopIterations = 0;
42
- // Keep reading until we have data to enqueue or input is exhausted
43
- while (!inputDone) {
44
- loopIterations++;
45
- const { done, value } = await reader.read();
46
- if (done) {
47
- // Final flush
48
- const chunk = compressor.process(new Uint8Array(0), true);
49
- if (chunk.length > 0) {
50
- controller.enqueue(chunk);
51
- totalWritten += chunk.length;
45
+ while (true) {
46
+ if (chunk && chunkPos < chunk.length) {
47
+ const n = Math.min(compressor.inputBufferSize, chunk.length - chunkPos);
48
+ const out = compressor.compressStep(chunk, chunkPos, n);
49
+ chunkPos += n;
50
+ if (out.length) {
51
+ controller.enqueue(out);
52
+ totalWritten += out.length;
52
53
  onProgress?.(totalWritten);
54
+ return;
53
55
  }
54
- controller.close();
55
- reader.releaseLock();
56
- compressor?.destroy();
57
- inputDone = true;
58
- return;
56
+ continue; // buffered internally; keep feeding
57
+ }
58
+ if (!finishing) {
59
+ const { done, value } = await reader.read();
60
+ if (done) {
61
+ finishing = true;
62
+ continue;
63
+ }
64
+ if (value.length === 0)
65
+ continue;
66
+ chunk = value;
67
+ chunkPos = 0;
68
+ continue;
59
69
  }
60
- // Process chunk
61
- const chunk = compressor.process(value, false);
62
- if (chunk.length > 0) {
63
- controller.enqueue(chunk);
64
- totalWritten += chunk.length;
70
+ // Flushing the final frame, one output buffer at a time.
71
+ const { output, done } = compressor.compressFinish();
72
+ if (output.length) {
73
+ controller.enqueue(output);
74
+ totalWritten += output.length;
65
75
  onProgress?.(totalWritten);
66
- return; // Exit after enqueuing data
67
76
  }
68
- // If chunk is empty, continue loop to read next input chunk
77
+ if (done) {
78
+ controller.close();
79
+ release();
80
+ }
81
+ return;
69
82
  }
70
83
  }
71
84
  catch (error) {
72
- inputDone = true;
73
- reader.releaseLock();
74
- compressor?.destroy();
85
+ release();
75
86
  controller.error(error);
76
87
  }
77
88
  },
78
89
  cancel() {
79
- reader.releaseLock();
80
- compressor?.destroy();
90
+ release();
81
91
  },
82
- });
92
+ }, new ByteLengthQueuingStrategy({ highWaterMark }));
83
93
  }
84
94
  // Decompress Uint8Array -> Uint8Array
85
95
  export async function decompress(input, options) {
@@ -99,49 +109,61 @@ export async function decompress(input, options) {
99
109
  // Decompress ReadableStream -> ReadableStream with backpressure
100
110
  export async function decompressStream(input, options) {
101
111
  await ensureInit();
102
- const { onProgress } = options || {};
112
+ const { onProgress, highWaterMark = DEFAULT_HIGH_WATER_MARK } = options || {};
103
113
  const reader = input.getReader();
104
- let decompressor = null;
114
+ const decompressor = new ZstdDecompressor();
115
+ await decompressor.init();
116
+ let chunk = null; // current source chunk
117
+ let chunkPos = 0; // bytes of `chunk` already loaded into WASM
105
118
  let totalWritten = 0;
106
- let inputDone = false;
119
+ const release = () => {
120
+ reader.releaseLock();
121
+ decompressor.destroy();
122
+ };
107
123
  return new ReadableStream({
108
- async start() {
109
- decompressor = new ZstdDecompressor();
110
- await decompressor.init();
111
- },
124
+ // One output slice per pull. A small compressed slice can expand to many
125
+ // gigabytes, so we never decode more than one output buffer ahead of the
126
+ // consumer.
112
127
  async pull(controller) {
113
128
  try {
114
- if (inputDone) {
115
- return; // Already finished
116
- }
117
- const { done, value } = await reader.read();
118
- if (done) {
119
- inputDone = true;
120
- controller.close();
121
- reader.releaseLock();
122
- decompressor?.destroy();
123
- return;
124
- }
125
- const chunk = decompressor.process(value);
126
- if (chunk.length > 0) {
127
- controller.enqueue(chunk);
128
- totalWritten += chunk.length;
129
- onProgress?.(totalWritten);
129
+ while (true) {
130
+ if (decompressor.hasInput()) {
131
+ const out = decompressor.decompressStep();
132
+ if (out.length) {
133
+ controller.enqueue(out);
134
+ totalWritten += out.length;
135
+ onProgress?.(totalWritten);
136
+ return;
137
+ }
138
+ continue; // consumed input without output yet
139
+ }
140
+ if (chunk && chunkPos < chunk.length) {
141
+ const n = Math.min(decompressor.inputBufferSize, chunk.length - chunkPos);
142
+ decompressor.setInput(chunk, chunkPos, n);
143
+ chunkPos += n;
144
+ continue;
145
+ }
146
+ const { done, value } = await reader.read();
147
+ if (done) {
148
+ controller.close();
149
+ release();
150
+ return;
151
+ }
152
+ if (value.length === 0)
153
+ continue;
154
+ chunk = value;
155
+ chunkPos = 0;
130
156
  }
131
157
  }
132
158
  catch (error) {
133
- inputDone = true;
134
- reader.releaseLock();
135
- decompressor?.destroy();
159
+ release();
136
160
  controller.error(error);
137
- throw error;
138
161
  }
139
162
  },
140
163
  cancel() {
141
- reader.releaseLock();
142
- decompressor?.destroy();
164
+ release();
143
165
  },
144
- });
166
+ }, new ByteLengthQueuingStrategy({ highWaterMark }));
145
167
  }
146
168
  export async function initialize() {
147
169
  await ensureInit();
package/dist/loader.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { Module } from "./types.ts";
1
+ import type { Module } from "./types.js";
2
2
  export declare function initZstd(): Promise<void>;
3
3
  export declare function getModule(): Module;
package/dist/loader.js CHANGED
@@ -28,10 +28,8 @@ async function evaluateCode(code) {
28
28
  const mod = await import(/* @vite-ignore */ url);
29
29
  return mod.default || mod.ZstdWasm || mod;
30
30
  }
31
- catch (e) {
32
- if (e.code !== "ERR_UNKNOWN_FILE_EXTENSION") {
33
- console.debug("Data URL import failed:", e?.message);
34
- }
31
+ catch {
32
+ // Older Node (pre-22) can't import a data: URL — fall back to vm.
35
33
  const vm = await import("node:vm");
36
34
  // Create a context with required globals
37
35
  const context = vm.createContext({
@@ -51,8 +49,7 @@ async function evaluateCode(code) {
51
49
  try {
52
50
  return await import(/* @vite-ignore */ specifier);
53
51
  }
54
- catch (e) {
55
- console.warn(`⚠️ Dynamic import failed: ${specifier}`, e.message);
52
+ catch {
56
53
  throw new Error(`Dynamic import not supported: ${specifier}`);
57
54
  }
58
55
  },
package/dist/types.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface Module {
9
9
  _decompressStream(ctx: number, dst: number, dstSize: number, src: number, srcSize: number): number;
10
10
  _dStreamOutSize(): number;
11
11
  _cStreamOutSize(): number;
12
+ _cStreamInSize(): number;
13
+ _dStreamInSize(): number;
12
14
  _getErrorName(error: number): string;
13
15
  _malloc(size: number): number;
14
16
  _free(ptr: number): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zstd-stream",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Simple and efficient Zstandard compression/decompression for Node.js and browsers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",