streaming zip archiver/extractor jsr.io/@mary/zip
typescript jsr

initial commit

mary.my.id 6329fc47

+4
.vscode/settings.json
··· 1 + { 2 + "editor.defaultFormatter": "denoland.vscode-deno", 3 + "deno.enable": true 4 + }
+40
.zed/settings.json
··· 1 + { 2 + "lsp": { 3 + "deno": { 4 + "settings": { 5 + "deno": { 6 + "enable": true 7 + } 8 + } 9 + } 10 + }, 11 + "languages": { 12 + "JavaScript": { 13 + "language_servers": [ 14 + "deno", 15 + "!typescript-language-server", 16 + "!vtsls", 17 + "!eslint" 18 + ], 19 + "formatter": "language_server" 20 + }, 21 + "TypeScript": { 22 + "language_servers": [ 23 + "deno", 24 + "!typescript-language-server", 25 + "!vtsls", 26 + "!eslint" 27 + ], 28 + "formatter": "language_server" 29 + }, 30 + "TSX": { 31 + "language_servers": [ 32 + "deno", 33 + "!typescript-language-server", 34 + "!vtsls", 35 + "!eslint" 36 + ], 37 + "formatter": "language_server" 38 + } 39 + } 40 + }
+14
LICENSE
··· 1 + BSD Zero Clause License 2 + 3 + Copyright (c) 2025 Mary 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted. 7 + 8 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 + PERFORMANCE OF THIS SOFTWARE.
+15
README.md
··· 1 + # zip 2 + 3 + [JSR](https://jsr.io/@mary/zip) | [source code](https://tangled.sh/@mary.my.id/pkg-zip) 4 + 5 + streaming zip archiver/extractor 6 + 7 + ```ts 8 + { 9 + const iterable = ReadableStream.from( 10 + zip([ 11 + { name: 'README.md', data: `Hello, **world**!` }, 12 + ]), 13 + ); 14 + } 15 + ```
+26
deno.json
··· 1 + { 2 + "name": "@mary/zip", 3 + "version": "0.1.0", 4 + "license": "0BSD", 5 + "exports": { 6 + ".": "./lib/mod.ts", 7 + "./deno": "./lib/reader/deno.ts", 8 + "./node": "./lib/reader/node.ts" 9 + }, 10 + "fmt": { 11 + "useTabs": true, 12 + "indentWidth": 2, 13 + "lineWidth": 110, 14 + "semiColons": true, 15 + "singleQuote": true 16 + }, 17 + "publish": { 18 + "include": ["lib/", "LICENSE", "README.md", "deno.json"] 19 + }, 20 + "imports": { 21 + "@mary/date-fns": "jsr:@mary/date-fns@^0.1.3", 22 + "@mary/ds-circular-buffer": "jsr:@mary/ds-circular-buffer@^0.1.0", 23 + "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 24 + "@mary/mutex": "jsr:@mary/mutex@^0.1.0" 25 + } 26 + }
+43
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@mary/date-fns@~0.1.3": "0.1.3", 5 + "jsr:@mary/ds-circular-buffer@0.1": "0.1.0", 6 + "jsr:@mary/ds-queue@~0.1.3": "0.1.3", 7 + "jsr:@mary/mutex@0.1": "0.1.0", 8 + "npm:@types/node@*": "24.1.0" 9 + }, 10 + "jsr": { 11 + "@mary/date-fns@0.1.3": { 12 + "integrity": "16dea8b5e45de5de6a442555e171544d4e565b8abc325a0ef86f5f6d04ba0c38" 13 + }, 14 + "@mary/ds-circular-buffer@0.1.0": { 15 + "integrity": "51c463436ffd625979bcd1fc63860c2d6a48f289e2d414b72a3ad95689ce4481" 16 + }, 17 + "@mary/ds-queue@0.1.3": { 18 + "integrity": "a743caa397b924cb08b0bbdffc526eb1ea2d3fc9e675da6edc137c437fc93c76" 19 + }, 20 + "@mary/mutex@0.1.0": { 21 + "integrity": "491d33bfd019f5467fd33cf75fcad293800c9954d76332865747201cbeecb0fc" 22 + } 23 + }, 24 + "npm": { 25 + "@types/node@24.1.0": { 26 + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", 27 + "dependencies": [ 28 + "undici-types" 29 + ] 30 + }, 31 + "undici-types@7.8.0": { 32 + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" 33 + } 34 + }, 35 + "workspace": { 36 + "dependencies": [ 37 + "jsr:@mary/date-fns@~0.1.3", 38 + "jsr:@mary/ds-circular-buffer@0.1", 39 + "jsr:@mary/ds-queue@~0.1.3", 40 + "jsr:@mary/mutex@0.1" 41 + ] 42 + } 43 + }
+5
lib/mod.ts
··· 1 + export * from './zip.ts'; 2 + export * from './unzip.ts'; 3 + 4 + export type * from './reader/types.ts'; 5 + export * from './reader/common.ts';
+104
lib/reader/common.ts
··· 1 + import type { Reader } from './types.ts'; 2 + 3 + /** 4 + * creates a reader from a Uint8Array 5 + * @param data the data to read from 6 + * @returns a reader for the data 7 + */ 8 + export function fromUint8Array(data: Uint8Array): Reader { 9 + const totalLength = data.length; 10 + 11 + return { 12 + length: totalLength, 13 + // deno-lint-ignore require-await 14 + async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> { 15 + const endOffset = length !== undefined ? offset + length : totalLength; 16 + 17 + const slice = data.subarray(offset, endOffset); 18 + const sliceLength = slice.length; 19 + 20 + let position = 0; 21 + 22 + return new ReadableStream<Uint8Array>({ 23 + pull(controller) { 24 + if (position >= sliceLength) { 25 + controller.close(); 26 + return; 27 + } 28 + 29 + const size = Math.min(controller.desiredSize!, sliceLength - position); 30 + const chunk = slice.subarray(position, position + size); 31 + 32 + controller.enqueue(chunk); 33 + position += size; 34 + 35 + if (position >= sliceLength) { 36 + controller.close(); 37 + return; 38 + } 39 + }, 40 + }, new ByteLengthQueuingStrategy({ highWaterMark: 64 * 1024 })); 41 + }, 42 + }; 43 + } 44 + 45 + export interface FetchReaderOptions { 46 + input: string | URL | Request; 47 + fetch?: typeof fetch; 48 + } 49 + 50 + /** 51 + * creates a reader from a fetch URL or Request 52 + * @param input URL string or Request object to fetch from 53 + * @returns a reader for the remote resource 54 + */ 55 + export async function fromFetch(opts: FetchReaderOptions): Promise<Reader> { 56 + const thisFetch = opts.fetch ?? fetch; 57 + 58 + const totalLength = await (async () => { 59 + // make a HEAD request to get content length upfront 60 + const response = await thisFetch(opts.input, { method: 'HEAD' }); 61 + if (!response.ok) { 62 + throw new Error(`failed to fetch resource: http ${response.status}`); 63 + } 64 + 65 + const raw = response.headers.get('content-length'); 66 + if (raw === null) { 67 + throw new Error(`content-length header not available`); 68 + } 69 + 70 + const value = parseInt(raw, 10); 71 + if (isNaN(value)) { 72 + throw new Error(`invalid content-length header`); 73 + } 74 + 75 + return value; 76 + })(); 77 + 78 + return { 79 + length: totalLength, 80 + async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> { 81 + const endOffset = length !== undefined ? offset + length - 1 : totalLength - 1; 82 + 83 + const req = opts.input; 84 + const headers = new Headers(req instanceof Request ? req.headers : undefined); 85 + headers.set('range', `bytes=${offset}-${endOffset}`); 86 + 87 + const response = await thisFetch(req, { headers }); 88 + 89 + if (!response.ok) { 90 + throw new Error(`failed to fetch range ${offset}-${endOffset}: http ${response.status}`); 91 + } 92 + 93 + if (response.status !== 206) { 94 + throw new Error(`server does not support range requests`); 95 + } 96 + 97 + if (response.body === null) { 98 + throw new Error(`response body is null`); 99 + } 100 + 101 + return response.body; 102 + }, 103 + }; 104 + }
+59
lib/reader/deno.ts
··· 1 + import Mutex from '@mary/mutex'; 2 + 3 + import type { Reader } from './types.ts'; 4 + 5 + /** 6 + * creates a reader from a Deno.FsFile 7 + * @param file the file to read from 8 + * @returns a reader for the file 9 + */ 10 + export async function fromFsFile(file: Deno.FsFile): Promise<Reader & Disposable> { 11 + const mutex = new Mutex(); 12 + const totalLength = (await file.stat()).size; 13 + 14 + return { 15 + length: totalLength, 16 + // deno-lint-ignore require-await 17 + async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> { 18 + let remaining = length !== undefined ? length : totalLength - offset; 19 + 20 + return new ReadableStream<Uint8Array>({ 21 + type: 'bytes', 22 + async pull(controller) { 23 + using _lock = await mutex.acquire(); 24 + 25 + await file.seek(offset, Deno.SeekMode.Start); 26 + 27 + const size = Math.min(controller.desiredSize!, remaining); 28 + 29 + const buffer = new Uint8Array(size); 30 + const read = await file.read(buffer); 31 + 32 + if (read === null) { 33 + // end of file 34 + controller.close(); 35 + return; 36 + } 37 + 38 + if (read < size) { 39 + // partial read, slice the buffer 40 + controller.enqueue(buffer.subarray(0, read)); 41 + } else { 42 + controller.enqueue(buffer); 43 + } 44 + 45 + remaining -= read; 46 + offset += read; 47 + 48 + if (remaining <= 0) { 49 + controller.close(); 50 + return; 51 + } 52 + }, 53 + }, { highWaterMark: 64 * 1024 }); 54 + }, 55 + [Symbol.dispose]() { 56 + file.close(); 57 + }, 58 + }; 59 + }
+51
lib/reader/node.ts
··· 1 + import type { FileHandle } from 'node:fs/promises'; 2 + 3 + import type { Reader } from './types.ts'; 4 + 5 + /** 6 + * creates a reader from a Node.js' FileHandle 7 + * @param handle the file handle to read from 8 + * @returns a reader for the file 9 + */ 10 + export const fromFileHandle = async (handle: FileHandle): Promise<Reader> => { 11 + const totalLength = (await handle.stat()).size; 12 + 13 + return { 14 + length: totalLength, 15 + // deno-lint-ignore require-await 16 + async read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>> { 17 + let remaining = length !== undefined ? length : totalLength - offset; 18 + 19 + return new ReadableStream<Uint8Array>({ 20 + type: 'bytes', 21 + async pull(controller) { 22 + const size = Math.min(controller.desiredSize!, remaining); 23 + 24 + const buffer = new Uint8Array(size); 25 + const { bytesRead } = await handle.read(buffer, 0, size, offset); 26 + 27 + if (bytesRead === 0) { 28 + // end of file 29 + controller.close(); 30 + return; 31 + } 32 + 33 + if (bytesRead < size) { 34 + // partial read, slice the buffer 35 + controller.enqueue(buffer.subarray(0, bytesRead)); 36 + } else { 37 + controller.enqueue(buffer); 38 + } 39 + 40 + remaining -= bytesRead; 41 + offset += bytesRead; 42 + 43 + if (remaining <= 0) { 44 + controller.close(); 45 + return; 46 + } 47 + }, 48 + }, { highWaterMark: 64 * 1024 }); 49 + }, 50 + }; 51 + };
+9
lib/reader/types.ts
··· 1 + /** 2 + * interface for reading data from a zip source 3 + */ 4 + export interface Reader { 5 + /** total length of the data source */ 6 + length: number; 7 + /** reads data from the specified offset and length */ 8 + read(offset: number, length?: number): Promise<ReadableStream<Uint8Array>>; 9 + }
+394
lib/unzip.ts
··· 1 + import CircularBuffer from '@mary/ds-circular-buffer'; 2 + 3 + import { decodeUtf8From, read } from './utils/buffer.ts'; 4 + import { cache } from './utils/decorator.ts'; 5 + import { StreamReader } from './utils/stream-reader.ts'; 6 + 7 + import type { Reader } from './reader/types.ts'; 8 + 9 + const EOCD_FIXED_SIZE = 22; 10 + const CRECORD_FIXED_SIZE = 46; 11 + const LHEADER_FIXED_SIZE = 30; 12 + 13 + function readUint16LE(view: DataView, offset: number): number { 14 + return view.getUint16(offset, true); 15 + } 16 + 17 + function readUint32LE(view: DataView, offset: number): number { 18 + return view.getUint32(offset, true); 19 + } 20 + 21 + /** 22 + * represents a single entry in a zip archive during extraction 23 + */ 24 + export class UnzippedEntry { 25 + readonly #reader: Reader; 26 + 27 + readonly #recordFixed: Uint8Array; 28 + readonly #recordVariable: Uint8Array; 29 + 30 + readonly #headerOffset: number; 31 + 32 + /** @ignore */ 33 + constructor(reader: Reader, recordFixed: Uint8Array, recordVariable: Uint8Array, headerOffset: number) { 34 + this.#reader = reader; 35 + 36 + this.#recordFixed = recordFixed; 37 + this.#recordVariable = recordVariable; 38 + 39 + this.#headerOffset = headerOffset; 40 + } 41 + 42 + get #centralFixedView(): DataView { 43 + const buffer = this.#recordFixed; 44 + return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); 45 + } 46 + 47 + /** 48 + * the filename of the entry 49 + * @returns the filename as string 50 + */ 51 + @cache 52 + get filename(): string { 53 + const fnameLength = readUint16LE(this.#centralFixedView, 28); 54 + const fname = this.#recordVariable.subarray(0, fnameLength); 55 + 56 + return decodeUtf8From(fname); 57 + } 58 + 59 + /** 60 + * the uncompressed size of the entry 61 + * @returns size in bytes 62 + */ 63 + @cache 64 + get size(): number { 65 + return readUint32LE(this.#centralFixedView, 24); 66 + } 67 + 68 + /** 69 + * the compressed size of the entry 70 + * @returns size in bytes 71 + */ 72 + @cache 73 + get compressedSize(): number { 74 + return readUint32LE(this.#centralFixedView, 20); 75 + } 76 + 77 + /** 78 + * the compression method used for the entry 79 + * @returns compression method code 80 + */ 81 + @cache 82 + get compressionMethod(): number { 83 + return readUint16LE(this.#centralFixedView, 10); 84 + } 85 + 86 + /** 87 + * the last modification time of the entry 88 + * @returns timestamp in milliseconds 89 + */ 90 + @cache 91 + get mtime(): number { 92 + const view = this.#centralFixedView; 93 + const dosTime = readUint16LE(view, 12); 94 + const dosDate = readUint16LE(view, 14); 95 + 96 + const seconds = (dosTime & 0x1f) * 2; 97 + const minutes = (dosTime >>> 5) & 0x3f; 98 + const hours = (dosTime >>> 11) & 0x1f; 99 + 100 + const day = dosDate & 0x1f; 101 + const month = ((dosDate >>> 5) & 0x0f) - 1; 102 + const year = ((dosDate >>> 9) & 0x7f) + 1980; 103 + 104 + return Date.UTC(year, month, day, hours, minutes, seconds); 105 + } 106 + 107 + /** 108 + * the file mode/permissions of the entry 109 + * @returns mode as number, or 0 if not set 110 + */ 111 + @cache 112 + get mode(): number { 113 + const attrs = readUint32LE(this.#centralFixedView, 38); 114 + const mode = (attrs >>> 16) & 0xffff; 115 + 116 + return mode; 117 + } 118 + 119 + /** 120 + * a readable stream of the entry's decompressed content 121 + * @returns stream of the entry data 122 + */ 123 + @cache 124 + get body(): ReadableStream<Uint8Array> { 125 + let r: ReadableStreamDefaultReader<Uint8Array>; 126 + 127 + return new ReadableStream({ 128 + start: async (controller) => { 129 + try { 130 + // read local file header to get actual data offset 131 + let offset: number; 132 + { 133 + const headerOffset = this.#headerOffset; 134 + 135 + const stream = await this.#reader.read(headerOffset, LHEADER_FIXED_SIZE); 136 + const bytes = await read(stream, LHEADER_FIXED_SIZE); 137 + 138 + const header = new DataView(bytes.buffer, bytes.byteOffset, LHEADER_FIXED_SIZE); 139 + 140 + const filenameLength = readUint16LE(header, 26); 141 + const extraFieldLength = readUint16LE(header, 28); 142 + 143 + offset = headerOffset + LHEADER_FIXED_SIZE + filenameLength + extraFieldLength; 144 + } 145 + 146 + // read data 147 + let stream = await this.#reader.read(offset, this.compressedSize); 148 + if (this.compressionMethod === 8) { 149 + // deflate compression 150 + stream = stream.pipeThrough(new DecompressionStream('deflate-raw')); 151 + } 152 + 153 + r = stream.getReader(); 154 + } catch (error) { 155 + controller.error(error); 156 + 157 + await r?.cancel(); 158 + } 159 + }, 160 + async pull(controller) { 161 + const { done, value } = await r.read(); 162 + 163 + if (done) { 164 + controller.close(); 165 + } else { 166 + controller.enqueue(value); 167 + } 168 + }, 169 + async cancel() { 170 + await r?.cancel(); 171 + }, 172 + }); 173 + } 174 + 175 + /** 176 + * reads the entry content as bytes 177 + * @returns the content as Uint8Array 178 + */ 179 + async bytes(): Promise<Uint8Array> { 180 + return await read(this.body, this.size); 181 + } 182 + 183 + /** 184 + * reads the entry content as an ArrayBuffer 185 + * @returns an ArrayBuffer 186 + */ 187 + async arrayBuffer(): Promise<ArrayBuffer> { 188 + const bytes = await this.bytes() as Uint8Array<ArrayBuffer>; 189 + 190 + // if bytes covers the entire buffer, return it directly 191 + if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { 192 + return bytes.buffer; 193 + } 194 + 195 + // otherwise we need to slice to get the right portion 196 + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); 197 + } 198 + 199 + /** 200 + * reads the entry content as a Blob 201 + * @returns a Blob 202 + */ 203 + async blob(): Promise<Blob> { 204 + const bytes = await this.bytes(); 205 + return new Blob([bytes]); 206 + } 207 + 208 + /** 209 + * reads the entry content as text 210 + * @returns the content as string 211 + */ 212 + async text(): Promise<string> { 213 + const bytes = await this.bytes(); 214 + return decodeUtf8From(bytes); 215 + } 216 + 217 + /** 218 + * reads the entry content as JSON 219 + * @returns the parsed JSON 220 + */ 221 + async json(): Promise<unknown> { 222 + const text = await this.text(); 223 + return JSON.parse(text); 224 + } 225 + } 226 + 227 + /** 228 + * extracts entries from a zip archive 229 + * @param reader data source to read the zip file from 230 + * @returns async generator yielding zip entries 231 + */ 232 + export async function* unzip(reader: Reader): AsyncGenerator<UnzippedEntry> { 233 + const eocd = await readEocd(reader); 234 + 235 + for await (const record of readCentralRecords(reader, eocd)) { 236 + yield new UnzippedEntry(reader, record.fixedBytes, record.variableBytes, record.headerOffset); 237 + } 238 + } 239 + 240 + interface EndOfCentralDirectory { 241 + recordCount: number; 242 + dirSize: number; 243 + dirOffset: number; 244 + } 245 + 246 + interface CentralRecord { 247 + fixedBytes: Uint8Array; 248 + variableBytes: Uint8Array; 249 + headerOffset: number; 250 + } 251 + 252 + async function readEocd(reader: Reader): Promise<EndOfCentralDirectory> { 253 + const maxSearchLength = Math.min(reader.length, 65557); // 22 + max comment length 254 + const start = reader.length - maxSearchLength; 255 + 256 + // use a 22-byte circular buffer sliding window 257 + const window = new CircularBuffer(EOCD_FIXED_SIZE); 258 + const stream = await reader.read(start, maxSearchLength); 259 + 260 + let pos = start; 261 + let eocd: EndOfCentralDirectory | undefined; 262 + 263 + for await (const chunk of stream) { 264 + for (let idx = 0, len = chunk.length; idx < len; idx++) { 265 + const byte = chunk[idx]; 266 + 267 + window.push(byte); 268 + pos++; 269 + 270 + // only check for signature once window is full 271 + if (!window.filled) { 272 + continue; 273 + } 274 + 275 + // 0 - end of central directory signature 276 + // 4 - this disk's number 277 + // 6 - disk number containing start of central directory 278 + // 8 - amount of records in this disk's central directory 279 + // 10 - total amount of central directory records 280 + // 12 - total size of this disk's central directory records 281 + // 16 - offset to start of this disk's central directory records 282 + // 20 - comment length 283 + 284 + // check for EOCD signature (0x06054b50) 285 + // signature is [0x50, 0x4b, 0x05, 0x06] in little endian 286 + if (window.at(0) === 0x50 && window.at(1) === 0x4b && window.at(2) === 0x05 && window.at(3) === 0x06) { 287 + const offset = pos - EOCD_FIXED_SIZE; // window start position 288 + 289 + const buffer = window.dump(); 290 + const view = new DataView(buffer.buffer, buffer.byteOffset); 291 + 292 + const diskNumber = readUint16LE(view, 4); 293 + const commentLength = readUint16LE(view, 20); 294 + 295 + if (diskNumber !== 0) { 296 + throw new Error(`multi-disk zip files not supported`); 297 + } 298 + 299 + // the EOCD should end exactly at the file end 300 + if (offset + EOCD_FIXED_SIZE + commentLength === reader.length) { 301 + // validate central directory bounds 302 + const centralDirSize = readUint32LE(view, 12); 303 + const centralDirOffset = readUint32LE(view, 16); 304 + 305 + if (centralDirOffset + centralDirSize <= offset) { 306 + eocd = { 307 + recordCount: readUint16LE(view, 10), 308 + dirSize: centralDirSize, 309 + dirOffset: centralDirOffset, 310 + }; 311 + } 312 + } 313 + } 314 + } 315 + } 316 + 317 + if (eocd === undefined) { 318 + throw new Error(`zip file might be invalid or corrupted`); 319 + } 320 + 321 + return eocd; 322 + } 323 + 324 + async function* readCentralRecords( 325 + reader: Reader, 326 + { dirOffset, dirSize, recordCount }: EndOfCentralDirectory, 327 + ): AsyncGenerator<CentralRecord> { 328 + const stream = await reader.read(dirOffset, dirSize); 329 + await using r = new StreamReader(stream); 330 + 331 + for (let idx = 0, len = recordCount; idx < len; idx++) { 332 + // check if we have room for at least the fixed header 333 + if (r.consumed + CRECORD_FIXED_SIZE > dirSize) { 334 + throw new Error(`central record extends beyond directory bounds`); 335 + } 336 + 337 + // ensure we have 46 bytes for the header 338 + await r.require(CRECORD_FIXED_SIZE); 339 + 340 + const fixedBytes = r.take(CRECORD_FIXED_SIZE); 341 + const fixed = new DataView(fixedBytes.buffer, fixedBytes.byteOffset); 342 + 343 + // 0 - central directory record signature 344 + // 4 - version used to create this archive 345 + // 6 - minimum required version for extraction 346 + // 8 - general purpose bitflag 347 + // 10 - compression method 348 + // 12 - file last modification time 349 + // 14 - file last modification date 350 + // 16 - CRC32 of uncompressed data 351 + // 20 - compressed size 352 + // 24 - uncompressed size 353 + // 28 - file name length 354 + // 30 - extra fields length 355 + // 32 - file comment length 356 + // 34 - disk number containing start of file 357 + // 36 - internal file attributes 358 + // 38 - external file attributes 359 + // 42 - offset to start of entry 360 + 361 + const signature = readUint32LE(fixed, 0); 362 + if (signature !== 0x02014b50) { 363 + throw new Error(`invalid central directory file header signature`); 364 + } 365 + 366 + const filenameLength = readUint16LE(fixed, 28); 367 + const extraFieldLength = readUint16LE(fixed, 30); 368 + const commentLength = readUint16LE(fixed, 32); 369 + const headerOffset = readUint32LE(fixed, 42); 370 + 371 + const variableLength = filenameLength + extraFieldLength + commentLength; 372 + 373 + // validate variable field lengths are reasonable 374 + if (variableLength < 0 || r.consumed + variableLength > dirSize) { 375 + throw new Error(`central record variable fields extend beyond directory bounds`); 376 + } 377 + 378 + let variableBytes: Uint8Array; 379 + 380 + if (variableLength > 0) { 381 + await r.require(variableLength); 382 + 383 + variableBytes = r.take(variableLength); 384 + } else { 385 + variableBytes = new Uint8Array(0); 386 + } 387 + 388 + yield { 389 + fixedBytes: fixedBytes, 390 + variableBytes: variableBytes, 391 + headerOffset: headerOffset, 392 + }; 393 + } 394 + }
+106
lib/utils/buffer.ts
··· 1 + const fromCharCode = String.fromCharCode; 2 + 3 + export const textEncoder = new TextEncoder(); 4 + export const textDecoder = new TextDecoder(); 5 + 6 + export function concat(arrays: Uint8Array[], size?: number): Uint8Array { 7 + let written = 0; 8 + 9 + // deno-lint-ignore prefer-const 10 + let len = arrays.length; 11 + let idx: number; 12 + 13 + if (size === undefined) { 14 + for (idx = size = 0; idx < len; idx++) { 15 + const chunk = arrays[idx]; 16 + size += chunk.length; 17 + } 18 + } 19 + 20 + const buffer = new Uint8Array(size); 21 + 22 + for (idx = 0; idx < len; idx++) { 23 + const chunk = arrays[idx]; 24 + 25 + buffer.set(chunk, written); 26 + written += chunk.length; 27 + } 28 + 29 + return buffer; 30 + } 31 + 32 + export async function read(stream: ReadableStream<Uint8Array>, size: number): Promise<Uint8Array> { 33 + const buffer = new Uint8Array(size); 34 + 35 + let read = 0; 36 + 37 + for await (const chunk of stream) { 38 + const length = chunk.length; 39 + const remaining = Math.min(length, size - read); 40 + 41 + if (remaining === length) { 42 + buffer.set(chunk, read); 43 + } else { 44 + buffer.set(chunk.subarray(0, remaining), read); 45 + } 46 + 47 + read += remaining; 48 + 49 + if (read >= size) { 50 + break; 51 + } 52 + } 53 + 54 + if (read < size) { 55 + throw new Error(`unexpected end of stream: expected ${size} bytes, got ${read}`); 56 + } 57 + 58 + return buffer; 59 + } 60 + 61 + export const decodeUtf8From = (from: Uint8Array, offset?: number, length?: number): string => { 62 + let buffer: Uint8Array; 63 + 64 + if (offset === undefined) { 65 + buffer = from; 66 + } else if (length === undefined) { 67 + buffer = from.subarray(offset); 68 + } else { 69 + buffer = from.subarray(offset, offset + length); 70 + } 71 + 72 + const end = buffer.length; 73 + if (end > 24) { 74 + return textDecoder.decode(buffer); 75 + } 76 + 77 + { 78 + let str = ''; 79 + let idx = 0; 80 + 81 + for (; idx + 3 < end; idx += 4) { 82 + const a = buffer[idx]; 83 + const b = buffer[idx + 1]; 84 + const c = buffer[idx + 2]; 85 + const d = buffer[idx + 3]; 86 + 87 + if ((a | b | c | d) & 0x80) { 88 + return str + textDecoder.decode(buffer.subarray(idx)); 89 + } 90 + 91 + str += fromCharCode(a, b, c, d); 92 + } 93 + 94 + for (; idx < end; idx++) { 95 + const x = buffer[idx]; 96 + 97 + if (x & 0x80) { 98 + return str + textDecoder.decode(buffer.subarray(idx)); 99 + } 100 + 101 + str += fromCharCode(x); 102 + } 103 + 104 + return str; 105 + } 106 + };
+11
lib/utils/decorator.ts
··· 1 + /** 2 + * caches the result of a getter on first access 3 + */ 4 + export function cache<T, K>(originalMethod: (this: T) => K, context: ClassGetterDecoratorContext) { 5 + return function (this: T): K { 6 + const value = originalMethod.call(this); 7 + 8 + Object.defineProperty(this, context.name, { value, writable: false, enumerable: false }); 9 + return value; 10 + }; 11 + }
+89
lib/utils/stream-reader.ts
··· 1 + import Queue from '@mary/ds-queue'; 2 + 3 + import { concat } from './buffer.ts'; 4 + 5 + export class StreamReader implements AsyncDisposable { 6 + #reader: ReadableStreamDefaultReader<Uint8Array>; 7 + 8 + #chunks = new Queue<Uint8Array>(); 9 + #buffered = 0; 10 + #consumed = 0; 11 + 12 + constructor(stream: ReadableStream<Uint8Array>) { 13 + this.#reader = stream.getReader(); 14 + } 15 + 16 + get buffered(): number { 17 + return this.#buffered; 18 + } 19 + 20 + get consumed(): number { 21 + return this.#consumed; 22 + } 23 + 24 + async require(size: number): Promise<void> { 25 + const reader = this.#reader; 26 + const chunks = this.#chunks; 27 + 28 + while (this.#buffered < size) { 29 + const { done, value } = await reader.read(); 30 + 31 + if (done) { 32 + throw new Error(`unexpected end of stream: needed ${size} bytes, have ${this.#buffered}`); 33 + } 34 + 35 + chunks.enqueue(value); 36 + 37 + this.#buffered += value.length; 38 + } 39 + } 40 + 41 + take(size: number): Uint8Array { 42 + if (size === 0) { 43 + return new Uint8Array(0); 44 + } 45 + 46 + if (this.#buffered < size) { 47 + throw new Error(`needed ${size} bytes, have ${this.#buffered}`); 48 + } 49 + 50 + const chunks = this.#chunks; 51 + const needed: Uint8Array[] = []; 52 + 53 + let taken = 0; 54 + 55 + while (taken < size) { 56 + const chunk = chunks.dequeue()!; 57 + const chunkLength = chunk.length; 58 + 59 + const took = Math.min(chunkLength, size - taken); 60 + 61 + if (took < chunkLength) { 62 + // Split chunk 63 + needed.push(chunk.subarray(0, took)); 64 + 65 + chunks.enqueueFront(chunk.subarray(took)); 66 + 67 + taken += took; 68 + break; 69 + } else { 70 + needed.push(chunk); 71 + 72 + taken += chunkLength; 73 + } 74 + } 75 + 76 + this.#buffered -= size; 77 + this.#consumed += size; 78 + 79 + if (needed.length === 1) { 80 + return needed[0]; 81 + } else { 82 + return concat(needed, size); 83 + } 84 + } 85 + 86 + async [Symbol.asyncDispose](): Promise<void> { 87 + await this.#reader.cancel(); 88 + } 89 + }
+350
lib/zip.ts
··· 1 + import { getDayOfMonth, getHours, getMinutes, getMonth, getSeconds, getYear } from '@mary/date-fns'; 2 + import { textEncoder } from './utils/buffer.ts'; 3 + 4 + /** 5 + * file attributes for zip entries 6 + */ 7 + export interface ZipFileAttributes { 8 + /** file permissions mode */ 9 + mode?: number; 10 + /** user id of the file owner */ 11 + uid?: number; 12 + /** group id of the file owner */ 13 + gid?: number; 14 + /** modification time as unix timestamp */ 15 + mtime?: number; 16 + /** owner username */ 17 + owner?: string; 18 + /** group name */ 19 + group?: string; 20 + } 21 + 22 + /** 23 + * represents a single entry in a zip archive 24 + */ 25 + export interface ZipEntry { 26 + /** path and name of the file in the zip archive */ 27 + filename: string; 28 + /** file content as string, bytes, or stream */ 29 + data: string | Uint8Array | ReadableStream<Uint8Array>; 30 + /** file attributes like permissions and timestamps */ 31 + attrs?: ZipFileAttributes; 32 + /** whether to compress the file data */ 33 + compress?: false | 'deflate'; 34 + } 35 + 36 + const DEFAULT_ATTRS: ZipFileAttributes = {}; 37 + 38 + // deno-lint-ignore no-control-regex 39 + const INVALID_FILENAME_CHARS = /[<>:"|?*\x00-\x1f]/; 40 + const INVALID_FILENAME_TRAVERSAL = /(?:^|[/\\])\.\.(?:[/\\]|$)/; 41 + // deno-lint-ignore no-control-regex 42 + const NON_ASCII_CHARS = /[^\x00-\x7f]/; 43 + 44 + function writeUtf8String(view: DataView, offset: number, length: number, str: string) { 45 + const u8 = new Uint8Array(view.buffer, view.byteOffset + offset, length); 46 + textEncoder.encodeInto(str, u8); 47 + } 48 + 49 + function writeUint32LE(view: DataView, offset: number, value: number) { 50 + view.setUint32(offset, value, true); 51 + } 52 + function writeUint16LE(view: DataView, offset: number, value: number) { 53 + view.setUint16(offset, value, true); 54 + } 55 + 56 + const CRC32_TABLE = /*#__PURE__*/ (() => { 57 + const t = new Int32Array(256); 58 + 59 + for (let i = 0; i < 256; ++i) { 60 + let c = i, k = 9; 61 + while (--k) c = (c & 1 ? 0xedb88320 : 0) ^ (c >>> 1); 62 + t[i] = c; 63 + } 64 + 65 + return t; 66 + })(); 67 + 68 + function crc32(chunk: Uint8Array, crc: number = 0xffffffff): number { 69 + for (let idx = 0, len = chunk.length; idx < len; idx++) { 70 + crc = CRC32_TABLE[(crc ^ chunk[idx]) & 0xff] ^ (crc >>> 8); 71 + } 72 + 73 + return crc ^ -1; 74 + } 75 + 76 + function unixToDosTime(unixTimestamp: number): { time: number; date: number } { 77 + const date = new Date(unixTimestamp * 1000); 78 + 79 + const dosTime = ((getSeconds(date) >> 1) & 0x1f) | ((getMinutes(date) & 0x3f) << 5) | 80 + ((getHours(date) & 0x1f) << 11); 81 + 82 + const dosDate = (getDayOfMonth(date) & 0x1f) | 83 + (((getMonth(date) + 1) & 0x0f) << 5) | 84 + (((getYear(date) - 1980) & 0x7f) << 9); 85 + 86 + return { time: dosTime, date: dosDate }; 87 + } 88 + 89 + function validateFilename(filename: string): void { 90 + if (filename.length === 0) { 91 + throw new Error(`invalid filename: cannot be empty`); 92 + } 93 + 94 + if (filename.length > 65535) { 95 + throw new Error(`invalid filename: too long (max 65535 bytes)`); 96 + } 97 + 98 + if (INVALID_FILENAME_TRAVERSAL.test(filename)) { 99 + throw new Error(`invalid filename: contains path traversal`); 100 + } 101 + 102 + if (filename.startsWith('/')) { 103 + throw new Error(`invalid filename: is an absolute path`); 104 + } 105 + 106 + if (INVALID_FILENAME_CHARS.test(filename)) { 107 + throw new Error('invalid filename: contains invalid characters'); 108 + } 109 + } 110 + 111 + function isNonAscii(filename: string): boolean { 112 + // check if filename contains non-ASCII characters 113 + return NON_ASCII_CHARS.test(filename); 114 + } 115 + 116 + /** 117 + * creates a zip archive from entries and yields chunks as Uint8Array 118 + * @param entries iterable of zip entries to include in the archive 119 + * @returns async generator that yields zip file chunks 120 + */ 121 + export async function* zip( 122 + entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>, 123 + ): AsyncGenerator<Uint8Array> { 124 + const listing: Uint8Array[] = []; 125 + let offset: number = 0; 126 + 127 + for await (const { filename, data, compress = 'deflate', attrs = DEFAULT_ATTRS } of entries) { 128 + validateFilename(filename); 129 + 130 + const startOffset = offset; 131 + 132 + const fname = textEncoder.encode(filename); 133 + const fnameLen = fname.length; 134 + 135 + const mtimeSeconds = attrs?.mtime ?? Math.floor(Date.now() / 1000); 136 + const { time: dosTime, date: dosDate } = unixToDosTime(mtimeSeconds); 137 + 138 + let method: number = 0; 139 + let crc: number = 0xffffffff; 140 + let flags = 0x0008; 141 + 142 + let uncompressedSize: number = 0; 143 + let compressedSize: number = 0; 144 + 145 + if (compress === 'deflate') { 146 + method = 8; 147 + } 148 + 149 + if (isNonAscii(filename)) { 150 + flags |= 0x0800; 151 + } 152 + 153 + // local header 154 + { 155 + const header = new ArrayBuffer(30 + fnameLen); 156 + const view = new DataView(header); 157 + 158 + writeUint32LE(view, 0, 0x04034b50); // local file header signature 159 + writeUint16LE(view, 4, 20); // version needed to extract (2.0) 160 + writeUint16LE(view, 6, flags); // general purpose bit flag 161 + writeUint16LE(view, 8, method); // compression method (0=stored, 8=deflate) 162 + writeUint16LE(view, 10, dosTime); // last mod file time (DOS format) 163 + writeUint16LE(view, 12, dosDate); // last mod file date (DOS format) 164 + writeUint32LE(view, 14, 0); // crc-32 (set to 0, actual value in data descriptor) 165 + writeUint32LE(view, 18, 0); // compressed size (set to 0, actual value in data descriptor) 166 + writeUint32LE(view, 22, 0); // uncompressed size (set to 0, actual value in data descriptor) 167 + writeUint16LE(view, 26, fnameLen); // file name length 168 + writeUint16LE(view, 28, 0); // extra field length 169 + 170 + writeUtf8String(view, 30, fnameLen, filename); 171 + 172 + offset += 30 + fnameLen; 173 + 174 + yield new Uint8Array(header); 175 + } 176 + 177 + // data 178 + if (compress === 'deflate') { 179 + let stream: ReadableStream<Uint8Array>; 180 + if (data instanceof ReadableStream) { 181 + stream = data.pipeThrough( 182 + new TransformStream({ 183 + transform(chunk, controller) { 184 + uncompressedSize += chunk.length; 185 + crc = crc32(chunk, crc); 186 + 187 + controller.enqueue(chunk); 188 + }, 189 + }), 190 + ); 191 + } else { 192 + const chunk = typeof data === 'string' ? textEncoder.encode(data) : data; 193 + 194 + uncompressedSize = chunk.length; 195 + crc = crc32(chunk, crc); 196 + 197 + stream = new ReadableStream({ 198 + start(controller) { 199 + controller.enqueue(chunk); 200 + controller.close(); 201 + }, 202 + }); 203 + } 204 + 205 + yield* stream.pipeThrough(new CompressionStream('deflate-raw')).pipeThrough( 206 + new TransformStream({ 207 + transform(chunk, controller) { 208 + controller.enqueue(chunk); 209 + 210 + compressedSize += chunk.length; 211 + }, 212 + }), 213 + ); 214 + } else { 215 + if (data instanceof ReadableStream) { 216 + yield* data.pipeThrough( 217 + new TransformStream({ 218 + transform(chunk, controller) { 219 + uncompressedSize += chunk.length; 220 + crc = crc32(chunk, crc); 221 + 222 + controller.enqueue(chunk); 223 + }, 224 + }), 225 + ); 226 + 227 + compressedSize = uncompressedSize; 228 + } else { 229 + const chunk = typeof data === 'string' ? textEncoder.encode(data) : data; 230 + 231 + uncompressedSize = chunk.length; 232 + compressedSize = uncompressedSize; 233 + crc = crc32(chunk, crc); 234 + 235 + yield chunk; 236 + } 237 + } 238 + 239 + offset += compressedSize; 240 + 241 + // data descriptor 242 + { 243 + const descriptor = new ArrayBuffer(16); 244 + const view = new DataView(descriptor); 245 + 246 + // 0 - data descriptor signature 247 + // 4 - CRC32 of uncompressed data 248 + // 8 - compressed size 249 + // 12 - uncompressed size 250 + writeUint32LE(view, 0, 0x08074b50); 251 + writeUint32LE(view, 4, crc); 252 + writeUint32LE(view, 8, compressedSize); 253 + writeUint32LE(view, 12, uncompressedSize); 254 + 255 + offset += 16; 256 + 257 + yield new Uint8Array(descriptor); 258 + } 259 + 260 + // central directory record 261 + { 262 + const record = new ArrayBuffer(46 + fnameLen); 263 + const view = new DataView(record); 264 + 265 + const mode = attrs?.mode ?? 0o100644; 266 + const externalAttrs = (mode & 0xffff) << 16; 267 + 268 + // 0 - central directory record signature 269 + // 4 - version used to create this archive 270 + // 6 - minimum required version for extraction 271 + // 8 - general purpose bitflag 272 + // 10 - compression method 273 + // 12 - file last modification time 274 + // 14 - file last modification date 275 + // 16 - CRC32 of uncompressed data 276 + // 20 - compressed size 277 + // 24 - uncompressed size 278 + // 28 - file name length 279 + // 30 - extra fields length 280 + // 32 - file comment length 281 + // 34 - disk number containing start of file 282 + // 36 - internal file attributes 283 + // 38 - external file attributes 284 + // 42 - offset to start of entry 285 + writeUint32LE(view, 0, 0x02014b50); 286 + writeUint16LE(view, 4, (3 << 8) | 20); 287 + writeUint16LE(view, 6, 20); 288 + writeUint16LE(view, 8, flags); 289 + writeUint16LE(view, 10, method); 290 + writeUint16LE(view, 12, dosTime); 291 + writeUint16LE(view, 14, dosDate); 292 + writeUint32LE(view, 16, crc); 293 + writeUint32LE(view, 20, compressedSize); 294 + writeUint32LE(view, 24, uncompressedSize); 295 + writeUint16LE(view, 28, fnameLen); 296 + writeUint16LE(view, 30, 0); 297 + writeUint16LE(view, 32, 0); 298 + writeUint16LE(view, 34, 0); 299 + writeUint16LE(view, 36, 0); 300 + writeUint32LE(view, 38, externalAttrs); 301 + writeUint32LE(view, 42, startOffset); 302 + 303 + writeUtf8String(view, 46, fnameLen, filename); 304 + 305 + listing.push(new Uint8Array(record)); 306 + } 307 + } 308 + 309 + // central directory 310 + { 311 + const startCentralOffset = offset; 312 + const recordCount = listing.length; 313 + 314 + let centralSize = 0; 315 + 316 + for (let idx = 0; idx < recordCount; idx++) { 317 + const record = listing[idx]; 318 + const recordLen = record.length; 319 + 320 + offset += recordLen; 321 + centralSize += recordLen; 322 + 323 + yield record; 324 + } 325 + 326 + { 327 + const directory = new ArrayBuffer(22); 328 + const view = new DataView(directory); 329 + 330 + // 0 - end of central directory signature 331 + // 4 - this disk's number 332 + // 6 - disk number containing start of central directory 333 + // 8 - amount of records in this disk's central directory 334 + // 10 - total amount of central directory records 335 + // 12 - total size of this disk's central directory records 336 + // 16 - offset of this disk's central directory records 337 + // 20 - comment length 338 + writeUint32LE(view, 0, 0x06054b50); 339 + writeUint16LE(view, 4, 0); 340 + writeUint16LE(view, 6, 0); 341 + writeUint16LE(view, 8, recordCount); 342 + writeUint16LE(view, 10, recordCount); 343 + writeUint32LE(view, 12, centralSize); 344 + writeUint32LE(view, 16, startCentralOffset); 345 + writeUint16LE(view, 20, 0); 346 + 347 + yield new Uint8Array(directory); 348 + } 349 + } 350 + }