@naturalcycles/nodejs-lib 15.109.0 → 15.110.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.
@@ -0,0 +1,475 @@
1
+ /*
2
+
3
+ A minimal, dependency-free zip archive reader for Node.js 24+.
4
+
5
+ Adapted from yauzl (https://github.com/thejoshwolfe/yauzl) by Josh Wolfe (MIT License),
6
+ rewritten in a modern, Promise-based, async/await style:
7
+
8
+ - No external dependencies. `buffer-crc32` is replaced by the built-in `zlib.crc32`,
9
+ `fd-slicer`/`pend` are replaced by `node:fs/promises` FileHandle reads.
10
+ - Promise API instead of callbacks/EventEmitter (inspired by yauzl PR #171).
11
+ - The central directory is parsed eagerly into `ZipReader.entries`.
12
+ - Scope is intentionally small: read/extract `stored` (0) and `deflate` (8) entries.
13
+ Encryption and other compression methods are detected and rejected, not implemented.
14
+
15
+ Robustness kept from yauzl: backwards EOCD search, ZIP64 reading, bounds checks,
16
+ file name decoding (UTF-8, cp437, Info-ZIP Unicode Path field) and path-traversal
17
+ validation.
18
+
19
+ */
20
+ import { isUtf8 } from 'node:buffer';
21
+ import { createReadStream, createWriteStream } from 'node:fs';
22
+ import fsp from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { Readable } from 'node:stream';
25
+ import { pipeline } from 'node:stream/promises';
26
+ import { promisify } from 'node:util';
27
+ import zlib from 'node:zlib';
28
+ import { assertSafeZipEntryName, CDFH_SIG, CDFH_SIZE, DEFLATE, dosDateTimeToUnix, EOCDR_SIG, EOCDR_SIZE, LOCAL_FILE_HEADER_SIG, MAX_COMMENT_SIZE, normalizeZipEntryName, readUInt64LE, STORED, ZIP64_EOCDL_SIG, ZIP64_EOCDL_SIZE, ZIP64_EOCDR_SIG, ZIP64_EOCDR_SIZE, } from './zipInternal.js';
29
+ // oxlint-disable no-bitwise -- parsing the binary ZIP format requires bitwise ops on bit flags and packed DOS date/time fields
30
+ const inflateRawAsync = promisify(zlib.inflateRaw.bind(zlib));
31
+ /**
32
+ * Open a zip archive from a file on disk.
33
+ *
34
+ * Reads and parses the central directory; the returned {@link ZipReader} exposes
35
+ * the list of entries and lets you read their contents.
36
+ *
37
+ * Remember to call {@link ZipReader.close} when done, or use {@link extractZip}.
38
+ */
39
+ export async function openZip(filePath) {
40
+ const fileHandle = await fsp.open(filePath, 'r');
41
+ let source;
42
+ try {
43
+ const { size } = await fileHandle.stat();
44
+ source = new FileSource(fileHandle, filePath, size);
45
+ }
46
+ catch (err) {
47
+ await fileHandle.close();
48
+ throw err;
49
+ }
50
+ return await readArchive(source);
51
+ }
52
+ /**
53
+ * Open a zip archive from an in-memory Buffer.
54
+ */
55
+ export async function openZipBuffer(buffer) {
56
+ return await readArchive(new BufferSource(buffer));
57
+ }
58
+ /**
59
+ * Reads entries from an open zip archive.
60
+ *
61
+ * Create one via {@link openZip} or {@link openZipBuffer}.
62
+ *
63
+ * Implements `AsyncDisposable`, so it can be used with `await using` to close
64
+ * automatically on scope exit:
65
+ *
66
+ * ```ts
67
+ * await using zip = await openZip('archive.zip')
68
+ * const buf = await zip.readEntry(zip.entries[0])
69
+ * // zip.close() is called automatically here
70
+ * ```
71
+ */
72
+ export class ZipReader {
73
+ source;
74
+ entries;
75
+ comment;
76
+ constructor(source,
77
+ /**
78
+ * All entries (files and directories) found in the archive,
79
+ * in central-directory order.
80
+ */
81
+ entries,
82
+ /**
83
+ * Archive-level comment (empty string if none).
84
+ */
85
+ comment) {
86
+ this.source = source;
87
+ this.entries = entries;
88
+ this.comment = comment;
89
+ }
90
+ /**
91
+ * Read and fully decompress an entry into a Buffer.
92
+ *
93
+ * Validates the uncompressed size and CRC-32 checksum.
94
+ * For large entries prefer {@link openReadStream}.
95
+ */
96
+ async readEntry(entry) {
97
+ this.assertReadable(entry);
98
+ const fileDataStart = await this.findFileDataStart(entry);
99
+ const raw = await this.source.read(fileDataStart, entry.compressedSize);
100
+ const data = entry.compressionMethod === STORED ? raw : await inflateRawAsync(raw);
101
+ if (data.length !== entry.uncompressedSize) {
102
+ throw new Error(`uncompressed size mismatch for ${entry.fileName}: expected ${entry.uncompressedSize}, got ${data.length}`);
103
+ }
104
+ const actualCrc = zlib.crc32(data);
105
+ if (actualCrc !== entry.crc32) {
106
+ throw new Error(`crc32 mismatch for ${entry.fileName}: expected ${entry.crc32}, got ${actualCrc}`);
107
+ }
108
+ return data;
109
+ }
110
+ /**
111
+ * Open a Readable stream of an entry's decompressed contents.
112
+ *
113
+ * Useful for piping large entries to disk without buffering them in memory.
114
+ * Unlike {@link readEntry}, this does not verify the CRC-32 checksum.
115
+ */
116
+ async openReadStream(entry) {
117
+ this.assertReadable(entry);
118
+ const fileDataStart = await this.findFileDataStart(entry);
119
+ const raw = this.source.createReadStream(fileDataStart, fileDataStart + entry.compressedSize);
120
+ if (entry.compressionMethod === STORED)
121
+ return raw;
122
+ const inflate = zlib.createInflateRaw();
123
+ // Forward read errors into the decompression stream, and tear down the source
124
+ // stream if the consumer abandons the decompression stream early.
125
+ raw.once('error', err => inflate.destroy(err));
126
+ inflate.once('close', () => {
127
+ if (!raw.destroyed)
128
+ raw.destroy();
129
+ });
130
+ raw.pipe(inflate);
131
+ return inflate;
132
+ }
133
+ /**
134
+ * Extract all entries into `destDir`, streaming each file to disk.
135
+ * See {@link extractZip}.
136
+ */
137
+ async extractAll(destDir) {
138
+ const root = path.resolve(destDir);
139
+ for (const entry of this.entries) {
140
+ const targetPath = assertPathInside(root, entry.fileName);
141
+ if (entry.isDirectory) {
142
+ await fsp.mkdir(targetPath, { recursive: true });
143
+ continue;
144
+ }
145
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
146
+ const readStream = await this.openReadStream(entry);
147
+ await pipeline(readStream, createWriteStream(targetPath));
148
+ }
149
+ return this.entries;
150
+ }
151
+ /**
152
+ * Close the underlying file handle. No-op for buffer-backed archives.
153
+ * Safe to call multiple times.
154
+ */
155
+ async close() {
156
+ await this.source.close();
157
+ }
158
+ /**
159
+ * Called by `await using`; closes the archive. See {@link close}.
160
+ */
161
+ async [Symbol.asyncDispose]() {
162
+ await this.close();
163
+ }
164
+ assertReadable(entry) {
165
+ if (entry.isEncrypted) {
166
+ throw new Error(`encrypted entries are not supported: ${entry.fileName}`);
167
+ }
168
+ if (entry.compressionMethod !== STORED && entry.compressionMethod !== DEFLATE) {
169
+ throw new Error(`unsupported compression method ${entry.compressionMethod} for ${entry.fileName}`);
170
+ }
171
+ }
172
+ /**
173
+ * Read the local file header to locate the start of the entry's data.
174
+ * The local header's name/extra-field lengths can differ from the central
175
+ * directory's, so this must be read per entry.
176
+ */
177
+ async findFileDataStart(entry) {
178
+ const header = await this.source.read(entry.relativeOffsetOfLocalHeader, 30);
179
+ const signature = header.readUInt32LE(0);
180
+ if (signature !== LOCAL_FILE_HEADER_SIG) {
181
+ throw new Error(`invalid local file header signature: 0x${signature.toString(16)}`);
182
+ }
183
+ const fileNameLength = header.readUInt16LE(26);
184
+ const extraFieldLength = header.readUInt16LE(28);
185
+ const fileDataStart = entry.relativeOffsetOfLocalHeader + 30 + fileNameLength + extraFieldLength;
186
+ if (fileDataStart + entry.compressedSize > this.source.size) {
187
+ throw new Error(`file data overflows archive bounds for ${entry.fileName}`);
188
+ }
189
+ return fileDataStart;
190
+ }
191
+ }
192
+ async function readArchive(source) {
193
+ try {
194
+ const eocd = await readEndOfCentralDirectory(source);
195
+ const cdSize = eocd.centralDirectoryEnd - eocd.centralDirectoryOffset;
196
+ if (eocd.centralDirectoryOffset < 0 || cdSize < 0 || eocd.centralDirectoryEnd > source.size) {
197
+ throw new Error('invalid central directory location');
198
+ }
199
+ const centralDirectory = await source.read(eocd.centralDirectoryOffset, cdSize);
200
+ const entries = parseCentralDirectory(centralDirectory, eocd.entryCount);
201
+ return new ZipReader(source, entries, eocd.comment);
202
+ }
203
+ catch (err) {
204
+ await source.close();
205
+ throw err;
206
+ }
207
+ }
208
+ /**
209
+ * The End of Central Directory (EOCD) record sits at the very end of the file,
210
+ * followed only by a variable-length comment, so we search backwards for its
211
+ * signature. A ZIP64 EOCD locator may precede it for large archives.
212
+ */
213
+ async function readEndOfCentralDirectory(source) {
214
+ const { size } = source;
215
+ if (size < EOCDR_SIZE) {
216
+ throw new Error('not a zip file: file is too small');
217
+ }
218
+ const searchLength = Math.min(EOCDR_SIZE + MAX_COMMENT_SIZE + ZIP64_EOCDL_SIZE, size);
219
+ const searchStart = size - searchLength;
220
+ const buf = await source.read(searchStart, searchLength);
221
+ for (let i = buf.length - EOCDR_SIZE; i >= 0; i--) {
222
+ if (buf.readUInt32LE(i) !== EOCDR_SIG)
223
+ continue;
224
+ const eocdr = buf.subarray(i);
225
+ const diskNumber = eocdr.readUInt16LE(4);
226
+ let entryCount = eocdr.readUInt16LE(10);
227
+ let centralDirectoryOffset = eocdr.readUInt32LE(16);
228
+ const commentLength = eocdr.readUInt16LE(20);
229
+ const expectedCommentLength = eocdr.length - EOCDR_SIZE;
230
+ if (commentLength !== expectedCommentLength) {
231
+ throw new Error(`invalid comment length: expected ${expectedCommentLength}, found ${commentLength}`);
232
+ }
233
+ // The EOCD comment is always cp437-encoded.
234
+ const comment = decodeBuffer(eocdr.subarray(EOCDR_SIZE), false);
235
+ // The central directory region ends where this EOCD record begins.
236
+ let centralDirectoryEnd = searchStart + i;
237
+ // A ZIP64 End of Central Directory Locator sits immediately before the EOCD.
238
+ const locatorIndex = i - ZIP64_EOCDL_SIZE;
239
+ if (locatorIndex >= 0 && buf.readUInt32LE(locatorIndex) === ZIP64_EOCDL_SIG) {
240
+ const zip64EocdrOffset = readUInt64LE(buf, locatorIndex + 8);
241
+ const zip64 = await source.read(zip64EocdrOffset, ZIP64_EOCDR_SIZE);
242
+ if (zip64.readUInt32LE(0) !== ZIP64_EOCDR_SIG) {
243
+ throw new Error('invalid zip64 end of central directory record signature');
244
+ }
245
+ if (zip64.readUInt32LE(16) !== 0) {
246
+ throw new Error('multi-disk zip files are not supported');
247
+ }
248
+ entryCount = readUInt64LE(zip64, 32);
249
+ centralDirectoryOffset = readUInt64LE(zip64, 48);
250
+ centralDirectoryEnd = zip64EocdrOffset;
251
+ }
252
+ else if (diskNumber !== 0) {
253
+ throw new Error('multi-disk zip files are not supported');
254
+ }
255
+ return { entryCount, centralDirectoryOffset, centralDirectoryEnd, comment };
256
+ }
257
+ throw new Error('end of central directory record not found: not a zip file, or it is truncated');
258
+ }
259
+ function parseCentralDirectory(buf, entryCount) {
260
+ const entries = [];
261
+ let cursor = 0;
262
+ for (let n = 0; n < entryCount; n++) {
263
+ if (cursor + CDFH_SIZE > buf.length) {
264
+ throw new Error('central directory is truncated');
265
+ }
266
+ const signature = buf.readUInt32LE(cursor);
267
+ if (signature !== CDFH_SIG) {
268
+ throw new Error(`invalid central directory file header signature: 0x${signature.toString(16)}`);
269
+ }
270
+ const generalPurposeBitFlag = buf.readUInt16LE(cursor + 8);
271
+ const compressionMethod = buf.readUInt16LE(cursor + 10);
272
+ const lastModFileTime = buf.readUInt16LE(cursor + 12);
273
+ const lastModFileDate = buf.readUInt16LE(cursor + 14);
274
+ const crc32 = buf.readUInt32LE(cursor + 16);
275
+ let compressedSize = buf.readUInt32LE(cursor + 20);
276
+ let uncompressedSize = buf.readUInt32LE(cursor + 24);
277
+ const fileNameLength = buf.readUInt16LE(cursor + 28);
278
+ const extraFieldLength = buf.readUInt16LE(cursor + 30);
279
+ const fileCommentLength = buf.readUInt16LE(cursor + 32);
280
+ let relativeOffsetOfLocalHeader = buf.readUInt32LE(cursor + 42);
281
+ if (generalPurposeBitFlag & 0x40) {
282
+ throw new Error('strong encryption is not supported');
283
+ }
284
+ const nameStart = cursor + CDFH_SIZE;
285
+ const extraStart = nameStart + fileNameLength;
286
+ const commentStart = extraStart + extraFieldLength;
287
+ const entryEnd = commentStart + fileCommentLength;
288
+ if (entryEnd > buf.length) {
289
+ throw new Error('central directory entry overflows the central directory');
290
+ }
291
+ const fileNameRaw = buf.subarray(nameStart, extraStart);
292
+ const extraFieldRaw = buf.subarray(extraStart, commentStart);
293
+ const fileCommentRaw = buf.subarray(commentStart, entryEnd);
294
+ const extraFields = parseExtraFields(extraFieldRaw);
295
+ // ZIP64: when a 32-bit field holds the 0xffffffff sentinel, the real value
296
+ // lives in the 0x0001 extra field.
297
+ const zip64 = readZip64ExtraField(extraFields, uncompressedSize, compressedSize, relativeOffsetOfLocalHeader);
298
+ uncompressedSize = zip64.uncompressedSize;
299
+ compressedSize = zip64.compressedSize;
300
+ relativeOffsetOfLocalHeader = zip64.relativeOffsetOfLocalHeader;
301
+ const hasUtf8Flag = (generalPurposeBitFlag & 0x800) !== 0;
302
+ const fileName = decodeFileName(generalPurposeBitFlag, fileNameRaw, extraFields);
303
+ assertSafeZipEntryName(fileName);
304
+ entries.push({
305
+ fileName,
306
+ uncompressedSize,
307
+ compressedSize,
308
+ compressionMethod,
309
+ crc32,
310
+ lastModified: parseLastModified(lastModFileDate, lastModFileTime, extraFields),
311
+ isDirectory: fileName.endsWith('/'),
312
+ isEncrypted: (generalPurposeBitFlag & 0x1) !== 0,
313
+ comment: decodeBuffer(fileCommentRaw, hasUtf8Flag),
314
+ generalPurposeBitFlag,
315
+ relativeOffsetOfLocalHeader,
316
+ });
317
+ cursor = entryEnd;
318
+ }
319
+ return entries;
320
+ }
321
+ function parseExtraFields(buf) {
322
+ const fields = [];
323
+ let i = 0;
324
+ while (i < buf.length - 3) {
325
+ const id = buf.readUInt16LE(i);
326
+ const dataSize = buf.readUInt16LE(i + 2);
327
+ const dataStart = i + 4;
328
+ const dataEnd = dataStart + dataSize;
329
+ if (dataEnd > buf.length) {
330
+ throw new Error('extra field length exceeds extra field buffer size');
331
+ }
332
+ fields.push({ id, data: buf.subarray(dataStart, dataEnd) });
333
+ i = dataEnd;
334
+ }
335
+ return fields;
336
+ }
337
+ function readZip64ExtraField(extraFields, uncompressedSize, compressedSize, relativeOffsetOfLocalHeader) {
338
+ const zip64 = extraFields.find(f => f.id === 0x0001);
339
+ if (!zip64) {
340
+ return { uncompressedSize, compressedSize, relativeOffsetOfLocalHeader };
341
+ }
342
+ const { data } = zip64;
343
+ let index = 0;
344
+ const next = () => {
345
+ if (index + 8 > data.length) {
346
+ throw new Error('zip64 extended information extra field is too short');
347
+ }
348
+ const value = readUInt64LE(data, index);
349
+ index += 8;
350
+ return value;
351
+ };
352
+ // Fields appear in this fixed order, but only the ones using the sentinel are present.
353
+ if (uncompressedSize === 0xffffffff)
354
+ uncompressedSize = next();
355
+ if (compressedSize === 0xffffffff)
356
+ compressedSize = next();
357
+ if (relativeOffsetOfLocalHeader === 0xffffffff)
358
+ relativeOffsetOfLocalHeader = next();
359
+ return { uncompressedSize, compressedSize, relativeOffsetOfLocalHeader };
360
+ }
361
+ function decodeFileName(generalPurposeBitFlag, fileNameRaw, extraFields) {
362
+ // Info-ZIP Unicode Path Extra Field (0x7075): an authoritative UTF-8 name,
363
+ // used only if its stored CRC-32 matches the raw name. See yauzl#33.
364
+ const unicodePath = extraFields.find(f => f.id === 0x7075);
365
+ if (unicodePath &&
366
+ unicodePath.data.length >= 6 &&
367
+ unicodePath.data.readUInt8(0) === 1 &&
368
+ unicodePath.data.readUInt32LE(1) === zlib.crc32(fileNameRaw)) {
369
+ return normalizeZipEntryName(unicodePath.data.subarray(5).toString('utf8'));
370
+ }
371
+ const hasUtf8Flag = (generalPurposeBitFlag & 0x800) !== 0;
372
+ return normalizeZipEntryName(decodeBuffer(fileNameRaw, hasUtf8Flag));
373
+ }
374
+ function parseLastModified(date, time, extraFields) {
375
+ // Prefer the Info-ZIP "UT" extended timestamp (0x5455) if it carries mtime.
376
+ // Its payload is already a Unix timestamp in seconds.
377
+ const ut = extraFields.find(f => f.id === 0x5455);
378
+ if (ut && ut.data.length >= 5 && (ut.data.readUInt8(0) & 0x01) !== 0) {
379
+ return ut.data.readInt32LE(1);
380
+ }
381
+ return dosDateTimeToUnix(date, time);
382
+ }
383
+ function decodeBuffer(buf, hasUtf8Flag) {
384
+ // Many tools (Info-ZIP, Linux `zip`) store UTF-8 names without setting the UTF-8
385
+ // flag, so also trust UTF-8 when the bytes are valid UTF-8; else fall back to cp437.
386
+ if (hasUtf8Flag || isUtf8(buf))
387
+ return buf.toString('utf8');
388
+ // Legacy cp437: ASCII passthrough for 0x00-0x7f, lookup table for the high half.
389
+ let result = '';
390
+ for (const byte of buf) {
391
+ result += byte < 0x80 ? String.fromCodePoint(byte) : CP437_HIGH.charAt(byte - 0x80);
392
+ }
393
+ return result;
394
+ }
395
+ function assertPathInside(root, fileName) {
396
+ const targetPath = path.resolve(root, fileName);
397
+ if (targetPath !== root && !targetPath.startsWith(root + path.sep)) {
398
+ throw new Error(`zip entry escapes destination directory: ${fileName}`);
399
+ }
400
+ return targetPath;
401
+ }
402
+ class FileSource {
403
+ fileHandle;
404
+ filePath;
405
+ size;
406
+ closed = false;
407
+ constructor(fileHandle, filePath, size) {
408
+ this.fileHandle = fileHandle;
409
+ this.filePath = filePath;
410
+ this.size = size;
411
+ }
412
+ async read(position, length) {
413
+ if (length === 0)
414
+ return Buffer.alloc(0);
415
+ const buf = Buffer.allocUnsafe(length);
416
+ let read = 0;
417
+ while (read < length) {
418
+ const { bytesRead } = await this.fileHandle.read(buf, read, length - read, position + read);
419
+ if (bytesRead === 0) {
420
+ throw new Error(`unexpected EOF: read ${read} of ${length} bytes at offset ${position}`);
421
+ }
422
+ read += bytesRead;
423
+ }
424
+ return buf;
425
+ }
426
+ createReadStream(start, end) {
427
+ if (start >= end)
428
+ return Readable.from([]);
429
+ // Stream via a fresh, self-contained fd rather than the shared FileHandle: its
430
+ // own autoClose closes only that fd on stream end/destroy, leaving the handle
431
+ // (used for positioned header reads) intact. `end` is inclusive here, hence -1.
432
+ return createReadStream(this.filePath, { start, end: end - 1 });
433
+ }
434
+ async close() {
435
+ if (this.closed)
436
+ return;
437
+ this.closed = true;
438
+ await this.fileHandle.close();
439
+ }
440
+ }
441
+ class BufferSource {
442
+ buffer;
443
+ constructor(buffer) {
444
+ this.buffer = buffer;
445
+ }
446
+ get size() {
447
+ return this.buffer.length;
448
+ }
449
+ async read(position, length) {
450
+ if (position + length > this.buffer.length) {
451
+ throw new Error(`unexpected EOF: cannot read ${length} bytes at offset ${position}`);
452
+ }
453
+ return this.buffer.subarray(position, position + length);
454
+ }
455
+ createReadStream(start, end) {
456
+ if (start >= end)
457
+ return Readable.from([]);
458
+ return Readable.from(chunkBuffer(this.buffer.subarray(start, end)));
459
+ }
460
+ async close() {
461
+ return;
462
+ }
463
+ }
464
+ /**
465
+ * Split a buffer into smaller chunks for friendlier memory usage when piping
466
+ * into a decompression stream. See yauzl#87.
467
+ */
468
+ function* chunkBuffer(buf, chunkSize = 0x10000) {
469
+ for (let offset = 0; offset < buf.length; offset += chunkSize) {
470
+ yield buf.subarray(offset, offset + chunkSize);
471
+ }
472
+ }
473
+ // cp437 high half (bytes 0x80-0xff), used to decode legacy (non-UTF-8) names/comments.
474
+ // The last entry (0xff) is a non-breaking space (U+00A0).
475
+ const CP437_HIGH = 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ';
@@ -0,0 +1,168 @@
1
+ import type { Readable } from 'node:stream';
2
+ import { Writable } from 'node:stream';
3
+ import type { UnixTimestamp } from '@naturalcycles/js-lib/types';
4
+ /**
5
+ * Create a zip archive on disk from a list of files and/or directories.
6
+ *
7
+ * Each input path may be a file (added directly) or a directory (walked
8
+ * recursively; all nested files are added). By default an entry's name is its
9
+ * path relative to its input's parent directory, so `zipFiles(['./photos'], 'out.zip')`
10
+ * stores entries under `photos/...`. Override the base via {@link ZipPathsOptions.baseDir}.
11
+ *
12
+ * Files discovered by walking a directory are added in deterministic (sorted)
13
+ * order; the explicit input order is otherwise preserved.
14
+ *
15
+ * ```ts
16
+ * await zipPaths(['./photos'], 'photos.zip') // a whole directory
17
+ * await zipPaths(['a.txt', 'log/b.txt'], 'out.zip') // a list of files
18
+ * ```
19
+ *
20
+ * On failure the partially-written archive is removed.
21
+ */
22
+ export declare function zipPaths(paths: string[], outputZipFilePath: string, opt?: ZipPathsOptions): Promise<void>;
23
+ /**
24
+ * Build a zip archive entirely in memory and return it as a Buffer.
25
+ *
26
+ * For large archives or streamed inputs prefer {@link createZip} / {@link ZipWriter}.
27
+ */
28
+ export declare function createZipBuffer(entries: ZipFileEntry[]): Promise<Buffer>;
29
+ /**
30
+ * Options for {@link zipPaths}.
31
+ */
32
+ export interface ZipPathsOptions extends ZipWriterEntryOptions {
33
+ /**
34
+ * Base directory used to compute entry names: each file is stored under its path
35
+ * relative to `baseDir`.
36
+ *
37
+ * Defaults to the parent of each input path, so a file `/a/b.txt` is stored as
38
+ * `b.txt` and a directory `/a/photos` is stored under `photos/...` (its own name
39
+ * is preserved). Pass the directory itself as `baseDir` to instead place its
40
+ * contents at the archive root.
41
+ */
42
+ baseDir?: string;
43
+ }
44
+ /**
45
+ * Writes a zip archive to a Node.js {@link Writable} stream.
46
+ *
47
+ * Create one directly over any `Writable`, or use {@link createZip} to write to
48
+ * a file. Add entries sequentially (each add method must be awaited before the
49
+ * next), then call {@link finalize} to write the central directory and close the
50
+ * stream.
51
+ *
52
+ * Implements `AsyncDisposable`, so `await using` finalizes automatically on scope
53
+ * exit:
54
+ *
55
+ * ```ts
56
+ * await using zip = createZip('archive.zip')
57
+ * await zip.addBuffer(Buffer.from('hello'), 'hello.txt')
58
+ * // zip.finalize() is called automatically here
59
+ * ```
60
+ */
61
+ export declare class ZipWriter implements AsyncDisposable {
62
+ private out;
63
+ private offset;
64
+ private finalized;
65
+ private streamError?;
66
+ private readonly entries;
67
+ constructor(out: Writable);
68
+ /**
69
+ * Add an in-memory buffer as a file entry.
70
+ * The CRC-32 and sizes are computed up front and written inline (no data descriptor).
71
+ */
72
+ addBuffer(data: Buffer, fileName: string, opt?: ZipWriterEntryOptions): Promise<void>;
73
+ /**
74
+ * Add a file from disk, streaming its contents. The entry's mtime and mode
75
+ * default to the file's own (override via `opt`). If `fileName` is omitted,
76
+ * the file's base name is used.
77
+ */
78
+ addFile(filePath: string, fileName?: string, opt?: ZipWriterEntryOptions): Promise<void>;
79
+ /**
80
+ * Add a readable stream as a file entry. The size and CRC are computed while
81
+ * streaming and written in a trailing data descriptor.
82
+ */
83
+ addStream(stream: Readable, fileName: string, opt?: ZipWriterEntryOptions): Promise<void>;
84
+ /**
85
+ * Add an explicit (empty) directory entry. A trailing `/` is added if missing.
86
+ * Directory entries are optional in zip archives but make empty directories explicit.
87
+ */
88
+ addDirectory(fileName: string, opt?: ZipWriterEntryOptions): Promise<void>;
89
+ /**
90
+ * Write the central directory and end-of-central-directory records, then end
91
+ * the underlying stream and wait for it to flush. Idempotent.
92
+ */
93
+ finalize(opt?: ZipFinalizeOptions): Promise<void>;
94
+ /**
95
+ * Called by `await using`; finalizes the archive if not already done. See {@link finalize}.
96
+ */
97
+ [Symbol.asyncDispose](): Promise<void>;
98
+ private assertWritable;
99
+ /** Build the in-memory representation of an entry from its name and options. */
100
+ private createEntry;
101
+ private writeKnownEntry;
102
+ private pumpEntry;
103
+ private pumpData;
104
+ private write;
105
+ private waitDrain;
106
+ private finishStream;
107
+ }
108
+ /**
109
+ * Per-entry options for {@link ZipWriter} add methods.
110
+ */
111
+ export interface ZipWriterEntryOptions {
112
+ /**
113
+ * Compress the entry with deflate. Default `true` for files, always `false`
114
+ * for directories. Set to `false` to store the bytes uncompressed.
115
+ */
116
+ compress?: boolean;
117
+ /**
118
+ * Deflate level, `0`-`9`. Implies compression; `0` means stored.
119
+ * Default `6`. Ignored when {@link compress} is `false`.
120
+ */
121
+ level?: number;
122
+ /**
123
+ * Last modification time, as a Unix timestamp in seconds. Default: now.
124
+ */
125
+ mtime?: UnixTimestamp;
126
+ /**
127
+ * Unix file mode bits (low 16 bits), e.g. `0o644`.
128
+ * Default `0o664` for files, `0o775` for directories.
129
+ */
130
+ mode?: number;
131
+ /**
132
+ * Optional per-entry comment.
133
+ */
134
+ comment?: string;
135
+ /**
136
+ * Force ZIP64 format for this entry. ZIP64 is also enabled automatically for
137
+ * entries larger than ~4 GiB or located past the 4 GiB offset; set this when
138
+ * adding a stream you know will be large.
139
+ */
140
+ forceZip64?: boolean;
141
+ }
142
+ /**
143
+ * Options for {@link ZipWriter.finalize}.
144
+ */
145
+ export interface ZipFinalizeOptions {
146
+ /**
147
+ * Archive-level comment. Must not contain the end-of-central-directory signature.
148
+ */
149
+ comment?: string;
150
+ /**
151
+ * Force ZIP64 end-of-central-directory records, regardless of size/count.
152
+ */
153
+ forceZip64?: boolean;
154
+ }
155
+ /**
156
+ * A single entry for {@link createZipBuffer}.
157
+ */
158
+ export interface ZipFileEntry extends ZipWriterEntryOptions {
159
+ /**
160
+ * Entry name (path inside the archive), using `/` as the separator.
161
+ */
162
+ name: string;
163
+ /**
164
+ * File contents. Omit to create a directory entry (a trailing `/` is added
165
+ * to {@link name} if missing). An empty `content` still creates a file.
166
+ */
167
+ content?: Buffer;
168
+ }