···11+BSD Zero Clause License
22+33+Copyright (c) 2025 Mary
44+55+Permission to use, copy, modify, and/or distribute this software for any
66+purpose with or without fee is hereby granted.
77+88+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
99+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1010+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1111+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1212+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1313+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1414+PERFORMANCE OF THIS SOFTWARE.
···11+export * from './zip.ts';
22+export * from './unzip.ts';
33+44+export type * from './reader/types.ts';
55+export * from './reader/common.ts';
+104
lib/reader/common.ts
···11+import type { Reader } from './types.ts';
22+33+/**
44+ * creates a reader from a Uint8Array
55+ * @param data the data to read from
66+ * @returns a reader for the data
77+ */
88+export function fromUint8Array(data: Uint8Array): Reader {
99+ const totalLength = data.length;
1010+1111+ return {
1212+ length: totalLength,
1313+ // deno-lint-ignore require-await
1414+ async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> {
1515+ const endOffset = length !== undefined ? offset + length : totalLength;
1616+1717+ const slice = data.subarray(offset, endOffset);
1818+ const sliceLength = slice.length;
1919+2020+ let position = 0;
2121+2222+ return new ReadableStream<Uint8Array>({
2323+ pull(controller) {
2424+ if (position >= sliceLength) {
2525+ controller.close();
2626+ return;
2727+ }
2828+2929+ const size = Math.min(controller.desiredSize!, sliceLength - position);
3030+ const chunk = slice.subarray(position, position + size);
3131+3232+ controller.enqueue(chunk);
3333+ position += size;
3434+3535+ if (position >= sliceLength) {
3636+ controller.close();
3737+ return;
3838+ }
3939+ },
4040+ }, new ByteLengthQueuingStrategy({ highWaterMark: 64 * 1024 }));
4141+ },
4242+ };
4343+}
4444+4545+export interface FetchReaderOptions {
4646+ input: string | URL | Request;
4747+ fetch?: typeof fetch;
4848+}
4949+5050+/**
5151+ * creates a reader from a fetch URL or Request
5252+ * @param input URL string or Request object to fetch from
5353+ * @returns a reader for the remote resource
5454+ */
5555+export async function fromFetch(opts: FetchReaderOptions): Promise<Reader> {
5656+ const thisFetch = opts.fetch ?? fetch;
5757+5858+ const totalLength = await (async () => {
5959+ // make a HEAD request to get content length upfront
6060+ const response = await thisFetch(opts.input, { method: 'HEAD' });
6161+ if (!response.ok) {
6262+ throw new Error(`failed to fetch resource: http ${response.status}`);
6363+ }
6464+6565+ const raw = response.headers.get('content-length');
6666+ if (raw === null) {
6767+ throw new Error(`content-length header not available`);
6868+ }
6969+7070+ const value = parseInt(raw, 10);
7171+ if (isNaN(value)) {
7272+ throw new Error(`invalid content-length header`);
7373+ }
7474+7575+ return value;
7676+ })();
7777+7878+ return {
7979+ length: totalLength,
8080+ async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> {
8181+ const endOffset = length !== undefined ? offset + length - 1 : totalLength - 1;
8282+8383+ const req = opts.input;
8484+ const headers = new Headers(req instanceof Request ? req.headers : undefined);
8585+ headers.set('range', `bytes=${offset}-${endOffset}`);
8686+8787+ const response = await thisFetch(req, { headers });
8888+8989+ if (!response.ok) {
9090+ throw new Error(`failed to fetch range ${offset}-${endOffset}: http ${response.status}`);
9191+ }
9292+9393+ if (response.status !== 206) {
9494+ throw new Error(`server does not support range requests`);
9595+ }
9696+9797+ if (response.body === null) {
9898+ throw new Error(`response body is null`);
9999+ }
100100+101101+ return response.body;
102102+ },
103103+ };
104104+}
+59
lib/reader/deno.ts
···11+import Mutex from '@mary/mutex';
22+33+import type { Reader } from './types.ts';
44+55+/**
66+ * creates a reader from a Deno.FsFile
77+ * @param file the file to read from
88+ * @returns a reader for the file
99+ */
1010+export async function fromFsFile(file: Deno.FsFile): Promise<Reader & Disposable> {
1111+ const mutex = new Mutex();
1212+ const totalLength = (await file.stat()).size;
1313+1414+ return {
1515+ length: totalLength,
1616+ // deno-lint-ignore require-await
1717+ async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> {
1818+ let remaining = length !== undefined ? length : totalLength - offset;
1919+2020+ return new ReadableStream<Uint8Array>({
2121+ type: 'bytes',
2222+ async pull(controller) {
2323+ using _lock = await mutex.acquire();
2424+2525+ await file.seek(offset, Deno.SeekMode.Start);
2626+2727+ const size = Math.min(controller.desiredSize!, remaining);
2828+2929+ const buffer = new Uint8Array(size);
3030+ const read = await file.read(buffer);
3131+3232+ if (read === null) {
3333+ // end of file
3434+ controller.close();
3535+ return;
3636+ }
3737+3838+ if (read < size) {
3939+ // partial read, slice the buffer
4040+ controller.enqueue(buffer.subarray(0, read));
4141+ } else {
4242+ controller.enqueue(buffer);
4343+ }
4444+4545+ remaining -= read;
4646+ offset += read;
4747+4848+ if (remaining <= 0) {
4949+ controller.close();
5050+ return;
5151+ }
5252+ },
5353+ }, { highWaterMark: 64 * 1024 });
5454+ },
5555+ [Symbol.dispose]() {
5656+ file.close();
5757+ },
5858+ };
5959+}
+51
lib/reader/node.ts
···11+import type { FileHandle } from 'node:fs/promises';
22+33+import type { Reader } from './types.ts';
44+55+/**
66+ * creates a reader from a Node.js' FileHandle
77+ * @param handle the file handle to read from
88+ * @returns a reader for the file
99+ */
1010+export const fromFileHandle = async (handle: FileHandle): Promise<Reader> => {
1111+ const totalLength = (await handle.stat()).size;
1212+1313+ return {
1414+ length: totalLength,
1515+ // deno-lint-ignore require-await
1616+ async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> {
1717+ let remaining = length !== undefined ? length : totalLength - offset;
1818+1919+ return new ReadableStream<Uint8Array>({
2020+ type: 'bytes',
2121+ async pull(controller) {
2222+ const size = Math.min(controller.desiredSize!, remaining);
2323+2424+ const buffer = new Uint8Array(size);
2525+ const { bytesRead } = await handle.read(buffer, 0, size, offset);
2626+2727+ if (bytesRead === 0) {
2828+ // end of file
2929+ controller.close();
3030+ return;
3131+ }
3232+3333+ if (bytesRead < size) {
3434+ // partial read, slice the buffer
3535+ controller.enqueue(buffer.subarray(0, bytesRead));
3636+ } else {
3737+ controller.enqueue(buffer);
3838+ }
3939+4040+ remaining -= bytesRead;
4141+ offset += bytesRead;
4242+4343+ if (remaining <= 0) {
4444+ controller.close();
4545+ return;
4646+ }
4747+ },
4848+ }, { highWaterMark: 64 * 1024 });
4949+ },
5050+ };
5151+};
+9
lib/reader/types.ts
···11+/**
22+ * interface for reading data from a zip source
33+ */
44+export interface Reader {
55+ /** total length of the data source */
66+ length: number;
77+ /** reads data from the specified offset and length */
88+ read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>>;
99+}
+394
lib/unzip.ts
···11+import CircularBuffer from '@mary/ds-circular-buffer';
22+33+import { decodeUtf8From, read } from './utils/buffer.ts';
44+import { cache } from './utils/decorator.ts';
55+import { StreamReader } from './utils/stream-reader.ts';
66+77+import type { Reader } from './reader/types.ts';
88+99+const EOCD_FIXED_SIZE = 22;
1010+const CRECORD_FIXED_SIZE = 46;
1111+const LHEADER_FIXED_SIZE = 30;
1212+1313+function readUint16LE(view: DataView, offset: number): number {
1414+ return view.getUint16(offset, true);
1515+}
1616+1717+function readUint32LE(view: DataView, offset: number): number {
1818+ return view.getUint32(offset, true);
1919+}
2020+2121+/**
2222+ * represents a single entry in a zip archive during extraction
2323+ */
2424+export class UnzippedEntry {
2525+ readonly #reader: Reader;
2626+2727+ readonly #recordFixed: Uint8Array;
2828+ readonly #recordVariable: Uint8Array;
2929+3030+ readonly #headerOffset: number;
3131+3232+ /** @ignore */
3333+ constructor(reader: Reader, recordFixed: Uint8Array, recordVariable: Uint8Array, headerOffset: number) {
3434+ this.#reader = reader;
3535+3636+ this.#recordFixed = recordFixed;
3737+ this.#recordVariable = recordVariable;
3838+3939+ this.#headerOffset = headerOffset;
4040+ }
4141+4242+ get #centralFixedView(): DataView {
4343+ const buffer = this.#recordFixed;
4444+ return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
4545+ }
4646+4747+ /**
4848+ * the filename of the entry
4949+ * @returns the filename as string
5050+ */
5151+ @cache
5252+ get filename(): string {
5353+ const fnameLength = readUint16LE(this.#centralFixedView, 28);
5454+ const fname = this.#recordVariable.subarray(0, fnameLength);
5555+5656+ return decodeUtf8From(fname);
5757+ }
5858+5959+ /**
6060+ * the uncompressed size of the entry
6161+ * @returns size in bytes
6262+ */
6363+ @cache
6464+ get size(): number {
6565+ return readUint32LE(this.#centralFixedView, 24);
6666+ }
6767+6868+ /**
6969+ * the compressed size of the entry
7070+ * @returns size in bytes
7171+ */
7272+ @cache
7373+ get compressedSize(): number {
7474+ return readUint32LE(this.#centralFixedView, 20);
7575+ }
7676+7777+ /**
7878+ * the compression method used for the entry
7979+ * @returns compression method code
8080+ */
8181+ @cache
8282+ get compressionMethod(): number {
8383+ return readUint16LE(this.#centralFixedView, 10);
8484+ }
8585+8686+ /**
8787+ * the last modification time of the entry
8888+ * @returns timestamp in milliseconds
8989+ */
9090+ @cache
9191+ get mtime(): number {
9292+ const view = this.#centralFixedView;
9393+ const dosTime = readUint16LE(view, 12);
9494+ const dosDate = readUint16LE(view, 14);
9595+9696+ const seconds = (dosTime & 0x1f) * 2;
9797+ const minutes = (dosTime >>> 5) & 0x3f;
9898+ const hours = (dosTime >>> 11) & 0x1f;
9999+100100+ const day = dosDate & 0x1f;
101101+ const month = ((dosDate >>> 5) & 0x0f) - 1;
102102+ const year = ((dosDate >>> 9) & 0x7f) + 1980;
103103+104104+ return Date.UTC(year, month, day, hours, minutes, seconds);
105105+ }
106106+107107+ /**
108108+ * the file mode/permissions of the entry
109109+ * @returns mode as number, or 0 if not set
110110+ */
111111+ @cache
112112+ get mode(): number {
113113+ const attrs = readUint32LE(this.#centralFixedView, 38);
114114+ const mode = (attrs >>> 16) & 0xffff;
115115+116116+ return mode;
117117+ }
118118+119119+ /**
120120+ * a readable stream of the entry's decompressed content
121121+ * @returns stream of the entry data
122122+ */
123123+ @cache
124124+ get body(): ReadableStream<Uint8Array> {
125125+ let r: ReadableStreamDefaultReader<Uint8Array>;
126126+127127+ return new ReadableStream({
128128+ start: async (controller) => {
129129+ try {
130130+ // read local file header to get actual data offset
131131+ let offset: number;
132132+ {
133133+ const headerOffset = this.#headerOffset;
134134+135135+ const stream = await this.#reader.read(headerOffset, LHEADER_FIXED_SIZE);
136136+ const bytes = await read(stream, LHEADER_FIXED_SIZE);
137137+138138+ const header = new DataView(bytes.buffer, bytes.byteOffset, LHEADER_FIXED_SIZE);
139139+140140+ const filenameLength = readUint16LE(header, 26);
141141+ const extraFieldLength = readUint16LE(header, 28);
142142+143143+ offset = headerOffset + LHEADER_FIXED_SIZE + filenameLength + extraFieldLength;
144144+ }
145145+146146+ // read data
147147+ let stream = await this.#reader.read(offset, this.compressedSize);
148148+ if (this.compressionMethod === 8) {
149149+ // deflate compression
150150+ stream = stream.pipeThrough(new DecompressionStream('deflate-raw'));
151151+ }
152152+153153+ r = stream.getReader();
154154+ } catch (error) {
155155+ controller.error(error);
156156+157157+ await r?.cancel();
158158+ }
159159+ },
160160+ async pull(controller) {
161161+ const { done, value } = await r.read();
162162+163163+ if (done) {
164164+ controller.close();
165165+ } else {
166166+ controller.enqueue(value);
167167+ }
168168+ },
169169+ async cancel() {
170170+ await r?.cancel();
171171+ },
172172+ });
173173+ }
174174+175175+ /**
176176+ * reads the entry content as bytes
177177+ * @returns the content as Uint8Array
178178+ */
179179+ async bytes(): Promise<Uint8Array> {
180180+ return await read(this.body, this.size);
181181+ }
182182+183183+ /**
184184+ * reads the entry content as an ArrayBuffer
185185+ * @returns an ArrayBuffer
186186+ */
187187+ async arrayBuffer(): Promise<ArrayBuffer> {
188188+ const bytes = await this.bytes() as Uint8Array<ArrayBuffer>;
189189+190190+ // if bytes covers the entire buffer, return it directly
191191+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
192192+ return bytes.buffer;
193193+ }
194194+195195+ // otherwise we need to slice to get the right portion
196196+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
197197+ }
198198+199199+ /**
200200+ * reads the entry content as a Blob
201201+ * @returns a Blob
202202+ */
203203+ async blob(): Promise<Blob> {
204204+ const bytes = await this.bytes();
205205+ return new Blob([bytes]);
206206+ }
207207+208208+ /**
209209+ * reads the entry content as text
210210+ * @returns the content as string
211211+ */
212212+ async text(): Promise<string> {
213213+ const bytes = await this.bytes();
214214+ return decodeUtf8From(bytes);
215215+ }
216216+217217+ /**
218218+ * reads the entry content as JSON
219219+ * @returns the parsed JSON
220220+ */
221221+ async json(): Promise<unknown> {
222222+ const text = await this.text();
223223+ return JSON.parse(text);
224224+ }
225225+}
226226+227227+/**
228228+ * extracts entries from a zip archive
229229+ * @param reader data source to read the zip file from
230230+ * @returns async generator yielding zip entries
231231+ */
232232+export async function* unzip(reader: Reader): AsyncGenerator<UnzippedEntry> {
233233+ const eocd = await readEocd(reader);
234234+235235+ for await (const record of readCentralRecords(reader, eocd)) {
236236+ yield new UnzippedEntry(reader, record.fixedBytes, record.variableBytes, record.headerOffset);
237237+ }
238238+}
239239+240240+interface EndOfCentralDirectory {
241241+ recordCount: number;
242242+ dirSize: number;
243243+ dirOffset: number;
244244+}
245245+246246+interface CentralRecord {
247247+ fixedBytes: Uint8Array;
248248+ variableBytes: Uint8Array;
249249+ headerOffset: number;
250250+}
251251+252252+async function readEocd(reader: Reader): Promise<EndOfCentralDirectory> {
253253+ const maxSearchLength = Math.min(reader.length, 65557); // 22 + max comment length
254254+ const start = reader.length - maxSearchLength;
255255+256256+ // use a 22-byte circular buffer sliding window
257257+ const window = new CircularBuffer(EOCD_FIXED_SIZE);
258258+ const stream = await reader.read(start, maxSearchLength);
259259+260260+ let pos = start;
261261+ let eocd: EndOfCentralDirectory | undefined;
262262+263263+ for await (const chunk of stream) {
264264+ for (let idx = 0, len = chunk.length; idx < len; idx++) {
265265+ const byte = chunk[idx];
266266+267267+ window.push(byte);
268268+ pos++;
269269+270270+ // only check for signature once window is full
271271+ if (!window.filled) {
272272+ continue;
273273+ }
274274+275275+ // 0 - end of central directory signature
276276+ // 4 - this disk's number
277277+ // 6 - disk number containing start of central directory
278278+ // 8 - amount of records in this disk's central directory
279279+ // 10 - total amount of central directory records
280280+ // 12 - total size of this disk's central directory records
281281+ // 16 - offset to start of this disk's central directory records
282282+ // 20 - comment length
283283+284284+ // check for EOCD signature (0x06054b50)
285285+ // signature is [0x50, 0x4b, 0x05, 0x06] in little endian
286286+ if (window.at(0) === 0x50 && window.at(1) === 0x4b && window.at(2) === 0x05 && window.at(3) === 0x06) {
287287+ const offset = pos - EOCD_FIXED_SIZE; // window start position
288288+289289+ const buffer = window.dump();
290290+ const view = new DataView(buffer.buffer, buffer.byteOffset);
291291+292292+ const diskNumber = readUint16LE(view, 4);
293293+ const commentLength = readUint16LE(view, 20);
294294+295295+ if (diskNumber !== 0) {
296296+ throw new Error(`multi-disk zip files not supported`);
297297+ }
298298+299299+ // the EOCD should end exactly at the file end
300300+ if (offset + EOCD_FIXED_SIZE + commentLength === reader.length) {
301301+ // validate central directory bounds
302302+ const centralDirSize = readUint32LE(view, 12);
303303+ const centralDirOffset = readUint32LE(view, 16);
304304+305305+ if (centralDirOffset + centralDirSize <= offset) {
306306+ eocd = {
307307+ recordCount: readUint16LE(view, 10),
308308+ dirSize: centralDirSize,
309309+ dirOffset: centralDirOffset,
310310+ };
311311+ }
312312+ }
313313+ }
314314+ }
315315+ }
316316+317317+ if (eocd === undefined) {
318318+ throw new Error(`zip file might be invalid or corrupted`);
319319+ }
320320+321321+ return eocd;
322322+}
323323+324324+async function* readCentralRecords(
325325+ reader: Reader,
326326+ { dirOffset, dirSize, recordCount }: EndOfCentralDirectory,
327327+): AsyncGenerator<CentralRecord> {
328328+ const stream = await reader.read(dirOffset, dirSize);
329329+ await using r = new StreamReader(stream);
330330+331331+ for (let idx = 0, len = recordCount; idx < len; idx++) {
332332+ // check if we have room for at least the fixed header
333333+ if (r.consumed + CRECORD_FIXED_SIZE > dirSize) {
334334+ throw new Error(`central record extends beyond directory bounds`);
335335+ }
336336+337337+ // ensure we have 46 bytes for the header
338338+ await r.require(CRECORD_FIXED_SIZE);
339339+340340+ const fixedBytes = r.take(CRECORD_FIXED_SIZE);
341341+ const fixed = new DataView(fixedBytes.buffer, fixedBytes.byteOffset);
342342+343343+ // 0 - central directory record signature
344344+ // 4 - version used to create this archive
345345+ // 6 - minimum required version for extraction
346346+ // 8 - general purpose bitflag
347347+ // 10 - compression method
348348+ // 12 - file last modification time
349349+ // 14 - file last modification date
350350+ // 16 - CRC32 of uncompressed data
351351+ // 20 - compressed size
352352+ // 24 - uncompressed size
353353+ // 28 - file name length
354354+ // 30 - extra fields length
355355+ // 32 - file comment length
356356+ // 34 - disk number containing start of file
357357+ // 36 - internal file attributes
358358+ // 38 - external file attributes
359359+ // 42 - offset to start of entry
360360+361361+ const signature = readUint32LE(fixed, 0);
362362+ if (signature !== 0x02014b50) {
363363+ throw new Error(`invalid central directory file header signature`);
364364+ }
365365+366366+ const filenameLength = readUint16LE(fixed, 28);
367367+ const extraFieldLength = readUint16LE(fixed, 30);
368368+ const commentLength = readUint16LE(fixed, 32);
369369+ const headerOffset = readUint32LE(fixed, 42);
370370+371371+ const variableLength = filenameLength + extraFieldLength + commentLength;
372372+373373+ // validate variable field lengths are reasonable
374374+ if (variableLength < 0 || r.consumed + variableLength > dirSize) {
375375+ throw new Error(`central record variable fields extend beyond directory bounds`);
376376+ }
377377+378378+ let variableBytes: Uint8Array;
379379+380380+ if (variableLength > 0) {
381381+ await r.require(variableLength);
382382+383383+ variableBytes = r.take(variableLength);
384384+ } else {
385385+ variableBytes = new Uint8Array(0);
386386+ }
387387+388388+ yield {
389389+ fixedBytes: fixedBytes,
390390+ variableBytes: variableBytes,
391391+ headerOffset: headerOffset,
392392+ };
393393+ }
394394+}
+106
lib/utils/buffer.ts
···11+const fromCharCode = String.fromCharCode;
22+33+export const textEncoder = new TextEncoder();
44+export const textDecoder = new TextDecoder();
55+66+export function concat(arrays: Uint8Array[], size?: number): Uint8Array {
77+ let written = 0;
88+99+ // deno-lint-ignore prefer-const
1010+ let len = arrays.length;
1111+ let idx: number;
1212+1313+ if (size === undefined) {
1414+ for (idx = size = 0; idx < len; idx++) {
1515+ const chunk = arrays[idx];
1616+ size += chunk.length;
1717+ }
1818+ }
1919+2020+ const buffer = new Uint8Array(size);
2121+2222+ for (idx = 0; idx < len; idx++) {
2323+ const chunk = arrays[idx];
2424+2525+ buffer.set(chunk, written);
2626+ written += chunk.length;
2727+ }
2828+2929+ return buffer;
3030+}
3131+3232+export async function read(stream: ReadableStream<Uint8Array>, size: number): Promise<Uint8Array> {
3333+ const buffer = new Uint8Array(size);
3434+3535+ let read = 0;
3636+3737+ for await (const chunk of stream) {
3838+ const length = chunk.length;
3939+ const remaining = Math.min(length, size - read);
4040+4141+ if (remaining === length) {
4242+ buffer.set(chunk, read);
4343+ } else {
4444+ buffer.set(chunk.subarray(0, remaining), read);
4545+ }
4646+4747+ read += remaining;
4848+4949+ if (read >= size) {
5050+ break;
5151+ }
5252+ }
5353+5454+ if (read < size) {
5555+ throw new Error(`unexpected end of stream: expected ${size} bytes, got ${read}`);
5656+ }
5757+5858+ return buffer;
5959+}
6060+6161+export const decodeUtf8From = (from: Uint8Array, offset?: number, length?: number): string => {
6262+ let buffer: Uint8Array;
6363+6464+ if (offset === undefined) {
6565+ buffer = from;
6666+ } else if (length === undefined) {
6767+ buffer = from.subarray(offset);
6868+ } else {
6969+ buffer = from.subarray(offset, offset + length);
7070+ }
7171+7272+ const end = buffer.length;
7373+ if (end > 24) {
7474+ return textDecoder.decode(buffer);
7575+ }
7676+7777+ {
7878+ let str = '';
7979+ let idx = 0;
8080+8181+ for (; idx + 3 < end; idx += 4) {
8282+ const a = buffer[idx];
8383+ const b = buffer[idx + 1];
8484+ const c = buffer[idx + 2];
8585+ const d = buffer[idx + 3];
8686+8787+ if ((a | b | c | d) & 0x80) {
8888+ return str + textDecoder.decode(buffer.subarray(idx));
8989+ }
9090+9191+ str += fromCharCode(a, b, c, d);
9292+ }
9393+9494+ for (; idx < end; idx++) {
9595+ const x = buffer[idx];
9696+9797+ if (x & 0x80) {
9898+ return str + textDecoder.decode(buffer.subarray(idx));
9999+ }
100100+101101+ str += fromCharCode(x);
102102+ }
103103+104104+ return str;
105105+ }
106106+};
+11
lib/utils/decorator.ts
···11+/**
22+ * caches the result of a getter on first access
33+ */
44+export function cache<T, K>(originalMethod: (this: T) => K, context: ClassGetterDecoratorContext) {
55+ return function (this: T): K {
66+ const value = originalMethod.call(this);
77+88+ Object.defineProperty(this, context.name, { value, writable: false, enumerable: false });
99+ return value;
1010+ };
1111+}
+89
lib/utils/stream-reader.ts
···11+import Queue from '@mary/ds-queue';
22+33+import { concat } from './buffer.ts';
44+55+export class StreamReader implements AsyncDisposable {
66+ #reader: ReadableStreamDefaultReader<Uint8Array>;
77+88+ #chunks = new Queue<Uint8Array>();
99+ #buffered = 0;
1010+ #consumed = 0;
1111+1212+ constructor(stream: ReadableStream<Uint8Array>) {
1313+ this.#reader = stream.getReader();
1414+ }
1515+1616+ get buffered(): number {
1717+ return this.#buffered;
1818+ }
1919+2020+ get consumed(): number {
2121+ return this.#consumed;
2222+ }
2323+2424+ async require(size: number): Promise<void> {
2525+ const reader = this.#reader;
2626+ const chunks = this.#chunks;
2727+2828+ while (this.#buffered < size) {
2929+ const { done, value } = await reader.read();
3030+3131+ if (done) {
3232+ throw new Error(`unexpected end of stream: needed ${size} bytes, have ${this.#buffered}`);
3333+ }
3434+3535+ chunks.enqueue(value);
3636+3737+ this.#buffered += value.length;
3838+ }
3939+ }
4040+4141+ take(size: number): Uint8Array {
4242+ if (size === 0) {
4343+ return new Uint8Array(0);
4444+ }
4545+4646+ if (this.#buffered < size) {
4747+ throw new Error(`needed ${size} bytes, have ${this.#buffered}`);
4848+ }
4949+5050+ const chunks = this.#chunks;
5151+ const needed: Uint8Array[] = [];
5252+5353+ let taken = 0;
5454+5555+ while (taken < size) {
5656+ const chunk = chunks.dequeue()!;
5757+ const chunkLength = chunk.length;
5858+5959+ const took = Math.min(chunkLength, size - taken);
6060+6161+ if (took < chunkLength) {
6262+ // Split chunk
6363+ needed.push(chunk.subarray(0, took));
6464+6565+ chunks.enqueueFront(chunk.subarray(took));
6666+6767+ taken += took;
6868+ break;
6969+ } else {
7070+ needed.push(chunk);
7171+7272+ taken += chunkLength;
7373+ }
7474+ }
7575+7676+ this.#buffered -= size;
7777+ this.#consumed += size;
7878+7979+ if (needed.length === 1) {
8080+ return needed[0];
8181+ } else {
8282+ return concat(needed, size);
8383+ }
8484+ }
8585+8686+ async [Symbol.asyncDispose](): Promise<void> {
8787+ await this.#reader.cancel();
8888+ }
8989+}
+350
lib/zip.ts
···11+import { getDayOfMonth, getHours, getMinutes, getMonth, getSeconds, getYear } from '@mary/date-fns';
22+import { textEncoder } from './utils/buffer.ts';
33+44+/**
55+ * file attributes for zip entries
66+ */
77+export interface ZipFileAttributes {
88+ /** file permissions mode */
99+ mode?: number;
1010+ /** user id of the file owner */
1111+ uid?: number;
1212+ /** group id of the file owner */
1313+ gid?: number;
1414+ /** modification time as unix timestamp */
1515+ mtime?: number;
1616+ /** owner username */
1717+ owner?: string;
1818+ /** group name */
1919+ group?: string;
2020+}
2121+2222+/**
2323+ * represents a single entry in a zip archive
2424+ */
2525+export interface ZipEntry {
2626+ /** path and name of the file in the zip archive */
2727+ filename: string;
2828+ /** file content as string, bytes, or stream */
2929+ data: string | Uint8Array | ReadableStream<Uint8Array>;
3030+ /** file attributes like permissions and timestamps */
3131+ attrs?: ZipFileAttributes;
3232+ /** whether to compress the file data */
3333+ compress?: false | 'deflate';
3434+}
3535+3636+const DEFAULT_ATTRS: ZipFileAttributes = {};
3737+3838+// deno-lint-ignore no-control-regex
3939+const INVALID_FILENAME_CHARS = /[<>:"|?*\x00-\x1f]/;
4040+const INVALID_FILENAME_TRAVERSAL = /(?:^|[/\\])\.\.(?:[/\\]|$)/;
4141+// deno-lint-ignore no-control-regex
4242+const NON_ASCII_CHARS = /[^\x00-\x7f]/;
4343+4444+function writeUtf8String(view: DataView, offset: number, length: number, str: string) {
4545+ const u8 = new Uint8Array(view.buffer, view.byteOffset + offset, length);
4646+ textEncoder.encodeInto(str, u8);
4747+}
4848+4949+function writeUint32LE(view: DataView, offset: number, value: number) {
5050+ view.setUint32(offset, value, true);
5151+}
5252+function writeUint16LE(view: DataView, offset: number, value: number) {
5353+ view.setUint16(offset, value, true);
5454+}
5555+5656+const CRC32_TABLE = /*#__PURE__*/ (() => {
5757+ const t = new Int32Array(256);
5858+5959+ for (let i = 0; i < 256; ++i) {
6060+ let c = i, k = 9;
6161+ while (--k) c = (c & 1 ? 0xedb88320 : 0) ^ (c >>> 1);
6262+ t[i] = c;
6363+ }
6464+6565+ return t;
6666+})();
6767+6868+function crc32(chunk: Uint8Array, crc: number = 0xffffffff): number {
6969+ for (let idx = 0, len = chunk.length; idx < len; idx++) {
7070+ crc = CRC32_TABLE[(crc ^ chunk[idx]) & 0xff] ^ (crc >>> 8);
7171+ }
7272+7373+ return crc ^ -1;
7474+}
7575+7676+function unixToDosTime(unixTimestamp: number): { time: number; date: number } {
7777+ const date = new Date(unixTimestamp * 1000);
7878+7979+ const dosTime = ((getSeconds(date) >> 1) & 0x1f) | ((getMinutes(date) & 0x3f) << 5) |
8080+ ((getHours(date) & 0x1f) << 11);
8181+8282+ const dosDate = (getDayOfMonth(date) & 0x1f) |
8383+ (((getMonth(date) + 1) & 0x0f) << 5) |
8484+ (((getYear(date) - 1980) & 0x7f) << 9);
8585+8686+ return { time: dosTime, date: dosDate };
8787+}
8888+8989+function validateFilename(filename: string): void {
9090+ if (filename.length === 0) {
9191+ throw new Error(`invalid filename: cannot be empty`);
9292+ }
9393+9494+ if (filename.length > 65535) {
9595+ throw new Error(`invalid filename: too long (max 65535 bytes)`);
9696+ }
9797+9898+ if (INVALID_FILENAME_TRAVERSAL.test(filename)) {
9999+ throw new Error(`invalid filename: contains path traversal`);
100100+ }
101101+102102+ if (filename.startsWith('/')) {
103103+ throw new Error(`invalid filename: is an absolute path`);
104104+ }
105105+106106+ if (INVALID_FILENAME_CHARS.test(filename)) {
107107+ throw new Error('invalid filename: contains invalid characters');
108108+ }
109109+}
110110+111111+function isNonAscii(filename: string): boolean {
112112+ // check if filename contains non-ASCII characters
113113+ return NON_ASCII_CHARS.test(filename);
114114+}
115115+116116+/**
117117+ * creates a zip archive from entries and yields chunks as Uint8Array
118118+ * @param entries iterable of zip entries to include in the archive
119119+ * @returns async generator that yields zip file chunks
120120+ */
121121+export async function* zip(
122122+ entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>,
123123+): AsyncGenerator<Uint8Array> {
124124+ const listing: Uint8Array[] = [];
125125+ let offset: number = 0;
126126+127127+ for await (const { filename, data, compress = 'deflate', attrs = DEFAULT_ATTRS } of entries) {
128128+ validateFilename(filename);
129129+130130+ const startOffset = offset;
131131+132132+ const fname = textEncoder.encode(filename);
133133+ const fnameLen = fname.length;
134134+135135+ const mtimeSeconds = attrs?.mtime ?? Math.floor(Date.now() / 1000);
136136+ const { time: dosTime, date: dosDate } = unixToDosTime(mtimeSeconds);
137137+138138+ let method: number = 0;
139139+ let crc: number = 0xffffffff;
140140+ let flags = 0x0008;
141141+142142+ let uncompressedSize: number = 0;
143143+ let compressedSize: number = 0;
144144+145145+ if (compress === 'deflate') {
146146+ method = 8;
147147+ }
148148+149149+ if (isNonAscii(filename)) {
150150+ flags |= 0x0800;
151151+ }
152152+153153+ // local header
154154+ {
155155+ const header = new ArrayBuffer(30 + fnameLen);
156156+ const view = new DataView(header);
157157+158158+ writeUint32LE(view, 0, 0x04034b50); // local file header signature
159159+ writeUint16LE(view, 4, 20); // version needed to extract (2.0)
160160+ writeUint16LE(view, 6, flags); // general purpose bit flag
161161+ writeUint16LE(view, 8, method); // compression method (0=stored, 8=deflate)
162162+ writeUint16LE(view, 10, dosTime); // last mod file time (DOS format)
163163+ writeUint16LE(view, 12, dosDate); // last mod file date (DOS format)
164164+ writeUint32LE(view, 14, 0); // crc-32 (set to 0, actual value in data descriptor)
165165+ writeUint32LE(view, 18, 0); // compressed size (set to 0, actual value in data descriptor)
166166+ writeUint32LE(view, 22, 0); // uncompressed size (set to 0, actual value in data descriptor)
167167+ writeUint16LE(view, 26, fnameLen); // file name length
168168+ writeUint16LE(view, 28, 0); // extra field length
169169+170170+ writeUtf8String(view, 30, fnameLen, filename);
171171+172172+ offset += 30 + fnameLen;
173173+174174+ yield new Uint8Array(header);
175175+ }
176176+177177+ // data
178178+ if (compress === 'deflate') {
179179+ let stream: ReadableStream<Uint8Array>;
180180+ if (data instanceof ReadableStream) {
181181+ stream = data.pipeThrough(
182182+ new TransformStream({
183183+ transform(chunk, controller) {
184184+ uncompressedSize += chunk.length;
185185+ crc = crc32(chunk, crc);
186186+187187+ controller.enqueue(chunk);
188188+ },
189189+ }),
190190+ );
191191+ } else {
192192+ const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
193193+194194+ uncompressedSize = chunk.length;
195195+ crc = crc32(chunk, crc);
196196+197197+ stream = new ReadableStream({
198198+ start(controller) {
199199+ controller.enqueue(chunk);
200200+ controller.close();
201201+ },
202202+ });
203203+ }
204204+205205+ yield* stream.pipeThrough(new CompressionStream('deflate-raw')).pipeThrough(
206206+ new TransformStream({
207207+ transform(chunk, controller) {
208208+ controller.enqueue(chunk);
209209+210210+ compressedSize += chunk.length;
211211+ },
212212+ }),
213213+ );
214214+ } else {
215215+ if (data instanceof ReadableStream) {
216216+ yield* data.pipeThrough(
217217+ new TransformStream({
218218+ transform(chunk, controller) {
219219+ uncompressedSize += chunk.length;
220220+ crc = crc32(chunk, crc);
221221+222222+ controller.enqueue(chunk);
223223+ },
224224+ }),
225225+ );
226226+227227+ compressedSize = uncompressedSize;
228228+ } else {
229229+ const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
230230+231231+ uncompressedSize = chunk.length;
232232+ compressedSize = uncompressedSize;
233233+ crc = crc32(chunk, crc);
234234+235235+ yield chunk;
236236+ }
237237+ }
238238+239239+ offset += compressedSize;
240240+241241+ // data descriptor
242242+ {
243243+ const descriptor = new ArrayBuffer(16);
244244+ const view = new DataView(descriptor);
245245+246246+ // 0 - data descriptor signature
247247+ // 4 - CRC32 of uncompressed data
248248+ // 8 - compressed size
249249+ // 12 - uncompressed size
250250+ writeUint32LE(view, 0, 0x08074b50);
251251+ writeUint32LE(view, 4, crc);
252252+ writeUint32LE(view, 8, compressedSize);
253253+ writeUint32LE(view, 12, uncompressedSize);
254254+255255+ offset += 16;
256256+257257+ yield new Uint8Array(descriptor);
258258+ }
259259+260260+ // central directory record
261261+ {
262262+ const record = new ArrayBuffer(46 + fnameLen);
263263+ const view = new DataView(record);
264264+265265+ const mode = attrs?.mode ?? 0o100644;
266266+ const externalAttrs = (mode & 0xffff) << 16;
267267+268268+ // 0 - central directory record signature
269269+ // 4 - version used to create this archive
270270+ // 6 - minimum required version for extraction
271271+ // 8 - general purpose bitflag
272272+ // 10 - compression method
273273+ // 12 - file last modification time
274274+ // 14 - file last modification date
275275+ // 16 - CRC32 of uncompressed data
276276+ // 20 - compressed size
277277+ // 24 - uncompressed size
278278+ // 28 - file name length
279279+ // 30 - extra fields length
280280+ // 32 - file comment length
281281+ // 34 - disk number containing start of file
282282+ // 36 - internal file attributes
283283+ // 38 - external file attributes
284284+ // 42 - offset to start of entry
285285+ writeUint32LE(view, 0, 0x02014b50);
286286+ writeUint16LE(view, 4, (3 << 8) | 20);
287287+ writeUint16LE(view, 6, 20);
288288+ writeUint16LE(view, 8, flags);
289289+ writeUint16LE(view, 10, method);
290290+ writeUint16LE(view, 12, dosTime);
291291+ writeUint16LE(view, 14, dosDate);
292292+ writeUint32LE(view, 16, crc);
293293+ writeUint32LE(view, 20, compressedSize);
294294+ writeUint32LE(view, 24, uncompressedSize);
295295+ writeUint16LE(view, 28, fnameLen);
296296+ writeUint16LE(view, 30, 0);
297297+ writeUint16LE(view, 32, 0);
298298+ writeUint16LE(view, 34, 0);
299299+ writeUint16LE(view, 36, 0);
300300+ writeUint32LE(view, 38, externalAttrs);
301301+ writeUint32LE(view, 42, startOffset);
302302+303303+ writeUtf8String(view, 46, fnameLen, filename);
304304+305305+ listing.push(new Uint8Array(record));
306306+ }
307307+ }
308308+309309+ // central directory
310310+ {
311311+ const startCentralOffset = offset;
312312+ const recordCount = listing.length;
313313+314314+ let centralSize = 0;
315315+316316+ for (let idx = 0; idx < recordCount; idx++) {
317317+ const record = listing[idx];
318318+ const recordLen = record.length;
319319+320320+ offset += recordLen;
321321+ centralSize += recordLen;
322322+323323+ yield record;
324324+ }
325325+326326+ {
327327+ const directory = new ArrayBuffer(22);
328328+ const view = new DataView(directory);
329329+330330+ // 0 - end of central directory signature
331331+ // 4 - this disk's number
332332+ // 6 - disk number containing start of central directory
333333+ // 8 - amount of records in this disk's central directory
334334+ // 10 - total amount of central directory records
335335+ // 12 - total size of this disk's central directory records
336336+ // 16 - offset of this disk's central directory records
337337+ // 20 - comment length
338338+ writeUint32LE(view, 0, 0x06054b50);
339339+ writeUint16LE(view, 4, 0);
340340+ writeUint16LE(view, 6, 0);
341341+ writeUint16LE(view, 8, recordCount);
342342+ writeUint16LE(view, 10, recordCount);
343343+ writeUint32LE(view, 12, centralSize);
344344+ writeUint32LE(view, 16, startCentralOffset);
345345+ writeUint16LE(view, 20, 0);
346346+347347+ yield new Uint8Array(directory);
348348+ }
349349+ }
350350+}