zip-writer 1.0.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,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test)",
5
+ "Bash(mkdir:*)",
6
+ "Bash(npm run test:e2e:*)"
7
+ ],
8
+ "deny": [],
9
+ "ask": []
10
+ }
11
+ }
@@ -0,0 +1,36 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ strategy:
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest, macos-latest]
14
+ runs-on: ${{ matrix.os }}
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Setup Node.js
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: '20'
23
+ cache: 'npm'
24
+
25
+ - name: Install dependencies
26
+ run: npm ci
27
+
28
+ - name: Install Playwright browsers
29
+ run: npx playwright install --with-deps chromium firefox
30
+
31
+ - name: Install Playwright webkit (macOS only)
32
+ if: runner.os == 'macOS'
33
+ run: npx playwright install --with-deps webkit
34
+
35
+ - name: Run tests
36
+ run: npm test
@@ -0,0 +1 @@
1
+ npm test
@@ -0,0 +1,9 @@
1
+ import globals from "globals";
2
+ import { defineConfig } from "eslint/config";
3
+
4
+ export default defineConfig([
5
+ {
6
+ files: ["**/*.{js,mjs,cjs}"],
7
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
8
+ },
9
+ ]);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "zip-writer",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "run-s test:types test:lint test:e2e",
8
+ "prepare": "husky",
9
+ "test:types": "tsc --noEmit",
10
+ "test:lint": "eslint .",
11
+ "test:e2e": "vitest",
12
+ "prepack": "tsc"
13
+ },
14
+ "keywords": [],
15
+ "author": "",
16
+ "license": "ISC",
17
+ "description": "",
18
+ "devDependencies": {
19
+ "@types/node": "^24.10.0",
20
+ "@types/yauzl-promise": "^4.0.1",
21
+ "@vitest/browser": "^4.0.7",
22
+ "@vitest/browser-playwright": "^4.0.7",
23
+ "eslint": "^9.39.1",
24
+ "globals": "^16.5.0",
25
+ "husky": "^9.1.7",
26
+ "lint-staged": "^16.2.6",
27
+ "npm-run-all": "^4.1.5",
28
+ "playwright": "^1.56.1",
29
+ "prettier": "^3.6.2",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^4.0.7",
32
+ "yauzl-promise": "^4.0.0"
33
+ }
34
+ }
@@ -0,0 +1,31 @@
1
+ // ZIP format constants (stored in file byte order)
2
+ export const LOCAL_FILE_HEADER_SIGNATURE = 0x504b0304; // "PK\x03\x04"
3
+ export const DATA_DESCRIPTOR_SIGNATURE = 0x504b0708; // "PK\x07\x08"
4
+ export const CENTRAL_DIRECTORY_SIGNATURE = 0x504b0102; // "PK\x01\x02"
5
+ export const END_OF_CENTRAL_DIR_SIGNATURE = 0x504b0506; // "PK\x05\x06"
6
+ export const ZIP64_END_OF_CENTRAL_DIR_SIGNATURE = 0x504b0606; // "PK\x06\x06"
7
+ export const ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE = 0x504b0607; // "PK\x06\x07"
8
+
9
+ // Version constants
10
+ export const VERSION_MADE_BY = 45; // Version 4.5 (ZIP64 support)
11
+ export const VERSION_NEEDED_STANDARD = 20; // Version 2.0
12
+ export const VERSION_NEEDED_ZIP64 = 45; // Version 4.5 for ZIP64
13
+
14
+ // Flags
15
+ export const GENERAL_PURPOSE_FLAGS = 0b0000_1000_0000_1000; // Bit 3: data descriptor, Bit 11: UTF-8
16
+
17
+ // Compression methods
18
+ export const COMPRESSION_METHOD_STORE = 0;
19
+ export const COMPRESSION_METHOD_DEFLATE = 8;
20
+
21
+ // Size constants
22
+ export const LOCAL_FILE_HEADER_SIZE = 30;
23
+ export const CENTRAL_DIRECTORY_HEADER_SIZE = 46;
24
+ export const DATA_DESCRIPTOR_SIZE = 16;
25
+ export const DATA_DESCRIPTOR_SIZE_ZIP64 = 24;
26
+ export const EOCD_SIZE = 22;
27
+ export const EOCD64_SIZE = 56;
28
+ export const EOCD64_LOCATOR_SIZE = 20;
29
+
30
+ // Limits
31
+ export const ZIP64_LIMIT = BigInt(0xffffffff);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * From https://github.com/holepunchto/crc-universal/blob/main/lookup.js
3
+ * Licensed under Apache-2.0
4
+ */
5
+
6
+ export const lookup = new Array(16) as Uint32Array<ArrayBuffer>[];
7
+
8
+ for (let i = 0; i < 16; i++) {
9
+ lookup[i] = new Uint32Array(0x100);
10
+ }
11
+
12
+ for (let i = 0; i <= 0xff; i++) {
13
+ let crc = i;
14
+
15
+ for (let j = 0; j < 8; j++) {
16
+ crc = (crc >>> 1) ^ ((crc & 1) * 0xedb88320);
17
+ }
18
+
19
+ lookup[0][i] = crc;
20
+ }
21
+
22
+ for (let i = 0; i <= 0xff; i++) {
23
+ for (let j = 1; j < 16; j++) {
24
+ lookup[j][i] =
25
+ (lookup[j - 1][i] >>> 8) ^ lookup[0][lookup[j - 1][i] & 0xff];
26
+ }
27
+ }
package/src/crc.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The JavaScript implementation of CRC32 is a version of the slice-by-16 algorithm
3
+ * as implemented by Stephan Brumme, see https://github.com/stbrumme/crc32.
4
+ *
5
+ * Copyright (c) 2011-2016 Stephan Brumme
6
+ *
7
+ * This software is provided 'as-is', without any express or implied warranty.
8
+ * In no event will the authors be held liable for any damages arising from the
9
+ * use of this software.
10
+ *
11
+ * Permission is granted to anyone to use this software for any purpose,
12
+ * including commercial applications, and to alter it and redistribute it freely,
13
+ * subject to the following restrictions:
14
+ *
15
+ * 1. The origin of this software must not be misrepresented; you must not claim
16
+ * that you wrote the original software.
17
+ * If you use this software in a product, an acknowledgment in the product
18
+ * documentation would be appreciated but is not required.
19
+ * 2. Altered source versions must be plainly marked as such, and must not be
20
+ * misrepresented as being the original software.
21
+ * 3. This notice may not be removed or altered from any source distribution.
22
+ */
23
+
24
+ import { lookup } from "./crc-lookup.js";
25
+
26
+ /**
27
+ * Computes a 32-bit Cyclic Redundancy Check checksum of data. If value is
28
+ * specified, it is used as the starting value of the checksum, otherwise, 0 is
29
+ * used as the starting value.
30
+ *
31
+ * @param data - The input data to compute the checksum for.
32
+ * @param value - An optional starting value. It must be a 32-bit unsigned integer. Default: 0
33
+ * @returns The computed CRC32 checksum.
34
+ */
35
+ export function crc32(
36
+ data: Uint8Array<ArrayBuffer>,
37
+ value: number = 0
38
+ ): number {
39
+ let crc = ~value;
40
+ let i = 0;
41
+ let length = data.byteLength;
42
+
43
+ while (length >= 16) {
44
+ crc =
45
+ lookup[15][data[i++] ^ (crc & 0xff)] ^
46
+ lookup[14][data[i++] ^ ((crc >>> 8) & 0xff)] ^
47
+ lookup[13][data[i++] ^ ((crc >>> 16) & 0xff)] ^
48
+ lookup[12][data[i++] ^ (crc >>> 24)] ^
49
+ lookup[11][data[i++]] ^
50
+ lookup[10][data[i++]] ^
51
+ lookup[9][data[i++]] ^
52
+ lookup[8][data[i++]] ^
53
+ lookup[7][data[i++]] ^
54
+ lookup[6][data[i++]] ^
55
+ lookup[5][data[i++]] ^
56
+ lookup[4][data[i++]] ^
57
+ lookup[3][data[i++]] ^
58
+ lookup[2][data[i++]] ^
59
+ lookup[1][data[i++]] ^
60
+ lookup[0][data[i++]];
61
+
62
+ length -= 16;
63
+ }
64
+
65
+ while (length-- > 0) {
66
+ crc = (crc >>> 8) ^ lookup[0][(crc & 0xff) ^ data[i++]];
67
+ }
68
+
69
+ return ~crc >>> 0;
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,342 @@
1
+ import { writeZipEntry } from "./write-entry.js";
2
+ import {
3
+ getCDFH,
4
+ getEOCDZip64,
5
+ getEOCDStandard,
6
+ } from "./write-central-directory.js";
7
+ import { ZIP64_LIMIT } from "./constants.js";
8
+ import { noop } from "./utils.js";
9
+
10
+ const BUFFER_SIZE = 16 * 1024; // 16 KB
11
+ const bufferedQueue = new ByteLengthQueuingStrategy({
12
+ highWaterMark: BUFFER_SIZE,
13
+ });
14
+ const unbufferedQueue = { highWaterMark: 0 };
15
+
16
+ export interface EntryOptions {
17
+ /** Entry name including internal path */
18
+ name: string;
19
+ /** Entry comment */
20
+ comment?: string;
21
+ /** Entry date */
22
+ date?: Date;
23
+ /** Entry permissions */
24
+ mode?: number;
25
+ /** Compression method to `STORE` (defaults to `DEFLATE`) */
26
+ store?: boolean;
27
+ }
28
+
29
+ export interface EntryInfoZip64 extends EntryOptions {
30
+ /** Byte offset of the local file header within the ZIP archive */
31
+ startOffset: bigint;
32
+ /** CRC32 checksum of the uncompressed data */
33
+ crc32: number;
34
+ /** Uncompressed size in bytes */
35
+ uncompressedSize: bigint;
36
+ /** Compressed size in bytes */
37
+ compressedSize: bigint;
38
+ /** Entry information is in ZIP64 format */
39
+ zip64: true;
40
+ }
41
+
42
+ export type EntryInfoStandard = {
43
+ [K in keyof EntryInfoZip64]: EntryInfoZip64[K] extends bigint
44
+ ? number
45
+ : K extends "zip64"
46
+ ? false
47
+ : EntryInfoZip64[K];
48
+ };
49
+
50
+ export type EntryInfo = EntryInfoZip64 | EntryInfoStandard;
51
+
52
+ export class ZipWriter<TIsZip64 extends boolean = false> {
53
+ #offset = BigInt(0);
54
+ #zipStream = new TransformStream(
55
+ {
56
+ transform: (chunk, controller) => {
57
+ controller.enqueue(chunk);
58
+ this.#offset += BigInt(chunk.byteLength);
59
+ },
60
+ },
61
+ // Unbuffered on the writable side, we already queue entries
62
+ unbufferedQueue,
63
+ // Readable buffer, so that temporary delays on writable consumers don't
64
+ // block the zip writing.
65
+ bufferedQueue
66
+ );
67
+ #entries: EntryInfo[] = [];
68
+ #queued: Promise<unknown> = Promise.resolve();
69
+ #entryCount = 0;
70
+ #finalized = false;
71
+
72
+ get readable() {
73
+ return this.#zipStream.readable;
74
+ }
75
+
76
+ get entries(): ReadonlyArray<Readonly<EntryInfo>> {
77
+ return this.#entries;
78
+ }
79
+
80
+ /**
81
+ * Wait until all currently queued entries have been written.
82
+ */
83
+ async onceQueueEmpty(): Promise<void> {
84
+ while (this.#entries.length < this.#entryCount) {
85
+ await this.#queued;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create a new entry in the Zip archive. Use the returned
91
+ * entryWriter.writable to write data to the entry. You can await
92
+ * entryWriter.entry() to get the entry info once all data has been written.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * const response = await fetch(imageUrl);
97
+ * response.body!.pipeTo(
98
+ * zipWriter.entry({ name: "path/to/file.png" }).writable
99
+ * );
100
+ * ```
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * const entryWriter = zipWriter.entry({ name: "hello.txt" });
105
+ * const writer = entryWriter.writable.getWriter();
106
+ * await writer.write(
107
+ * new TextEncoder().encode("Hello, World!")
108
+ * );
109
+ * await writer.close();
110
+ * console.log(await entryWriter.entry()); // Entry info
111
+ * ```
112
+ *
113
+ * @param options Entry options
114
+ */
115
+ entry(options: EntryOptions) {
116
+ if (this.#finalized) {
117
+ throw new TypeError("Cannot add entry after finalize() has been called");
118
+ }
119
+ this.#entryCount++;
120
+
121
+ // Capture the previous queue position
122
+ const prevQueued = this.#queued;
123
+
124
+ // Create a promise that will be resolved when this entry completes
125
+ let entryDone: () => void;
126
+ let entryError: (reason: unknown) => void;
127
+ this.#queued = new Promise<void>((resolve, reject) => {
128
+ entryDone = resolve;
129
+ entryError = reject;
130
+ });
131
+ // prevent unhandled rejection, but we still want this.#queued to reject
132
+ // when awaited if there's an error
133
+ this.#queued.catch(noop);
134
+
135
+ return new EntryWriter(async (readable) => {
136
+ // Wait for previous entry to complete
137
+ await prevQueued;
138
+
139
+ const writer = this.#zipStream.writable.getWriter();
140
+ const reader = readable.getReader();
141
+ try {
142
+ const entryInfo = await writeZipEntry({
143
+ writer,
144
+ reader,
145
+ entryOptions: options,
146
+ startOffset: this.#offset,
147
+ });
148
+ this.#entries.push(entryInfo);
149
+ entryDone();
150
+ return entryInfo;
151
+ } catch (err) {
152
+ entryError(err);
153
+ await reader.cancel(err);
154
+ // Abort the zip stream - it's corrupted after a partial entry write
155
+ this.#zipStream.writable.abort(err);
156
+ // Don't call entryDone() - leave subsequent entries waiting forever
157
+ // since the zip stream is corrupted
158
+ // This error is handled in the EntryWriter constructor, and used to
159
+ // abort the entry writable stream
160
+ throw err;
161
+ } finally {
162
+ reader.releaseLock();
163
+ writer.releaseLock();
164
+ }
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Set the entries of the Zip archive. This can be used to reorder,
170
+ * remove, or rename entries before finalizing the archive.
171
+ *
172
+ * @param entries New entries array
173
+ */
174
+ setEntries(entries: ReadonlyArray<Readonly<EntryInfo>>) {
175
+ const currentEntriesByOffset = new Map<bigint, EntryInfo>();
176
+ for (const entry of this.#entries) {
177
+ currentEntriesByOffset.set(BigInt(entry.startOffset), entry);
178
+ }
179
+ // Entries can't have their offset, crc32, compressed size, or uncompressed
180
+ // size changed, but they can be removed, reordered, or have the name and
181
+ // comment changed.
182
+ for (const entry of entries) {
183
+ const existingEntry = currentEntriesByOffset.get(
184
+ BigInt(entry.startOffset)
185
+ );
186
+ if (!existingEntry) {
187
+ throw new Error(
188
+ `Cannot set entries: entry at offset ${entry.startOffset} does not exist`
189
+ );
190
+ }
191
+ if (existingEntry.crc32 !== entry.crc32) {
192
+ throw new Error(
193
+ `Cannot set entries: entry at offset ${entry.startOffset} has different CRC32`
194
+ );
195
+ }
196
+ if (existingEntry.uncompressedSize !== entry.uncompressedSize) {
197
+ throw new Error(
198
+ `Cannot set entries: entry at offset ${entry.startOffset} has different uncompressed size`
199
+ );
200
+ }
201
+ if (existingEntry.compressedSize !== entry.compressedSize) {
202
+ throw new Error(
203
+ `Cannot set entries: entry at offset ${entry.startOffset} has different compressed size`
204
+ );
205
+ }
206
+ }
207
+ this.#entries = entries.map((entry) => ({ ...entry }));
208
+ }
209
+
210
+ async finalize(): Promise<{
211
+ /** Does the archive use ZIP64 format? */
212
+ zip64: boolean;
213
+ /** Total uncompressed size of all entries */
214
+ uncompressedEntriesSize: bigint;
215
+ /** Total compressed size of all entries */
216
+ compressedEntriesSize: bigint;
217
+ /** Total size of the ZIP file */
218
+ fileSize: bigint;
219
+ }> {
220
+ if (this.#finalized) {
221
+ throw new TypeError("finalize() has already been called");
222
+ }
223
+ this.#finalized = true;
224
+
225
+ // We do this as an IIFE to avoid an unhandled error, so that a user can
226
+ // call finalize() without needing to await and catch an error. Any error
227
+ // will be propagated to the stream output, but the user can optionally
228
+ // await finalize() to also see the error.
229
+ const finalizePromise = (async () => {
230
+ await this.onceQueueEmpty();
231
+
232
+ // Write central directory headers for all entries
233
+ const centralDirectoryStart = this.#offset;
234
+ const writer = this.#zipStream.writable.getWriter();
235
+
236
+ try {
237
+ let entryUsesZip64 = false;
238
+ let uncompressedEntriesSize = BigInt(0);
239
+ let compressedEntriesSize = BigInt(0);
240
+ for (const entry of this.#entries) {
241
+ if (entry.zip64) {
242
+ entryUsesZip64 = true;
243
+ }
244
+ uncompressedEntriesSize += BigInt(entry.uncompressedSize);
245
+ compressedEntriesSize += BigInt(entry.compressedSize);
246
+ await writer.write(getCDFH(entry));
247
+ }
248
+
249
+ const centralDirectorySize = this.#offset - centralDirectoryStart;
250
+
251
+ // Determine if the archive needs ZIP64 based on entries or central directory
252
+ const eocdNeedsZip64 =
253
+ this.#entries.length > 0xffff ||
254
+ centralDirectoryStart > ZIP64_LIMIT ||
255
+ centralDirectorySize > ZIP64_LIMIT;
256
+
257
+ let eocd: Uint8Array<ArrayBuffer>;
258
+ if (eocdNeedsZip64) {
259
+ eocd = getEOCDZip64(
260
+ BigInt(this.#entries.length),
261
+ centralDirectoryStart,
262
+ centralDirectorySize
263
+ );
264
+ } else {
265
+ eocd = getEOCDStandard(
266
+ this.#entries.length,
267
+ Number(centralDirectoryStart),
268
+ Number(centralDirectorySize)
269
+ );
270
+ }
271
+
272
+ await writer.write(eocd);
273
+
274
+ // Close the zip stream
275
+ await writer.close();
276
+
277
+ return {
278
+ zip64: eocdNeedsZip64 || entryUsesZip64,
279
+ uncompressedEntriesSize,
280
+ compressedEntriesSize,
281
+ fileSize: this.#offset,
282
+ };
283
+ } catch (err) {
284
+ await writer.abort(err);
285
+ throw err;
286
+ } finally {
287
+ writer.releaseLock();
288
+ }
289
+ })();
290
+ finalizePromise.catch(noop); // prevent unhandled rejection
291
+ return finalizePromise;
292
+ }
293
+ }
294
+
295
+ class EntryWriter {
296
+ // Unbuffered stream to directly pipe to the zip writer
297
+ #entryStream = new TransformStream(
298
+ undefined,
299
+ unbufferedQueue,
300
+ unbufferedQueue
301
+ );
302
+ #written: Promise<EntryInfo>;
303
+
304
+ /**
305
+ * @param enqueueAndPipe Function that enqueues the entry data readable, pipes it to the Zip stream, and returns a promise that resolves to the entry info once done.
306
+ */
307
+ constructor(
308
+ enqueueAndPipe: (
309
+ readable: ReadableStream<Uint8Array<ArrayBuffer>>
310
+ ) => Promise<EntryInfo>
311
+ ) {
312
+ // We could try to pass the zipstream writable to EntryWriter, but then
313
+ // EntryWriter would not know when the entry has finished writing, and would
314
+ // not have a way to get the entry info.
315
+ this.#written = enqueueAndPipe(this.#entryStream.readable);
316
+ // abort the writable quickly if there is an error, and ensure we don't
317
+ // throw an uncaught error.
318
+ this.#written.catch((reason) => {
319
+ this.#entryStream.writable.abort(reason);
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Writable stream to write entry data to.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * ReadableStream.from([new TextEncoder().encode("Hello, World!")])
329
+ * .pipeTo(entryWriter.writable);
330
+ * ```
331
+ */
332
+ get writable() {
333
+ return this.#entryStream.writable;
334
+ }
335
+
336
+ /**
337
+ * Get entry info. Resolves once all data has been written to the Zip stream.
338
+ */
339
+ async entry(): Promise<EntryInfo> {
340
+ return this.#written;
341
+ }
342
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,17 @@
1
+ export function getDosTime(date: Date): number {
2
+ return (
3
+ (date.getHours() << 11) |
4
+ (date.getMinutes() << 5) |
5
+ (date.getSeconds() >> 1)
6
+ );
7
+ }
8
+
9
+ export function getDosDate(date: Date): number {
10
+ return (
11
+ ((date.getFullYear() - 1980) << 9) |
12
+ ((date.getMonth() + 1) << 5) |
13
+ date.getDate()
14
+ );
15
+ }
16
+
17
+ export function noop() {}