zip-writer 1.0.0 → 1.1.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.
- package/README.md +427 -0
- package/dist/constants.d.ts +21 -0
- package/dist/constants.d.ts.map +1 -0
- package/{src/constants.ts → dist/constants.js} +1 -5
- package/dist/constants.js.map +1 -0
- package/{src/crc.ts → dist/crc-browser.d.ts} +2 -39
- package/dist/crc-browser.d.ts.map +1 -0
- package/dist/crc-browser.js +82 -0
- package/dist/crc-browser.js.map +1 -0
- package/dist/crc-node.d.ts +2 -0
- package/dist/crc-node.d.ts.map +1 -0
- package/dist/crc-node.js +3 -0
- package/dist/crc-node.js.map +1 -0
- package/dist/index.d.ts +138 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +298 -0
- package/dist/index.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +12 -0
- package/dist/utils.js.map +1 -0
- package/dist/write-central-directory.d.ts +14 -0
- package/dist/write-central-directory.d.ts.map +1 -0
- package/dist/write-central-directory.js +279 -0
- package/dist/write-central-directory.js.map +1 -0
- package/dist/write-entry.d.ts +26 -0
- package/dist/write-entry.d.ts.map +1 -0
- package/dist/write-entry.js +172 -0
- package/dist/write-entry.js.map +1 -0
- package/package.json +32 -5
- package/.claude/settings.local.json +0 -11
- package/.github/workflows/test.yml +0 -36
- package/.husky/pre-commit +0 -1
- package/eslint.config.mjs +0 -9
- package/src/crc-lookup.ts +0 -27
- package/src/index.ts +0 -342
- package/src/utils.ts +0 -17
- package/src/write-central-directory.ts +0 -405
- package/src/write-entry.ts +0 -246
- package/test/commands.ts +0 -47
- package/test/crc.test.ts +0 -45
- package/test/integration.test.ts +0 -483
- package/test/utils.ts +0 -40
- package/tsconfig.json +0 -36
- package/tsconfig.node.json +0 -10
- package/vitest.config.ts +0 -46
package/src/index.ts
DELETED
|
@@ -1,342 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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() {}
|