7zip-native 0.0.6 → 0.0.7

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
@@ -139,21 +139,39 @@ await archive.extractToStream(entry.path, createWriteStream('./page.jpg'), {
139
139
 
140
140
  This respects backpressure and ends the destination when extraction succeeds. A destination error, early close, or abort rejects the Promise.
141
141
 
142
+ ### Creating ZIP, 7z, TAR, and WIM archives
143
+
144
+ ```ts
145
+ await Archive.create('./comic.cbz', [
146
+ {sourcePath: './pages/cover.jpg', path: 'cover.jpg'},
147
+ {sourcePath: './pages/01.jpg', path: 'pages/01.jpg'},
148
+ ], {
149
+ compressionLevel: 5,
150
+ overwrite: false,
151
+ });
152
+ ```
153
+
154
+ `Archive.create()` accepts regular source files and their desired archive-internal paths. It infers ZIP from `.zip` or `.cbz`, 7z from `.7z`, TAR from `.tar`, and WIM from `.wim`; set `format: 'zip'`, `'7z'`, `'tar'`, or `'wim'` to use another output extension. Compression level is an integer from 0 to 9, defaults to 5, and applies only to ZIP and 7z. TAR is uncompressed; WIM uses the upstream handler's default settings. Duplicate, unsafe, or conflicting internal paths are rejected. The output is first written to a temporary file beside the destination, then installed atomically; an existing file is preserved unless `overwrite: true` is specified. Archive creation runs in the bounded native worker pool and does not launch a 7-Zip executable.
155
+
142
156
  ### Extracting to a folder
143
157
 
144
158
  ```ts
145
159
  await archive.extractToFolder('./unpacked', {
146
160
  paths: ['chapter/page1.jpg', 'chapter/page2.jpg'], // alternatively, use ids
161
+ concurrency: 2,
147
162
  overwrite: true,
148
163
  onProgress: ({filesExtracted, totalFiles, bytesProcessed, totalBytes, percent}) => {
149
164
  console.log(`${filesExtracted}/${totalFiles} files, ${percent}% (${bytesProcessed}/${totalBytes} bytes)`);
150
165
  },
166
+ onExtract: ({entry, outputPath}) => {
167
+ console.log(`Extracted ${entry.path} to ${outputPath}`);
168
+ },
151
169
  maxBytes: 2n * 1024n * 1024n * 1024n,
152
170
  signal: abortController.signal,
153
171
  });
154
172
  ```
155
173
 
156
- Folder extraction accepts either `ids` or exact `paths`; omit both to extract every entry, and do not provide both together. Paths use archive-internal `/` separators. Extraction is sequential and uses bounded memory. Its default total output limit is 4 GiB; `maxBytes: null` disables that limit. Set `onProgress` to receive the number of regular files selected and fully extracted, the bytes emitted so far, the total uncompressed bytes, and the calculated percentage. It is called before extraction, as data chunks are processed, and after each file completes. By default, existing files are not overwritten; set `overwrite: true` to replace existing regular files. Replacement is committed only after that entry has been fully extracted, using a temporary file alongside the destination so a failure does not truncate the existing file. This can temporarily require extra disk space up to the size of the entry being replaced. Symlink or non-file targets are rejected. Unsafe archive paths, path collisions, and symlinked destination components are also rejected. Archive links are written as regular file data; the API does not create symbolic or hard links. If extraction fails partway through, files already written (or partially written) may remain in the destination.
174
+ Folder extraction accepts either `ids` or exact, original archive `paths`; omit both to extract every entry, and do not provide both together. Paths use archive-internal `/` separators. `concurrency` sets the maximum number of files extracted simultaneously by this call; it must be an integer from 1 to 128, defaults to 1, and is capped at the process-wide native worker-pool concurrency. It does not change `Archive.concurrency()`. Extraction uses bounded memory and a bounded scheduler rather than queueing one promise per entry. Its default total output limit is 4 GiB; `maxBytes: null` disables that limit. Set `onProgress` to receive the number of regular files selected and fully extracted, the bytes emitted so far across active files, the total uncompressed bytes, and the calculated percentage. It is called before extraction, as data chunks are processed, and after each file completes. `onExtract` is called once per regular file after it is fully written; with `overwrite: true`, it runs after the final replacement. Its `entry` retains the original archive metadata and `outputPath` is the final destination path. On Windows only, invalid filename characters (`< > : | ? * "`) and trailing dots/spaces in each output component are replaced with `_`; selectors and entry metadata remain unchanged. By default, existing files are not overwritten; set `overwrite: true` to replace existing regular files. Replacement is committed only after that entry has been fully extracted, using a temporary file alongside the destination so a failure does not truncate the existing file. This can temporarily require extra disk space up to the size of the entry being replaced. Symlink or non-file targets are rejected. Unsafe archive paths, path collisions, and symlinked destination components are also rejected. Archive links are written as regular file data; the API does not create symbolic or hard links. If extraction fails partway through, files already written (or partially written) may remain in the destination.
157
175
 
158
176
  ### Concurrency
159
177
 
@@ -189,6 +207,7 @@ Invalid argument types and values throw standard `TypeError` or `RangeError`. Ar
189
207
  | `ERR_ARCHIVE_PASSWORD` | Password is missing or incorrect when requested by the handler |
190
208
  | `ERR_ARCHIVE_PATH` | Unsafe or colliding path during folder extraction |
191
209
  | `ERR_ARCHIVE_EXTRACT` | Entry extraction failed |
210
+ | `ERR_ARCHIVE_CREATE` | Archive creation failed or the output already exists |
192
211
 
193
212
  Buffered `extract()` and `read()` do not currently support cancellation. Streams and folder extraction accept an `AbortSignal`.
194
213
 
@@ -199,6 +218,7 @@ The package uses the handlers registered in the pinned upstream `Format7zF` bund
199
218
  ### Format-specific notes
200
219
 
201
220
  - **RAR:** extraction uses upstream code subject to its LGPL terms and the unRAR restriction. This package does not create RAR archives or implement the proprietary RAR compression algorithm. Review the upstream notices before redistribution.
221
+ - **Creation:** ZIP, 7z, TAR, and WIM archives can be created from filesystem files. Streams and directory trees are not accepted as sources.
202
222
  - **ACE:** not supported by this 7-Zip revision.
203
223
  - **Encryption:** password callbacks are connected to archive opening and extraction. AES-256 encrypted ZIP extraction and range reads are tested on Linux x64; encrypted-header behavior and password variants in other formats are not yet verified.
204
224
  - **Partial reads:** memory use is limited to the requested range, but the decoder may still process more data internally, especially for solid or sequential formats.
@@ -295,6 +315,17 @@ npm run benchmark:concurrency -- \
295
315
 
296
316
  The report includes median and p95 latency, throughput, speedup, and the number of entries that can overlap. `--max-files` selects evenly spaced entries, which is useful when sampling solid archives. Benchmarks report measurements only; results depend on the archive, machine, and workload and do not guarantee a speedup.
297
317
 
318
+ Compare `extractToFolder()` concurrency (1 through 16) across one or more archives:
319
+
320
+ ```sh
321
+ npm run benchmark:folder-concurrency -- \
322
+ --archive "./samples/sample.zip" \
323
+ --archive "./archive.7z" \
324
+ --iterations 1
325
+ ```
326
+
327
+ Each run extracts one archive into a temporary directory, verifies the completed file and byte counts, and removes the output before continuing. The default is one iteration per concurrency; increase `--iterations` for more stable measurements. The global worker pool is set to 16 for the benchmark and restored afterward.
328
+
298
329
  ## License
299
330
 
300
331
  This package is MIT-licensed. The integrated 7-Zip code and other bundled dependencies have their own licensing terms; see [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) before redistribution.
@@ -0,0 +1,143 @@
1
+ import {mkdtemp, rm, stat} from 'node:fs/promises';
2
+ import {tmpdir} from 'node:os';
3
+ import {join, resolve} from 'node:path';
4
+ import {Archive, type ExtractToFolderProgress} from '../dist/index.mjs';
5
+
6
+ const usage = 'npm run benchmark:folder-concurrency -- --archive <path> [--archive <path> ...] [--iterations <count>]';
7
+ const archivePaths: string[] = [];
8
+ let iterations = 1;
9
+ const args = process.argv.slice(2);
10
+ for(let index = 0; index < args.length; index++)
11
+ {
12
+ const argument = args[index]!;
13
+ if(argument === '--archive' || argument.startsWith('--archive='))
14
+ {
15
+ const equalsIndex = argument.indexOf('=');
16
+ const path = equalsIndex === -1 ? args[++index] : argument.slice(equalsIndex + 1);
17
+ if(!path || path.startsWith('--'))
18
+ {
19
+ throw new TypeError(`Missing value for --archive\nUsage: ${usage}`);
20
+ }
21
+ archivePaths.push(resolve(path));
22
+ continue;
23
+ }
24
+ if(argument === '--iterations' || argument.startsWith('--iterations='))
25
+ {
26
+ const equalsIndex = argument.indexOf('=');
27
+ const value = equalsIndex === -1 ? args[++index] : argument.slice(equalsIndex + 1);
28
+ if(!value || value.startsWith('--'))
29
+ {
30
+ throw new TypeError(`Missing value for --iterations\nUsage: ${usage}`);
31
+ }
32
+ iterations = Number(value);
33
+ continue;
34
+ }
35
+ throw new TypeError(`Unknown argument ${argument}\nUsage: ${usage}`);
36
+ }
37
+
38
+ if(archivePaths.length === 0)
39
+ {
40
+ throw new TypeError(`Specify at least one --archive\nUsage: ${usage}`);
41
+ }
42
+ if(!Number.isSafeInteger(iterations) || iterations < 1)
43
+ {
44
+ throw new RangeError('--iterations must be a positive integer');
45
+ }
46
+
47
+ const concurrencies = Array.from({length: 16}, (_, index) => index + 1);
48
+ const originalConcurrency = Archive.concurrency();
49
+ const archiveResults = [];
50
+ try
51
+ {
52
+ Archive.concurrency(16);
53
+ for(const archivePath of archivePaths)
54
+ {
55
+ const archive = await Archive.open(archivePath);
56
+ try
57
+ {
58
+ const archiveSize = (await stat(archivePath)).size;
59
+ const files = (await archive.entries()).filter(entry => !entry.isDirectory);
60
+ if(files.length === 0)
61
+ {
62
+ throw new Error(`Archive contains no regular files: ${archivePath}`);
63
+ }
64
+ const expectedBytes = files.reduce((total, file) => total + file.size, 0n);
65
+ const measurements = [];
66
+ let serialMedian = 0;
67
+ for(const concurrency of concurrencies)
68
+ {
69
+ const durations: number[] = [];
70
+ for(let iteration = 0; iteration < iterations; iteration++)
71
+ {
72
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), '7zip-native-folder-bench-'));
73
+ let finalProgress: ExtractToFolderProgress | undefined;
74
+ let completedFiles = 0;
75
+ try
76
+ {
77
+ const start = process.hrtime.bigint();
78
+ await archive.extractToFolder(join(temporaryDirectory, 'output'), {
79
+ concurrency,
80
+ onProgress: progress => {
81
+ finalProgress = progress;
82
+ },
83
+ onExtract: () => {
84
+ completedFiles++;
85
+ },
86
+ });
87
+ durations.push(Number(process.hrtime.bigint() - start) / 1e6);
88
+ if(!finalProgress
89
+ || finalProgress.filesExtracted !== files.length
90
+ || finalProgress.totalFiles !== files.length
91
+ || finalProgress.bytesProcessed !== expectedBytes
92
+ || finalProgress.totalBytes !== expectedBytes
93
+ || completedFiles !== files.length)
94
+ {
95
+ throw new Error(`Extraction verification failed for ${archivePath} at concurrency ${concurrency}`);
96
+ }
97
+ }
98
+ finally
99
+ {
100
+ await rm(temporaryDirectory, {recursive: true, force: true});
101
+ }
102
+ }
103
+ const sorted = [...durations].sort((a, b) => a - b);
104
+ const median = sorted[Math.floor(sorted.length / 2)]!;
105
+ const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1]!;
106
+ if(concurrency === 1)
107
+ {
108
+ serialMedian = median;
109
+ }
110
+ measurements.push({
111
+ concurrency,
112
+ iterations,
113
+ median_ms: median,
114
+ p95_ms: p95,
115
+ throughput_mib_per_second: Number(expectedBytes) / (median / 1000) / 1024 / 1024,
116
+ speedup_vs_1: serialMedian / median,
117
+ runs_ms: durations,
118
+ });
119
+ }
120
+ archiveResults.push({
121
+ archive: archivePath,
122
+ archive_bytes: archiveSize,
123
+ files: files.length,
124
+ uncompressed_bytes: expectedBytes.toString(),
125
+ measurements,
126
+ });
127
+ }
128
+ finally
129
+ {
130
+ await archive.close();
131
+ }
132
+ }
133
+ console.log(JSON.stringify({
134
+ global_pool_concurrency: 16,
135
+ concurrencies,
136
+ iterations,
137
+ archives: archiveResults,
138
+ }, null, 2));
139
+ }
140
+ finally
141
+ {
142
+ Archive.concurrency(originalConcurrency);
143
+ }
package/dist/index.cjs CHANGED
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  var node_fs = require('node:fs');
4
- var promises$1 = require('node:fs/promises');
4
+ var promises = require('node:fs/promises');
5
5
  var node_crypto = require('node:crypto');
6
6
  var node_module = require('node:module');
7
7
  var node_os = require('node:os');
8
8
  var node_path = require('node:path');
9
9
  var node_stream = require('node:stream');
10
- var promises = require('node:stream/promises');
10
+ var promises$1 = require('node:stream/promises');
11
11
  var node_url = require('node:url');
12
12
 
13
13
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
@@ -76,10 +76,32 @@ function safeEntrySegments(entryPath) {
76
76
  }
77
77
  return segments;
78
78
  }
79
+ function sanitizeOutputPath(entryPath) {
80
+ if (process.platform !== 'win32') {
81
+ return entryPath;
82
+ }
83
+ return entryPath
84
+ .replace(/([. ]+)([\\/]|$)/g, (_match, unsupported, separator) => '_'.repeat(unsupported.length) + separator)
85
+ .replace(/[<>:|?*"]/g, '_');
86
+ }
87
+ function assertSafeRelativeArchivePath(entryPath) {
88
+ const normalized = entryPath.replace(/\\/g, '/');
89
+ const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
90
+ if (entryPath.includes('\0')
91
+ || normalized.startsWith('/')
92
+ || /^[a-zA-Z]:/.test(normalized)
93
+ || segments.length === 0
94
+ || segments.some(segment => segment === '..')) {
95
+ throw new ArchiveError('ERR_ARCHIVE_PATH', `Unsafe archive entry path: ${entryPath}`);
96
+ }
97
+ }
79
98
  function makeOutputPlans(root, entries) {
80
99
  const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
81
100
  const plans = entries.map((entry) => {
82
- const segments = safeEntrySegments(entry.path);
101
+ if (process.platform === 'win32') {
102
+ assertSafeRelativeArchivePath(entry.path);
103
+ }
104
+ const segments = safeEntrySegments(sanitizeOutputPath(entry.path));
83
105
  const targetPath = node_path.resolve(root, ...segments);
84
106
  const relative = node_path.relative(root, targetPath);
85
107
  if (relative === ''
@@ -113,21 +135,21 @@ function makeOutputPlans(root, entries) {
113
135
  }
114
136
  async function ensureDirectory(directoryPath) {
115
137
  try {
116
- await promises$1.mkdir(directoryPath, { mode: 0o700 });
138
+ await promises.mkdir(directoryPath, { mode: 0o700 });
117
139
  }
118
140
  catch (error) {
119
141
  if (error.code !== 'EEXIST') {
120
142
  throw error;
121
143
  }
122
144
  }
123
- const details = await promises$1.lstat(directoryPath);
145
+ const details = await promises.lstat(directoryPath);
124
146
  if (details.isSymbolicLink() || !details.isDirectory()) {
125
147
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination path is not a real directory: ${directoryPath}`);
126
148
  }
127
149
  }
128
150
  async function assertReplaceableOutput(filePath) {
129
151
  try {
130
- const details = await promises$1.lstat(filePath);
152
+ const details = await promises.lstat(filePath);
131
153
  if (!details.isFile()) {
132
154
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Cannot replace a non-file extraction target: ${filePath}`);
133
155
  }
@@ -138,9 +160,40 @@ async function assertReplaceableOutput(filePath) {
138
160
  }
139
161
  }
140
162
  }
163
+ async function assertOutputDoesNotExist(filePath) {
164
+ try {
165
+ await promises.lstat(filePath);
166
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Output archive already exists: ${filePath}`);
167
+ }
168
+ catch (error) {
169
+ if (error instanceof ArchiveError || error.code !== 'ENOENT') {
170
+ throw error;
171
+ }
172
+ }
173
+ }
174
+ async function normalizeCreateEntry(entry) {
175
+ if (entry === null || typeof entry !== 'object'
176
+ || typeof entry.sourcePath !== 'string' || entry.sourcePath.length === 0
177
+ || typeof entry.path !== 'string' || entry.path.length === 0) {
178
+ throw new TypeError('Each archive creation entry must have non-empty sourcePath and path strings');
179
+ }
180
+ const internalPath = safeEntrySegments(entry.path).join('/');
181
+ const sourcePath = node_path.resolve(entry.sourcePath);
182
+ let details;
183
+ try {
184
+ details = await promises.lstat(sourcePath);
185
+ }
186
+ catch (cause) {
187
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Unable to access archive creation source: ${sourcePath}`, { cause });
188
+ }
189
+ if (details.isSymbolicLink() || !details.isFile()) {
190
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Archive creation source is not a regular file: ${sourcePath}`);
191
+ }
192
+ return { sourcePath, path: internalPath };
193
+ }
141
194
  async function ensureExtractionRoot(root) {
142
195
  try {
143
- const details = await promises$1.lstat(root);
196
+ const details = await promises.lstat(root);
144
197
  if (details.isSymbolicLink() || !details.isDirectory()) {
145
198
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination is not a real directory: ${root}`);
146
199
  }
@@ -151,8 +204,8 @@ async function ensureExtractionRoot(root) {
151
204
  throw error;
152
205
  }
153
206
  }
154
- await promises$1.mkdir(root, { recursive: true, mode: 0o700 });
155
- const details = await promises$1.lstat(root);
207
+ await promises.mkdir(root, { recursive: true, mode: 0o700 });
208
+ const details = await promises.lstat(root);
156
209
  if (details.isSymbolicLink() || !details.isDirectory()) {
157
210
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination is not a real directory: ${root}`);
158
211
  }
@@ -299,6 +352,99 @@ class Archive {
299
352
  throw new ArchiveError(/password/i.test(message) ? 'ERR_ARCHIVE_PASSWORD' : 'ERR_ARCHIVE_OPEN', message, { cause });
300
353
  }
301
354
  }
355
+ static async create(outputPath, entries, options = {}) {
356
+ if (typeof outputPath !== 'string' || outputPath.length === 0) {
357
+ throw new TypeError('Output archive path must be a non-empty string');
358
+ }
359
+ if (!Array.isArray(entries)) {
360
+ throw new TypeError('Archive creation entries must be an array');
361
+ }
362
+ if (entries.length > 1_000_000) {
363
+ throw new RangeError('Archive creation exceeds the 1,000,000-entry limit');
364
+ }
365
+ if (options === null || typeof options !== 'object') {
366
+ throw new TypeError('Archive creation options must be an object');
367
+ }
368
+ if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
369
+ throw new TypeError('Archive creation overwrite option must be a boolean');
370
+ }
371
+ const compressionLevel = options.compressionLevel ?? 5;
372
+ if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
373
+ throw new RangeError('Compression level must be an integer between 0 and 9');
374
+ }
375
+ const targetPath = node_path.resolve(outputPath);
376
+ let format;
377
+ if (options.format !== undefined) {
378
+ if (options.format !== '7z' && options.format !== 'zip'
379
+ && options.format !== 'tar' && options.format !== 'wim') {
380
+ throw new TypeError('Archive format must be "7z", "zip", "tar", or "wim"');
381
+ }
382
+ format = options.format;
383
+ }
384
+ else {
385
+ const extension = node_path.extname(targetPath).toLowerCase();
386
+ if (extension === '.7z') {
387
+ format = '7z';
388
+ }
389
+ else if (extension === '.zip' || extension === '.cbz') {
390
+ format = 'zip';
391
+ }
392
+ else if (extension === '.tar') {
393
+ format = 'tar';
394
+ }
395
+ else if (extension === '.wim') {
396
+ format = 'wim';
397
+ }
398
+ else {
399
+ throw new TypeError('Specify format as "7z", "zip", "tar", or "wim" for an unrecognized output extension');
400
+ }
401
+ }
402
+ if (options.compressionLevel !== undefined && format !== '7z' && format !== 'zip') {
403
+ throw new TypeError('compressionLevel is only supported for ZIP and 7z archives');
404
+ }
405
+ const normalizedEntries = await Promise.all(entries.map(normalizeCreateEntry));
406
+ const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
407
+ const pathKeys = normalizedEntries.map(entry => caseInsensitivePaths ? entry.path.toLowerCase() : entry.path);
408
+ const archivePaths = new Set(pathKeys);
409
+ if (archivePaths.size !== normalizedEntries.length) {
410
+ throw new ArchiveError('ERR_ARCHIVE_PATH', 'Archive creation paths must be unique');
411
+ }
412
+ for (const entry of normalizedEntries) {
413
+ const segments = entry.path.split('/');
414
+ for (let index = 1; index < segments.length; index++) {
415
+ const parent = segments.slice(0, index).join('/');
416
+ const parentKey = caseInsensitivePaths ? parent.toLowerCase() : parent;
417
+ if (archivePaths.has(parentKey)) {
418
+ throw new ArchiveError('ERR_ARCHIVE_PATH', `Archive creation file path is a parent of another entry: ${parent}`);
419
+ }
420
+ }
421
+ }
422
+ if (options.overwrite) {
423
+ await assertReplaceableOutput(targetPath);
424
+ }
425
+ else {
426
+ await assertOutputDoesNotExist(targetPath);
427
+ }
428
+ const temporaryPath = node_path.join(node_path.dirname(targetPath), `.7zip-native-${node_crypto.randomUUID()}.tmp`);
429
+ try {
430
+ await native.create(temporaryPath, format, normalizedEntries, compressionLevel, enginePath);
431
+ if (options.overwrite) {
432
+ await assertReplaceableOutput(targetPath);
433
+ await promises.rename(temporaryPath, targetPath);
434
+ }
435
+ else {
436
+ await promises.link(temporaryPath, targetPath);
437
+ await promises.rm(temporaryPath);
438
+ }
439
+ }
440
+ catch (cause) {
441
+ await promises.rm(temporaryPath, { force: true });
442
+ if (cause instanceof ArchiveError) {
443
+ throw cause;
444
+ }
445
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', errorMessage(cause), { cause });
446
+ }
447
+ }
302
448
  async entries() {
303
449
  this.assertOpen();
304
450
  this.entriesPromise ??= native.entries(this.handle).catch((cause) => {
@@ -369,7 +515,7 @@ class Archive {
369
515
  if (!destination || typeof destination.write !== 'function') {
370
516
  throw new TypeError('Destination must be a writable stream');
371
517
  }
372
- await promises.pipeline(this.createReadStream(idOrPath, options), destination, { signal: options.signal });
518
+ await promises$1.pipeline(this.createReadStream(idOrPath, options), destination, { signal: options.signal });
373
519
  }
374
520
  async extractToFolder(destination, options = {}) {
375
521
  this.assertOpen();
@@ -382,9 +528,19 @@ class Archive {
382
528
  if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
383
529
  throw new TypeError('Folder extraction overwrite option must be a boolean');
384
530
  }
531
+ if (options.concurrency !== undefined
532
+ && (!Number.isSafeInteger(options.concurrency)
533
+ || options.concurrency < 1
534
+ || options.concurrency > 128)) {
535
+ throw new RangeError('Folder extraction concurrency must be an integer between 1 and 128');
536
+ }
385
537
  if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
386
538
  throw new TypeError('Folder extraction onProgress option must be a function');
387
539
  }
540
+ if (options.onExtract !== undefined && typeof options.onExtract !== 'function') {
541
+ throw new TypeError('Folder extraction onExtract option must be a function');
542
+ }
543
+ const concurrency = Math.min(options.concurrency ?? 1, Archive.concurrency());
388
544
  options.signal?.throwIfAborted();
389
545
  const maxBytes = normalizeMaxBytes(options.maxBytes, 'maxBytes');
390
546
  const entries = await this.entries();
@@ -454,8 +610,28 @@ class Archive {
454
610
  options.signal?.throwIfAborted();
455
611
  if (plan.isDirectory) {
456
612
  await ensureOutputDirectoryTree(root, plan.targetPath);
457
- continue;
458
613
  }
614
+ }
615
+ const filePlans = plans.filter(plan => !plan.isDirectory);
616
+ const controller = new AbortController();
617
+ const extractionSignal = controller.signal;
618
+ let hasFailure = false;
619
+ let failure;
620
+ let nextFileIndex = 0;
621
+ const stop = (error) => {
622
+ if (!hasFailure) {
623
+ hasFailure = true;
624
+ failure = error;
625
+ }
626
+ if (!extractionSignal.aborted) {
627
+ controller.abort(error);
628
+ }
629
+ };
630
+ const abortFromCaller = () => {
631
+ stop(options.signal?.reason);
632
+ };
633
+ options.signal?.addEventListener('abort', abortFromCaller, { once: true });
634
+ const extractFile = async (plan) => {
459
635
  await ensureOutputParents(root, plan.targetPath);
460
636
  const limit = new node_stream.Transform({
461
637
  transform(chunk, _encoding, callback) {
@@ -479,26 +655,59 @@ class Archive {
479
655
  await assertReplaceableOutput(plan.targetPath);
480
656
  const temporaryPath = node_path.join(node_path.dirname(plan.targetPath), `.7zip-native-${node_crypto.randomUUID()}.tmp`);
481
657
  try {
482
- await promises.pipeline(this.createReadStream(plan.entry.id, {
483
- signal: options.signal,
658
+ await promises$1.pipeline(this.createReadStream(plan.entry.id, {
659
+ signal: extractionSignal,
484
660
  maxBytes: null,
485
- }), limit, node_fs.createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
661
+ }), limit, node_fs.createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
662
+ extractionSignal.throwIfAborted();
486
663
  await assertReplaceableOutput(plan.targetPath);
487
- await promises$1.rename(temporaryPath, plan.targetPath);
664
+ extractionSignal.throwIfAborted();
665
+ await promises.rename(temporaryPath, plan.targetPath);
488
666
  }
489
667
  catch (error) {
490
- await promises$1.rm(temporaryPath, { force: true });
668
+ await promises.rm(temporaryPath, { force: true });
491
669
  throw error;
492
670
  }
493
671
  }
494
672
  else {
495
- await promises.pipeline(this.createReadStream(plan.entry.id, {
496
- signal: options.signal,
673
+ await promises$1.pipeline(this.createReadStream(plan.entry.id, {
674
+ signal: extractionSignal,
497
675
  maxBytes: null,
498
- }), limit, node_fs.createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
676
+ }), limit, node_fs.createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
499
677
  }
500
678
  filesExtracted++;
501
679
  reportProgress();
680
+ options.onExtract?.({ entry: plan.entry, outputPath: plan.targetPath });
681
+ };
682
+ const runWorker = async () => {
683
+ while (!hasFailure) {
684
+ if (options.signal?.aborted) {
685
+ stop(options.signal.reason);
686
+ return;
687
+ }
688
+ const plan = filePlans[nextFileIndex++];
689
+ if (!plan) {
690
+ return;
691
+ }
692
+ try {
693
+ await extractFile(plan);
694
+ }
695
+ catch (error) {
696
+ stop(error);
697
+ return;
698
+ }
699
+ }
700
+ };
701
+ try {
702
+ options.signal?.throwIfAborted();
703
+ const workers = Array.from({ length: Math.min(concurrency, filePlans.length) }, () => runWorker());
704
+ await Promise.all(workers);
705
+ if (hasFailure) {
706
+ throw failure;
707
+ }
708
+ }
709
+ finally {
710
+ options.signal?.removeEventListener('abort', abortFromCaller);
502
711
  }
503
712
  }
504
713
  async read(idOrPath, options) {
package/dist/index.d.cts CHANGED
@@ -19,6 +19,16 @@ interface ReadOptions {
19
19
  interface OpenOptions {
20
20
  readonly password?: string;
21
21
  }
22
+ type ArchiveFormat = '7z' | 'zip' | 'tar' | 'wim';
23
+ interface ArchiveCreateEntry {
24
+ readonly sourcePath: string;
25
+ readonly path: string;
26
+ }
27
+ interface CreateArchiveOptions {
28
+ readonly format?: ArchiveFormat;
29
+ readonly compressionLevel?: number;
30
+ readonly overwrite?: boolean;
31
+ }
22
32
  interface ExtractToFolderProgress {
23
33
  readonly filesExtracted: number;
24
34
  readonly totalFiles: number;
@@ -26,6 +36,10 @@ interface ExtractToFolderProgress {
26
36
  readonly totalBytes: bigint;
27
37
  readonly percent: number;
28
38
  }
39
+ interface ExtractedArchiveFile {
40
+ readonly entry: ArchiveEntry;
41
+ readonly outputPath: string;
42
+ }
29
43
  interface ReadStreamOptions {
30
44
  readonly highWaterMark?: number;
31
45
  readonly maxBytes?: number | bigint | null;
@@ -34,12 +48,14 @@ interface ReadStreamOptions {
34
48
  interface ExtractToFolderOptions {
35
49
  readonly ids?: readonly EntryId[];
36
50
  readonly paths?: readonly string[];
51
+ readonly concurrency?: number;
37
52
  readonly overwrite?: boolean;
38
53
  readonly onProgress?: (progress: ExtractToFolderProgress) => void;
54
+ readonly onExtract?: (result: ExtractedArchiveFile) => void;
39
55
  readonly maxBytes?: number | bigint | null;
40
56
  readonly signal?: AbortSignal;
41
57
  }
42
- type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT';
58
+ type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT' | 'ERR_ARCHIVE_CREATE';
43
59
  declare class ArchiveError extends Error {
44
60
  readonly code: ArchiveErrorCode;
45
61
  constructor(code: ArchiveErrorCode, message: string, options?: ErrorOptions);
@@ -52,6 +68,7 @@ declare class Archive {
52
68
  private constructor();
53
69
  static concurrency(value?: number): number;
54
70
  static open(path: string, options?: OpenOptions): Promise<Archive>;
71
+ static create(outputPath: string, entries: readonly ArchiveCreateEntry[], options?: CreateArchiveOptions): Promise<void>;
55
72
  entries(): Promise<readonly ArchiveEntry[]>;
56
73
  getEntry(idOrPath: EntrySelector): Promise<ArchiveEntry | undefined>;
57
74
  extract(idOrPath: EntrySelector): Promise<Buffer>;
@@ -69,4 +86,4 @@ declare class Archive {
69
86
  }
70
87
 
71
88
  export { Archive, ArchiveError };
72
- export type { ArchiveEntry, ArchiveErrorCode, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, OpenOptions, ReadOptions, ReadStreamOptions };
89
+ export type { ArchiveCreateEntry, ArchiveEntry, ArchiveErrorCode, ArchiveFormat, CreateArchiveOptions, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, ExtractedArchiveFile, OpenOptions, ReadOptions, ReadStreamOptions };