+4
.vscode/settings.json
+4
.vscode/settings.json
+40
.zed/settings.json
+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
+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
+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
+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
+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
+5
lib/mod.ts
+104
lib/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
+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
+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
+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
+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
+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
+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
+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
+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
+
}