@arraypress/extension-kit 1.11.1 → 1.12.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,498 @@
1
+ /**
2
+ * A ZIP written a file at a time, with nothing but the platform: each file's
3
+ * bytes go into the ZIP as they're added and from there into a Blob (which
4
+ * Chrome keeps outside the page's memory, on disk once large), so a page
5
+ * never holds the files and the ZIP together. Files already compressed
6
+ * (images, audio, video, PDFs, archives) are stored as they are; the rest
7
+ * (SVG, CSV, plain text) is deflated by `CompressionStream('deflate-raw')`
8
+ * (Chrome 103, Node 20.12). ZIP64 is written when a file, the ZIP or the
9
+ * number of files passes what a plain ZIP can say. Runs anywhere (page,
10
+ * worker, Node).
11
+ *
12
+ * How each entry is written, and why:
13
+ *
14
+ * - **Stored** entries are read twice: once for the CRC-32, then the local
15
+ * header is written with the real CRC and sizes, then the bytes. No data
16
+ * descriptor: a stored entry whose header says "sizes follow the data"
17
+ * can't be read forwards (nothing marks where its data ends), and readers
18
+ * that go by the local headers refuse it or stop there. A Blob is read in
19
+ * slices both times, so a 2 GB video never sits in memory whole.
20
+ * - **Deflated** entries are read once and followed by a data descriptor
21
+ * (general-purpose bit 3), as every streaming ZIP writer does: the
22
+ * compressed size is known only afterwards, and a deflate stream marks its
23
+ * own end, so every reader copes.
24
+ *
25
+ * @module files/zip
26
+ */
27
+
28
+ /** Formats that are compressed already: deflating them again only costs time. */
29
+ const STORED = /\.(?:jpe?g|png|gif|webp|avif|heic|jxl|mp3|m4a|aac|ogg|oga|opus|flac|mp4|m4v|mov|mkv|webm|ogv|pdf|zip|gz|tgz|7z|rar|xz|bz2|zst|br|docx|xlsx|pptx|epub|odt|ods|odp|crx|woff2?)$/i;
30
+
31
+ /** ZIP bytes held in memory before they're handed to the Blob: 16 MiB. */
32
+ export const HELD_BYTES = 16 * 1024 ** 2;
33
+
34
+ /** How much of a Blob is read at a time: 4 MiB. A smaller Blob is read whole. */
35
+ export const SLICE_BYTES = 4 * 1024 ** 2;
36
+
37
+ /** The most a plain ZIP's 32-bit sizes and offsets say; at or past it, ZIP64. */
38
+ const MAX_32 = 0xFFFFFFFF;
39
+
40
+ /**
41
+ * A file this size or more is written as ZIP64: 16 MiB short of 4 GiB, since a deflated file's
42
+ * header is written before its compressed size is known, and deflate can make incompressible
43
+ * bytes slightly bigger (zlib's bound: about 0.03%, 1.3 MiB here).
44
+ */
45
+ const ZIP64_FILE_BYTES = MAX_32 - HELD_BYTES;
46
+
47
+ /**
48
+ * "Made on Unix", the high byte of a central entry's "version made by". Info-ZIP's `unzip` (the
49
+ * one on macOS and Linux) takes the names of a ZIP made on MS-DOS, what a 0 here says, for an
50
+ * old code page and converts them, UTF-8 bit or not: “Zürich.png” then fails to extract
51
+ * (“Illegal byte sequence” on macOS). A Unix ZIP's names it leaves alone.
52
+ */
53
+ const MADE_ON_UNIX = 3 << 8;
54
+
55
+ /** A Unix ZIP's files need a mode, or they extract as unreadable: a regular file, rw-r--r--, in the external attributes' high half. */
56
+ const FILE_MODE = 0o100644 * 2 ** 16;
57
+
58
+ /** The most files a plain ZIP counts. */
59
+ const MAX_16 = 0xFFFF;
60
+
61
+ /** CRC-32's tables for eight bytes at a time (slicing-by-8): table `t` at `t * 256`. */
62
+ const CRC_TABLES = crcTables();
63
+
64
+ /**
65
+ * Make CRC-32's tables (polynomial 0xEDB88320, as ZIP, PNG and gzip use).
66
+ *
67
+ * @returns {Uint32Array} Eight tables of 256.
68
+ */
69
+ function crcTables() {
70
+ const tables = new Uint32Array(8 * 256);
71
+ for (let n = 0; n < 256; n++) {
72
+ let c = n;
73
+ for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
74
+ tables[n] = c;
75
+ }
76
+ for (let n = 0; n < 256; n++) {
77
+ for (let t = 1; t < 8; t++) {
78
+ const before = tables[(t - 1) * 256 + n];
79
+ tables[t * 256 + n] = tables[before & 255] ^ (before >>> 8);
80
+ }
81
+ }
82
+ return tables;
83
+ }
84
+
85
+ /**
86
+ * The CRC-32 of some bytes (ZIP's, PNG's and gzip's), or of more bytes after others: give the
87
+ * CRC so far as `seed` and the result is the CRC of both together.
88
+ *
89
+ * @param {Uint8Array} bytes - The bytes.
90
+ * @param {number} [seed] - The CRC of what came before. Default 0 (nothing).
91
+ * @returns {number} The CRC, 0 to 0xFFFFFFFF.
92
+ * @example
93
+ * crc32(new TextEncoder().encode('123456789')); // 0xCBF43926
94
+ * crc32(rest, crc32(start)); // the CRC of start followed by rest
95
+ */
96
+ export function crc32(bytes, seed = 0) {
97
+ const T = CRC_TABLES;
98
+ let c = ~seed;
99
+ let at = 0;
100
+ for (const end = bytes.length - 7; at < end; at += 8) {
101
+ c ^= bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24);
102
+ c = T[1792 + (c & 255)] ^ T[1536 + ((c >>> 8) & 255)] ^ T[1280 + ((c >>> 16) & 255)] ^ T[1024 + (c >>> 24)]
103
+ ^ T[768 + bytes[at + 4]] ^ T[512 + bytes[at + 5]] ^ T[256 + bytes[at + 6]] ^ T[bytes[at + 7]];
104
+ }
105
+ for (; at < bytes.length; at++) c = T[(c ^ bytes[at]) & 255] ^ (c >>> 8);
106
+ return ~c >>> 0;
107
+ }
108
+
109
+ /**
110
+ * Whether a file of this name is stored in a ZIP as it is, going by its extension: formats that
111
+ * are compressed already (JPEG, PNG, WebP, MP4, PDF, ZIP, Office documents, WOFF…). Anything else
112
+ * (SVG, CSV, JSON, text, BMP, WAV) is deflated.
113
+ *
114
+ * @param {string} name - The file's name or path.
115
+ * @returns {boolean} True for a compressed format.
116
+ * @example
117
+ * isStoredName('Lake.JPG'); // true
118
+ * isStoredName('icons/logo.svg'); // false
119
+ */
120
+ export function isStoredName(name) {
121
+ return STORED.test(name);
122
+ }
123
+
124
+ /**
125
+ * A date as ZIP writes it: MS-DOS date and time, local time, two-second steps, 1980 to 2107
126
+ * (a date outside them, or one that isn't a date, is the nearest end or 1980).
127
+ *
128
+ * @param {Date} date - The date.
129
+ * @returns {{time: number, day: number}} The two 16-bit fields.
130
+ */
131
+ function dosTime(date) {
132
+ const year = date.getFullYear();
133
+ if (!(year >= 1980)) return { time: 0, day: (1 << 5) | 1 };
134
+ if (year > 2107) return { time: (23 << 11) | (59 << 5) | 29, day: (127 << 9) | (12 << 5) | 31 };
135
+ return {
136
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
137
+ day: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Bytes being laid out, little-endian as ZIP's are.
143
+ *
144
+ * @typedef {object} Record
145
+ * @property {(value: number) => Record} u16 Two bytes.
146
+ * @property {(value: number) => Record} u32 Four bytes.
147
+ * @property {(value: number) => Record} u64 Eight bytes (a whole number up to 2^53).
148
+ * @property {(part: Uint8Array) => Record} raw Bytes as they are.
149
+ * @property {Uint8Array<ArrayBuffer>} bytes The record.
150
+ */
151
+
152
+ /**
153
+ * Start a record of a known length.
154
+ *
155
+ * @param {number} length - Its length in bytes.
156
+ * @returns {Record} The record, each call writing after the last.
157
+ */
158
+ function record(length) {
159
+ const bytes = new Uint8Array(length);
160
+ const view = new DataView(bytes.buffer);
161
+ let at = 0;
162
+ /** @type {Record} */
163
+ const self = {
164
+ u16(value) { view.setUint16(at, value, true); at += 2; return self; },
165
+ u32(value) { view.setUint32(at, value, true); at += 4; return self; },
166
+ u64(value) { return self.u32(value % 2 ** 32).u32(Math.floor(value / 2 ** 32)); },
167
+ raw(part) { bytes.set(part, at); at += part.length; return self; },
168
+ bytes,
169
+ };
170
+ return self;
171
+ }
172
+
173
+ /**
174
+ * One file in the ZIP, as its headers describe it.
175
+ *
176
+ * @typedef {object} Entry
177
+ * @property {Uint8Array} name The name, UTF-8.
178
+ * @property {boolean} stored Stored (method 0) rather than deflated (method 8, with a data descriptor).
179
+ * @property {boolean} zip64 Sizes and offset written as ZIP64.
180
+ * @property {{time: number, day: number}} when Its time.
181
+ * @property {number} offset Where its local header starts.
182
+ * @property {number} crc Its CRC-32.
183
+ * @property {number} size Its size.
184
+ * @property {number} packed Its size in the ZIP.
185
+ */
186
+
187
+ /**
188
+ * The fields a local header and a central one share, from "version needed" to the name's length:
189
+ * 26 bytes.
190
+ *
191
+ * @param {Entry} entry - The file.
192
+ * @param {boolean} known - Whether to write the CRC and sizes (a deflated file's local header can't: they follow its data).
193
+ * @param {number} extra - The extra field's length.
194
+ * @returns {Uint8Array<ArrayBuffer>} The fields.
195
+ */
196
+ function commonFields(entry, known, extra) {
197
+ const size = (/** @type {number} */ value) => (entry.zip64 ? MAX_32 : known ? value : 0);
198
+ return record(26)
199
+ .u16(entry.zip64 ? 45 : 20)
200
+ // Bit 11: the name is UTF-8. Bit 3: CRC and sizes follow the data.
201
+ .u16(0x0800 | (entry.stored ? 0 : 0x0008))
202
+ .u16(entry.stored ? 0 : 8)
203
+ .u16(entry.when.time).u16(entry.when.day)
204
+ .u32(known ? entry.crc : 0)
205
+ .u32(size(entry.packed)).u32(size(entry.size))
206
+ .u16(entry.name.length).u16(extra)
207
+ .bytes;
208
+ }
209
+
210
+ /**
211
+ * A file's local header. A ZIP64 one carries both sizes in its extra field (zeros when they
212
+ * follow the data), which is also what tells a reader the data descriptor's sizes are 8 bytes.
213
+ *
214
+ * @param {Entry} entry - The file; a stored one's CRC and sizes known.
215
+ * @returns {Uint8Array<ArrayBuffer>} The header.
216
+ */
217
+ function localHeader(entry) {
218
+ const extra = entry.zip64 ? 20 : 0;
219
+ const header = record(30 + entry.name.length + extra).u32(0x04034B50)
220
+ .raw(commonFields(entry, entry.stored, extra)).raw(entry.name);
221
+ if (entry.zip64) header.u16(1).u16(16).u64(entry.stored ? entry.size : 0).u64(entry.stored ? entry.packed : 0);
222
+ return header.bytes;
223
+ }
224
+
225
+ /**
226
+ * What follows a deflated file's data: its CRC and sizes.
227
+ *
228
+ * @param {Entry} entry - The file, written.
229
+ * @returns {Uint8Array<ArrayBuffer>} The data descriptor.
230
+ */
231
+ function dataDescriptor(entry) {
232
+ const descriptor = record(entry.zip64 ? 24 : 16).u32(0x08074B50).u32(entry.crc);
233
+ if (entry.zip64) return descriptor.u64(entry.packed).u64(entry.size).bytes;
234
+ return descriptor.u32(entry.packed).u32(entry.size).bytes;
235
+ }
236
+
237
+ /**
238
+ * A file's entry in the central directory. A ZIP64 one marks both sizes and the offset as "in
239
+ * the extra field" and writes all three there, in the order the format gives.
240
+ *
241
+ * @param {Entry} entry - The file, written.
242
+ * @returns {Uint8Array<ArrayBuffer>} The entry.
243
+ */
244
+ function centralHeader(entry) {
245
+ const extra = entry.zip64 ? 28 : 0;
246
+ const header = record(46 + entry.name.length + extra).u32(0x02014B50).u16(MADE_ON_UNIX | (entry.zip64 ? 45 : 20))
247
+ .raw(commonFields(entry, true, extra))
248
+ // No comment, disk 0, no internal attributes.
249
+ .u16(0).u16(0).u16(0).u32(FILE_MODE)
250
+ .u32(entry.zip64 ? MAX_32 : entry.offset)
251
+ .raw(entry.name);
252
+ if (entry.zip64) header.u16(1).u16(24).u64(entry.size).u64(entry.packed).u64(entry.offset);
253
+ return header.bytes;
254
+ }
255
+
256
+ /**
257
+ * The ZIP's last records: the end of the central directory, after ZIP64's own end record and
258
+ * its locator when the plain one can't say it all (its fields then all say "see ZIP64's").
259
+ *
260
+ * @param {number} count - How many files.
261
+ * @param {number} offset - Where the central directory starts.
262
+ * @param {number} size - Its length.
263
+ * @param {boolean} zip64 - Whether to write ZIP64's records.
264
+ * @returns {Uint8Array<ArrayBuffer>} The records.
265
+ */
266
+ function endRecords(count, offset, size, zip64) {
267
+ const end = record(zip64 ? 56 + 20 + 22 : 22);
268
+ if (zip64) {
269
+ end.u32(0x06064B50).u64(44).u16(45).u16(45).u32(0).u32(0).u64(count).u64(count).u64(size).u64(offset);
270
+ end.u32(0x07064B50).u32(0).u64(offset + size).u32(1);
271
+ }
272
+ return end.u32(0x06054B50).u16(0).u16(0)
273
+ .u16(zip64 ? MAX_16 : count).u16(zip64 ? MAX_16 : count)
274
+ .u32(zip64 ? MAX_32 : size).u32(zip64 ? MAX_32 : offset)
275
+ .u16(0).bytes;
276
+ }
277
+
278
+ /**
279
+ * A file's bytes a part at a time: a Blob's read in slices of {@link SLICE_BYTES} (so it is
280
+ * never in memory whole), bytes already in memory as they are.
281
+ *
282
+ * @param {Blob | Uint8Array<ArrayBuffer>} data - The file.
283
+ * @returns {AsyncGenerator<Uint8Array<ArrayBuffer>, void, void>} Its parts, in order.
284
+ */
285
+ async function* partsOf(data) {
286
+ if (!(data instanceof Blob)) {
287
+ yield data;
288
+ return;
289
+ }
290
+ for (let at = 0; at < data.size; at += SLICE_BYTES) {
291
+ yield new Uint8Array(await data.slice(at, at + SLICE_BYTES).arrayBuffer());
292
+ }
293
+ }
294
+
295
+ /**
296
+ * How a ZIP is written.
297
+ *
298
+ * @typedef {object} ZipOptions
299
+ * @property {boolean} [store] Store every file as it is, deflating none (a ZIP of PNGs whatever
300
+ * their names, or when time matters more than size). Default: by name, see {@link isStoredName}.
301
+ * @property {boolean} [zip64] Write ZIP64 records for every file and for the ZIP's end, however
302
+ * small. Default: only when needed (a file of about 4 GiB or more, a ZIP that has grown past
303
+ * 4 GiB, more than 65,535 files).
304
+ * @property {number} [heldBytes] ZIP bytes held in memory before they're handed to the Blob.
305
+ * Default {@link HELD_BYTES}, 16 MiB.
306
+ * @property {Date} [date] The files' time in the ZIP (local time, as ZIP keeps it). Default now.
307
+ */
308
+
309
+ /**
310
+ * How one file is written, where it differs from the ZIP's own options.
311
+ *
312
+ * @typedef {object} ZipEntryOptions
313
+ * @property {boolean} [store] Store this file as it is (true), or go by its name (false) even
314
+ * if the ZIP stores everything.
315
+ * @property {Date} [date] This file's time.
316
+ */
317
+
318
+ /**
319
+ * A ZIP being written.
320
+ *
321
+ * @typedef {object} ZipWriter
322
+ * @property {(name: string, data: Blob | Uint8Array, options?: ZipEntryOptions) => Promise<void>} add
323
+ * Put a file in; resolves once it's written. Files are written in the order they're added,
324
+ * one at a time (an `add` made before the last has resolved waits for it). The name is used
325
+ * as it is, `/` between folders; a name already in the ZIP is refused (two files of one name
326
+ * extract as one), as is `add` after `finish`. A file that can't be read rejects and leaves
327
+ * the ZIP as it was before it. Bytes given as a `Uint8Array` are kept, not copied, until
328
+ * they're handed to the Blob: don't write into them again.
329
+ * @property {() => number} count How many files are in.
330
+ * @property {() => number} bytes Their size in all, uncompressed.
331
+ * @property {() => Promise<Blob>} finish Close the ZIP and give it, of type `application/zip` (once).
332
+ */
333
+
334
+ /**
335
+ * Start a ZIP.
336
+ *
337
+ * @param {ZipOptions} [options] - How to write it.
338
+ * @returns {ZipWriter} The ZIP.
339
+ * @example
340
+ * const zip = createZip();
341
+ * await zip.add('Lake.jpg', blob); // stored
342
+ * await zip.add('notes/Read me.txt', new TextEncoder().encode('Hello')); // deflated
343
+ * const blob = await zip.finish();
344
+ */
345
+ export function createZip(options = {}) {
346
+ const { store = false, zip64 = false, heldBytes: limit = HELD_BYTES, date = new Date() } = options;
347
+ const type = 'application/zip';
348
+ const encoder = new TextEncoder();
349
+ let blob = new Blob([], { type });
350
+ /** @type {Uint8Array<ArrayBuffer>[]} */
351
+ let held = [];
352
+ let heldBytes = 0;
353
+ /** The ZIP's length so far: where the next record goes. */
354
+ let offset = 0;
355
+ /** @type {Uint8Array<ArrayBuffer>[]} */
356
+ const central = [];
357
+ let centralBytes = 0;
358
+ /** @type {Set<string>} */
359
+ const names = new Set();
360
+ let bytes = 0;
361
+ let finished = false;
362
+ /** @type {Promise<unknown>} */
363
+ let last = Promise.resolve();
364
+
365
+ /** Hand what's held to the Blob. */
366
+ const hand = () => {
367
+ if (!held.length) return;
368
+ blob = new Blob([blob, ...held], { type });
369
+ held = [];
370
+ heldBytes = 0;
371
+ };
372
+
373
+ /**
374
+ * Write bytes at the ZIP's end.
375
+ *
376
+ * @param {Uint8Array<ArrayBuffer>} chunk - The bytes.
377
+ */
378
+ const write = (chunk) => {
379
+ held.push(chunk);
380
+ heldBytes += chunk.length;
381
+ offset += chunk.length;
382
+ if (heldBytes >= limit) hand();
383
+ };
384
+
385
+ /**
386
+ * Write a stored file: its CRC first, so the header before its bytes is complete.
387
+ *
388
+ * @param {Entry} entry - The file's entry, to fill in.
389
+ * @param {Blob | Uint8Array<ArrayBuffer>} data - The file.
390
+ */
391
+ const writeStored = async (entry, data) => {
392
+ for await (const part of partsOf(data)) entry.crc = crc32(part, entry.crc);
393
+ write(localHeader(entry));
394
+ let written = 0;
395
+ for await (const part of partsOf(data)) {
396
+ write(part);
397
+ written += part.length;
398
+ }
399
+ if (written !== entry.size) throw new Error(`“${new TextDecoder().decode(entry.name)}” changed while it was being read`);
400
+ };
401
+
402
+ /**
403
+ * Write a deflated file: header, the compressor's output as it comes, then CRC and sizes.
404
+ *
405
+ * @param {Entry} entry - The file's entry, to fill in.
406
+ * @param {Blob | Uint8Array<ArrayBuffer>} data - The file.
407
+ */
408
+ const writeDeflated = async (entry, data) => {
409
+ write(localHeader(entry));
410
+ const { readable, writable } = new CompressionStream('deflate-raw');
411
+ const writer = writable.getWriter();
412
+ const reading = (async () => {
413
+ const reader = readable.getReader();
414
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
415
+ entry.packed += read.value.length;
416
+ write(/** @type {Uint8Array<ArrayBuffer>} */ (read.value));
417
+ }
418
+ })();
419
+ // The reason is thrown below, by whichever side failed first.
420
+ reading.catch(() => {});
421
+ try {
422
+ for await (const part of partsOf(data)) {
423
+ entry.crc = crc32(part, entry.crc);
424
+ await writer.write(part);
425
+ }
426
+ await writer.close();
427
+ } catch (error) {
428
+ await writer.abort(error).catch(() => {});
429
+ await reading.catch(() => {});
430
+ throw error;
431
+ }
432
+ await reading;
433
+ if (!entry.zip64 && entry.packed >= MAX_32) throw new Error('A deflated file passed 4 GiB without ZIP64');
434
+ write(dataDescriptor(entry));
435
+ };
436
+
437
+ /**
438
+ * Write one file, or leave the ZIP as it was.
439
+ *
440
+ * @param {Uint8Array} name - Its name, UTF-8.
441
+ * @param {Blob | Uint8Array<ArrayBuffer>} data - The file.
442
+ * @param {boolean} stored - Stored rather than deflated.
443
+ * @param {Date} when - Its time.
444
+ */
445
+ const writeEntry = async (name, data, stored, when) => {
446
+ const size = data instanceof Blob ? data.size : data.length;
447
+ const start = offset;
448
+ /** @type {Entry} */
449
+ const entry = {
450
+ // Nothing to deflate in an empty file, and no descriptor needed after it.
451
+ name, stored: stored || size === 0, when: dosTime(when), offset: start, crc: 0, size, packed: stored ? size : 0,
452
+ zip64: zip64 || size >= ZIP64_FILE_BYTES || start >= MAX_32,
453
+ };
454
+ // A small Blob is read once; a big one in slices, twice when it's stored.
455
+ const source = data instanceof Blob && size <= SLICE_BYTES ? new Uint8Array(await data.arrayBuffer()) : data;
456
+ try {
457
+ await (entry.stored ? writeStored(entry, source) : writeDeflated(entry, source));
458
+ } catch (error) {
459
+ // What was written of it goes: the ZIP is still a ZIP of the files before.
460
+ hand();
461
+ blob = blob.slice(0, start, type);
462
+ offset = start;
463
+ throw error;
464
+ }
465
+ const header = centralHeader(entry);
466
+ central.push(header);
467
+ centralBytes += header.length;
468
+ bytes += size;
469
+ };
470
+
471
+ return {
472
+ async add(name, data, entryOptions = {}) {
473
+ if (finished) throw new Error(`The ZIP is finished: “${name}” can't be added`);
474
+ if (names.has(name)) throw new Error(`The ZIP already has a file named “${name}”`);
475
+ const encoded = encoder.encode(name);
476
+ if (!encoded.length || encoded.length > MAX_16) throw new Error(`A ZIP's file names are 1 to 65,535 bytes: “${name.slice(0, 80)}”`);
477
+ if (!(data instanceof Blob) && !(data instanceof Uint8Array)) throw new TypeError(`“${name}” is neither a Blob nor a Uint8Array`);
478
+ names.add(name);
479
+ const stored = entryOptions.store ?? (store || isStoredName(name));
480
+ const written = last.then(() => writeEntry(encoded, /** @type {Blob | Uint8Array<ArrayBuffer>} */ (data), stored, entryOptions.date ?? date));
481
+ last = written.catch(() => { names.delete(name); });
482
+ await written;
483
+ },
484
+ count: () => central.length,
485
+ bytes: () => bytes,
486
+ async finish() {
487
+ if (finished) throw new Error('The ZIP is finished already');
488
+ finished = true;
489
+ await last;
490
+ const start = offset;
491
+ const count = central.length;
492
+ for (const header of central) write(header);
493
+ write(endRecords(count, start, centralBytes, zip64 || count > MAX_16 || start >= MAX_32 || centralBytes >= MAX_32));
494
+ hand();
495
+ return blob;
496
+ },
497
+ };
498
+ }
package/src/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
6
  * a content script's slices, change watcher, overlay and “Show on page” boxes, a module's
7
7
  * function called in a page or every frame of a tab, a tab's side panel opened from a click,
8
- * copying (plain text and HTML), and making elements and icon buttons in a line. Every function takes the `chrome`
8
+ * copying (plain text and HTML), a ZIP written a file at a time and files saved, and making elements and icon buttons in a line. Every function takes the `chrome`
9
9
  * APIs it uses as arguments, so tests run the real logic against fakes.
10
10
  *
11
11
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -27,8 +27,11 @@ export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
27
27
  export { copyFormats } from './dom/copy.js';
28
28
  export { focusedElement, focusedIn } from './dom/focus.js';
29
29
  export { iconButton, make, spriteIcon } from './dom/make.js';
30
+ export { KEEP_URL_MS, saveFiles } from './files/save.js';
31
+ export { HELD_BYTES, SLICE_BYTES, crc32, createZip, isStoredName } from './files/zip.js';
30
32
  export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
31
33
  export { counted, localize, translator } from './dom/i18n.js';
34
+ export { HEADERS_WAIT_MS, OWN_REQUESTS, ownRequests } from './net/requests.js';
32
35
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
33
36
  export { setUpPage } from './dom/setup.js';
34
37
  export { createShowOnPage } from './panel/show-on-page.js';
@@ -40,7 +43,7 @@ export { callInTab, isBrowserPage } from './scripting/tab.js';
40
43
  export { createSettingsSaver } from './settings/saver.js';
41
44
  export { MAX_SYNC_ITEM_BYTES, syncItemBytes } from './settings/storage.js';
42
45
  export { createSettingsStore } from './settings/store.js';
43
- export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
46
+ export { MAX_HOST_LENGTH, hostPatterns, isLocalUrl, isWebUrl, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
44
47
  export { eachLimited } from './time/pool.js';
45
48
  export { within } from './time/time.js';
46
49
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
@@ -67,6 +70,10 @@ export type ShowOnPage = import('./panel/show-on-page.js').ShowOnPage;
67
70
  export type PageOverlay = import('./content/overlay.js').PageOverlay;
68
71
  export type ClipboardFormats = import('./dom/copy.js').ClipboardFormats;
69
72
  export type WatchHandlers = import('./content/watch.js').WatchHandlers;
73
+ export type SavedFile = import('./files/save.js').SavedFile;
74
+ export type ZipEntryOptions = import('./files/zip.js').ZipEntryOptions;
75
+ export type ZipOptions = import('./files/zip.js').ZipOptions;
76
+ export type ZipWriter = import('./files/zip.js').ZipWriter;
70
77
  export type WindowTab = import('./frame/window.js').Tab;
71
78
  export type WindowTheme = import('./frame/window.js').Theme;
72
79
  export type WindowShadow = import('./frame/window.js').WindowShadow;
@@ -75,6 +82,8 @@ export type ContentScript = import('./scripting/registration.js').ContentScript;
75
82
  export type FrameResult = import('./scripting/call.js').FrameResult<unknown>;
76
83
  export type TabReading = import('./scripting/tab.js').TabReading<unknown>;
77
84
  export type TabEdges = import('./scripting/tab.js').TabEdges;
85
+ export type OwnAnswer = import('./net/requests.js').OwnAnswer;
86
+ export type OwnRequests = import('./net/requests.js').OwnRequests;
78
87
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
79
88
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
80
89
  export type MenuItem = import('./ui/menu.js').MenuItem;
@@ -103,6 +112,10 @@ export type Toast = import('./ui/toast.js').Toast;
103
112
  * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
104
113
  * @typedef {import('./dom/copy.js').ClipboardFormats} ClipboardFormats
105
114
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
115
+ * @typedef {import('./files/save.js').SavedFile} SavedFile
116
+ * @typedef {import('./files/zip.js').ZipEntryOptions} ZipEntryOptions
117
+ * @typedef {import('./files/zip.js').ZipOptions} ZipOptions
118
+ * @typedef {import('./files/zip.js').ZipWriter} ZipWriter
106
119
  * @typedef {import('./frame/window.js').Tab} WindowTab
107
120
  * @typedef {import('./frame/window.js').Theme} WindowTheme
108
121
  * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
@@ -111,6 +124,8 @@ export type Toast = import('./ui/toast.js').Toast;
111
124
  * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
112
125
  * @typedef {import('./scripting/tab.js').TabReading<unknown>} TabReading
113
126
  * @typedef {import('./scripting/tab.js').TabEdges} TabEdges
127
+ * @typedef {import('./net/requests.js').OwnAnswer} OwnAnswer
128
+ * @typedef {import('./net/requests.js').OwnRequests} OwnRequests
114
129
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
115
130
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
116
131
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
package/src/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * the built-in AI row), Chrome's window drawn around a capture, per-site lists' sites, Chrome's on-device model,
6
6
  * a content script's slices, change watcher, overlay and “Show on page” boxes, a module's
7
7
  * function called in a page or every frame of a tab, a tab's side panel opened from a click,
8
- * copying (plain text and HTML), and making elements and icon buttons in a line. Every function takes the `chrome`
8
+ * copying (plain text and HTML), a ZIP written a file at a time and files saved, and making elements and icon buttons in a line. Every function takes the `chrome`
9
9
  * APIs it uses as arguments, so tests run the real logic against fakes.
10
10
  *
11
11
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -30,8 +30,11 @@ export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
30
30
  export { copyFormats } from './dom/copy.js';
31
31
  export { focusedElement, focusedIn } from './dom/focus.js';
32
32
  export { iconButton, make, spriteIcon } from './dom/make.js';
33
+ export { KEEP_URL_MS, saveFiles } from './files/save.js';
34
+ export { HELD_BYTES, SLICE_BYTES, crc32, createZip, isStoredName } from './files/zip.js';
33
35
  export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
34
36
  export { counted, localize, translator } from './dom/i18n.js';
37
+ export { HEADERS_WAIT_MS, OWN_REQUESTS, ownRequests } from './net/requests.js';
35
38
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
36
39
  export { setUpPage } from './dom/setup.js';
37
40
  export { createShowOnPage } from './panel/show-on-page.js';
@@ -44,7 +47,7 @@ export { createSettingsSaver } from './settings/saver.js';
44
47
  export { MAX_SYNC_ITEM_BYTES, syncItemBytes } from './settings/storage.js';
45
48
  export { createSettingsStore } from './settings/store.js';
46
49
  export {
47
- MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
50
+ MAX_HOST_LENGTH, hostPatterns, isLocalUrl, isWebUrl, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
48
51
  } from './sites/sites.js';
49
52
  export { eachLimited } from './time/pool.js';
50
53
  export { within } from './time/time.js';
@@ -78,6 +81,10 @@ export { TOAST_MS, createToast } from './ui/toast.js';
78
81
  * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
79
82
  * @typedef {import('./dom/copy.js').ClipboardFormats} ClipboardFormats
80
83
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
84
+ * @typedef {import('./files/save.js').SavedFile} SavedFile
85
+ * @typedef {import('./files/zip.js').ZipEntryOptions} ZipEntryOptions
86
+ * @typedef {import('./files/zip.js').ZipOptions} ZipOptions
87
+ * @typedef {import('./files/zip.js').ZipWriter} ZipWriter
81
88
  * @typedef {import('./frame/window.js').Tab} WindowTab
82
89
  * @typedef {import('./frame/window.js').Theme} WindowTheme
83
90
  * @typedef {import('./frame/window.js').WindowShadow} WindowShadow
@@ -86,6 +93,8 @@ export { TOAST_MS, createToast } from './ui/toast.js';
86
93
  * @typedef {import('./scripting/call.js').FrameResult<unknown>} FrameResult
87
94
  * @typedef {import('./scripting/tab.js').TabReading<unknown>} TabReading
88
95
  * @typedef {import('./scripting/tab.js').TabEdges} TabEdges
96
+ * @typedef {import('./net/requests.js').OwnAnswer} OwnAnswer
97
+ * @typedef {import('./net/requests.js').OwnRequests} OwnRequests
89
98
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
90
99
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
91
100
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem