@naturalcycles/nodejs-lib 15.109.0 → 15.111.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,579 @@
1
+ /*
2
+
3
+ A minimal, dependency-free zip archive writer for Node.js 24+.
4
+
5
+ Adapted from yazl (https://github.com/thejoshwolfe/yazl) 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
+ (used incrementally while streaming), and the deflate is done with `node:zlib`.
10
+ - Promise API instead of callbacks/EventEmitter: `await writer.addFile(...)` etc.,
11
+ finished with `await writer.finalize()`.
12
+ - Streaming entries (`addStream`/`addFile`) are written with a data descriptor
13
+ (general purpose bit 3), so their size and CRC need not be known up front.
14
+ - Buffer entries (`addBuffer`) are written with their sizes and CRC inline.
15
+
16
+ Kept from yazl: ZIP64 output (auto-enabled past the 4 GiB / 0xffff limits, or via
17
+ `forceZip64`), the Info-ZIP extended timestamp (0x5455) for accurate mtimes, DOS
18
+ date/time encoding, file-name validation and per-entry/archive comments.
19
+
20
+ Shares the low-level format details (signatures, sizes, 64-bit and DOS date/time
21
+ helpers, name validation) with the reader via `zipInternal.ts`.
22
+
23
+ */
24
+ import { createReadStream, createWriteStream } from 'node:fs';
25
+ import fsp from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { Transform, Writable } from 'node:stream';
28
+ import { finished, pipeline } from 'node:stream/promises';
29
+ import { promisify } from 'node:util';
30
+ import zlib from 'node:zlib';
31
+ import { comparators } from '@naturalcycles/js-lib/array/sort.js';
32
+ import { glob } from 'tinyglobby';
33
+ import { assertSafeZipEntryName, CDFH_SIG, CDFH_SIZE, DATA_DESCRIPTOR_SIG, DEFLATE, EOCDR_SIG, EOCDR_SIZE, LOCAL_FILE_HEADER_SIG, MAX_COMMENT_SIZE, normalizeZipEntryName, STORED, unixToDosDateTime, writeUInt64LE, ZIP64_EOCDL_SIG, ZIP64_EOCDL_SIZE, ZIP64_EOCDR_SIG, ZIP64_EOCDR_SIZE, } from './zipInternal.js';
34
+ // oxlint-disable no-bitwise, unicorn/prefer-math-trunc -- writing the binary ZIP format requires bitwise ops on bit flags, packed DOS date/time fields, and unsigned 32-bit attribute packing
35
+ const deflateRawAsync = promisify(zlib.deflateRaw.bind(zlib));
36
+ /**
37
+ * Create a zip archive on disk from a list of files and/or directories.
38
+ *
39
+ * Each input path may be a file (added directly) or a directory (walked
40
+ * recursively; all nested files are added). By default an entry's name is its
41
+ * path relative to its input's parent directory, so `zipFiles(['./photos'], 'out.zip')`
42
+ * stores entries under `photos/...`. Override the base via {@link ZipPathsOptions.baseDir}.
43
+ *
44
+ * Files discovered by walking a directory are added in deterministic (sorted)
45
+ * order; the explicit input order is otherwise preserved.
46
+ *
47
+ * ```ts
48
+ * await zipPaths(['./photos'], 'photos.zip') // a whole directory
49
+ * await zipPaths(['a.txt', 'log/b.txt'], 'out.zip') // a list of files
50
+ * ```
51
+ *
52
+ * On failure the partially-written archive is removed.
53
+ */
54
+ export async function zipPaths(paths, outputZipFilePath, opt = {}) {
55
+ const { baseDir, ...entryOpt } = opt;
56
+ const inputs = paths.map(p => path.resolve(p));
57
+ const files = await collectFiles(inputs, baseDir);
58
+ // Build the write stream here (rather than via createZip) so a mid-way failure
59
+ // can tear down the stream and remove the half-written archive.
60
+ const out = createWriteStream(outputZipFilePath);
61
+ const writer = new ZipWriter(out);
62
+ try {
63
+ for (const file of files) {
64
+ await writer.addFile(file.absPath, file.name, entryOpt);
65
+ }
66
+ await writer.finalize();
67
+ }
68
+ catch (err) {
69
+ out.destroy();
70
+ await fsp.rm(outputZipFilePath, { force: true });
71
+ throw err;
72
+ }
73
+ }
74
+ /**
75
+ * Build a zip archive entirely in memory and return it as a Buffer.
76
+ *
77
+ * For large archives or streamed inputs prefer {@link createZip} / {@link ZipWriter}.
78
+ */
79
+ export async function createZipBuffer(entries) {
80
+ const chunks = [];
81
+ const sink = new Writable({
82
+ write(chunk, _enc, cb) {
83
+ chunks.push(chunk);
84
+ cb();
85
+ },
86
+ });
87
+ const writer = new ZipWriter(sink);
88
+ for (const { name, content, ...opt } of entries) {
89
+ if (content === undefined) {
90
+ await writer.addDirectory(name, opt);
91
+ }
92
+ else {
93
+ await writer.addBuffer(content, name, opt);
94
+ }
95
+ }
96
+ await writer.finalize();
97
+ return Buffer.concat(chunks);
98
+ }
99
+ /**
100
+ * Writes a zip archive to a Node.js {@link Writable} stream.
101
+ *
102
+ * Create one directly over any `Writable`, or use {@link createZip} to write to
103
+ * a file. Add entries sequentially (each add method must be awaited before the
104
+ * next), then call {@link finalize} to write the central directory and close the
105
+ * stream.
106
+ *
107
+ * Implements `AsyncDisposable`, so `await using` finalizes automatically on scope
108
+ * exit:
109
+ *
110
+ * ```ts
111
+ * await using zip = createZip('archive.zip')
112
+ * await zip.addBuffer(Buffer.from('hello'), 'hello.txt')
113
+ * // zip.finalize() is called automatically here
114
+ * ```
115
+ */
116
+ export class ZipWriter {
117
+ out;
118
+ offset = 0;
119
+ finalized = false;
120
+ streamError;
121
+ entries = [];
122
+ constructor(out) {
123
+ this.out = out;
124
+ // Capture stream errors so a pending/next write rejects instead of the
125
+ // 'error' event going unhandled and crashing the process.
126
+ this.out.once('error', err => {
127
+ this.streamError ??= err;
128
+ });
129
+ }
130
+ /**
131
+ * Add an in-memory buffer as a file entry.
132
+ * The CRC-32 and sizes are computed up front and written inline (no data descriptor).
133
+ */
134
+ async addBuffer(data, fileName, opt = {}) {
135
+ this.assertWritable();
136
+ const entry = this.createEntry(fileName, false, opt);
137
+ entry.crc32 = zlib.crc32(data);
138
+ entry.uncompressedSize = data.length;
139
+ const stored = entry.method === STORED ? data : await deflateRawAsync(data, { level: entry.level });
140
+ entry.compressedSize = stored.length;
141
+ entry.crcAndFileSizeKnown = true;
142
+ await this.writeKnownEntry(entry, stored);
143
+ }
144
+ /**
145
+ * Add a file from disk, streaming its contents. The entry's mtime and mode
146
+ * default to the file's own (override via `opt`). If `fileName` is omitted,
147
+ * the file's base name is used.
148
+ */
149
+ async addFile(filePath, fileName, opt = {}) {
150
+ this.assertWritable();
151
+ const stats = await fsp.stat(filePath);
152
+ const entry = this.createEntry(fileName ?? path.basename(filePath), false, {
153
+ mtime: Math.floor(stats.mtimeMs / 1000),
154
+ mode: stats.mode & 0xffff,
155
+ ...opt,
156
+ });
157
+ await this.pumpEntry(entry, createReadStream(filePath));
158
+ }
159
+ /**
160
+ * Add a readable stream as a file entry. The size and CRC are computed while
161
+ * streaming and written in a trailing data descriptor.
162
+ */
163
+ async addStream(stream, fileName, opt = {}) {
164
+ this.assertWritable();
165
+ const entry = this.createEntry(fileName, false, opt);
166
+ await this.pumpEntry(entry, stream);
167
+ }
168
+ /**
169
+ * Add an explicit (empty) directory entry. A trailing `/` is added if missing.
170
+ * Directory entries are optional in zip archives but make empty directories explicit.
171
+ */
172
+ async addDirectory(fileName, opt = {}) {
173
+ this.assertWritable();
174
+ const entry = this.createEntry(fileName, true, opt);
175
+ await this.writeKnownEntry(entry, EMPTY);
176
+ }
177
+ /**
178
+ * Write the central directory and end-of-central-directory records, then end
179
+ * the underlying stream and wait for it to flush. Idempotent.
180
+ */
181
+ async finalize(opt = {}) {
182
+ if (this.finalized)
183
+ return;
184
+ const comment = opt.comment ? Buffer.from(opt.comment, 'utf8') : EMPTY;
185
+ if (comment.length > MAX_COMMENT_SIZE) {
186
+ throw new Error(`archive comment is too long: ${comment.length} > ${MAX_COMMENT_SIZE} bytes`);
187
+ }
188
+ // A comment containing this signature would confuse readers that scan
189
+ // backwards for the end-of-central-directory record.
190
+ if (comment.includes(EOCDR_SIG_BYTES)) {
191
+ throw new Error('archive comment must not contain the end-of-central-directory signature');
192
+ }
193
+ this.finalized = true;
194
+ const centralDirectoryOffset = this.offset;
195
+ for (const entry of this.entries) {
196
+ await this.write(buildCentralDirectoryRecord(entry));
197
+ }
198
+ const centralDirectorySize = this.offset - centralDirectoryOffset;
199
+ await this.write(buildEndRecords({
200
+ entryCount: this.entries.length,
201
+ centralDirectoryOffset,
202
+ centralDirectorySize,
203
+ comment,
204
+ zip64EocdrOffset: this.offset,
205
+ forceZip64: opt.forceZip64 ?? false,
206
+ }));
207
+ await this.finishStream();
208
+ }
209
+ /**
210
+ * Called by `await using`; finalizes the archive if not already done. See {@link finalize}.
211
+ */
212
+ async [Symbol.asyncDispose]() {
213
+ await this.finalize();
214
+ }
215
+ assertWritable() {
216
+ if (this.streamError)
217
+ throw this.streamError;
218
+ if (this.finalized)
219
+ throw new Error('ZipWriter has already been finalized');
220
+ }
221
+ /** Build the in-memory representation of an entry from its name and options. */
222
+ createEntry(fileName, isDirectory, opt) {
223
+ const nameBuf = Buffer.from(normalizeAndValidateEntryName(fileName, isDirectory), 'utf8');
224
+ if (nameBuf.length > MAX_COMMENT_SIZE) {
225
+ throw new Error(`zip entry name is too long: ${nameBuf.length} > ${MAX_COMMENT_SIZE} bytes`);
226
+ }
227
+ const mtime = opt.mtime ?? Math.floor(Date.now() / 1000);
228
+ const { date, time } = unixToDosDateTime(mtime);
229
+ const mode = opt.mode ?? (isDirectory ? DEFAULT_DIR_MODE : DEFAULT_FILE_MODE);
230
+ if ((mode & 0xffff) !== mode) {
231
+ throw new Error(`invalid mode: expected 0 <= ${mode} <= 65535`);
232
+ }
233
+ const compress = isDirectory ? false : (opt.compress ?? true);
234
+ const level = compress ? (opt.level ?? DEFAULT_DEFLATE_LEVEL) : 0;
235
+ const commentBuf = opt.comment ? Buffer.from(opt.comment, 'utf8') : EMPTY;
236
+ if (commentBuf.length > MAX_COMMENT_SIZE) {
237
+ throw new Error(`zip entry comment is too long: ${commentBuf.length} > ${MAX_COMMENT_SIZE} bytes`);
238
+ }
239
+ return {
240
+ nameBuf,
241
+ isDirectory,
242
+ method: level === 0 ? STORED : DEFLATE,
243
+ level,
244
+ // Directories carry no data, so their (zero) sizes and CRC are known up front.
245
+ crcAndFileSizeKnown: isDirectory,
246
+ crc32: 0,
247
+ uncompressedSize: 0,
248
+ compressedSize: 0,
249
+ relativeOffsetOfLocalHeader: 0,
250
+ lastModFileTime: time,
251
+ lastModFileDate: date,
252
+ mtimeSeconds: clampInt32(mtime),
253
+ // Unix mode packed into the high 16 bits; `>>> 0` keeps it an unsigned uint32.
254
+ externalFileAttributes: (mode << 16) >>> 0,
255
+ commentBuf,
256
+ forceZip64: opt.forceZip64 ?? false,
257
+ };
258
+ }
259
+ /** Write an entry whose CRC and sizes are already known: header, name, data, no descriptor. */
260
+ async writeKnownEntry(entry, data) {
261
+ entry.relativeOffsetOfLocalHeader = this.offset;
262
+ await this.write(buildLocalFileHeader(entry));
263
+ await this.write(entry.nameBuf);
264
+ await this.write(data);
265
+ this.entries.push(entry);
266
+ }
267
+ /** Write a streamed entry: header with bit 3 set, streamed data, then a data descriptor. */
268
+ async pumpEntry(entry, source) {
269
+ entry.relativeOffsetOfLocalHeader = this.offset;
270
+ await this.write(buildLocalFileHeader(entry));
271
+ await this.write(entry.nameBuf);
272
+ const { crc32, uncompressedSize, compressedSize } = await this.pumpData(source, entry.method === DEFLATE, entry.level);
273
+ entry.crc32 = crc32;
274
+ entry.uncompressedSize = uncompressedSize;
275
+ entry.compressedSize = compressedSize;
276
+ await this.write(buildDataDescriptor(entry, useZip64(entry)));
277
+ this.entries.push(entry);
278
+ }
279
+ /**
280
+ * Pipe `source` to the output, computing the CRC-32 and uncompressed size on
281
+ * the way in, optionally deflating, and counting the compressed bytes written.
282
+ */
283
+ async pumpData(source, compress, level) {
284
+ let crc32 = 0;
285
+ let uncompressedSize = 0;
286
+ let compressedSize = 0;
287
+ // Tap the uncompressed bytes for the CRC-32 and size before they are deflated.
288
+ const tap = new Transform({
289
+ transform(chunk, _enc, cb) {
290
+ crc32 = zlib.crc32(chunk, crc32);
291
+ uncompressedSize += chunk.length;
292
+ cb(null, chunk);
293
+ },
294
+ });
295
+ // Final pipeline stage: write each (possibly compressed) chunk to the output,
296
+ // counting bytes. Awaiting `write` propagates backpressure up the pipeline.
297
+ const drain = async (src) => {
298
+ for await (const chunk of src) {
299
+ compressedSize += chunk.length;
300
+ await this.write(chunk);
301
+ }
302
+ };
303
+ if (compress) {
304
+ await pipeline(source, tap, zlib.createDeflateRaw({ level }), drain);
305
+ }
306
+ else {
307
+ await pipeline(source, tap, drain);
308
+ }
309
+ return { crc32, uncompressedSize, compressedSize };
310
+ }
311
+ /** Write a buffer to the output, tracking the byte offset and respecting backpressure. */
312
+ async write(buf) {
313
+ if (this.streamError)
314
+ throw this.streamError;
315
+ if (buf.length === 0)
316
+ return;
317
+ this.offset += buf.length;
318
+ if (!this.out.write(buf)) {
319
+ await this.waitDrain();
320
+ }
321
+ }
322
+ async waitDrain() {
323
+ return new Promise((resolve, reject) => {
324
+ const cleanup = () => {
325
+ this.out.off('drain', onDrain);
326
+ this.out.off('error', onError);
327
+ };
328
+ const onDrain = () => {
329
+ cleanup();
330
+ resolve();
331
+ };
332
+ const onError = (err) => {
333
+ cleanup();
334
+ reject(err);
335
+ };
336
+ this.out.once('drain', onDrain);
337
+ this.out.once('error', onError);
338
+ });
339
+ }
340
+ async finishStream() {
341
+ this.out.end();
342
+ await finished(this.out);
343
+ }
344
+ }
345
+ function buildLocalFileHeader(entry) {
346
+ const buf = Buffer.allocUnsafe(LOCAL_FILE_HEADER_SIZE);
347
+ let generalPurposeBitFlag = FILE_NAME_IS_UTF8;
348
+ let crc32 = 0;
349
+ let compressedSize = 0;
350
+ let uncompressedSize = 0;
351
+ if (entry.crcAndFileSizeKnown) {
352
+ crc32 = entry.crc32;
353
+ compressedSize = entry.compressedSize;
354
+ uncompressedSize = entry.uncompressedSize;
355
+ }
356
+ else {
357
+ // Sizes/CRC are unknown until the data has streamed; bit 3 says a data
358
+ // descriptor follows the file data.
359
+ generalPurposeBitFlag |= UNKNOWN_CRC32_AND_FILE_SIZES;
360
+ }
361
+ buf.writeUInt32LE(LOCAL_FILE_HEADER_SIG, 0);
362
+ buf.writeUInt16LE(VERSION_NEEDED_UTF8, 4);
363
+ buf.writeUInt16LE(generalPurposeBitFlag, 6);
364
+ buf.writeUInt16LE(entry.method, 8);
365
+ buf.writeUInt16LE(entry.lastModFileTime, 10);
366
+ buf.writeUInt16LE(entry.lastModFileDate, 12);
367
+ buf.writeUInt32LE(crc32, 14);
368
+ buf.writeUInt32LE(compressedSize, 18);
369
+ buf.writeUInt32LE(uncompressedSize, 22);
370
+ buf.writeUInt16LE(entry.nameBuf.length, 26);
371
+ buf.writeUInt16LE(0, 28); // no extra field in the local header
372
+ return buf;
373
+ }
374
+ function buildDataDescriptor(entry, asZip64) {
375
+ if (!asZip64) {
376
+ const buf = Buffer.allocUnsafe(DATA_DESCRIPTOR_SIZE);
377
+ buf.writeUInt32LE(DATA_DESCRIPTOR_SIG, 0);
378
+ buf.writeUInt32LE(entry.crc32, 4);
379
+ buf.writeUInt32LE(entry.compressedSize, 8);
380
+ buf.writeUInt32LE(entry.uncompressedSize, 12);
381
+ return buf;
382
+ }
383
+ const buf = Buffer.allocUnsafe(ZIP64_DATA_DESCRIPTOR_SIZE);
384
+ buf.writeUInt32LE(DATA_DESCRIPTOR_SIG, 0);
385
+ buf.writeUInt32LE(entry.crc32, 4);
386
+ writeUInt64LE(buf, entry.compressedSize, 8);
387
+ writeUInt64LE(buf, entry.uncompressedSize, 16);
388
+ return buf;
389
+ }
390
+ function buildCentralDirectoryRecord(entry) {
391
+ let generalPurposeBitFlag = FILE_NAME_IS_UTF8;
392
+ if (!entry.crcAndFileSizeKnown)
393
+ generalPurposeBitFlag |= UNKNOWN_CRC32_AND_FILE_SIZES;
394
+ const timestampField = buildExtendedTimestampField(entry.mtimeSeconds);
395
+ // When ZIP64 is needed, the 32-bit fields hold the 0xffffffff sentinel and the
396
+ // real values live in the ZIP64 extended information extra field.
397
+ let compressedSize = entry.compressedSize;
398
+ let uncompressedSize = entry.uncompressedSize;
399
+ let localHeaderOffset = entry.relativeOffsetOfLocalHeader;
400
+ let versionNeeded = VERSION_NEEDED_UTF8;
401
+ let zip64Field = EMPTY;
402
+ if (useZip64(entry)) {
403
+ compressedSize = 0xffffffff;
404
+ uncompressedSize = 0xffffffff;
405
+ localHeaderOffset = 0xffffffff;
406
+ versionNeeded = VERSION_NEEDED_ZIP64;
407
+ zip64Field = buildZip64ExtraField(entry);
408
+ }
409
+ const buf = Buffer.allocUnsafe(CDFH_SIZE);
410
+ buf.writeUInt32LE(CDFH_SIG, 0);
411
+ buf.writeUInt16LE(VERSION_MADE_BY, 4);
412
+ buf.writeUInt16LE(versionNeeded, 6);
413
+ buf.writeUInt16LE(generalPurposeBitFlag, 8);
414
+ buf.writeUInt16LE(entry.method, 10);
415
+ buf.writeUInt16LE(entry.lastModFileTime, 12);
416
+ buf.writeUInt16LE(entry.lastModFileDate, 14);
417
+ buf.writeUInt32LE(entry.crc32, 16);
418
+ buf.writeUInt32LE(compressedSize, 20);
419
+ buf.writeUInt32LE(uncompressedSize, 24);
420
+ buf.writeUInt16LE(entry.nameBuf.length, 28);
421
+ buf.writeUInt16LE(timestampField.length + zip64Field.length, 30);
422
+ buf.writeUInt16LE(entry.commentBuf.length, 32);
423
+ buf.writeUInt16LE(0, 34); // disk number start
424
+ buf.writeUInt16LE(0, 36); // internal file attributes
425
+ buf.writeUInt32LE(entry.externalFileAttributes, 38);
426
+ buf.writeUInt32LE(localHeaderOffset, 42);
427
+ return Buffer.concat([buf, entry.nameBuf, timestampField, zip64Field, entry.commentBuf]);
428
+ }
429
+ /**
430
+ * Info-ZIP universal (extended) timestamp extra field (0x5455), central-directory
431
+ * variant: a single 32-bit UTC mtime. Gives 1-second, timezone-independent mtimes,
432
+ * which readers prefer over the coarse local-time DOS fields.
433
+ */
434
+ function buildExtendedTimestampField(mtimeSeconds) {
435
+ const buf = Buffer.allocUnsafe(INFO_ZIP_TIMESTAMP_FIELD_SIZE);
436
+ buf.writeUInt16LE(0x5455, 0);
437
+ buf.writeUInt16LE(INFO_ZIP_TIMESTAMP_FIELD_SIZE - 4, 2);
438
+ // Set both the mtime and atime flags to match Info-ZIP, even though only the
439
+ // mtime field follows (the central-directory variant never carries atime).
440
+ buf.writeUInt8(EB_UT_FL_MTIME | EB_UT_FL_ATIME, 4);
441
+ buf.writeInt32LE(mtimeSeconds, 5);
442
+ return buf;
443
+ }
444
+ /** ZIP64 extended information extra field (0x0001) for a central directory record. */
445
+ function buildZip64ExtraField(entry) {
446
+ const buf = Buffer.allocUnsafe(ZIP64_EIEF_SIZE);
447
+ buf.writeUInt16LE(0x0001, 0);
448
+ buf.writeUInt16LE(ZIP64_EIEF_SIZE - 4, 2);
449
+ // Order must match the 0xffffffff sentinels above: uncompressed, compressed, offset.
450
+ writeUInt64LE(buf, entry.uncompressedSize, 4);
451
+ writeUInt64LE(buf, entry.compressedSize, 12);
452
+ writeUInt64LE(buf, entry.relativeOffsetOfLocalHeader, 20);
453
+ return buf;
454
+ }
455
+ function useZip64(entry) {
456
+ return (entry.forceZip64 ||
457
+ entry.uncompressedSize > 0xfffffffe ||
458
+ entry.compressedSize > 0xfffffffe ||
459
+ entry.relativeOffsetOfLocalHeader > 0xfffffffe);
460
+ }
461
+ function buildEndRecords(input) {
462
+ const { entryCount, centralDirectoryOffset, centralDirectorySize, comment, zip64EocdrOffset, forceZip64, } = input;
463
+ let needZip64 = forceZip64;
464
+ let normalEntryCount = entryCount;
465
+ if (forceZip64 || entryCount >= 0xffff) {
466
+ normalEntryCount = 0xffff;
467
+ needZip64 = true;
468
+ }
469
+ let normalCentralDirectorySize = centralDirectorySize;
470
+ if (forceZip64 || centralDirectorySize >= 0xffffffff) {
471
+ normalCentralDirectorySize = 0xffffffff;
472
+ needZip64 = true;
473
+ }
474
+ let normalCentralDirectoryOffset = centralDirectoryOffset;
475
+ if (forceZip64 || centralDirectoryOffset >= 0xffffffff) {
476
+ normalCentralDirectoryOffset = 0xffffffff;
477
+ needZip64 = true;
478
+ }
479
+ const eocdr = Buffer.allocUnsafe(EOCDR_SIZE + comment.length);
480
+ eocdr.writeUInt32LE(EOCDR_SIG, 0);
481
+ eocdr.writeUInt16LE(0, 4); // number of this disk
482
+ eocdr.writeUInt16LE(0, 6); // disk with the start of the central directory
483
+ eocdr.writeUInt16LE(normalEntryCount, 8); // entries on this disk
484
+ eocdr.writeUInt16LE(normalEntryCount, 10); // total entries
485
+ eocdr.writeUInt32LE(normalCentralDirectorySize, 12);
486
+ eocdr.writeUInt32LE(normalCentralDirectoryOffset, 16);
487
+ eocdr.writeUInt16LE(comment.length, 20);
488
+ comment.copy(eocdr, 22);
489
+ if (!needZip64)
490
+ return eocdr;
491
+ const zip64Eocdr = Buffer.allocUnsafe(ZIP64_EOCDR_SIZE);
492
+ zip64Eocdr.writeUInt32LE(ZIP64_EOCDR_SIG, 0);
493
+ // size of this record, excluding the first 12 bytes (signature + this field)
494
+ writeUInt64LE(zip64Eocdr, ZIP64_EOCDR_SIZE - 12, 4);
495
+ zip64Eocdr.writeUInt16LE(VERSION_MADE_BY, 12);
496
+ zip64Eocdr.writeUInt16LE(VERSION_NEEDED_ZIP64, 14);
497
+ zip64Eocdr.writeUInt32LE(0, 16); // number of this disk
498
+ zip64Eocdr.writeUInt32LE(0, 20); // disk with the start of the central directory
499
+ writeUInt64LE(zip64Eocdr, entryCount, 24); // entries on this disk
500
+ writeUInt64LE(zip64Eocdr, entryCount, 32); // total entries
501
+ writeUInt64LE(zip64Eocdr, centralDirectorySize, 40);
502
+ writeUInt64LE(zip64Eocdr, centralDirectoryOffset, 48);
503
+ const locator = Buffer.allocUnsafe(ZIP64_EOCDL_SIZE);
504
+ locator.writeUInt32LE(ZIP64_EOCDL_SIG, 0);
505
+ locator.writeUInt32LE(0, 4); // disk with the ZIP64 end-of-central-directory record
506
+ writeUInt64LE(locator, zip64EocdrOffset, 8);
507
+ locator.writeUInt32LE(1, 16); // total number of disks
508
+ return Buffer.concat([zip64Eocdr, locator, eocdr]);
509
+ }
510
+ function normalizeAndValidateEntryName(fileName, isDirectory) {
511
+ if (!fileName)
512
+ throw new Error('zip entry name must not be empty');
513
+ let name = normalizeZipEntryName(fileName);
514
+ if (isDirectory) {
515
+ if (!name.endsWith('/'))
516
+ name += '/';
517
+ }
518
+ else if (name.endsWith('/')) {
519
+ throw new Error(`file entry name must not end with "/": ${fileName}`);
520
+ }
521
+ assertSafeZipEntryName(name);
522
+ return name;
523
+ }
524
+ function clampInt32(n) {
525
+ if (n < -0x80000000)
526
+ return -0x80000000;
527
+ if (n > 0x7fffffff)
528
+ return 0x7fffffff;
529
+ return n;
530
+ }
531
+ /**
532
+ * Expand input paths (files and/or directories) into a flat list of files to add,
533
+ * resolving each entry's archive name relative to `baseDir` (or each input's parent).
534
+ */
535
+ async function collectFiles(inputs, baseDir) {
536
+ const files = [];
537
+ for (const input of inputs) {
538
+ const stats = await fsp.stat(input);
539
+ const base = baseDir ? path.resolve(baseDir) : path.dirname(input);
540
+ if (stats.isDirectory()) {
541
+ // `glob` does the recursive walk and (with the default `onlyFiles`) drops
542
+ // directories; `**` is the only pattern and `input` is the cwd (never
543
+ // interpreted), so paths containing glob metacharacters stay safe. Sorted
544
+ // for deterministic archives.
545
+ const relPaths = (await glob('**', { cwd: input, dot: true })).sort(comparators.localeAsc);
546
+ for (const rel of relPaths) {
547
+ const absPath = path.join(input, rel);
548
+ files.push({ absPath, name: toEntryName(path.relative(base, absPath)) });
549
+ }
550
+ }
551
+ else {
552
+ files.push({ absPath: input, name: toEntryName(path.relative(base, input)) });
553
+ }
554
+ }
555
+ return files;
556
+ }
557
+ /** Convert an OS-native relative path into a forward-slash zip entry name. */
558
+ function toEntryName(relPath) {
559
+ return path.sep === '/' ? relPath : relPath.replaceAll(path.sep, '/');
560
+ }
561
+ const EMPTY = Buffer.alloc(0);
562
+ const LOCAL_FILE_HEADER_SIZE = 30;
563
+ const DATA_DESCRIPTOR_SIZE = 16;
564
+ const ZIP64_DATA_DESCRIPTOR_SIZE = 24;
565
+ const INFO_ZIP_TIMESTAMP_FIELD_SIZE = 9;
566
+ const ZIP64_EIEF_SIZE = 28;
567
+ // version made by: 3 (Unix) in the high byte, spec version 6.3 (63) in the low byte.
568
+ const VERSION_MADE_BY = (3 << 8) | 63;
569
+ const VERSION_NEEDED_UTF8 = 20;
570
+ const VERSION_NEEDED_ZIP64 = 45;
571
+ const FILE_NAME_IS_UTF8 = 1 << 11;
572
+ const UNKNOWN_CRC32_AND_FILE_SIZES = 1 << 3;
573
+ const EB_UT_FL_MTIME = 1 << 0;
574
+ const EB_UT_FL_ATIME = 1 << 1;
575
+ const DEFAULT_FILE_MODE = 0o100664;
576
+ const DEFAULT_DIR_MODE = 0o40775;
577
+ const DEFAULT_DEFLATE_LEVEL = 6;
578
+ // The 4-byte end-of-central-directory signature, as bytes, for comment validation.
579
+ const EOCDR_SIG_BYTES = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.109.0",
4
+ "version": "15.111.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "devDependencies": {
20
20
  "@typescript/native-preview": "beta",
21
- "@naturalcycles/dev-lib": "20.48.0"
21
+ "@naturalcycles/dev-lib": "20.49.0"
22
22
  },
23
23
  "exports": {
24
24
  ".": "./dist/index.js",
@@ -38,7 +38,7 @@
38
38
  "./stream/*.js": "./dist/stream/*.js",
39
39
  "./yargs": "./dist/yargs/yargs.util.js",
40
40
  "./ajv": "./dist/validation/ajv/index.js",
41
- "./zip": "./dist/zip/zip2.js"
41
+ "./zip": "./dist/zip/index.js"
42
42
  },
43
43
  "bin": {
44
44
  "kpy": "bin/kpy.js",
@@ -5,6 +5,7 @@ import type { AnyObject, JWTString } from '@naturalcycles/js-lib/types'
5
5
  import type { Algorithm, JwtHeader, SignOptions, VerifyOptions } from 'jsonwebtoken'
6
6
  import jsonwebtoken from 'jsonwebtoken'
7
7
  import type { AjvSchema, JSchema } from '../validation/ajv/jSchema.js'
8
+
8
9
  export { jsonwebtoken }
9
10
  export type { Algorithm, JwtHeader, SignOptions, VerifyOptions }
10
11
 
@@ -1,6 +1,7 @@
1
1
  const started = Date.now()
2
2
  import { inspect } from 'node:util'
3
3
  import { workerData, parentPort } from 'node:worker_threads'
4
+
4
5
  const { workerFile, workerIndex, logEvery = 1000, metric = 'worker', silent } = workerData || {}
5
6
 
6
7
  if (!workerFile) {