cursedbelt-server 4.44.1 → 4.45.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,83 @@
1
+ /**
2
+ * A streaming ZIP writer — a download of many files as one, built as it is sent.
3
+ *
4
+ * Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
5
+ * needed the same thing for "download this folder": two copies of a hand-rolled format writer
6
+ * is two places for the next bug. Folders (directory entries) and file dates arrived with it,
7
+ * because a folder download that loses its empty folders and stamps every file 1980 is not a
8
+ * backup.
9
+ *
10
+ * ── Why this is hand-written rather than a dependency ───────────────────────────────────
11
+ * Three properties are needed together, and the popular libraries give at most two:
12
+ *
13
+ * · **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
14
+ * Deflating them spends CPU — on a Worker, the scarce thing — to produce a slightly LARGER
15
+ * file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
16
+ * beside them are not worth a second code path.
17
+ * · **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
18
+ * Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
19
+ * browser in the same pass.
20
+ * · **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
21
+ * fields wrap silently at that point. A truncated archive that reports success is the worst
22
+ * available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
23
+ * crossed — a conditional format is a format that is only tested on the small path.
24
+ *
25
+ * ── Streaming means data descriptors ────────────────────────────────────────────────────
26
+ * A local file header must state the CRC-32 and the sizes, and none of those are known until
27
+ * the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
28
+ * in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
29
+ * what makes a single forward pass possible, why this writer never seeks, and so why the
30
+ * response can be a `ReadableStream` and the server's memory stays flat whatever the size.
31
+ *
32
+ * ── The CPU it costs ────────────────────────────────────────────────────────────────────
33
+ * The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
34
+ * per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
35
+ * caller on a Worker caps what one archive may hold — music at 4 GB / 2,000 files — and says so
36
+ * BEFORE the download starts, rather than letting the edge cut it off part-way.
37
+ */
38
+ /** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
39
+ export declare class Crc32 {
40
+ private crc;
41
+ update(bytes: Uint8Array): void;
42
+ get value(): number;
43
+ }
44
+ /** One member of the archive: a file whose bytes are opened lazily, or a folder. */
45
+ export type ZipEntry = {
46
+ /** Path inside the archive, `/`-separated. Sanitized by {@link zipStream}. */
47
+ name: string;
48
+ /**
49
+ * Opens the bytes — a stream, or bytes already in hand. Called only when the entry's turn
50
+ * comes, so a 2,000-file archive does not open 2,000 connections at once. Returning `null`
51
+ * (or throwing) skips the entry: a file that vanished must not abort the rest.
52
+ */
53
+ open: () => Promise<ReadableStream<Uint8Array> | Uint8Array | null>;
54
+ /** When the file last changed. Omitted: the archive says 1980-01-01, the format's zero. */
55
+ modified?: Date | string | null;
56
+ directory?: false;
57
+ } | {
58
+ /** A folder — written so an EMPTY one survives the round trip. A trailing `/` is added. */
59
+ name: string;
60
+ directory: true;
61
+ modified?: Date | string | null;
62
+ };
63
+ /**
64
+ * Names inside a zip must not escape the extraction directory, and must not carry characters
65
+ * Windows refuses. `..` segments and absolute paths are the security half; the character set is
66
+ * the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
67
+ * deep folder keeps its folders.
68
+ */
69
+ export declare function safeEntryName(name: string, fallback?: string): string;
70
+ /** The MS-DOS date and time a zip header carries — local wall-clock, 2-second resolution, 1980 at the earliest. */
71
+ export declare function dosDateTime(when: Date | string | null | undefined): {
72
+ date: number;
73
+ time: number;
74
+ };
75
+ /**
76
+ * Build the archive as a stream.
77
+ *
78
+ * Duplicate file names are disambiguated rather than colliding — two different files that both
79
+ * want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
80
+ * identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
81
+ * once: its files already land in it.
82
+ */
83
+ export declare function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array>;
@@ -0,0 +1,320 @@
1
+ /**
2
+ * A streaming ZIP writer — a download of many files as one, built as it is sent.
3
+ *
4
+ * Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
5
+ * needed the same thing for "download this folder": two copies of a hand-rolled format writer
6
+ * is two places for the next bug. Folders (directory entries) and file dates arrived with it,
7
+ * because a folder download that loses its empty folders and stamps every file 1980 is not a
8
+ * backup.
9
+ *
10
+ * ── Why this is hand-written rather than a dependency ───────────────────────────────────
11
+ * Three properties are needed together, and the popular libraries give at most two:
12
+ *
13
+ * · **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
14
+ * Deflating them spends CPU — on a Worker, the scarce thing — to produce a slightly LARGER
15
+ * file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
16
+ * beside them are not worth a second code path.
17
+ * · **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
18
+ * Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
19
+ * browser in the same pass.
20
+ * · **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
21
+ * fields wrap silently at that point. A truncated archive that reports success is the worst
22
+ * available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
23
+ * crossed — a conditional format is a format that is only tested on the small path.
24
+ *
25
+ * ── Streaming means data descriptors ────────────────────────────────────────────────────
26
+ * A local file header must state the CRC-32 and the sizes, and none of those are known until
27
+ * the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
28
+ * in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
29
+ * what makes a single forward pass possible, why this writer never seeks, and so why the
30
+ * response can be a `ReadableStream` and the server's memory stays flat whatever the size.
31
+ *
32
+ * ── The CPU it costs ────────────────────────────────────────────────────────────────────
33
+ * The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
34
+ * per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
35
+ * caller on a Worker caps what one archive may hold — music at 4 GB / 2,000 files — and says so
36
+ * BEFORE the download starts, rather than letting the edge cut it off part-way.
37
+ */
38
+ // ── CRC-32 ──────────────────────────────────────────────────────────────────────────────
39
+ /** The standard IEEE 802.3 table, built once. */
40
+ const CRC_TABLE = (() => {
41
+ const table = new Uint32Array(256);
42
+ for (let i = 0; i < 256; i++) {
43
+ let c = i;
44
+ for (let k = 0; k < 8; k++)
45
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
46
+ table[i] = c >>> 0;
47
+ }
48
+ return table;
49
+ })();
50
+ /** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
51
+ export class Crc32 {
52
+ crc = 0xffffffff;
53
+ update(bytes) {
54
+ let crc = this.crc;
55
+ for (let i = 0; i < bytes.length; i++)
56
+ crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
57
+ this.crc = crc;
58
+ }
59
+ get value() {
60
+ return (this.crc ^ 0xffffffff) >>> 0;
61
+ }
62
+ }
63
+ // ── Little-endian writing ───────────────────────────────────────────────────────────────
64
+ class ByteWriter {
65
+ parts = [];
66
+ u16(n) {
67
+ const b = new Uint8Array(2);
68
+ new DataView(b.buffer).setUint16(0, n, true);
69
+ this.parts.push(b);
70
+ return this;
71
+ }
72
+ u32(n) {
73
+ const b = new Uint8Array(4);
74
+ new DataView(b.buffer).setUint32(0, n >>> 0, true);
75
+ this.parts.push(b);
76
+ return this;
77
+ }
78
+ /** 64-bit, via BigInt — a whole-library download can pass 2^32, and Number would quietly lose precision past 2^53. */
79
+ u64(n) {
80
+ const b = new Uint8Array(8);
81
+ new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
82
+ this.parts.push(b);
83
+ return this;
84
+ }
85
+ raw(bytes) {
86
+ this.parts.push(bytes);
87
+ return this;
88
+ }
89
+ done() {
90
+ const total = this.parts.reduce((n, p) => n + p.length, 0);
91
+ const out = new Uint8Array(total);
92
+ let at = 0;
93
+ for (const part of this.parts) {
94
+ out.set(part, at);
95
+ at += part.length;
96
+ }
97
+ return out;
98
+ }
99
+ }
100
+ const encoder = new TextEncoder();
101
+ /** ZIP64 needs version 4.5; bit 3 = sizes/CRC follow in a data descriptor; bit 11 = UTF-8
102
+ * names, which every accented and non-Latin file name needs. */
103
+ const VERSION_ZIP64 = 45;
104
+ const FLAG_DATA_DESCRIPTOR = 0x08;
105
+ const FLAG_UTF8 = 0x0800;
106
+ const METHOD_STORE = 0;
107
+ /** MS-DOS directory attribute, in the low byte of the external attributes. */
108
+ const ATTR_DIRECTORY = 0x10;
109
+ /**
110
+ * Names inside a zip must not escape the extraction directory, and must not carry characters
111
+ * Windows refuses. `..` segments and absolute paths are the security half; the character set is
112
+ * the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
113
+ * deep folder keeps its folders.
114
+ */
115
+ export function safeEntryName(name, fallback = "file") {
116
+ // Control characters (< 0x20) and the set Windows refuses. Filtered by code point rather than
117
+ // by a regex class, because a literal control character inside a regex is unreadable and the
118
+ // linter is right to object to it.
119
+ const FORBIDDEN = new Set(["<", ">", ":", '"', "|", "?", "*"]);
120
+ const segments = name
121
+ .split(/[/\\]/)
122
+ .filter((seg) => seg !== "" && seg !== "." && seg !== "..")
123
+ .map((seg) => {
124
+ let out = "";
125
+ for (const ch of seg)
126
+ out += ch.codePointAt(0) < 0x20 || FORBIDDEN.has(ch) ? "_" : ch;
127
+ // Windows also refuses a segment ending in a dot or a space; trimmed rather than replaced.
128
+ return out.slice(0, 200).replace(/[. ]+$/, "") || fallback;
129
+ });
130
+ return segments.join("/") || fallback;
131
+ }
132
+ /** The MS-DOS date and time a zip header carries — local wall-clock, 2-second resolution, 1980 at the earliest. */
133
+ export function dosDateTime(when) {
134
+ const at = when ? new Date(when) : null;
135
+ if (!at || Number.isNaN(at.getTime()) || at.getUTCFullYear() < 1980)
136
+ return { date: (0 << 9) | (1 << 5) | 1, time: 0 };
137
+ // UTC, not the server's zone: a Worker has none, and the same archive should not depend on where it was built.
138
+ const year = Math.min(at.getUTCFullYear(), 2107) - 1980;
139
+ return {
140
+ date: (year << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
141
+ time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | Math.floor(at.getUTCSeconds() / 2),
142
+ };
143
+ }
144
+ function asStream(body) {
145
+ if (!(body instanceof Uint8Array))
146
+ return body;
147
+ return new ReadableStream({
148
+ start(controller) {
149
+ if (body.byteLength > 0)
150
+ controller.enqueue(body);
151
+ controller.close();
152
+ },
153
+ });
154
+ }
155
+ /**
156
+ * Build the archive as a stream.
157
+ *
158
+ * Duplicate file names are disambiguated rather than colliding — two different files that both
159
+ * want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
160
+ * identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
161
+ * once: its files already land in it.
162
+ */
163
+ export function zipStream(entries) {
164
+ const central = [];
165
+ let offset = 0n;
166
+ const used = new Set();
167
+ const iterator = Symbol.asyncIterator in entries ? entries[Symbol.asyncIterator]() : entries[Symbol.iterator]();
168
+ return new ReadableStream({
169
+ async pull(controller) {
170
+ // One entry per pull keeps backpressure honest: the next file is not opened until the
171
+ // consumer has taken the previous one.
172
+ //
173
+ // 🔴 The loop is not decoration. A `pull` that returns having enqueued NOTHING is not
174
+ // guaranteed to be called again, so an entry that turns out to be unreadable would stall
175
+ // the entire archive — the consumer waits forever for bytes that will never come. Skipping
176
+ // therefore has to continue to the next entry within the SAME pull. (Measured in music:
177
+ // two tests hung for the full 5 s timeout before this loop existed.)
178
+ while (true) {
179
+ const next = await iterator.next();
180
+ if (next.done)
181
+ break;
182
+ const entry = next.value;
183
+ const directory = entry.directory === true;
184
+ let name = safeEntryName(entry.name);
185
+ if (directory) {
186
+ name = `${name}/`;
187
+ if (used.has(name))
188
+ continue;
189
+ }
190
+ else if (used.has(name)) {
191
+ const slash = name.lastIndexOf("/");
192
+ const dot = name.lastIndexOf(".");
193
+ const cut = dot > slash + 1 ? dot : name.length;
194
+ const stem = name.slice(0, cut);
195
+ const ext = name.slice(cut);
196
+ let n = 2;
197
+ while (used.has(`${stem} (${n})${ext}`))
198
+ n++;
199
+ name = `${stem} (${n})${ext}`;
200
+ }
201
+ let body = null;
202
+ if (!directory) {
203
+ const opened = await entry.open().catch(() => null);
204
+ // A file whose bytes have gone is skipped, not fatal: one missing file must not abort
205
+ // a 2,000-file download that is otherwise fine. `continue`, never `return` — see the
206
+ // note on the loop above.
207
+ if (!opened)
208
+ continue;
209
+ body = asStream(opened);
210
+ }
211
+ used.add(name);
212
+ const nameBytes = encoder.encode(name);
213
+ const { date, time } = dosDateTime(entry.modified);
214
+ // ZIP64 extra field, sizes zeroed — the data descriptor carries the truth.
215
+ const localExtra = new ByteWriter().u16(0x0001).u16(16).u64(0).u64(0).done();
216
+ const header = new ByteWriter()
217
+ .u32(0x04034b50)
218
+ .u16(VERSION_ZIP64)
219
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
220
+ .u16(METHOD_STORE)
221
+ .u16(time)
222
+ .u16(date)
223
+ .u32(0) // crc, in the descriptor
224
+ .u32(0xffffffff) // compressed size → see ZIP64 extra
225
+ .u32(0xffffffff) // uncompressed size → see ZIP64 extra
226
+ .u16(nameBytes.length)
227
+ .u16(localExtra.length)
228
+ .raw(nameBytes)
229
+ .raw(localExtra)
230
+ .done();
231
+ controller.enqueue(header);
232
+ const entryOffset = offset;
233
+ offset += BigInt(header.length);
234
+ const crc = new Crc32();
235
+ let size = 0n;
236
+ if (body) {
237
+ const reader = body.getReader();
238
+ while (true) {
239
+ const { done, value } = await reader.read();
240
+ if (done)
241
+ break;
242
+ crc.update(value);
243
+ size += BigInt(value.length);
244
+ controller.enqueue(value);
245
+ }
246
+ }
247
+ offset += size;
248
+ // ZIP64 data descriptor: 8-byte sizes, unlike the 4-byte classic form.
249
+ const descriptor = new ByteWriter().u32(0x08074b50).u32(crc.value).u64(size).u64(size).done();
250
+ controller.enqueue(descriptor);
251
+ offset += BigInt(descriptor.length);
252
+ central.push({ name: nameBytes, crc: crc.value, size, offset: entryOffset, date, time, directory });
253
+ return;
254
+ }
255
+ // Central directory, then the ZIP64 end records, then the classic end record.
256
+ const start = offset;
257
+ for (const item of central) {
258
+ const extra = new ByteWriter().u16(0x0001).u16(24).u64(item.size).u64(item.size).u64(item.offset).done();
259
+ const record = new ByteWriter()
260
+ .u32(0x02014b50)
261
+ .u16(VERSION_ZIP64) // version made by (MS-DOS attributes)
262
+ .u16(VERSION_ZIP64) // version needed
263
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
264
+ .u16(METHOD_STORE)
265
+ .u16(item.time)
266
+ .u16(item.date)
267
+ .u32(item.crc)
268
+ .u32(0xffffffff)
269
+ .u32(0xffffffff)
270
+ .u16(item.name.length)
271
+ .u16(extra.length)
272
+ .u16(0) // comment length
273
+ .u16(0) // disk number
274
+ .u16(0) // internal attrs
275
+ .u32(item.directory ? ATTR_DIRECTORY : 0) // external attrs
276
+ .u32(0xffffffff) // local header offset → ZIP64 extra
277
+ .raw(item.name)
278
+ .raw(extra)
279
+ .done();
280
+ controller.enqueue(record);
281
+ offset += BigInt(record.length);
282
+ }
283
+ const centralSize = offset - start;
284
+ const zip64End = new ByteWriter()
285
+ .u32(0x06064b50)
286
+ .u64(44) // size of this record, minus its first 12 bytes
287
+ .u16(VERSION_ZIP64)
288
+ .u16(VERSION_ZIP64)
289
+ .u32(0)
290
+ .u32(0)
291
+ .u64(central.length)
292
+ .u64(central.length)
293
+ .u64(centralSize)
294
+ .u64(start)
295
+ .done();
296
+ controller.enqueue(zip64End);
297
+ const locator = new ByteWriter()
298
+ .u32(0x07064b50)
299
+ .u32(0)
300
+ .u64(offset) // where the ZIP64 end record starts
301
+ .u32(1)
302
+ .done();
303
+ controller.enqueue(locator);
304
+ // The classic record still has to be there for every reader to find the archive; its
305
+ // 0xffff/0xffffffff sentinels say "look at the ZIP64 record instead".
306
+ const end = new ByteWriter()
307
+ .u32(0x06054b50)
308
+ .u16(0)
309
+ .u16(0)
310
+ .u16(central.length > 0xffff ? 0xffff : central.length)
311
+ .u16(central.length > 0xffff ? 0xffff : central.length)
312
+ .u32(0xffffffff)
313
+ .u32(0xffffffff)
314
+ .u16(0)
315
+ .done();
316
+ controller.enqueue(end);
317
+ controller.close();
318
+ },
319
+ });
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.44.1",
3
+ "version": "4.45.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -355,6 +355,12 @@
355
355
  "source": "./src/server/youtube/ytdlp.ts",
356
356
  "import": "./dist/server/youtube/ytdlp.js"
357
357
  },
358
+ "./zip-stream": {
359
+ "types": "./dist/server/zipStream.d.ts",
360
+ "bun": "./src/server/zipStream.ts",
361
+ "source": "./src/server/zipStream.ts",
362
+ "import": "./dist/server/zipStream.js"
363
+ },
358
364
  "./ws": {
359
365
  "types": "./dist/server/ws/index.d.ts",
360
366
  "bun": "./src/server/ws/index.ts",
@@ -243,6 +243,14 @@ const LEAVES = [
243
243
  */
244
244
  evaluates: 'createYtDlp',
245
245
  },
246
+ {
247
+ subpath: './zip-stream',
248
+ /**
249
+ * The streaming ZIP64 writer (4.45.0, forge task desk-updates) — out of Music so desk's folder
250
+ * downloads share it. Both run it on a Worker, so it imports nothing at all.
251
+ */
252
+ evaluates: 'zipStream',
253
+ },
246
254
  ] as const;
247
255
 
248
256
  /**
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The zip writer is hand-rolled (see the file header for why), so it is verified against a REAL
3
+ * unzip rather than against itself: a self-consistent archive that no extractor accepts is exactly
4
+ * the failure a hand-rolled format writer produces. Moved here from `apps/music/src/server/zip.test.ts`
5
+ * with the writer (2026-10-15); the folder and date cases arrived with desk.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { Crc32, dosDateTime, safeEntryName, type ZipEntry, zipStream } from './zipStream';
13
+
14
+ const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text);
15
+
16
+ const entryFrom = (name: string, body: string): ZipEntry => ({
17
+ name,
18
+ open: async () =>
19
+ new ReadableStream<Uint8Array>({
20
+ start(controller) {
21
+ // Two chunks, so the streaming CRC and size accumulation are exercised rather than a
22
+ // single-chunk fast path that would hide an accumulator bug.
23
+ const half = Math.ceil(body.length / 2);
24
+ controller.enqueue(bytesOf(body.slice(0, half)));
25
+ controller.enqueue(bytesOf(body.slice(half)));
26
+ controller.close();
27
+ },
28
+ }),
29
+ });
30
+
31
+ const collect = async (stream: ReadableStream<Uint8Array>): Promise<Uint8Array> => new Uint8Array(await new Response(stream).arrayBuffer());
32
+
33
+ /** Run the system `unzip` over the archive in a throwaway directory. */
34
+ async function unzip(archive: Uint8Array, args: string[]): Promise<{ out: string; dir: string; cleanup: () => void }> {
35
+ const dir = mkdtempSync(join(tmpdir(), 'cbs-zip-'));
36
+ const path = join(dir, 'test.zip');
37
+ writeFileSync(path, archive);
38
+ const proc = Bun.spawn(['unzip', ...args, path], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
39
+ const out = await new Response(proc.stdout).text();
40
+ await proc.exited;
41
+ return { out, dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
42
+ }
43
+
44
+ /** Write the archive out and list it with the system `unzip` — the real test. */
45
+ async function unzipList(archive: Uint8Array): Promise<string> {
46
+ const run = await unzip(archive, ['-l']);
47
+ run.cleanup();
48
+ return run.out;
49
+ }
50
+
51
+ /** Extract and read one member back, proving the BYTES survive, not just the listing. */
52
+ async function unzipRead(archive: Uint8Array, member: string): Promise<string> {
53
+ const dir = mkdtempSync(join(tmpdir(), 'cbs-zip-'));
54
+ const path = join(dir, 'test.zip');
55
+ writeFileSync(path, archive);
56
+ const proc = Bun.spawn(['unzip', '-p', path, member], { stdout: 'pipe', stderr: 'pipe' });
57
+ const out = await new Response(proc.stdout).text();
58
+ await proc.exited;
59
+ rmSync(dir, { recursive: true, force: true });
60
+ return out;
61
+ }
62
+
63
+ describe("Crc32", () => {
64
+ test("matches the known IEEE check value for '123456789'", () => {
65
+ const crc = new Crc32();
66
+ crc.update(bytesOf("123456789"));
67
+ expect(crc.value).toBe(0xcbf43926);
68
+ });
69
+
70
+ test("is order-dependent across chunks, i.e. genuinely incremental", () => {
71
+ const whole = new Crc32();
72
+ whole.update(bytesOf("hello world"));
73
+ const split = new Crc32();
74
+ split.update(bytesOf("hello "));
75
+ split.update(bytesOf("world"));
76
+ expect(split.value).toBe(whole.value);
77
+ });
78
+ });
79
+
80
+ describe("safeEntryName", () => {
81
+ test("🔴 strips '..' and absolute paths — an archive must not escape its extract dir", () => {
82
+ expect(safeEntryName("../../etc/passwd")).toBe("etc/passwd");
83
+ expect(safeEntryName("/etc/passwd")).toBe("etc/passwd");
84
+ expect(safeEntryName("a/../../b")).toBe("a/b");
85
+ });
86
+
87
+ test("replaces characters Windows refuses, so the archive extracts everywhere", () => {
88
+ expect(safeEntryName('a:b"c|d?e*f.mp3')).toBe("a_b_c_d_e_f.mp3");
89
+ });
90
+
91
+ test("never yields an empty name", () => {
92
+ expect(safeEntryName("")).toBe("file");
93
+ expect(safeEntryName("../..")).toBe("file");
94
+ expect(safeEntryName("", "track")).toBe("track");
95
+ });
96
+
97
+ test("keeps ordinary nested paths intact", () => {
98
+ expect(safeEntryName("In Sides/01 - Belfast.mp3")).toBe("In Sides/01 - Belfast.mp3");
99
+ });
100
+ });
101
+
102
+ describe("zipStream", () => {
103
+ test("produces an archive a real unzip accepts and lists", async () => {
104
+ const archive = await collect(
105
+ zipStream([entryFrom("one.mp3", "first body"), entryFrom("two.mp3", "second body")]),
106
+ );
107
+ const listing = await unzipList(archive);
108
+ expect(listing).toContain("one.mp3");
109
+ expect(listing).toContain("two.mp3");
110
+ // unzip prints this banner for anything it could not parse.
111
+ expect(listing).not.toContain("cannot find zipfile directory");
112
+ });
113
+
114
+ test("the extracted BYTES round-trip exactly", async () => {
115
+ const body = "the quick brown fox jumps over the lazy dog";
116
+ const archive = await collect(zipStream([entryFrom("song.mp3", body)]));
117
+ expect(await unzipRead(archive, "song.mp3")).toBe(body);
118
+ });
119
+
120
+ test("🔴 duplicate names are disambiguated, not silently collapsed to one file", async () => {
121
+ // Two different rips of one song produce the same "Artist - Title.mp3". A zip with
122
+ // two identical names extracts as ONE file, losing the other without a word.
123
+ const archive = await collect(
124
+ zipStream([entryFrom("same.mp3", "copy one"), entryFrom("same.mp3", "copy two")]),
125
+ );
126
+ const listing = await unzipList(archive);
127
+ expect(listing).toContain("same.mp3");
128
+ expect(listing).toContain("same (2).mp3");
129
+ expect(await unzipRead(archive, "same.mp3")).toBe("copy one");
130
+ expect(await unzipRead(archive, "same (2).mp3")).toBe("copy two");
131
+ });
132
+
133
+ test("🔴 a member whose bytes have gone is SKIPPED, not fatal", async () => {
134
+ // One missing track must not abort a 2,000-file download that is otherwise fine.
135
+ const archive = await collect(
136
+ zipStream([
137
+ entryFrom("good.mp3", "here"),
138
+ { name: "gone.mp3", open: async () => null },
139
+ entryFrom("also-good.mp3", "also here"),
140
+ ]),
141
+ );
142
+ const listing = await unzipList(archive);
143
+ expect(listing).toContain("good.mp3");
144
+ expect(listing).toContain("also-good.mp3");
145
+ expect(listing).not.toContain("gone.mp3");
146
+ });
147
+
148
+ test("an entry whose open() THROWS is skipped rather than killing the archive", async () => {
149
+ const archive = await collect(
150
+ zipStream([
151
+ {
152
+ name: "broken.mp3",
153
+ open: async () => {
154
+ throw new Error("upstream said no");
155
+ },
156
+ },
157
+ entryFrom("fine.mp3", "fine"),
158
+ ]),
159
+ );
160
+ expect(await unzipRead(archive, "fine.mp3")).toBe("fine");
161
+ });
162
+
163
+ test("an empty selection still produces a structurally valid (empty) archive", async () => {
164
+ const archive = await collect(zipStream([]));
165
+ const listing = await unzipList(archive);
166
+ expect(listing).not.toContain("cannot find zipfile directory");
167
+ });
168
+
169
+ test("nested paths survive, so a big archive extracts into album folders", async () => {
170
+ const archive = await collect(zipStream([entryFrom("In Sides/Belfast.mp3", "b")]));
171
+ expect(await unzipRead(archive, "In Sides/Belfast.mp3")).toBe("b");
172
+ });
173
+
174
+ test("UTF-8 names are stored as UTF-8 bytes with the UTF-8 flag set", async () => {
175
+ // Asserted on the ARCHIVE, not on `unzip -l`: macOS ships Info-ZIP 6.0, whose
176
+ // listing mangles non-ASCII for display regardless of what the file contains.
177
+ // This library has accented and non-Latin titles, so the encoding has to be right.
178
+ const name = "Björk - Jóga.mp3";
179
+ const archive = await collect(zipStream([entryFrom(name, "x")]));
180
+ const encoded = new TextEncoder().encode(name);
181
+ const found = archive.some((_, i) => encoded.every((b, k) => archive[i + k] === b));
182
+ expect(found).toBe(true);
183
+
184
+ // General-purpose flag bit 11 (0x0800) = "the name is UTF-8". It sits at offset 6
185
+ // of the local file header, which starts the archive.
186
+ const flags = archive[6]! | (archive[7]! << 8);
187
+ expect(flags & 0x0800).toBe(0x0800);
188
+ });
189
+
190
+ test("writes the ZIP64 end-of-central-directory record unconditionally", async () => {
191
+ // Written for every archive rather than only past 4 GB: a conditional format is a
192
+ // format that is only ever tested on the small path. 0x06064b50, little-endian.
193
+ const archive = await collect(zipStream([entryFrom("a.mp3", "a")]));
194
+ const signature = [0x50, 0x4b, 0x06, 0x06];
195
+ const found = archive.some((_, i) => signature.every((b, k) => archive[i + k] === b));
196
+ expect(found).toBe(true);
197
+ });
198
+
199
+ test("streams lazily — a later entry is not opened before an earlier one is consumed", async () => {
200
+ // This is what keeps a 2,000-file archive from opening 2,000 connections at once.
201
+ const opened: string[] = [];
202
+ const lazy = (name: string): ZipEntry => ({
203
+ name,
204
+ open: async () => {
205
+ opened.push(name);
206
+ return new ReadableStream<Uint8Array>({
207
+ start(controller) {
208
+ controller.enqueue(bytesOf("x"));
209
+ controller.close();
210
+ },
211
+ });
212
+ },
213
+ });
214
+ const stream = zipStream([lazy("1.mp3"), lazy("2.mp3"), lazy("3.mp3")]);
215
+ const reader = stream.getReader();
216
+ await reader.read();
217
+ expect(opened).toEqual(["1.mp3"]);
218
+ await reader.cancel();
219
+ });
220
+ });
221
+
222
+ describe('folders and dates (desk)', () => {
223
+ test('🔴 an EMPTY folder survives extraction — a folder download that drops it is not a backup', async () => {
224
+ const archive = await collect(zipStream([{ name: 'Project/Empty', directory: true }, entryFrom('Project/a.md', 'a')]));
225
+ const run = await unzip(archive, ['-o', '-q', '-d', 'out']);
226
+ try {
227
+ expect(statSync(join(run.dir, 'out/Project/Empty')).isDirectory()).toBe(true);
228
+ expect(existsSync(join(run.dir, 'out/Project/a.md'))).toBe(true);
229
+ } finally {
230
+ run.cleanup();
231
+ }
232
+ });
233
+
234
+ test('a folder named twice is written once', async () => {
235
+ const listing = await unzipList(await collect(zipStream([{ name: 'A', directory: true }, { name: 'A/', directory: true }])));
236
+ expect(listing.match(/ A\//g)?.length).toBe(1);
237
+ });
238
+
239
+ test("a duplicate inside a dotted folder numbers the FILE, not the folder's dot", async () => {
240
+ const listing = await unzipList(await collect(zipStream([entryFrom('v1.2/notes', 'a'), entryFrom('v1.2/notes', 'b')])));
241
+ expect(listing).toContain('v1.2/notes (2)');
242
+ });
243
+
244
+ test('a file keeps its date', async () => {
245
+ const listing = await unzipList(await collect(zipStream([{ ...entryFrom('dated.txt', 'x'), modified: '2026-10-15T12:34:56Z' }])));
246
+ expect(listing).toMatch(/10-15-2026\s+12:34/);
247
+ });
248
+
249
+ test('dosDateTime: before 1980 or unreadable is the format zero, never a wrapped year', () => {
250
+ expect(dosDateTime('1970-01-01T00:00:00Z')).toEqual({ date: 33, time: 0 });
251
+ expect(dosDateTime('not a date')).toEqual({ date: 33, time: 0 });
252
+ expect(dosDateTime(null)).toEqual({ date: 33, time: 0 });
253
+ });
254
+
255
+ test('bytes in hand and an async iterable of entries both work', async () => {
256
+ async function* entries(): AsyncGenerator<ZipEntry> {
257
+ yield { name: 'inline.txt', open: async () => bytesOf('inline bytes') };
258
+ yield { name: 'empty.txt', open: async () => new Uint8Array(0) };
259
+ }
260
+ const archive = await collect(zipStream(entries()));
261
+ expect(await unzipRead(archive, 'inline.txt')).toBe('inline bytes');
262
+ expect(await unzipList(archive)).toContain('empty.txt');
263
+ });
264
+ });
@@ -0,0 +1,372 @@
1
+ /**
2
+ * A streaming ZIP writer — a download of many files as one, built as it is sent.
3
+ *
4
+ * Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
5
+ * needed the same thing for "download this folder": two copies of a hand-rolled format writer
6
+ * is two places for the next bug. Folders (directory entries) and file dates arrived with it,
7
+ * because a folder download that loses its empty folders and stamps every file 1980 is not a
8
+ * backup.
9
+ *
10
+ * ── Why this is hand-written rather than a dependency ───────────────────────────────────
11
+ * Three properties are needed together, and the popular libraries give at most two:
12
+ *
13
+ * · **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
14
+ * Deflating them spends CPU — on a Worker, the scarce thing — to produce a slightly LARGER
15
+ * file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
16
+ * beside them are not worth a second code path.
17
+ * · **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
18
+ * Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
19
+ * browser in the same pass.
20
+ * · **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
21
+ * fields wrap silently at that point. A truncated archive that reports success is the worst
22
+ * available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
23
+ * crossed — a conditional format is a format that is only tested on the small path.
24
+ *
25
+ * ── Streaming means data descriptors ────────────────────────────────────────────────────
26
+ * A local file header must state the CRC-32 and the sizes, and none of those are known until
27
+ * the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
28
+ * in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
29
+ * what makes a single forward pass possible, why this writer never seeks, and so why the
30
+ * response can be a `ReadableStream` and the server's memory stays flat whatever the size.
31
+ *
32
+ * ── The CPU it costs ────────────────────────────────────────────────────────────────────
33
+ * The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
34
+ * per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
35
+ * caller on a Worker caps what one archive may hold — music at 4 GB / 2,000 files — and says so
36
+ * BEFORE the download starts, rather than letting the edge cut it off part-way.
37
+ */
38
+
39
+ // ── CRC-32 ──────────────────────────────────────────────────────────────────────────────
40
+
41
+ /** The standard IEEE 802.3 table, built once. */
42
+ const CRC_TABLE: Uint32Array = (() => {
43
+ const table = new Uint32Array(256);
44
+ for (let i = 0; i < 256; i++) {
45
+ let c = i;
46
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
47
+ table[i] = c >>> 0;
48
+ }
49
+ return table;
50
+ })();
51
+
52
+ /** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
53
+ export class Crc32 {
54
+ private crc = 0xffffffff;
55
+
56
+ update(bytes: Uint8Array): void {
57
+ let crc = this.crc;
58
+ for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ (bytes[i] as number)) & 0xff]! ^ (crc >>> 8);
59
+ this.crc = crc;
60
+ }
61
+
62
+ get value(): number {
63
+ return (this.crc ^ 0xffffffff) >>> 0;
64
+ }
65
+ }
66
+
67
+ // ── Little-endian writing ───────────────────────────────────────────────────────────────
68
+
69
+ class ByteWriter {
70
+ private parts: Uint8Array[] = [];
71
+
72
+ u16(n: number): this {
73
+ const b = new Uint8Array(2);
74
+ new DataView(b.buffer).setUint16(0, n, true);
75
+ this.parts.push(b);
76
+ return this;
77
+ }
78
+
79
+ u32(n: number): this {
80
+ const b = new Uint8Array(4);
81
+ new DataView(b.buffer).setUint32(0, n >>> 0, true);
82
+ this.parts.push(b);
83
+ return this;
84
+ }
85
+
86
+ /** 64-bit, via BigInt — a whole-library download can pass 2^32, and Number would quietly lose precision past 2^53. */
87
+ u64(n: number | bigint): this {
88
+ const b = new Uint8Array(8);
89
+ new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
90
+ this.parts.push(b);
91
+ return this;
92
+ }
93
+
94
+ raw(bytes: Uint8Array): this {
95
+ this.parts.push(bytes);
96
+ return this;
97
+ }
98
+
99
+ done(): Uint8Array {
100
+ const total = this.parts.reduce((n, p) => n + p.length, 0);
101
+ const out = new Uint8Array(total);
102
+ let at = 0;
103
+ for (const part of this.parts) {
104
+ out.set(part, at);
105
+ at += part.length;
106
+ }
107
+ return out;
108
+ }
109
+ }
110
+
111
+ /** One member of the archive: a file whose bytes are opened lazily, or a folder. */
112
+ export type ZipEntry =
113
+ | {
114
+ /** Path inside the archive, `/`-separated. Sanitized by {@link zipStream}. */
115
+ name: string;
116
+ /**
117
+ * Opens the bytes — a stream, or bytes already in hand. Called only when the entry's turn
118
+ * comes, so a 2,000-file archive does not open 2,000 connections at once. Returning `null`
119
+ * (or throwing) skips the entry: a file that vanished must not abort the rest.
120
+ */
121
+ open: () => Promise<ReadableStream<Uint8Array> | Uint8Array | null>;
122
+ /** When the file last changed. Omitted: the archive says 1980-01-01, the format's zero. */
123
+ modified?: Date | string | null;
124
+ directory?: false;
125
+ }
126
+ | {
127
+ /** A folder — written so an EMPTY one survives the round trip. A trailing `/` is added. */
128
+ name: string;
129
+ directory: true;
130
+ modified?: Date | string | null;
131
+ };
132
+
133
+ const encoder = new TextEncoder();
134
+
135
+ /** ZIP64 needs version 4.5; bit 3 = sizes/CRC follow in a data descriptor; bit 11 = UTF-8
136
+ * names, which every accented and non-Latin file name needs. */
137
+ const VERSION_ZIP64 = 45;
138
+ const FLAG_DATA_DESCRIPTOR = 0x08;
139
+ const FLAG_UTF8 = 0x0800;
140
+ const METHOD_STORE = 0;
141
+ /** MS-DOS directory attribute, in the low byte of the external attributes. */
142
+ const ATTR_DIRECTORY = 0x10;
143
+
144
+ /**
145
+ * Names inside a zip must not escape the extraction directory, and must not carry characters
146
+ * Windows refuses. `..` segments and absolute paths are the security half; the character set is
147
+ * the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
148
+ * deep folder keeps its folders.
149
+ */
150
+ export function safeEntryName(name: string, fallback = "file"): string {
151
+ // Control characters (< 0x20) and the set Windows refuses. Filtered by code point rather than
152
+ // by a regex class, because a literal control character inside a regex is unreadable and the
153
+ // linter is right to object to it.
154
+ const FORBIDDEN = new Set(["<", ">", ":", '"', "|", "?", "*"]);
155
+ const segments = name
156
+ .split(/[/\\]/)
157
+ .filter((seg) => seg !== "" && seg !== "." && seg !== "..")
158
+ .map((seg) => {
159
+ let out = "";
160
+ for (const ch of seg) out += ch.codePointAt(0)! < 0x20 || FORBIDDEN.has(ch) ? "_" : ch;
161
+ // Windows also refuses a segment ending in a dot or a space; trimmed rather than replaced.
162
+ return out.slice(0, 200).replace(/[. ]+$/, "") || fallback;
163
+ });
164
+ return segments.join("/") || fallback;
165
+ }
166
+
167
+ /** The MS-DOS date and time a zip header carries — local wall-clock, 2-second resolution, 1980 at the earliest. */
168
+ export function dosDateTime(when: Date | string | null | undefined): { date: number; time: number } {
169
+ const at = when ? new Date(when) : null;
170
+ if (!at || Number.isNaN(at.getTime()) || at.getUTCFullYear() < 1980) return { date: (0 << 9) | (1 << 5) | 1, time: 0 };
171
+ // UTC, not the server's zone: a Worker has none, and the same archive should not depend on where it was built.
172
+ const year = Math.min(at.getUTCFullYear(), 2107) - 1980;
173
+ return {
174
+ date: (year << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
175
+ time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | Math.floor(at.getUTCSeconds() / 2),
176
+ };
177
+ }
178
+
179
+ function asStream(body: ReadableStream<Uint8Array> | Uint8Array): ReadableStream<Uint8Array> {
180
+ if (!(body instanceof Uint8Array)) return body;
181
+ return new ReadableStream<Uint8Array>({
182
+ start(controller) {
183
+ if (body.byteLength > 0) controller.enqueue(body);
184
+ controller.close();
185
+ },
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Build the archive as a stream.
191
+ *
192
+ * Duplicate file names are disambiguated rather than colliding — two different files that both
193
+ * want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
194
+ * identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
195
+ * once: its files already land in it.
196
+ */
197
+ export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
198
+ interface Central {
199
+ name: Uint8Array;
200
+ crc: number;
201
+ size: bigint;
202
+ offset: bigint;
203
+ date: number;
204
+ time: number;
205
+ directory: boolean;
206
+ }
207
+
208
+ const central: Central[] = [];
209
+ let offset = 0n;
210
+ const used = new Set<string>();
211
+ const iterator: AsyncIterator<ZipEntry> | Iterator<ZipEntry> =
212
+ Symbol.asyncIterator in entries ? (entries as AsyncIterable<ZipEntry>)[Symbol.asyncIterator]() : (entries as Iterable<ZipEntry>)[Symbol.iterator]();
213
+
214
+ return new ReadableStream<Uint8Array>({
215
+ async pull(controller) {
216
+ // One entry per pull keeps backpressure honest: the next file is not opened until the
217
+ // consumer has taken the previous one.
218
+ //
219
+ // 🔴 The loop is not decoration. A `pull` that returns having enqueued NOTHING is not
220
+ // guaranteed to be called again, so an entry that turns out to be unreadable would stall
221
+ // the entire archive — the consumer waits forever for bytes that will never come. Skipping
222
+ // therefore has to continue to the next entry within the SAME pull. (Measured in music:
223
+ // two tests hung for the full 5 s timeout before this loop existed.)
224
+ while (true) {
225
+ const next = await iterator.next();
226
+ if (next.done) break;
227
+ const entry = next.value;
228
+
229
+ const directory = entry.directory === true;
230
+ let name = safeEntryName(entry.name);
231
+ if (directory) {
232
+ name = `${name}/`;
233
+ if (used.has(name)) continue;
234
+ } else if (used.has(name)) {
235
+ const slash = name.lastIndexOf("/");
236
+ const dot = name.lastIndexOf(".");
237
+ const cut = dot > slash + 1 ? dot : name.length;
238
+ const stem = name.slice(0, cut);
239
+ const ext = name.slice(cut);
240
+ let n = 2;
241
+ while (used.has(`${stem} (${n})${ext}`)) n++;
242
+ name = `${stem} (${n})${ext}`;
243
+ }
244
+
245
+ let body: ReadableStream<Uint8Array> | null = null;
246
+ if (!directory) {
247
+ const opened = await entry.open().catch(() => null);
248
+ // A file whose bytes have gone is skipped, not fatal: one missing file must not abort
249
+ // a 2,000-file download that is otherwise fine. `continue`, never `return` — see the
250
+ // note on the loop above.
251
+ if (!opened) continue;
252
+ body = asStream(opened);
253
+ }
254
+ used.add(name);
255
+ const nameBytes = encoder.encode(name);
256
+ const { date, time } = dosDateTime(entry.modified);
257
+
258
+ // ZIP64 extra field, sizes zeroed — the data descriptor carries the truth.
259
+ const localExtra = new ByteWriter().u16(0x0001).u16(16).u64(0).u64(0).done();
260
+ const header = new ByteWriter()
261
+ .u32(0x04034b50)
262
+ .u16(VERSION_ZIP64)
263
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
264
+ .u16(METHOD_STORE)
265
+ .u16(time)
266
+ .u16(date)
267
+ .u32(0) // crc, in the descriptor
268
+ .u32(0xffffffff) // compressed size → see ZIP64 extra
269
+ .u32(0xffffffff) // uncompressed size → see ZIP64 extra
270
+ .u16(nameBytes.length)
271
+ .u16(localExtra.length)
272
+ .raw(nameBytes)
273
+ .raw(localExtra)
274
+ .done();
275
+
276
+ controller.enqueue(header);
277
+ const entryOffset = offset;
278
+ offset += BigInt(header.length);
279
+
280
+ const crc = new Crc32();
281
+ let size = 0n;
282
+ if (body) {
283
+ const reader = body.getReader();
284
+ while (true) {
285
+ const { done, value } = await reader.read();
286
+ if (done) break;
287
+ crc.update(value);
288
+ size += BigInt(value.length);
289
+ controller.enqueue(value);
290
+ }
291
+ }
292
+ offset += size;
293
+
294
+ // ZIP64 data descriptor: 8-byte sizes, unlike the 4-byte classic form.
295
+ const descriptor = new ByteWriter().u32(0x08074b50).u32(crc.value).u64(size).u64(size).done();
296
+ controller.enqueue(descriptor);
297
+ offset += BigInt(descriptor.length);
298
+
299
+ central.push({ name: nameBytes, crc: crc.value, size, offset: entryOffset, date, time, directory });
300
+ return;
301
+ }
302
+
303
+ // Central directory, then the ZIP64 end records, then the classic end record.
304
+ const start = offset;
305
+ for (const item of central) {
306
+ const extra = new ByteWriter().u16(0x0001).u16(24).u64(item.size).u64(item.size).u64(item.offset).done();
307
+ const record = new ByteWriter()
308
+ .u32(0x02014b50)
309
+ .u16(VERSION_ZIP64) // version made by (MS-DOS attributes)
310
+ .u16(VERSION_ZIP64) // version needed
311
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
312
+ .u16(METHOD_STORE)
313
+ .u16(item.time)
314
+ .u16(item.date)
315
+ .u32(item.crc)
316
+ .u32(0xffffffff)
317
+ .u32(0xffffffff)
318
+ .u16(item.name.length)
319
+ .u16(extra.length)
320
+ .u16(0) // comment length
321
+ .u16(0) // disk number
322
+ .u16(0) // internal attrs
323
+ .u32(item.directory ? ATTR_DIRECTORY : 0) // external attrs
324
+ .u32(0xffffffff) // local header offset → ZIP64 extra
325
+ .raw(item.name)
326
+ .raw(extra)
327
+ .done();
328
+ controller.enqueue(record);
329
+ offset += BigInt(record.length);
330
+ }
331
+ const centralSize = offset - start;
332
+
333
+ const zip64End = new ByteWriter()
334
+ .u32(0x06064b50)
335
+ .u64(44) // size of this record, minus its first 12 bytes
336
+ .u16(VERSION_ZIP64)
337
+ .u16(VERSION_ZIP64)
338
+ .u32(0)
339
+ .u32(0)
340
+ .u64(central.length)
341
+ .u64(central.length)
342
+ .u64(centralSize)
343
+ .u64(start)
344
+ .done();
345
+ controller.enqueue(zip64End);
346
+
347
+ const locator = new ByteWriter()
348
+ .u32(0x07064b50)
349
+ .u32(0)
350
+ .u64(offset) // where the ZIP64 end record starts
351
+ .u32(1)
352
+ .done();
353
+ controller.enqueue(locator);
354
+
355
+ // The classic record still has to be there for every reader to find the archive; its
356
+ // 0xffff/0xffffffff sentinels say "look at the ZIP64 record instead".
357
+ const end = new ByteWriter()
358
+ .u32(0x06054b50)
359
+ .u16(0)
360
+ .u16(0)
361
+ .u16(central.length > 0xffff ? 0xffff : central.length)
362
+ .u16(central.length > 0xffff ? 0xffff : central.length)
363
+ .u32(0xffffffff)
364
+ .u32(0xffffffff)
365
+ .u16(0)
366
+ .done();
367
+ controller.enqueue(end);
368
+
369
+ controller.close();
370
+ },
371
+ });
372
+ }