@mappedin/mvf-fetch 3.0.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +23 -0
- package/dist/fetcher.d.ts +51 -0
- package/dist/fetcher.js +93 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +11 -0
- package/dist/source.d.ts +51 -0
- package/dist/source.js +112 -0
- package/dist/url/resolver.d.ts +34 -0
- package/dist/url/resolver.js +78 -0
- package/dist/validate.d.ts +32 -0
- package/dist/validate.js +39 -0
- package/dist/validators/index.d.ts +9 -0
- package/dist/validators/index.js +20 -0
- package/dist/validators/v2.d.ts +19 -0
- package/dist/validators/v2.js +69 -0
- package/dist/validators/v3-extensions.d.ts +10 -0
- package/dist/validators/v3-extensions.js +42 -0
- package/dist/validators/v3.d.ts +11 -0
- package/dist/validators/v3.js +47 -0
- package/dist/version.d.ts +53 -0
- package/dist/version.js +154 -0
- package/dist/zip/bytes.d.ts +17 -0
- package/dist/zip/bytes.js +36 -0
- package/dist/zip/entry.d.ts +52 -0
- package/dist/zip/entry.js +178 -0
- package/dist/zip/index.d.ts +44 -0
- package/dist/zip/index.js +140 -0
- package/dist/zip/range.d.ts +40 -0
- package/dist/zip/range.js +99 -0
- package/dist/zip/ranges.d.ts +33 -0
- package/dist/zip/ranges.js +84 -0
- package/package.json +81 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { inflateSync } from 'fflate';
|
|
2
|
+
import { readUint16LE, readUint32LE } from './bytes.js';
|
|
3
|
+
import { LOCAL_FILE_HEADER_SIGNATURE } from './index.js';
|
|
4
|
+
import { fetchByteRange } from './range.js';
|
|
5
|
+
import { RangeBufferCache, coalesceRanges, fetchCoalescedRanges } from './ranges.js';
|
|
6
|
+
export const LOCAL_EXTRA_FIELD_PAD = 1024;
|
|
7
|
+
/**
|
|
8
|
+
* Computes the initial byte range to fetch for a zip entry local header and payload.
|
|
9
|
+
*/
|
|
10
|
+
export function computeEntryEstimatedRange(entryPath, meta, totalSize) {
|
|
11
|
+
const headerStart = meta.relativeOffsetOfLocalHeader;
|
|
12
|
+
const nameByteLength = new TextEncoder().encode(entryPath).length;
|
|
13
|
+
const estimatedEnd = Math.min(headerStart + 30 + nameByteLength + LOCAL_EXTRA_FIELD_PAD + meta.compressedSize - 1, totalSize - 1);
|
|
14
|
+
return {
|
|
15
|
+
start: headerStart,
|
|
16
|
+
endInclusive: estimatedEnd,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Extracts compressed entry bytes from a fetched buffer covering the entry header region.
|
|
21
|
+
*/
|
|
22
|
+
export function extractCompressedBytes(buffer, bufferAbsoluteStart, entryPath, meta) {
|
|
23
|
+
if (meta.compressedSize === 0) {
|
|
24
|
+
return { compressed: new Uint8Array(0) };
|
|
25
|
+
}
|
|
26
|
+
const headerStart = meta.relativeOffsetOfLocalHeader;
|
|
27
|
+
const offsetInBuffer = headerStart - bufferAbsoluteStart;
|
|
28
|
+
if (offsetInBuffer < 0 || offsetInBuffer + 30 > buffer.length) {
|
|
29
|
+
throw new Error(`Local file header truncated: ${entryPath}`);
|
|
30
|
+
}
|
|
31
|
+
if (readUint32LE(buffer, offsetInBuffer) !== LOCAL_FILE_HEADER_SIGNATURE) {
|
|
32
|
+
throw new Error(`Invalid local file header: ${entryPath}`);
|
|
33
|
+
}
|
|
34
|
+
const fileNameLength = readUint16LE(buffer, offsetInBuffer + 26);
|
|
35
|
+
const extraFieldLength = readUint16LE(buffer, offsetInBuffer + 28);
|
|
36
|
+
const dataOffsetInBuffer = offsetInBuffer + 30 + fileNameLength + extraFieldLength;
|
|
37
|
+
if (dataOffsetInBuffer + meta.compressedSize <= buffer.length) {
|
|
38
|
+
return {
|
|
39
|
+
compressed: buffer.subarray(dataOffsetInBuffer, dataOffsetInBuffer + meta.compressedSize),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const absoluteDataStart = headerStart + 30 + fileNameLength + extraFieldLength;
|
|
43
|
+
const absoluteDataEnd = absoluteDataStart + meta.compressedSize - 1;
|
|
44
|
+
const alreadyHave = buffer.length > dataOffsetInBuffer ? buffer.subarray(dataOffsetInBuffer) : new Uint8Array(0);
|
|
45
|
+
return {
|
|
46
|
+
compressed: alreadyHave,
|
|
47
|
+
remainingRange: {
|
|
48
|
+
start: absoluteDataStart + alreadyHave.length,
|
|
49
|
+
endInclusive: absoluteDataEnd,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function mergeBytes(prefix, suffix) {
|
|
54
|
+
if (prefix.length === 0) {
|
|
55
|
+
return suffix;
|
|
56
|
+
}
|
|
57
|
+
if (suffix.length === 0) {
|
|
58
|
+
return prefix;
|
|
59
|
+
}
|
|
60
|
+
const merged = new Uint8Array(prefix.length + suffix.length);
|
|
61
|
+
merged.set(prefix, 0);
|
|
62
|
+
merged.set(suffix, prefix.length);
|
|
63
|
+
return merged;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reads the compressed bytes for a single zip entry via HTTP Range.
|
|
67
|
+
*/
|
|
68
|
+
export async function readZipEntryBytes(url, entryPath, index, fetchFn = fetch) {
|
|
69
|
+
const meta = index.entries[entryPath];
|
|
70
|
+
if (meta == null) {
|
|
71
|
+
throw new Error(`Zip entry not found: ${entryPath}`);
|
|
72
|
+
}
|
|
73
|
+
const primaryRange = computeEntryEstimatedRange(entryPath, meta, index.totalSize);
|
|
74
|
+
const buffer = await fetchByteRange(url, primaryRange.start, primaryRange.endInclusive, fetchFn);
|
|
75
|
+
const extracted = extractCompressedBytes(buffer, primaryRange.start, entryPath, meta);
|
|
76
|
+
if (extracted.remainingRange == null) {
|
|
77
|
+
return extracted.compressed;
|
|
78
|
+
}
|
|
79
|
+
const remaining = await fetchByteRange(url, extracted.remainingRange.start, extracted.remainingRange.endInclusive, fetchFn);
|
|
80
|
+
return mergeBytes(extracted.compressed, remaining);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Decompresses a zip entry payload.
|
|
84
|
+
*/
|
|
85
|
+
export function decompressEntry(compressed, meta) {
|
|
86
|
+
if (meta.compressionMethod === 0) {
|
|
87
|
+
return compressed;
|
|
88
|
+
}
|
|
89
|
+
if (meta.compressionMethod === 8) {
|
|
90
|
+
return inflateSync(compressed, { out: new Uint8Array(meta.uncompressedSize) });
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Unsupported compression method: ${meta.compressionMethod}`);
|
|
93
|
+
}
|
|
94
|
+
async function readZipEntriesWithCoalescedRanges(url, plans, fetchFn, concurrency, maxGapBytes) {
|
|
95
|
+
const results = new Map();
|
|
96
|
+
if (plans.length === 0) {
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
const cache = new RangeBufferCache();
|
|
100
|
+
await fetchCoalescedRanges(url, plans.map((plan) => plan.primaryRange), fetchFn, concurrency, cache, maxGapBytes);
|
|
101
|
+
const pendingRemaining = [];
|
|
102
|
+
for (const plan of plans) {
|
|
103
|
+
const buffer = cache.slice(plan.primaryRange.start, plan.primaryRange.endInclusive);
|
|
104
|
+
const extracted = extractCompressedBytes(buffer, plan.primaryRange.start, plan.entryPath, plan.meta);
|
|
105
|
+
if (extracted.remainingRange == null) {
|
|
106
|
+
results.set(plan.entryPath, decompressEntry(extracted.compressed, plan.meta));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
pendingRemaining.push({
|
|
110
|
+
entryPath: plan.entryPath,
|
|
111
|
+
meta: plan.meta,
|
|
112
|
+
prefix: extracted.compressed,
|
|
113
|
+
range: extracted.remainingRange,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (pendingRemaining.length > 0) {
|
|
117
|
+
await fetchCoalescedRanges(url, pendingRemaining.map((entry) => entry.range), fetchFn, concurrency, cache, maxGapBytes);
|
|
118
|
+
for (const entry of pendingRemaining) {
|
|
119
|
+
const suffix = cache.slice(entry.range.start, entry.range.endInclusive);
|
|
120
|
+
results.set(entry.entryPath, decompressEntry(mergeBytes(entry.prefix, suffix), entry.meta));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reads and decompresses multiple zip entries, coalescing adjacent HTTP Range requests.
|
|
127
|
+
*/
|
|
128
|
+
export async function readZipEntries(url, entryPaths, index, options = {}) {
|
|
129
|
+
const { allowMissing = false, concurrency = 32, fetchFn = fetch, maxGapBytes = 0 } = options;
|
|
130
|
+
const plans = [];
|
|
131
|
+
for (const entryPath of entryPaths) {
|
|
132
|
+
const meta = index.entries[entryPath];
|
|
133
|
+
if (meta == null) {
|
|
134
|
+
if (allowMissing) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Zip entry not found: ${entryPath}`);
|
|
138
|
+
}
|
|
139
|
+
plans.push({
|
|
140
|
+
entryPath,
|
|
141
|
+
meta,
|
|
142
|
+
primaryRange: computeEntryEstimatedRange(entryPath, meta, index.totalSize),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (plans.length === 0) {
|
|
146
|
+
return new Map();
|
|
147
|
+
}
|
|
148
|
+
if (plans.length === 1) {
|
|
149
|
+
const plan = plans[0];
|
|
150
|
+
const compressed = await readZipEntryBytes(url, plan.entryPath, index, fetchFn);
|
|
151
|
+
return new Map([[plan.entryPath, decompressEntry(compressed, plan.meta)]]);
|
|
152
|
+
}
|
|
153
|
+
return readZipEntriesWithCoalescedRanges(url, plans, fetchFn, concurrency, maxGapBytes);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Returns the coalesced HTTP Range count for a batch of entry paths.
|
|
157
|
+
* Useful for testing and diagnostics.
|
|
158
|
+
*/
|
|
159
|
+
export function countCoalescedRangesForEntries(entryPaths, index, maxGapBytes = 0) {
|
|
160
|
+
const ranges = [];
|
|
161
|
+
for (const entryPath of entryPaths) {
|
|
162
|
+
const meta = index.entries[entryPath];
|
|
163
|
+
if (meta != null) {
|
|
164
|
+
ranges.push(computeEntryEstimatedRange(entryPath, meta, index.totalSize));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
rawRanges: ranges.length,
|
|
169
|
+
coalescedRanges: coalesceRanges(ranges, maxGapBytes).length,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Decodes zip entry bytes as UTF-8 JSON.
|
|
174
|
+
*/
|
|
175
|
+
export function decodeZipEntryJson(raw) {
|
|
176
|
+
return JSON.parse(new TextDecoder().decode(raw));
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=entry.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type FetchFn } from './range.js';
|
|
2
|
+
export declare const EOCD_SIGNATURE = 101010256;
|
|
3
|
+
export declare const EOCD_MIN_SIZE = 22;
|
|
4
|
+
export declare const EOCD_MAX_COMMENT = 65535;
|
|
5
|
+
export declare const CENTRAL_DIRECTORY_SIGNATURE = 33639248;
|
|
6
|
+
export declare const LOCAL_FILE_HEADER_SIGNATURE = 67324752;
|
|
7
|
+
export declare const ZIP64_EXTRA_FIELD_ID = 1;
|
|
8
|
+
export interface ZipEntryMeta {
|
|
9
|
+
relativeOffsetOfLocalHeader: number;
|
|
10
|
+
compressedSize: number;
|
|
11
|
+
uncompressedSize: number;
|
|
12
|
+
compressionMethod: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ZipIndex {
|
|
15
|
+
totalSize: number;
|
|
16
|
+
entries: Record<string, ZipEntryMeta>;
|
|
17
|
+
}
|
|
18
|
+
export interface ZipEndOfCentralDirectory {
|
|
19
|
+
entryCount: number;
|
|
20
|
+
centralDirectorySize: number;
|
|
21
|
+
centralDirectoryOffset: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Size of the speculative first tail read.
|
|
25
|
+
*
|
|
26
|
+
* The EOCD record may legally sit up to {@link EOCD_MAX_COMMENT} bytes from the end, but
|
|
27
|
+
* only when the archive carries a large comment, which published MVF archives do not.
|
|
28
|
+
* Reading that maximum up front would be a large fraction of a small archive — 64 KiB
|
|
29
|
+
* against a 165 KiB bundle — so speculate small, big enough that a typical central
|
|
30
|
+
* directory arrives in the same response, and widen only when the record is not in there.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_TAIL_READ_LENGTH = 4096;
|
|
33
|
+
export declare function getZipTailReadLength(fileSize: number): number;
|
|
34
|
+
export declare function parseEndOfCentralDirectory(tail: Uint8Array): ZipEndOfCentralDirectory;
|
|
35
|
+
export declare function parseCentralDirectory(centralDirectory: Uint8Array, entryCount: number): Map<string, ZipEntryMeta>;
|
|
36
|
+
/**
|
|
37
|
+
* Opens a remote zip index by reading the EOCD and central directory via HTTP Range.
|
|
38
|
+
*
|
|
39
|
+
* Costs a single round trip for a typical archive: the suffix range that reads the tail
|
|
40
|
+
* also reports the total size, and the central directory is normally inside those same
|
|
41
|
+
* bytes.
|
|
42
|
+
*/
|
|
43
|
+
export declare function openRemoteZipIndex(url: string, fetchFn?: FetchFn): Promise<ZipIndex>;
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readUint16LE, readUint32LE } from './bytes.js';
|
|
2
|
+
import { fetchByteRange, fetchZipTail } from './range.js';
|
|
3
|
+
export const EOCD_SIGNATURE = 0x06054b50;
|
|
4
|
+
export const EOCD_MIN_SIZE = 22;
|
|
5
|
+
export const EOCD_MAX_COMMENT = 65535;
|
|
6
|
+
export const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
7
|
+
export const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
8
|
+
export const ZIP64_EXTRA_FIELD_ID = 0x0001;
|
|
9
|
+
/**
|
|
10
|
+
* Size of the speculative first tail read.
|
|
11
|
+
*
|
|
12
|
+
* The EOCD record may legally sit up to {@link EOCD_MAX_COMMENT} bytes from the end, but
|
|
13
|
+
* only when the archive carries a large comment, which published MVF archives do not.
|
|
14
|
+
* Reading that maximum up front would be a large fraction of a small archive — 64 KiB
|
|
15
|
+
* against a 165 KiB bundle — so speculate small, big enough that a typical central
|
|
16
|
+
* directory arrives in the same response, and widen only when the record is not in there.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_TAIL_READ_LENGTH = 4096;
|
|
19
|
+
export function getZipTailReadLength(fileSize) {
|
|
20
|
+
return Math.min(fileSize, EOCD_MIN_SIZE + EOCD_MAX_COMMENT);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Scans backwards for the EOCD record, which is the last thing in a zip.
|
|
24
|
+
* @returns The record, or null when it is not inside the given bytes
|
|
25
|
+
*/
|
|
26
|
+
function findEndOfCentralDirectory(tail) {
|
|
27
|
+
for (let commentLength = 0; commentLength <= EOCD_MAX_COMMENT; commentLength++) {
|
|
28
|
+
const eocdStart = tail.length - EOCD_MIN_SIZE - commentLength;
|
|
29
|
+
if (eocdStart < 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (readUint32LE(tail, eocdStart) !== EOCD_SIGNATURE) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
entryCount: readUint16LE(tail, eocdStart + 10),
|
|
37
|
+
centralDirectorySize: readUint32LE(tail, eocdStart + 12),
|
|
38
|
+
centralDirectoryOffset: readUint32LE(tail, eocdStart + 16),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function parseEndOfCentralDirectory(tail) {
|
|
44
|
+
const eocd = findEndOfCentralDirectory(tail);
|
|
45
|
+
if (eocd == null) {
|
|
46
|
+
throw new Error('End of Central Directory record not found in zip tail.');
|
|
47
|
+
}
|
|
48
|
+
return eocd;
|
|
49
|
+
}
|
|
50
|
+
function assertClassicZipSizes(...values) {
|
|
51
|
+
for (const value of values) {
|
|
52
|
+
if (value === 0xffffffff) {
|
|
53
|
+
throw new Error('Zip64 archives are not supported.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function parseCentralDirectory(centralDirectory, entryCount) {
|
|
58
|
+
const entries = new Map();
|
|
59
|
+
let offset = 0;
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
for (let i = 0; i < entryCount; i++) {
|
|
62
|
+
if (offset + 46 > centralDirectory.length) {
|
|
63
|
+
throw new Error('Central directory is truncated.');
|
|
64
|
+
}
|
|
65
|
+
if (readUint32LE(centralDirectory, offset) !== CENTRAL_DIRECTORY_SIGNATURE) {
|
|
66
|
+
throw new Error(`Invalid central directory signature at ${offset}`);
|
|
67
|
+
}
|
|
68
|
+
const compressionMethod = readUint16LE(centralDirectory, offset + 10);
|
|
69
|
+
const compressedSize = readUint32LE(centralDirectory, offset + 20);
|
|
70
|
+
const uncompressedSize = readUint32LE(centralDirectory, offset + 24);
|
|
71
|
+
const fileNameLength = readUint16LE(centralDirectory, offset + 28);
|
|
72
|
+
const extraFieldLength = readUint16LE(centralDirectory, offset + 30);
|
|
73
|
+
const fileCommentLength = readUint16LE(centralDirectory, offset + 32);
|
|
74
|
+
const relativeOffsetOfLocalHeader = readUint32LE(centralDirectory, offset + 42);
|
|
75
|
+
assertClassicZipSizes(compressedSize, uncompressedSize, relativeOffsetOfLocalHeader);
|
|
76
|
+
const nameStart = offset + 46;
|
|
77
|
+
const nameEnd = nameStart + fileNameLength;
|
|
78
|
+
const fileName = decoder.decode(centralDirectory.subarray(nameStart, nameEnd));
|
|
79
|
+
const extraStart = nameEnd;
|
|
80
|
+
const extraEnd = extraStart + extraFieldLength;
|
|
81
|
+
for (let extraOffset = extraStart; extraOffset + 4 <= extraEnd;) {
|
|
82
|
+
const headerId = readUint16LE(centralDirectory, extraOffset);
|
|
83
|
+
const dataSize = readUint16LE(centralDirectory, extraOffset + 2);
|
|
84
|
+
if (headerId === ZIP64_EXTRA_FIELD_ID) {
|
|
85
|
+
throw new Error('Zip64 archives are not supported.');
|
|
86
|
+
}
|
|
87
|
+
extraOffset += 4 + dataSize;
|
|
88
|
+
}
|
|
89
|
+
if (!fileName.endsWith('/')) {
|
|
90
|
+
entries.set(fileName, {
|
|
91
|
+
relativeOffsetOfLocalHeader,
|
|
92
|
+
compressedSize,
|
|
93
|
+
uncompressedSize,
|
|
94
|
+
compressionMethod,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
offset = nameEnd + extraFieldLength + fileCommentLength;
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The central directory usually sits inside the tail that was already fetched, so only
|
|
103
|
+
* go back to the network when it does not.
|
|
104
|
+
*/
|
|
105
|
+
async function readCentralDirectory(url, tail, eocd, fetchFn) {
|
|
106
|
+
const start = eocd.centralDirectoryOffset;
|
|
107
|
+
const endInclusive = start + eocd.centralDirectorySize - 1;
|
|
108
|
+
if (start >= tail.start && endInclusive < tail.start + tail.bytes.length) {
|
|
109
|
+
return tail.bytes.subarray(start - tail.start, endInclusive - tail.start + 1);
|
|
110
|
+
}
|
|
111
|
+
return fetchByteRange(url, start, endInclusive, fetchFn);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Opens a remote zip index by reading the EOCD and central directory via HTTP Range.
|
|
115
|
+
*
|
|
116
|
+
* Costs a single round trip for a typical archive: the suffix range that reads the tail
|
|
117
|
+
* also reports the total size, and the central directory is normally inside those same
|
|
118
|
+
* bytes.
|
|
119
|
+
*/
|
|
120
|
+
export async function openRemoteZipIndex(url, fetchFn = fetch) {
|
|
121
|
+
let tail = await fetchZipTail(url, DEFAULT_TAIL_READ_LENGTH, fetchFn);
|
|
122
|
+
let eocd = findEndOfCentralDirectory(tail.bytes);
|
|
123
|
+
// The speculative read missed the record, so widen to the largest legal window.
|
|
124
|
+
const maxTailLength = getZipTailReadLength(tail.totalSize);
|
|
125
|
+
if (eocd == null && tail.bytes.length < maxTailLength) {
|
|
126
|
+
tail = await fetchZipTail(url, maxTailLength, fetchFn);
|
|
127
|
+
eocd = findEndOfCentralDirectory(tail.bytes);
|
|
128
|
+
}
|
|
129
|
+
if (eocd == null) {
|
|
130
|
+
throw new Error('End of Central Directory record not found in zip tail.');
|
|
131
|
+
}
|
|
132
|
+
assertClassicZipSizes(eocd.centralDirectorySize, eocd.centralDirectoryOffset);
|
|
133
|
+
const centralDirectory = await readCentralDirectory(url, tail, eocd, fetchFn);
|
|
134
|
+
const entryMap = parseCentralDirectory(centralDirectory, eocd.entryCount);
|
|
135
|
+
return {
|
|
136
|
+
totalSize: tail.totalSize,
|
|
137
|
+
entries: Object.fromEntries(entryMap),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare class HttpRangeError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
constructor(message: string, status: number);
|
|
4
|
+
}
|
|
5
|
+
export type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
/**
|
|
7
|
+
* Fetches an inclusive byte range from a URL using HTTP Range requests.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fetchByteRange(url: string, start: number, endInclusive: number, fetchFn?: FetchFn): Promise<Uint8Array>;
|
|
10
|
+
/**
|
|
11
|
+
* The trailing bytes of a remote file, positioned within it.
|
|
12
|
+
*/
|
|
13
|
+
export interface ZipTail {
|
|
14
|
+
bytes: Uint8Array;
|
|
15
|
+
/** Absolute offset of `bytes[0]` within the file. */
|
|
16
|
+
start: number;
|
|
17
|
+
totalSize: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parses a `Content-Range: bytes <start>-<end>/<total>` header.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseContentRange(header: string | null): {
|
|
23
|
+
start: number;
|
|
24
|
+
totalSize: number;
|
|
25
|
+
} | null;
|
|
26
|
+
/**
|
|
27
|
+
* Reads the last `tailLength` bytes of a remote file, along with its total size.
|
|
28
|
+
*
|
|
29
|
+
* Uses a suffix range (`bytes=-N`), so `Content-Range` reports the total size and no
|
|
30
|
+
* separate `HEAD` round trip is needed to learn it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchZipTail(url: string, tailLength: number, fetchFn?: FetchFn): Promise<ZipTail>;
|
|
33
|
+
/**
|
|
34
|
+
* Reads the total size of a remote resource from its Content-Length header.
|
|
35
|
+
*
|
|
36
|
+
* Prefer {@link fetchZipTail} when the tail bytes are wanted too: it returns the size
|
|
37
|
+
* from `Content-Range` and saves a round trip.
|
|
38
|
+
*/
|
|
39
|
+
export declare function getTotalSize(url: string, fetchFn?: FetchFn): Promise<number>;
|
|
40
|
+
//# sourceMappingURL=range.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export class HttpRangeError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = 'HttpRangeError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Fetches an inclusive byte range from a URL using HTTP Range requests.
|
|
11
|
+
*/
|
|
12
|
+
export async function fetchByteRange(url, start, endInclusive, fetchFn = fetch) {
|
|
13
|
+
const response = await fetchFn(url, {
|
|
14
|
+
headers: { Range: `bytes=${start}-${endInclusive}` },
|
|
15
|
+
redirect: 'follow',
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok && response.status !== 206) {
|
|
18
|
+
throw new HttpRangeError(`Range request failed: ${response.status}`, response.status);
|
|
19
|
+
}
|
|
20
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
21
|
+
// Anything other than 206 means the server ignored Range and sent the whole entity.
|
|
22
|
+
// Returning that as-is would leave the caller slicing at the wrong offsets, so cut
|
|
23
|
+
// the requested window out of it here.
|
|
24
|
+
if (response.status !== 206) {
|
|
25
|
+
return bytes.subarray(start, endInclusive + 1);
|
|
26
|
+
}
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parses a `Content-Range: bytes <start>-<end>/<total>` header.
|
|
31
|
+
*/
|
|
32
|
+
export function parseContentRange(header) {
|
|
33
|
+
if (header == null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const match = /bytes\s+(\d+)-(\d+)\/(\d+)/.exec(header);
|
|
37
|
+
if (match == null) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
start: Number.parseInt(match[1] ?? '', 10),
|
|
42
|
+
totalSize: Number.parseInt(match[3] ?? '', 10),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads the last `tailLength` bytes of a remote file, along with its total size.
|
|
47
|
+
*
|
|
48
|
+
* Uses a suffix range (`bytes=-N`), so `Content-Range` reports the total size and no
|
|
49
|
+
* separate `HEAD` round trip is needed to learn it.
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchZipTail(url, tailLength, fetchFn = fetch) {
|
|
52
|
+
const response = await fetchFn(url, {
|
|
53
|
+
headers: { Range: `bytes=-${tailLength}` },
|
|
54
|
+
redirect: 'follow',
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok && response.status !== 206) {
|
|
57
|
+
throw new HttpRangeError(`Tail range request failed: ${response.status}`, response.status);
|
|
58
|
+
}
|
|
59
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
60
|
+
// The server ignored Range and sent the whole entity, which still contains the tail.
|
|
61
|
+
if (response.status !== 206) {
|
|
62
|
+
return { bytes, start: 0, totalSize: bytes.length };
|
|
63
|
+
}
|
|
64
|
+
const contentRange = parseContentRange(response.headers.get('content-range'));
|
|
65
|
+
if (contentRange == null) {
|
|
66
|
+
throw new Error(`Unusable Content-Range header: ${response.headers.get('content-range')}`);
|
|
67
|
+
}
|
|
68
|
+
const { start, totalSize } = contentRange;
|
|
69
|
+
const expectedStart = Math.max(0, totalSize - Math.min(tailLength, totalSize));
|
|
70
|
+
// Some static file servers misread `bytes=-N` as `bytes=0-N` while still answering
|
|
71
|
+
// 206 with a plausible Content-Range. Absolute ranges are unambiguous, so retry.
|
|
72
|
+
if (start !== expectedStart) {
|
|
73
|
+
const absolute = await fetchByteRange(url, expectedStart, totalSize - 1, fetchFn);
|
|
74
|
+
return { bytes: absolute, start: expectedStart, totalSize };
|
|
75
|
+
}
|
|
76
|
+
return { bytes, start, totalSize };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Reads the total size of a remote resource from its Content-Length header.
|
|
80
|
+
*
|
|
81
|
+
* Prefer {@link fetchZipTail} when the tail bytes are wanted too: it returns the size
|
|
82
|
+
* from `Content-Range` and saves a round trip.
|
|
83
|
+
*/
|
|
84
|
+
export async function getTotalSize(url, fetchFn = fetch) {
|
|
85
|
+
const response = await fetchFn(url, { method: 'HEAD', redirect: 'follow' });
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new HttpRangeError(`HEAD request failed: ${response.status}`, response.status);
|
|
88
|
+
}
|
|
89
|
+
const contentLength = response.headers.get('content-length');
|
|
90
|
+
if (contentLength == null) {
|
|
91
|
+
throw new Error('Missing Content-Length header');
|
|
92
|
+
}
|
|
93
|
+
const totalSize = Number.parseInt(contentLength, 10);
|
|
94
|
+
if (!Number.isFinite(totalSize) || totalSize <= 0) {
|
|
95
|
+
throw new Error('Invalid Content-Length header');
|
|
96
|
+
}
|
|
97
|
+
return totalSize;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=range.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type FetchFn } from './range.js';
|
|
2
|
+
/**
|
|
3
|
+
* An inclusive byte range within a remote file.
|
|
4
|
+
*/
|
|
5
|
+
export interface ByteRange {
|
|
6
|
+
start: number;
|
|
7
|
+
endInclusive: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Merges overlapping or adjacent byte ranges to minimize HTTP requests.
|
|
11
|
+
*
|
|
12
|
+
* @param ranges - The ranges to merge
|
|
13
|
+
* @param maxGapBytes - Also merge ranges separated by at most this many unwanted bytes.
|
|
14
|
+
* Entries a consumer wants are rarely laid out contiguously, so tolerating a small gap
|
|
15
|
+
* trades a few wasted bytes for noticeably fewer round trips. Merging without a bound
|
|
16
|
+
* would pull the whole archive, so this is deliberately a ceiling rather than a flag.
|
|
17
|
+
* @default 0
|
|
18
|
+
*/
|
|
19
|
+
export declare function coalesceRanges(ranges: ByteRange[], maxGapBytes?: number): ByteRange[];
|
|
20
|
+
/**
|
|
21
|
+
* In-memory cache of fetched byte ranges keyed by absolute file offset.
|
|
22
|
+
*/
|
|
23
|
+
export declare class RangeBufferCache {
|
|
24
|
+
#private;
|
|
25
|
+
add(start: number, buffer: Uint8Array): void;
|
|
26
|
+
slice(start: number, endInclusive: number): Uint8Array;
|
|
27
|
+
get size(): number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Fetches byte ranges after coalescing overlaps and adjacency.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchCoalescedRanges(url: string, ranges: ByteRange[], fetchFn: FetchFn, concurrency: number, cache: RangeBufferCache, maxGapBytes?: number): Promise<void>;
|
|
33
|
+
//# sourceMappingURL=ranges.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fetchByteRange } from './range.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs async tasks with a bounded concurrency limit.
|
|
4
|
+
*/
|
|
5
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
6
|
+
if (items.length === 0) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const limit = Math.max(1, concurrency);
|
|
10
|
+
const results = new Array(items.length);
|
|
11
|
+
let nextIndex = 0;
|
|
12
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
13
|
+
while (nextIndex < items.length) {
|
|
14
|
+
const current = nextIndex++;
|
|
15
|
+
const item = items[current];
|
|
16
|
+
if (item === undefined) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
results[current] = await fn(item);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
await Promise.all(workers);
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Merges overlapping or adjacent byte ranges to minimize HTTP requests.
|
|
27
|
+
*
|
|
28
|
+
* @param ranges - The ranges to merge
|
|
29
|
+
* @param maxGapBytes - Also merge ranges separated by at most this many unwanted bytes.
|
|
30
|
+
* Entries a consumer wants are rarely laid out contiguously, so tolerating a small gap
|
|
31
|
+
* trades a few wasted bytes for noticeably fewer round trips. Merging without a bound
|
|
32
|
+
* would pull the whole archive, so this is deliberately a ceiling rather than a flag.
|
|
33
|
+
* @default 0
|
|
34
|
+
*/
|
|
35
|
+
export function coalesceRanges(ranges, maxGapBytes = 0) {
|
|
36
|
+
if (ranges.length === 0) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start || a.endInclusive - b.endInclusive);
|
|
40
|
+
const merged = [{ ...sorted[0] }];
|
|
41
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
42
|
+
const current = sorted[i];
|
|
43
|
+
const last = merged[merged.length - 1];
|
|
44
|
+
if (current.start <= last.endInclusive + 1 + maxGapBytes) {
|
|
45
|
+
last.endInclusive = Math.max(last.endInclusive, current.endInclusive);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
merged.push({ ...current });
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* In-memory cache of fetched byte ranges keyed by absolute file offset.
|
|
54
|
+
*/
|
|
55
|
+
export class RangeBufferCache {
|
|
56
|
+
#segments = [];
|
|
57
|
+
add(start, buffer) {
|
|
58
|
+
this.#segments.push({ start, buffer });
|
|
59
|
+
}
|
|
60
|
+
slice(start, endInclusive) {
|
|
61
|
+
for (const segment of this.#segments) {
|
|
62
|
+
const segmentEnd = segment.start + segment.buffer.length - 1;
|
|
63
|
+
if (start >= segment.start && endInclusive <= segmentEnd) {
|
|
64
|
+
const offset = start - segment.start;
|
|
65
|
+
return segment.buffer.subarray(offset, offset + (endInclusive - start + 1));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Byte range ${start}-${endInclusive} is not available in the cache.`);
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.#segments.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Fetches byte ranges after coalescing overlaps and adjacency.
|
|
76
|
+
*/
|
|
77
|
+
export async function fetchCoalescedRanges(url, ranges, fetchFn, concurrency, cache, maxGapBytes = 0) {
|
|
78
|
+
const coalesced = coalesceRanges(ranges, maxGapBytes);
|
|
79
|
+
await mapWithConcurrency(coalesced, concurrency, async (range) => {
|
|
80
|
+
const buffer = await fetchByteRange(url, range.start, range.endInclusive, fetchFn);
|
|
81
|
+
cache.add(range.start, buffer);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=ranges.js.map
|