rar-stream 4.0.3 โ†’ 5.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,22 +1,46 @@
1
1
  # rar-stream
2
2
 
3
- > Fast RAR archive streaming for Node.js and browsers. Zero dependencies, pure Rust.
3
+ > Fast RAR archive streaming for Rust, Node.js, and browsers. Zero dependencies core.
4
4
 
5
+ [![CI](https://github.com/doom-fish/rar-stream/actions/workflows/ci.yml/badge.svg)](https://github.com/doom-fish/rar-stream/actions/workflows/ci.yml)
5
6
  [![npm version](https://badge.fury.io/js/rar-stream.svg)](https://www.npmjs.com/package/rar-stream)
7
+ [![npm downloads](https://img.shields.io/npm/dm/rar-stream.svg)](https://www.npmjs.com/package/rar-stream)
8
+ [![crates.io](https://img.shields.io/crates/v/rar-stream.svg)](https://crates.io/crates/rar-stream)
9
+ [![crates.io downloads](https://img.shields.io/crates/d/rar-stream.svg)](https://crates.io/crates/rar-stream)
10
+ [![docs.rs](https://docs.rs/rar-stream/badge.svg)](https://docs.rs/rar-stream)
11
+ [![MSRV](https://img.shields.io/badge/MSRV-1.85-blue.svg)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html)
6
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
13
 
14
+ ## What's New in v5.1.0
15
+
16
+ - โšก **Parallel RAR5 pipeline**: Beats the official C `unrar` in all 24 benchmark scenarios
17
+ - ๐ŸŒ **WASM RAR5 support**: Full RAR5 decompression in the browser via `WasmRar5Decoder`
18
+ - ๐Ÿ”Œ **NAPI pipeline**: Node.js users get 40% faster decompression automatically
19
+ - ๐Ÿงช **E2E browser tests**: Full Playwright tests for upload โ†’ decompress โ†’ verify workflow
20
+
8
21
  ## Features
9
22
 
10
23
  - ๐Ÿš€ **Fast**: Native Rust implementation with NAPI bindings
11
- - ๐Ÿ“ฆ **Zero dependencies**: No external runtime dependencies
24
+ - ๐Ÿ“ฆ **Zero dependencies**: Core library has no external dependencies
12
25
  - ๐ŸŒ **Cross-platform**: Works on Linux, macOS, Windows
13
26
  - ๐Ÿ”„ **Streaming**: Stream files directly from RAR archives
14
27
  - ๐Ÿ“š **Multi-volume**: Supports split archives (.rar, .r00, .r01, ...)
15
- - ๐Ÿ—œ๏ธ **Full decompression**: LZSS, PPMd, and VM filters
28
+ - ๐Ÿ—œ๏ธ **Full decompression**: LZSS, PPMd, and filters
29
+ - ๐Ÿ” **Encrypted archives**: AES-256/AES-128 decryption for RAR4 & RAR5
30
+ - ๐Ÿ†• **RAR4 + RAR5**: Full support for both RAR formats
16
31
  - ๐ŸŒ **Browser support**: WASM build available
17
32
 
18
33
  ## Installation
19
34
 
35
+ ### Rust
36
+
37
+ ```toml
38
+ [dependencies]
39
+ rar-stream = { version = "5", features = ["async", "crypto"] }
40
+ ```
41
+
42
+ ### Node.js
43
+
20
44
  ```bash
21
45
  npm install rar-stream
22
46
  # or
@@ -43,8 +67,34 @@ for (const file of files) {
43
67
  // Read entire file into memory
44
68
  const buffer = await file.readToEnd();
45
69
 
46
- // Or read a specific byte range (for streaming)
47
- const chunk = await file.createReadStream({ start: 0, end: 1023 });
70
+ // Or stream (returns Node.js Readable)
71
+ const stream = file.createReadStream();
72
+ stream.pipe(process.stdout);
73
+ }
74
+ ```
75
+
76
+ ### Rust Quick Start
77
+
78
+ ```rust
79
+ use rar_stream::{RarFilesPackage, ParseOptions, LocalFileMedia, FileMedia};
80
+ use std::sync::Arc;
81
+
82
+ #[tokio::main]
83
+ async fn main() -> Result<(), Box<dyn std::error::Error>> {
84
+ // Open a RAR archive
85
+ let file: Arc<dyn FileMedia> = Arc::new(LocalFileMedia::new("archive.rar")?);
86
+ let package = RarFilesPackage::new(vec![file]);
87
+
88
+ // Parse and list files
89
+ let files = package.parse(ParseOptions::default()).await?;
90
+ for f in &files {
91
+ println!("{}: {} bytes", f.name, f.length);
92
+ }
93
+
94
+ // Read file content
95
+ let content = files[0].read_to_end().await?;
96
+ println!("Read {} bytes", content.len());
97
+ Ok(())
48
98
  }
49
99
  ```
50
100
 
@@ -69,10 +119,11 @@ if (targetFile) {
69
119
  }
70
120
  ```
71
121
 
72
- ### Stream Video from RAR (Partial Reads)
122
+ ### Stream Video from RAR (Node.js Readable Stream)
73
123
 
74
124
  ```javascript
75
125
  import { LocalFileMedia, RarFilesPackage } from 'rar-stream';
126
+ import fs from 'fs';
76
127
 
77
128
  const media = new LocalFileMedia('./movie.rar');
78
129
  const pkg = new RarFilesPackage([media]);
@@ -80,20 +131,92 @@ const files = await pkg.parse();
80
131
 
81
132
  const video = files.find(f => f.name.endsWith('.mkv'));
82
133
  if (video) {
83
- // Read first 1MB for header analysis
84
- const header = await video.createReadStream({ start: 0, end: 1024 * 1024 - 1 });
85
- console.log(`Video: ${video.name}, Total size: ${video.length} bytes`);
134
+ // Get a Node.js Readable stream for the entire file
135
+ const stream = video.createReadStream();
136
+ stream.pipe(fs.createWriteStream('./extracted-video.mkv'));
86
137
 
87
- // Stream in chunks
88
- const chunkSize = 1024 * 1024; // 1MB chunks
89
- for (let offset = 0; offset < video.length; offset += chunkSize) {
90
- const end = Math.min(offset + chunkSize - 1, video.length - 1);
91
- const chunk = await video.createReadStream({ start: offset, end });
92
- // Process chunk...
93
- }
138
+ // Or stream a specific byte range (for HTTP range requests)
139
+ const rangeStream = video.createReadStream({ start: 0, end: 1024 * 1024 - 1 });
94
140
  }
95
141
  ```
96
142
 
143
+ ### WebTorrent Integration
144
+
145
+ Use `rar-stream` with WebTorrent to stream video from RAR archives inside torrents:
146
+
147
+ ```javascript
148
+ import WebTorrent from 'webtorrent';
149
+ import { RarFilesPackage } from 'rar-stream';
150
+
151
+ const client = new WebTorrent();
152
+
153
+ client.add(magnetUri, (torrent) => {
154
+ // Find RAR files (includes .rar, .r00, .r01, etc. for multi-volume)
155
+ // WebTorrent files already implement the FileMedia interface!
156
+ const rarFiles = torrent.files
157
+ .filter(f => /\.(rar|r\d{2})$/i.test(f.name))
158
+ .sort((a, b) => a.name.localeCompare(b.name));
159
+
160
+ // No wrapper needed - pass torrent files directly
161
+ const pkg = new RarFilesPackage(rarFiles);
162
+ pkg.parse().then(innerFiles => {
163
+ const video = innerFiles.find(f => f.name.endsWith('.mkv'));
164
+ if (video) {
165
+ // Stream video content - this returns a Node.js Readable
166
+ const stream = video.createReadStream();
167
+
168
+ // Pipe to HTTP response, media player, etc.
169
+ stream.pipe(process.stdout);
170
+ }
171
+ });
172
+ });
173
+ ```
174
+
175
+ ### HTTP Range Request Handler (Express)
176
+
177
+ ```javascript
178
+ import express from 'express';
179
+ import { LocalFileMedia, RarFilesPackage } from 'rar-stream';
180
+
181
+ const app = express();
182
+
183
+ // Pre-parse the RAR archive
184
+ const media = new LocalFileMedia('./videos.rar');
185
+ const pkg = new RarFilesPackage([media]);
186
+ const files = await pkg.parse();
187
+ const video = files.find(f => f.name.endsWith('.mp4'));
188
+
189
+ app.get('/video', (req, res) => {
190
+ const range = req.headers.range;
191
+ const fileSize = video.length;
192
+
193
+ if (range) {
194
+ const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
195
+ const start = parseInt(startStr, 10);
196
+ const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
197
+
198
+ res.writeHead(206, {
199
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
200
+ 'Accept-Ranges': 'bytes',
201
+ 'Content-Length': end - start + 1,
202
+ 'Content-Type': 'video/mp4',
203
+ });
204
+
205
+ // Stream the range directly from the RAR archive
206
+ const stream = video.createReadStream({ start, end });
207
+ stream.pipe(res);
208
+ } else {
209
+ res.writeHead(200, {
210
+ 'Content-Length': fileSize,
211
+ 'Content-Type': 'video/mp4',
212
+ });
213
+ video.createReadStream().pipe(res);
214
+ }
215
+ });
216
+
217
+ app.listen(3000);
218
+ ```
219
+
97
220
  ### Multi-Volume Archives
98
221
 
99
222
  ```javascript
@@ -170,7 +293,21 @@ class LocalFileMedia {
170
293
  readonly name: string; // Filename (basename)
171
294
  readonly length: number; // File size in bytes
172
295
 
173
- createReadStream(opts: { start: number; end: number }): Promise<Buffer>;
296
+ // Read a byte range into a Buffer
297
+ // Create a Readable stream for a byte range
298
+ createReadStream(opts: { start: number; end: number }): Readable;
299
+ }
300
+ ```
301
+
302
+ ### FileMedia Interface
303
+
304
+ Custom data sources (WebTorrent, S3, HTTP, etc.) must implement this interface:
305
+
306
+ ```typescript
307
+ interface FileMedia {
308
+ readonly name: string;
309
+ readonly length: number;
310
+ createReadStream(opts: { start: number; end: number }): Readable;
174
311
  }
175
312
  ```
176
313
 
@@ -180,7 +317,7 @@ Parses single or multi-volume RAR archives.
180
317
 
181
318
  ```typescript
182
319
  class RarFilesPackage {
183
- constructor(files: LocalFileMedia[]);
320
+ constructor(files: FileMedia[]); // LocalFileMedia or custom FileMedia
184
321
 
185
322
  parse(opts?: {
186
323
  maxFiles?: number; // Limit number of files to parse
@@ -193,12 +330,20 @@ class RarFilesPackage {
193
330
  Represents a file inside the RAR archive.
194
331
 
195
332
  ```typescript
333
+ import { Readable } from 'stream';
334
+
196
335
  class InnerFile {
197
336
  readonly name: string; // Full path inside archive
198
337
  readonly length: number; // Uncompressed size in bytes
199
338
 
339
+ // Read entire file into memory
200
340
  readToEnd(): Promise<Buffer>;
201
- createReadStream(opts: { start: number; end: number }): Promise<Buffer>;
341
+
342
+ // Create a Readable stream for the entire file or a byte range
343
+ createReadStream(opts?: {
344
+ start?: number; // Default: 0
345
+ end?: number; // Default: length - 1
346
+ }): Readable;
202
347
  }
203
348
  ```
204
349
 
@@ -211,6 +356,12 @@ function isRarArchive(buffer: Buffer): boolean;
211
356
  // Parse RAR header from buffer (needs ~300 bytes)
212
357
  function parseRarHeader(buffer: Buffer): RarFileInfo | null;
213
358
 
359
+ // Convert a Readable stream to a Buffer
360
+ function streamToBuffer(stream: Readable): Promise<Buffer>;
361
+
362
+ // Create a FileMedia from any source with createReadStream
363
+ function createFileMedia(source: FileMedia): FileMedia;
364
+
214
365
  interface RarFileInfo {
215
366
  name: string;
216
367
  packedSize: number;
@@ -222,22 +373,123 @@ interface RarFileInfo {
222
373
 
223
374
  ## Compression Support
224
375
 
225
- | Method | Support | Description |
226
- |--------|---------|-------------|
227
- | Store (0x30) | โœ… | No compression |
228
- | LZSS (0x31-0x35) | โœ… | Huffman + LZ77 |
229
- | PPMd | โœ… | Context-based |
230
- | VM Filters | โœ… | E8, Delta, Audio, RGB |
376
+ ### RAR Format Compatibility
377
+
378
+ | Format | Signature | Support |
379
+ |--------|-----------|---------|
380
+ | RAR 1.5-4.x (RAR4) | `Rar!\x1a\x07\x00` | โœ… Full |
381
+ | RAR 5.0+ (RAR5) | `Rar!\x1a\x07\x01\x00` | โœ… Full |
382
+
383
+ ### Compression Methods
384
+
385
+ | Method | RAR4 | RAR5 | Description |
386
+ |--------|------|------|-------------|
387
+ | Store | โœ… | โœ… | No compression |
388
+ | LZSS | โœ… | โœ… | Huffman + LZ77 sliding window |
389
+ | PPMd | โœ… | โ€” | Context-based (RAR4 only) |
390
+
391
+ ### Filter Support
392
+
393
+ | Filter | RAR4 | RAR5 | Description |
394
+ |--------|------|------|-------------|
395
+ | E8 | โœ… | โœ… | x86 CALL preprocessing |
396
+ | E8E9 | โœ… | โœ… | x86 CALL/JMP preprocessing |
397
+ | Delta | โœ… | โœ… | Byte delta per channel |
398
+ | ARM | โ€” | โœ… | ARM branch preprocessing |
399
+ | Itanium | โœ… | โ€” | IA-64 preprocessing |
400
+ | RGB | โœ… | โ€” | Predictive color filter |
401
+ | Audio | โœ… | โ€” | Audio sample predictor |
402
+
403
+ ### Encryption Support
404
+
405
+ | Feature | RAR4 | RAR5 | Notes |
406
+ |---------|------|------|-------|
407
+ | Encrypted files | โœ… | โœ… | `crypto` feature |
408
+ | Encrypted headers | โ€” | โœ… | RAR5 `-hp` archives |
409
+ | Algorithm | AES-128-CBC | AES-256-CBC | โ€” |
410
+ | Key derivation | SHA-1 (262k rounds) | PBKDF2-HMAC-SHA256 | โ€” |
411
+
412
+ To enable encryption support:
413
+
414
+ **Node.js/npm:** Encryption is always available.
415
+
416
+ **Rust:**
417
+ ```toml
418
+ [dependencies]
419
+ rar-stream = { version = "5", features = ["async", "crypto"] }
420
+ ```
231
421
 
232
422
  ## Performance
233
423
 
234
- Benchmarks on M1 MacBook Pro (v4.x vs v3.x):
424
+ ### RAR5 Decompression: Faster Than unrar
425
+
426
+ rar-stream's parallel pipeline **beats the official C `unrar`** (v7.0, multi-threaded) across all tested workloads.
427
+
428
+ Benchmark on AMD Ryzen 5 7640HS (6 cores):
429
+
430
+ | Archive | Size | rar-stream (pipeline) | unrar | Ratio |
431
+ |---------|------|----------------------|-------|-------|
432
+ | ISO (binary) | 200 MB | **422ms** | 453ms | **0.93ร—** |
433
+ | Text | 200 MB | **144ms** | 202ms | **0.71ร—** |
434
+ | Mixed | 200 MB | **342ms** | 527ms | **0.65ร—** |
435
+ | Binary | 500 MB | **824ms** | 1149ms | **0.72ร—** |
436
+ | Text | 500 MB | **424ms** | 604ms | **0.70ร—** |
437
+ | Mixed | 1 GB | **1953ms** | 2550ms | **0.77ร—** |
438
+
439
+ **Wins 24 out of 24 benchmark scenarios** across 6 data types ร— 4 compression settings.
440
+
441
+ Best case: **1.9ร— faster** than unrar (ISO 200MB, `-m5 -md128m`).
442
+
443
+ <details>
444
+ <summary>Full benchmark matrix (24 scenarios)</summary>
445
+
446
+ ```
447
+ Archive Single Pipeline Unrar Pipe/Unrar
448
+ ----------------------------------------------------------------
449
+ bin-500_m3_32m 1278ms 884ms 1187ms 0.74x
450
+ bin-500_m5_128m 1200ms 824ms 1149ms 0.72x
451
+ bin-500_m5_32m 1247ms 852ms 1162ms 0.73x
452
+ bin-500_m5_4m 1378ms 942ms 1770ms 0.53x
453
+ iso-200_m3_32m 715ms 426ms 760ms 0.56x
454
+ iso-200_m5_128m 720ms 423ms 811ms 0.52x
455
+ iso-200_m5_32m 721ms 422ms 453ms 0.93x
456
+ iso-200_m5_4m 717ms 422ms 442ms 0.95x
457
+ mixed-1g_m3_32m 2974ms 2109ms 2775ms 0.76x
458
+ mixed-1g_m5_128m 3177ms 2213ms 2984ms 0.74x
459
+ mixed-1g_m5_32m 2979ms 2086ms 2731ms 0.76x
460
+ mixed-1g_m5_4m 2761ms 1953ms 2550ms 0.77x
461
+ mixed-200_m3_32m 499ms 385ms 547ms 0.70x
462
+ mixed-200_m5_128m 438ms 342ms 527ms 0.65x
463
+ mixed-200_m5_32m 495ms 384ms 539ms 0.71x
464
+ mixed-200_m5_4m 511ms 395ms 538ms 0.73x
465
+ text-200_m3_32m 209ms 145ms 202ms 0.72x
466
+ text-200_m5_128m 205ms 144ms 239ms 0.60x
467
+ text-200_m5_32m 209ms 144ms 202ms 0.71x
468
+ text-200_m5_4m 227ms 153ms 207ms 0.74x
469
+ text-500_m3_32m 606ms 432ms 613ms 0.70x
470
+ text-500_m5_128m 601ms 431ms 644ms 0.67x
471
+ text-500_m5_32m 604ms 424ms 604ms 0.70x
472
+ text-500_m5_4m 659ms 455ms 643ms 0.71x
473
+ ```
474
+
475
+ </details>
476
+
477
+ ### Node.js (NAPI) Performance
478
+
479
+ | Configuration | Time (200MB) | vs unrar |
480
+ |---------------|-------------|----------|
481
+ | v5.0.0 (single-threaded) | 1127ms | 2.73ร— slower |
482
+ | **v5.1.0 (pipeline)** | **673ms** | **1.53ร— slower** |
483
+
484
+ The remaining NAPI gap vs native is I/O + buffer copy overhead, not decompression.
485
+
486
+ ### Optimization Features
235
487
 
236
- | Operation | rar-stream v4 (Rust) | rar-stream v3 (JS) |
237
- |-----------|---------------------|-------------------|
238
- | Parse 1GB archive | ~50ms | ~200ms |
239
- | Decompress 100MB | ~800ms | ~3000ms |
240
- | Memory usage | ~50MB | ~200MB |
488
+ - **Parallel pipeline**: Decode + apply in parallel using rayon worker threads
489
+ - **Split-buffer decode**: Separates literals from commands for cache-friendly apply
490
+ - **LTO (Link-Time Optimization)**: Enabled by default in release builds
491
+ - **SIMD**: Automatic vectorization for E8/E9 filter scanning and memcpy
492
+ - **Zero-copy streaming**: Direct buffer access without intermediate copies
241
493
 
242
494
  ## Migrating from v3.x
243
495
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * rar-stream browser entry point with Web Streams API support
3
+ */
4
+
5
+ // Re-export WASM bindings
6
+ export {
7
+ default as init,
8
+ initSync,
9
+ isRarArchive,
10
+ getRarVersion,
11
+ parseRarHeader,
12
+ RarDecoder,
13
+ WasmRar5Crypto,
14
+ is_rar_archive,
15
+ get_rar_version,
16
+ parse_rar_header,
17
+ WasmRarDecoder,
18
+ } from '../browser.js';
19
+
20
+ /** Options for creating a ReadableStream */
21
+ export interface ReadableStreamOptions {
22
+ /** Total size of the data */
23
+ totalSize: number;
24
+ /** Start offset (default: 0) */
25
+ start?: number;
26
+ /** End offset inclusive (default: totalSize - 1) */
27
+ end?: number;
28
+ /** Size of each chunk to read (default: 65536) */
29
+ chunkSize?: number;
30
+ /** Function to read a chunk of data */
31
+ readChunk: (start: number, end: number) => Promise<Uint8Array>;
32
+ }
33
+
34
+ /** Options for creating a range response */
35
+ export interface RangeResponseOptions {
36
+ /** Total file size */
37
+ totalSize: number;
38
+ /** HTTP Range header value */
39
+ rangeHeader?: string;
40
+ /** MIME type (default: 'application/octet-stream') */
41
+ contentType?: string;
42
+ /** Function to read a chunk of data */
43
+ readChunk: (start: number, end: number) => Promise<Uint8Array>;
44
+ }
45
+
46
+ /** Result from createRangeResponse */
47
+ export interface RangeResponseResult {
48
+ stream: ReadableStream<Uint8Array>;
49
+ headers: Headers;
50
+ status: number;
51
+ }
52
+
53
+ /**
54
+ * Create a Web ReadableStream from an async data source.
55
+ * Useful for Service Workers and streaming responses.
56
+ */
57
+ export declare function createReadableStream(options: ReadableStreamOptions): ReadableStream<Uint8Array>;
58
+
59
+ /**
60
+ * Helper to create a streaming response for HTTP range requests.
61
+ * Parses the Range header and returns appropriate stream, headers, and status.
62
+ */
63
+ export declare function createRangeResponse(options: RangeResponseOptions): RangeResponseResult;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * rar-stream browser entry point with Web Streams API support
3
+ *
4
+ * Provides ReadableStream support for streaming file content in browsers.
5
+ */
6
+
7
+ // Re-export all WASM bindings
8
+ export {
9
+ default as init,
10
+ initSync,
11
+ is_rar_archive as isRarArchive,
12
+ get_rar_version as getRarVersion,
13
+ parse_rar_header as parseRarHeader,
14
+ WasmRarDecoder as RarDecoder,
15
+ } from '../pkg/rar_stream.js';
16
+
17
+ // Also export snake_case versions for compatibility
18
+ export {
19
+ is_rar_archive,
20
+ get_rar_version,
21
+ parse_rar_header,
22
+ WasmRarDecoder,
23
+ } from '../pkg/rar_stream.js';
24
+
25
+ // Re-export crypto if available
26
+ export { WasmRar5Crypto } from '../pkg/rar_stream.js';
27
+
28
+ /**
29
+ * Create a Web ReadableStream from an async data source.
30
+ *
31
+ * This utility helps create streaming responses for browsers,
32
+ * useful for Service Workers and fetch handlers.
33
+ *
34
+ * @param {Object} options
35
+ * @param {number} options.totalSize - Total size of the data
36
+ * @param {number} [options.start=0] - Start offset
37
+ * @param {number} [options.end] - End offset (inclusive), defaults to totalSize-1
38
+ * @param {number} [options.chunkSize=65536] - Size of each chunk to read
39
+ * @param {function(start: number, end: number): Promise<Uint8Array>} options.readChunk - Function to read a chunk
40
+ * @returns {ReadableStream<Uint8Array>}
41
+ *
42
+ * @example
43
+ * // In a Service Worker fetch handler
44
+ * const stream = createReadableStream({
45
+ * totalSize: file.length,
46
+ * start: rangeStart,
47
+ * end: rangeEnd,
48
+ * readChunk: async (start, end) => {
49
+ * const decoder = new WasmRarDecoder(file.unpackedSize);
50
+ * // ... fetch and decompress data
51
+ * return decompressedData.slice(start, end + 1);
52
+ * }
53
+ * });
54
+ * return new Response(stream, { headers: { 'Content-Type': 'video/mp4' } });
55
+ */
56
+ export function createReadableStream(options) {
57
+ const { totalSize, start = 0, end = totalSize - 1, chunkSize = 64 * 1024, readChunk } = options;
58
+ let offset = start;
59
+
60
+ return new ReadableStream({
61
+ async pull(controller) {
62
+ if (offset > end) {
63
+ controller.close();
64
+ return;
65
+ }
66
+
67
+ const chunkEnd = Math.min(offset + chunkSize - 1, end);
68
+ try {
69
+ const chunk = await readChunk(offset, chunkEnd);
70
+ controller.enqueue(chunk);
71
+ offset = chunkEnd + 1;
72
+ } catch (err) {
73
+ controller.error(err);
74
+ }
75
+ },
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Helper to create a streaming response for range requests.
81
+ *
82
+ * @param {Object} options
83
+ * @param {number} options.totalSize - Total file size
84
+ * @param {string} [options.rangeHeader] - HTTP Range header value
85
+ * @param {string} [options.contentType='application/octet-stream'] - MIME type
86
+ * @param {function(start: number, end: number): Promise<Uint8Array>} options.readChunk
87
+ * @returns {{ stream: ReadableStream, headers: Headers, status: number }}
88
+ *
89
+ * @example
90
+ * // In a Service Worker
91
+ * const { stream, headers, status } = createRangeResponse({
92
+ * totalSize: innerFile.length,
93
+ * rangeHeader: request.headers.get('Range'),
94
+ * contentType: 'video/mp4',
95
+ * readChunk: async (start, end) => decompress(start, end),
96
+ * });
97
+ * return new Response(stream, { status, headers });
98
+ */
99
+ export function createRangeResponse(options) {
100
+ const { totalSize, rangeHeader, contentType = 'application/octet-stream', readChunk } = options;
101
+
102
+ let start = 0;
103
+ let end = totalSize - 1;
104
+ let status = 200;
105
+
106
+ const headers = new Headers({
107
+ 'Content-Type': contentType,
108
+ 'Accept-Ranges': 'bytes',
109
+ });
110
+
111
+ // Parse Range header if present
112
+ if (rangeHeader) {
113
+ const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
114
+ if (match) {
115
+ start = match[1] ? parseInt(match[1], 10) : 0;
116
+ end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
117
+ status = 206;
118
+ headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
119
+ }
120
+ }
121
+
122
+ headers.set('Content-Length', String(end - start + 1));
123
+
124
+ const stream = createReadableStream({
125
+ totalSize,
126
+ start,
127
+ end,
128
+ readChunk,
129
+ });
130
+
131
+ return { stream, headers, status };
132
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * rar-stream - Node.js wrapper with Readable stream support
3
+ */
4
+
5
+ import { Readable } from 'stream';
6
+
7
+ /** Read interval options. */
8
+ export interface ReadIntervalJs {
9
+ start: number;
10
+ end: number;
11
+ }
12
+
13
+ /** Stream options for createReadStream. */
14
+ export interface StreamOptions {
15
+ start?: number;
16
+ end?: number;
17
+ }
18
+
19
+ /** Parse options for filtering results. */
20
+ export interface ParseOptionsJs {
21
+ maxFiles?: number;
22
+ }
23
+
24
+ /** Parsed file info from RAR header. */
25
+ export interface RarFileInfo {
26
+ name: string;
27
+ packedSize: number;
28
+ unpackedSize: number;
29
+ method: number;
30
+ continuesInNext: boolean;
31
+ }
32
+
33
+ /**
34
+ * FileMedia interface for custom file sources.
35
+ * Implement this to use WebTorrent, HTTP, S3, etc.
36
+ */
37
+ export interface FileMedia {
38
+ readonly name: string;
39
+ readonly length: number;
40
+ createReadStream(opts: ReadIntervalJs): Readable;
41
+ }
42
+
43
+ /**
44
+ * Helper to read a Readable stream into a Buffer.
45
+ */
46
+ export declare function streamToBuffer(stream: Readable): Promise<Buffer>;
47
+
48
+ /**
49
+ * Create a FileMedia wrapper from any object with createReadStream.
50
+ * Use this to wrap WebTorrent files, HTTP responses, S3 objects, etc.
51
+ */
52
+ export declare function createFileMedia(source: FileMedia): FileMedia;
53
+
54
+ /**
55
+ * Parse RAR file header from a buffer.
56
+ * This can be used to detect RAR archives and get inner file info
57
+ * without downloading the entire archive.
58
+ *
59
+ * The buffer should contain at least the first ~300 bytes of a .rar file.
60
+ */
61
+ export declare function parseRarHeader(buffer: Buffer): RarFileInfo | null;
62
+
63
+ /** Check if a buffer starts with a RAR signature. */
64
+ export declare function isRarArchive(buffer: Buffer): boolean;
65
+
66
+ /**
67
+ * LocalFileMedia - reads from local filesystem.
68
+ * Implements the FileMedia interface.
69
+ */
70
+ export declare class LocalFileMedia implements FileMedia {
71
+ constructor(path: string);
72
+
73
+ readonly name: string;
74
+ readonly length: number;
75
+
76
+ /**
77
+ * Create a Readable stream for a byte range.
78
+ * @param opts Byte range (inclusive)
79
+ */
80
+ createReadStream(opts: ReadIntervalJs): Readable;
81
+ }
82
+
83
+ /**
84
+ * InnerFile - a file inside the RAR archive.
85
+ * Provides stream-based access to file content.
86
+ */
87
+ export declare class InnerFile {
88
+ readonly name: string;
89
+ readonly length: number;
90
+
91
+ /**
92
+ * Create a Readable stream for the entire file or a byte range.
93
+ *
94
+ * @example
95
+ * // Stream entire file
96
+ * const stream = file.createReadStream();
97
+ * stream.pipe(fs.createWriteStream('output.bin'));
98
+ *
99
+ * @example
100
+ * // Stream with range (for HTTP range requests, WebTorrent, etc.)
101
+ * const stream = file.createReadStream({ start: 0, end: 1024 * 1024 - 1 });
102
+ */
103
+ createReadStream(opts?: StreamOptions): Readable;
104
+
105
+ /** Read entire file into memory. */
106
+ readToEnd(): Promise<Buffer>;
107
+ }
108
+
109
+ /**
110
+ * RarFilesPackage - parses multi-volume RAR archives.
111
+ *
112
+ * Supports both LocalFileMedia and custom FileMedia implementations.
113
+ */
114
+ export declare class RarFilesPackage {
115
+ constructor(files: FileMedia[]);
116
+
117
+ /** Parse the archive and return inner files. */
118
+ parse(opts?: ParseOptionsJs | undefined | null): Promise<InnerFile[]>;
119
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * rar-stream - Node.js wrapper with Readable stream support
3
+ *
4
+ * This module wraps the native NAPI bindings to provide Node.js Readable streams
5
+ * for streaming file content from RAR archives.
6
+ *
7
+ * Supports both native LocalFileMedia and custom FileMedia implementations
8
+ * (like WebTorrent files).
9
+ */
10
+
11
+ import { Readable } from 'stream';
12
+ import { createRequire } from 'module';
13
+
14
+ // Import native bindings (CommonJS, auto-generated by NAPI-RS)
15
+ const require = createRequire(import.meta.url);
16
+ const native = require('../index.js');
17
+ const {
18
+ LocalFileMedia: NativeLocalFileMedia,
19
+ InnerFile: NativeInnerFile,
20
+ RarFilesPackage: NativeRarFilesPackage,
21
+ parseRarHeader,
22
+ isRarArchive,
23
+ } = native;
24
+
25
+ // Re-export utility functions
26
+ export { parseRarHeader, isRarArchive };
27
+
28
+ // RAR signatures
29
+ const RAR4_SIGNATURE = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00]);
30
+ const RAR5_SIGNATURE = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00]);
31
+
32
+ /**
33
+ * Helper to read a Readable stream into a Buffer.
34
+ * @param {Readable} stream
35
+ * @returns {Promise<Buffer>}
36
+ */
37
+ export function streamToBuffer(stream) {
38
+ return new Promise((resolve, reject) => {
39
+ const chunks = [];
40
+ stream.on('data', chunk => chunks.push(chunk));
41
+ stream.on('end', () => resolve(Buffer.concat(chunks)));
42
+ stream.on('error', reject);
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Check if a file is a native LocalFileMedia
48
+ */
49
+ function isNativeMedia(file) {
50
+ return file._native instanceof NativeLocalFileMedia ||
51
+ file instanceof NativeLocalFileMedia ||
52
+ (file._nativeMedia && file._nativeMedia instanceof NativeLocalFileMedia);
53
+ }
54
+
55
+ /**
56
+ * Create a FileMedia wrapper from any object with createReadStream.
57
+ *
58
+ * This is optional - any object with name, length, and createReadStream
59
+ * can be passed directly to RarFilesPackage (like WebTorrent files).
60
+ *
61
+ * Use this helper when your source has different property names or
62
+ * needs adaptation to the FileMedia interface.
63
+ *
64
+ * @param {Object} source - Object implementing FileMedia interface
65
+ * @returns {FileMedia}
66
+ *
67
+ * @example
68
+ * // Adapt an S3 object to FileMedia
69
+ * const media = createFileMedia({
70
+ * name: s3Object.Key,
71
+ * length: s3Object.ContentLength,
72
+ * createReadStream: ({ start, end }) => s3.getObject({
73
+ * Bucket: bucket,
74
+ * Key: s3Object.Key,
75
+ * Range: `bytes=${start}-${end}`,
76
+ * }).createReadStream(),
77
+ * });
78
+ */
79
+ export function createFileMedia(source) {
80
+ return {
81
+ get name() { return source.name; },
82
+ get length() { return source.length; },
83
+ createReadStream(opts) {
84
+ return source.createReadStream(opts);
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * LocalFileMedia - reads from local filesystem.
91
+ * Implements the FileMedia interface.
92
+ */
93
+ export class LocalFileMedia {
94
+ constructor(path) {
95
+ this._native = new NativeLocalFileMedia(path);
96
+ }
97
+
98
+ get name() {
99
+ return this._native.name;
100
+ }
101
+
102
+ get length() {
103
+ return this._native.length;
104
+ }
105
+
106
+ /**
107
+ * Create a Readable stream for a byte range.
108
+ * @param {{ start: number, end: number }} opts - Byte range (inclusive)
109
+ * @returns {Readable}
110
+ */
111
+ createReadStream(opts) {
112
+ const { start, end } = opts;
113
+ const media = this._native;
114
+ let offset = start;
115
+
116
+ return new Readable({
117
+ highWaterMark: 64 * 1024,
118
+ async read(size) {
119
+ if (offset > end) {
120
+ this.push(null);
121
+ return;
122
+ }
123
+ const chunkEnd = Math.min(offset + size - 1, end);
124
+ try {
125
+ // Native returns Promise<Buffer>, we stream it
126
+ const chunk = await media.createReadStream({ start: offset, end: chunkEnd });
127
+ offset = chunkEnd + 1;
128
+ this.push(chunk);
129
+ } catch (err) {
130
+ this.destroy(err);
131
+ }
132
+ },
133
+ });
134
+ }
135
+
136
+ /** @internal */
137
+ get _nativeMedia() {
138
+ return this._native;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * InnerFile - a file inside the RAR archive.
144
+ * Wraps the native implementation with stream support.
145
+ */
146
+ export class InnerFile {
147
+ constructor(nativeInnerFile) {
148
+ this._native = nativeInnerFile;
149
+ }
150
+
151
+ get name() {
152
+ return this._native.name;
153
+ }
154
+
155
+ get length() {
156
+ return this._native.length;
157
+ }
158
+
159
+ /**
160
+ * Create a Readable stream for a byte range or entire file.
161
+ * @param {{ start?: number, end?: number }} [opts] - Byte range (inclusive)
162
+ * @returns {Readable}
163
+ *
164
+ * @example
165
+ * // Stream entire file
166
+ * const stream = file.createReadStream();
167
+ * stream.pipe(fs.createWriteStream('output.bin'));
168
+ *
169
+ * @example
170
+ * // Stream a specific range (for HTTP range requests)
171
+ * const stream = file.createReadStream({ start: 0, end: 1024 * 1024 - 1 });
172
+ * stream.pipe(res);
173
+ */
174
+ createReadStream(opts = {}) {
175
+ const start = opts.start ?? 0;
176
+ const end = opts.end ?? this.length - 1;
177
+ const innerFile = this._native;
178
+ let offset = start;
179
+
180
+ return new Readable({
181
+ highWaterMark: 64 * 1024,
182
+ async read(size) {
183
+ if (offset > end) {
184
+ this.push(null);
185
+ return;
186
+ }
187
+ const chunkEnd = Math.min(offset + size - 1, end);
188
+ try {
189
+ // Native returns Promise<Buffer>
190
+ const chunk = await innerFile.createReadStream({ start: offset, end: chunkEnd });
191
+ offset = chunkEnd + 1;
192
+ this.push(chunk);
193
+ } catch (err) {
194
+ this.destroy(err);
195
+ }
196
+ },
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Read entire file into memory.
202
+ * @returns {Promise<Buffer>}
203
+ */
204
+ async readToEnd() {
205
+ return this._native.readToEnd();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * JsInnerFile - a file inside the RAR archive (JS-based implementation).
211
+ * Used when parsing with custom FileMedia implementations.
212
+ */
213
+ class JsInnerFile {
214
+ constructor(opts) {
215
+ this._name = opts.name;
216
+ this._length = opts.unpackedSize;
217
+ this._packedSize = opts.packedSize;
218
+ this._method = opts.method;
219
+ this._dataOffset = opts.dataOffset;
220
+ this._media = opts.media;
221
+ this._isStored = opts.method === 0x30;
222
+ }
223
+
224
+ get name() {
225
+ return this._name;
226
+ }
227
+
228
+ get length() {
229
+ return this._length;
230
+ }
231
+
232
+ /**
233
+ * Create a Readable stream for a byte range or entire file.
234
+ * Note: Range reads only work for stored files (method 0x30).
235
+ * Compressed files require full decompression.
236
+ *
237
+ * @param {{ start?: number, end?: number }} [opts]
238
+ * @returns {Readable}
239
+ */
240
+ createReadStream(opts = {}) {
241
+ const start = opts.start ?? 0;
242
+ const end = opts.end ?? this.length - 1;
243
+ const file = this;
244
+
245
+ if (!this._isStored) {
246
+ // For compressed files, throw an error for range reads
247
+ if (start !== 0 || end !== this.length - 1) {
248
+ throw new Error('Range reads not supported for compressed files. Use readToEnd() instead.');
249
+ }
250
+ // TODO: streaming decompression
251
+ throw new Error('Streaming compressed files not yet supported for custom FileMedia.');
252
+ }
253
+
254
+ // For stored files, stream directly from the source
255
+ const dataOffset = this._dataOffset;
256
+ const media = this._media;
257
+ let offset = start;
258
+
259
+ return new Readable({
260
+ highWaterMark: 64 * 1024,
261
+ async read(size) {
262
+ if (offset > end) {
263
+ this.push(null);
264
+ return;
265
+ }
266
+ const chunkEnd = Math.min(offset + size - 1, end);
267
+ try {
268
+ // Get stream from underlying media and collect it
269
+ const dataStart = dataOffset + offset;
270
+ const dataEnd = dataOffset + chunkEnd;
271
+ const sourceStream = media.createReadStream({ start: dataStart, end: dataEnd });
272
+ const chunk = await streamToBuffer(sourceStream);
273
+ offset = chunkEnd + 1;
274
+ this.push(chunk);
275
+ } catch (err) {
276
+ this.destroy(err);
277
+ }
278
+ },
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Read entire file into memory.
284
+ * @returns {Promise<Buffer>}
285
+ */
286
+ async readToEnd() {
287
+ const stream = this.createReadStream();
288
+ return streamToBuffer(stream);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * RarFilesPackage - parses multi-volume RAR archives.
294
+ *
295
+ * Supports both native LocalFileMedia and custom FileMedia implementations
296
+ * (like WebTorrent files). Custom implementations must have:
297
+ * - name: string
298
+ * - length: number
299
+ * - createReadStream({ start, end }): Readable
300
+ */
301
+ export class RarFilesPackage {
302
+ constructor(files) {
303
+ this._files = files;
304
+ this._useNative = files.every(f => isNativeMedia(f));
305
+
306
+ if (this._useNative) {
307
+ const nativeFiles = files.map(f => f._nativeMedia ?? f._native ?? f);
308
+ this._native = new NativeRarFilesPackage(nativeFiles);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Parse the archive and return inner files.
314
+ * @param {{ maxFiles?: number }} [opts]
315
+ * @returns {Promise<InnerFile[]>}
316
+ */
317
+ async parse(opts) {
318
+ if (this._useNative) {
319
+ const nativeFiles = await this._native.parse(opts);
320
+ return nativeFiles.map(f => new InnerFile(f));
321
+ }
322
+
323
+ // JS-based parsing for custom FileMedia
324
+ return this._parseJs(opts);
325
+ }
326
+
327
+ /**
328
+ * JS-based parsing for custom FileMedia implementations.
329
+ * @private
330
+ */
331
+ async _parseJs(opts) {
332
+ const maxFiles = opts?.maxFiles;
333
+ const results = [];
334
+
335
+ for (const media of this._files) {
336
+ // Read header data (first 512 bytes should be enough for header)
337
+ const headerSize = Math.min(512, media.length);
338
+ const headerStream = media.createReadStream({ start: 0, end: headerSize - 1 });
339
+ const headerData = await streamToBuffer(headerStream);
340
+
341
+ // Check signature
342
+ if (!isRarArchive(headerData)) {
343
+ continue;
344
+ }
345
+
346
+ // Determine RAR version
347
+ const isRar5 = headerData.slice(0, 8).equals(RAR5_SIGNATURE);
348
+
349
+ // Parse header to get file info
350
+ const header = parseRarHeader(headerData);
351
+ if (!header) {
352
+ continue;
353
+ }
354
+
355
+ // Calculate data offset (approximate - after headers)
356
+ // For RAR4: marker(7) + archive(13) + file header (variable)
357
+ // For RAR5: signature(8) + headers (variable length)
358
+ const dataOffset = media.length - header.packedSize;
359
+
360
+ results.push(new JsInnerFile({
361
+ name: header.name,
362
+ packedSize: header.packedSize,
363
+ unpackedSize: header.unpackedSize,
364
+ method: header.method,
365
+ dataOffset,
366
+ media,
367
+ }));
368
+
369
+ if (maxFiles && results.length >= maxFiles) {
370
+ break;
371
+ }
372
+ }
373
+
374
+ return results;
375
+ }
376
+ }
package/package.json CHANGED
@@ -1,21 +1,27 @@
1
1
  {
2
2
  "name": "rar-stream",
3
- "version": "4.0.3",
3
+ "version": "5.1.0",
4
4
  "description": "RAR streaming library - Rust implementation with NAPI and WASM bindings",
5
- "main": "index.js",
6
- "browser": "browser.js",
7
- "types": "index.d.ts",
5
+ "main": "lib/index.mjs",
6
+ "browser": "lib/browser.mjs",
7
+ "types": "lib/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
10
  "node": {
11
- "types": "./index.d.ts",
12
- "default": "./index.js"
11
+ "types": "./lib/index.d.ts",
12
+ "import": "./lib/index.mjs",
13
+ "require": "./index.js"
13
14
  },
14
15
  "browser": {
15
- "types": "./browser.d.ts",
16
- "default": "./browser.js"
16
+ "types": "./lib/browser.d.ts",
17
+ "import": "./lib/browser.mjs"
17
18
  },
18
- "default": "./index.js"
19
+ "import": "./lib/index.mjs",
20
+ "require": "./index.js"
21
+ },
22
+ "./native": {
23
+ "types": "./index.d.ts",
24
+ "require": "./index.js"
19
25
  }
20
26
  },
21
27
  "author": "doom-fish",
@@ -55,15 +61,20 @@
55
61
  "scripts": {
56
62
  "build": "napi build --platform --release --features napi",
57
63
  "build:debug": "napi build --platform --features napi",
58
- "build:wasm": "wasm-pack build --target web --features wasm --no-default-features",
64
+ "build:wasm": "wasm-pack build --target web --features wasm,crypto --no-default-features",
59
65
  "lint": "eslint .",
60
- "lint:rust": "cargo clippy --features napi -- -D warnings && cargo clippy --features wasm --no-default-features -- -D warnings",
66
+ "lint:rust": "cargo clippy --features napi -- -D warnings && cargo clippy --features wasm,crypto --no-default-features -- -D warnings",
61
67
  "lint:fix": "eslint . --fix",
62
68
  "format": "cargo fmt && prettier --write '**/*.{ts,js,json,md}'",
63
69
  "format:check": "cargo fmt --check && prettier --check '**/*.{ts,js,json,md}'",
64
70
  "test": "vitest run",
65
71
  "test:watch": "vitest",
66
72
  "test:browser": "npx playwright test",
73
+ "bench": "cargo bench",
74
+ "bench:save": "cargo bench -- --save-baseline main",
75
+ "bench:compare": "cargo bench -- --baseline main",
76
+ "profile": "cargo build --release && perf record -g ./target/release/examples/profile_decompress && perf report",
77
+ "flamegraph": "cargo flamegraph --bench decompress -- --bench",
67
78
  "prepublishOnly": "napi prepublish -t npm --skip-gh-release"
68
79
  },
69
80
  "devDependencies": {
@@ -75,9 +86,14 @@
75
86
  "prettier": "^3.2.0",
76
87
  "typescript": "^5.4.0",
77
88
  "typescript-eslint": "^8.0.0",
78
- "vitest": "^2.1.8"
89
+ "vitest": "^2.1.8",
90
+ "webtorrent": "^2.8.5"
79
91
  },
80
92
  "files": [
93
+ "lib/index.mjs",
94
+ "lib/index.d.ts",
95
+ "lib/browser.mjs",
96
+ "lib/browser.d.ts",
81
97
  "index.js",
82
98
  "index.d.ts",
83
99
  "browser.js",
@@ -89,12 +105,12 @@
89
105
  "*.node"
90
106
  ],
91
107
  "optionalDependencies": {
92
- "rar-stream-win32-x64-msvc": "4.0.3",
93
- "rar-stream-darwin-x64": "4.0.3",
94
- "rar-stream-linux-x64-gnu": "4.0.3",
95
- "rar-stream-linux-x64-musl": "4.0.3",
96
- "rar-stream-linux-arm64-gnu": "4.0.3",
97
- "rar-stream-darwin-arm64": "4.0.3",
98
- "rar-stream-linux-arm64-musl": "4.0.3"
108
+ "rar-stream-win32-x64-msvc": "5.1.0",
109
+ "rar-stream-darwin-x64": "5.1.0",
110
+ "rar-stream-linux-x64-gnu": "5.1.0",
111
+ "rar-stream-linux-x64-musl": "5.1.0",
112
+ "rar-stream-linux-arm64-gnu": "5.1.0",
113
+ "rar-stream-darwin-arm64": "5.1.0",
114
+ "rar-stream-linux-arm64-musl": "5.1.0"
99
115
  }
100
116
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file