@2018kguo/recto 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets.d.ts +11 -6
- package/dist/assets.js +123 -42
- package/dist/document.d.ts +144 -2
- package/dist/document.js +163 -14
- package/dist/engine.d.ts +24 -9
- package/dist/engine.js +236 -49
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +4 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +13 -0
- package/dist/pool-worker.d.ts +15 -0
- package/dist/pool-worker.js +65 -0
- package/dist/pool.d.ts +42 -0
- package/dist/pool.js +295 -0
- package/dist/ranged.d.ts +44 -9
- package/dist/ranged.js +571 -86
- package/package.json +2 -1
- package/wasm/FoxitDingbats.pfb +0 -0
- package/wasm/FoxitFixed.pfb +0 -0
- package/wasm/FoxitFixedBold.pfb +0 -0
- package/wasm/FoxitFixedBoldItalic.pfb +0 -0
- package/wasm/FoxitFixedItalic.pfb +0 -0
- package/wasm/FoxitSans.pfb +0 -0
- package/wasm/FoxitSansBold.pfb +0 -0
- package/wasm/FoxitSansBoldItalic.pfb +0 -0
- package/wasm/FoxitSansItalic.pfb +0 -0
- package/wasm/FoxitSerif.pfb +0 -0
- package/wasm/FoxitSerifBold.pfb +0 -0
- package/wasm/FoxitSerifBoldItalic.pfb +0 -0
- package/wasm/FoxitSerifItalic.pfb +0 -0
- package/wasm/FoxitSymbol.pfb +0 -0
- package/wasm/THIRD_PARTY_NOTICES +2 -158
- package/wasm/cmaps.deflate +0 -0
- package/wasm/pins.d.mts +1 -1
- package/wasm/pins.mjs +19 -3
- package/wasm/recto-more.wasm +0 -0
- package/wasm/recto-rare.wasm +0 -0
- package/wasm/recto-render.wasm +0 -0
- package/wasm/recto.gen.d.mts +2 -1
- package/wasm/recto.gen.mjs +213 -19
- package/wasm/recto.types.d.mts +83 -4
- package/wasm/recto.wasm +0 -0
- package/wasm/cmaps.brotli +0 -0
- package/wasm/fonts.bin +0 -0
package/dist/ranged.js
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// `timeoutMs`), so run it on a worker thread of its own, never on a server's main loop.
|
|
10
10
|
//
|
|
11
11
|
// Fetched bytes are kept as fixed-size chunks indexed by chunk number, so a read costs
|
|
12
|
-
// O(chunks it spans), whatever the number of fetches so far. The first
|
|
13
|
-
//
|
|
14
|
-
// file's cross-reference table is at its end
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// O(chunks it spans), whatever the number of fetches so far. The first round asks for the head
|
|
13
|
+
// and the tail at once (the tail as a suffix range, `bytes=-n`, since the size isn't known
|
|
14
|
+
// yet): an ordinary file's cross-reference table is at its end, and a small file's head is
|
|
15
|
+
// all of it. That round, and the one for a cross-reference section the tail didn't hold, run
|
|
16
|
+
// before the engine's synchronous parse, so in a browser they're `fetch` calls in parallel
|
|
17
|
+
// rather than synchronous requests one after another. A miss that continues the previous
|
|
18
|
+
// fetch (a long xref table or page run read front to back) doubles the read-ahead, so a
|
|
19
|
+
// sequential scan of n chunks costs O(log n) round trips.
|
|
17
20
|
import { PdfError } from "./errors.js";
|
|
18
21
|
const DEFAULT_CHUNK_BYTES = 64 * 1024;
|
|
19
22
|
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
@@ -24,6 +27,11 @@ const MAX_READ_AHEAD_BYTES = 8 * 1024 * 1024;
|
|
|
24
27
|
* first-page cross-reference section.
|
|
25
28
|
*/
|
|
26
29
|
const HEAD_CHUNKS = 4;
|
|
30
|
+
/**
|
|
31
|
+
* The tail asked for alongside the head: the last chunk, which ends the file, and the one
|
|
32
|
+
* before, where a file's last objects and a short xref section usually are.
|
|
33
|
+
*/
|
|
34
|
+
const TAIL_CHUNKS = 2;
|
|
27
35
|
/**
|
|
28
36
|
* A ranged open reserves the file's length of linear memory: fresh pages, committed only where
|
|
29
37
|
* fetched, so memory use follows the bytes fetched and not the length (measured in
|
|
@@ -37,6 +45,8 @@ class RangeLoader {
|
|
|
37
45
|
chunkBytes;
|
|
38
46
|
/** Fetched chunks by chunk number (a flat array, sized once the length is known). */
|
|
39
47
|
#chunks = [];
|
|
48
|
+
/** A chunk's start or end, by chunk number, until the rest of it arrives. */
|
|
49
|
+
#pieces = new Map();
|
|
40
50
|
/** Set once a fetch failed; every later read fails and every call throws it. */
|
|
41
51
|
failure = null;
|
|
42
52
|
#bytesFetched = 0;
|
|
@@ -50,6 +60,8 @@ class RangeLoader {
|
|
|
50
60
|
#fetch;
|
|
51
61
|
#maxBytes;
|
|
52
62
|
#timeoutMs;
|
|
63
|
+
#totalTimeoutMs;
|
|
64
|
+
#deadline;
|
|
53
65
|
#signal;
|
|
54
66
|
constructor(fetch, options) {
|
|
55
67
|
this.#fetch = fetch;
|
|
@@ -57,6 +69,8 @@ class RangeLoader {
|
|
|
57
69
|
this.#readAhead = options.chunkBytes;
|
|
58
70
|
this.#maxBytes = options.maxBytes;
|
|
59
71
|
this.#timeoutMs = options.timeoutMs;
|
|
72
|
+
this.#totalTimeoutMs = options.totalTimeoutMs;
|
|
73
|
+
this.#deadline = performance.now() + options.totalTimeoutMs;
|
|
60
74
|
this.#signal = options.signal;
|
|
61
75
|
}
|
|
62
76
|
stats() {
|
|
@@ -71,16 +85,78 @@ class RangeLoader {
|
|
|
71
85
|
}
|
|
72
86
|
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
|
|
73
87
|
fetchChunks(firstChunk, endChunk) {
|
|
88
|
+
const runs = this.#missing(firstChunk, endChunk);
|
|
89
|
+
if (runs.length > 0) {
|
|
90
|
+
this.#absorb(this.#round(runs, (limit, timeoutMs, passesTotal) => this.#timed(passesTotal, () => this.#fetch(runs, timeoutMs, limit))));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** The same, through `fetchAsync` when there is one: before the parse, a browser need not block. */
|
|
94
|
+
async #fetchChunksAsync(firstChunk, endChunk, fetchAsync) {
|
|
95
|
+
await this.#fetchRunsAsync(this.#missing(firstChunk, endChunk), fetchAsync);
|
|
96
|
+
}
|
|
97
|
+
async #fetchRunsAsync(runs, fetchAsync) {
|
|
98
|
+
if (runs.length === 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.#absorb(await this.#round(runs, (limit, timeoutMs, passesTotal) => fetchAsync
|
|
102
|
+
? this.#timedAsync(passesTotal, fetchAsync(runs, timeoutMs, limit))
|
|
103
|
+
: this.#timed(passesTotal, () => this.#fetch(runs, timeoutMs, limit))));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The first round: the head and, in parallel, the tail as a suffix, then the last
|
|
107
|
+
* cross-reference section if the tail didn't hold it. A file the head covers needs no more;
|
|
108
|
+
* a server that doesn't answer the suffix gets the tail by offset in a round of its own.
|
|
109
|
+
*/
|
|
110
|
+
async begin(fetchAsync) {
|
|
111
|
+
const tail = TAIL_CHUNKS * this.chunkBytes;
|
|
112
|
+
// The head stops where the suffix starts, in a file shorter than the two.
|
|
113
|
+
const head = { start: 0, end: HEAD_CHUNKS * this.chunkBytes, yieldTail: tail };
|
|
114
|
+
const requests = [head, { suffix: tail }];
|
|
115
|
+
const answers = await this.#round(requests, (limit, timeoutMs, passesTotal) => fetchAsync
|
|
116
|
+
? this.#timedAsync(passesTotal, fetchAsync(requests, timeoutMs, limit))
|
|
117
|
+
: this.#timed(passesTotal, () => this.#fetch(requests, timeoutMs, limit)));
|
|
118
|
+
this.#absorb(answers);
|
|
119
|
+
if (this.length <= 0) {
|
|
120
|
+
throw new PdfError("invalid-file", "the file is empty");
|
|
121
|
+
}
|
|
122
|
+
if (this.#bytesFetched > this.#maxBytes) {
|
|
123
|
+
throw tooLarge(this.#maxBytes);
|
|
124
|
+
}
|
|
125
|
+
// Without the suffix's answer, the head and the last chunk by their offsets, as one round.
|
|
126
|
+
const totalChunks = Math.ceil(this.length / this.chunkBytes);
|
|
127
|
+
await this.#fetchRunsAsync([...this.#missing(0, Math.min(HEAD_CHUNKS, totalChunks)), ...this.#missing(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks)], fetchAsync);
|
|
128
|
+
if (totalChunks > HEAD_CHUNKS) {
|
|
129
|
+
// The tail names the last cross-reference section (`startxref`). Fetch all of it in one
|
|
130
|
+
// round, rather than letting the parser discover it a chunk at a time.
|
|
131
|
+
const xrefAt = this.startxref();
|
|
132
|
+
if (xrefAt !== null && this.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
|
|
133
|
+
await this.#fetchChunksAsync(Math.floor(xrefAt / this.chunkBytes), totalChunks, fetchAsync);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The runs of bytes in chunks `firstChunk..endChunk` not held yet. A chunk a range brought
|
|
139
|
+
* the start or end of needs only the rest.
|
|
140
|
+
*/
|
|
141
|
+
#missing(firstChunk, endChunk) {
|
|
74
142
|
const runs = [];
|
|
75
|
-
let wanted = 0;
|
|
76
143
|
for (let chunk = firstChunk; chunk < endChunk; chunk++) {
|
|
77
144
|
if (this.#chunks[chunk]) {
|
|
78
145
|
continue;
|
|
79
146
|
}
|
|
80
|
-
|
|
81
|
-
|
|
147
|
+
let start = chunk * this.chunkBytes;
|
|
148
|
+
let end = this.length > 0
|
|
82
149
|
? Math.min(this.length, start + this.chunkBytes)
|
|
83
150
|
: start + this.chunkBytes;
|
|
151
|
+
for (const piece of this.#pieces.get(chunk) ?? []) {
|
|
152
|
+
const pieceEnd = piece.start + piece.bytes.byteLength;
|
|
153
|
+
if (piece.start <= start) {
|
|
154
|
+
start = Math.max(start, pieceEnd);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
end = Math.min(end, piece.start);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
84
160
|
const last = runs.at(-1);
|
|
85
161
|
if (last && last.end === start) {
|
|
86
162
|
last.end = end;
|
|
@@ -88,33 +164,68 @@ class RangeLoader {
|
|
|
88
164
|
else {
|
|
89
165
|
runs.push({ start, end });
|
|
90
166
|
}
|
|
91
|
-
wanted += end - start;
|
|
92
|
-
}
|
|
93
|
-
if (runs.length === 0) {
|
|
94
|
-
return;
|
|
95
167
|
}
|
|
168
|
+
return runs;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One round trip for `requests`: the signal, the budget and the deadline checked first, then
|
|
172
|
+
* `fetch` with what's left of each.
|
|
173
|
+
*/
|
|
174
|
+
#round(requests, fetch) {
|
|
96
175
|
if (this.#signal?.aborted) {
|
|
97
176
|
throw new PdfError("aborted", "the signal was aborted", this.#signal.reason);
|
|
98
177
|
}
|
|
99
178
|
// Before the length is known the head's size is a guess, and the file may be far smaller;
|
|
100
179
|
// the fetch itself stops at the budget.
|
|
180
|
+
const wanted = requests.reduce((sum, request) => sum + ("suffix" in request ? request.suffix : request.end - request.start), 0);
|
|
101
181
|
if (this.length > 0 && this.#bytesFetched + wanted > this.#maxBytes) {
|
|
102
182
|
throw tooLarge(this.#maxBytes);
|
|
103
183
|
}
|
|
184
|
+
const left = this.#deadline - performance.now();
|
|
185
|
+
if (left <= 0) {
|
|
186
|
+
throw new PdfError("timeout", this.#passedTotal());
|
|
187
|
+
}
|
|
104
188
|
this.#rounds += 1;
|
|
105
|
-
const
|
|
189
|
+
const passesTotal = left < this.#timeoutMs;
|
|
190
|
+
return fetch(this.#maxBytes - this.#bytesFetched, passesTotal ? Math.ceil(left) : this.#timeoutMs, passesTotal);
|
|
191
|
+
}
|
|
192
|
+
#passedTotal() {
|
|
193
|
+
return `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
194
|
+
}
|
|
195
|
+
/** A round's timeout, when it's the deadline's, says so. */
|
|
196
|
+
#timed(passesTotal, fetch) {
|
|
197
|
+
try {
|
|
198
|
+
return fetch();
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (passesTotal && error.code === "timeout") {
|
|
202
|
+
throw new PdfError("timeout", this.#passedTotal(), error);
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async #timedAsync(passesTotal, fetch) {
|
|
208
|
+
return await fetch.catch((error) => {
|
|
209
|
+
throw passesTotal && error.code === "timeout" ? new PdfError("timeout", this.#passedTotal(), error) : error;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** Checks and keeps a round's answers; a suffix nobody answered is skipped. */
|
|
213
|
+
#absorb(answers) {
|
|
106
214
|
for (const answer of answers) {
|
|
215
|
+
if (!answer) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
107
218
|
this.#requests += 1;
|
|
108
|
-
this.#bytesFetched += answer.
|
|
219
|
+
this.#bytesFetched += answer.received;
|
|
109
220
|
if (this.length > 0 && answer.length !== this.length) {
|
|
110
|
-
throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})
|
|
221
|
+
throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})`, undefined, answer.status);
|
|
111
222
|
}
|
|
112
223
|
// Before anything is sized from it: the total is whatever the server said.
|
|
113
224
|
if (answer.length > MAX_FILE_BYTES) {
|
|
114
225
|
throw new PdfError("too-large", `a ranged read holds files up to ${MAX_FILE_BYTES} bytes (this one is ${answer.length}); use open() or a smaller file`);
|
|
115
226
|
}
|
|
116
227
|
if (this.#etag && answer.etag && answer.etag !== this.#etag) {
|
|
117
|
-
throw new PdfError("fetch-failed", "the file was replaced during the ranged read (its ETag changed)");
|
|
228
|
+
throw new PdfError("fetch-failed", "the file was replaced during the ranged read (its ETag changed)", undefined, answer.status);
|
|
118
229
|
}
|
|
119
230
|
this.#etag ??= answer.etag;
|
|
120
231
|
this.length = answer.length;
|
|
@@ -122,16 +233,48 @@ class RangeLoader {
|
|
|
122
233
|
}
|
|
123
234
|
}
|
|
124
235
|
#store(answer) {
|
|
125
|
-
//
|
|
126
|
-
|
|
236
|
+
// Whole chunks are kept as views, not copies; a chunk the answer has only part of is kept
|
|
237
|
+
// as a piece until the rest arrives.
|
|
238
|
+
const { bytes, start } = answer;
|
|
127
239
|
if (this.#chunks.length === 0) {
|
|
128
240
|
this.#chunks = Array.from({
|
|
129
241
|
length: Math.ceil(answer.length / this.chunkBytes),
|
|
130
242
|
});
|
|
131
243
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.#chunks[chunk]
|
|
244
|
+
const end = start + bytes.byteLength;
|
|
245
|
+
for (let chunk = Math.floor(start / this.chunkBytes); chunk * this.chunkBytes < end; chunk++) {
|
|
246
|
+
if (this.#chunks[chunk]) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const from = chunk * this.chunkBytes;
|
|
250
|
+
const to = Math.min(from + this.chunkBytes, answer.length);
|
|
251
|
+
const part = bytes.subarray(Math.max(from, start) - start, Math.min(to, end) - start);
|
|
252
|
+
if (from >= start && to <= end) {
|
|
253
|
+
this.#chunks[chunk] = part;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const pieces = this.#pieces.get(chunk) ?? [];
|
|
257
|
+
pieces.push({ start: Math.max(from, start), bytes: part });
|
|
258
|
+
pieces.sort((a, b) => a.start - b.start);
|
|
259
|
+
// Whole once its pieces meet end to end.
|
|
260
|
+
let at = from;
|
|
261
|
+
for (const piece of pieces) {
|
|
262
|
+
if (piece.start > at) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
at = Math.max(at, piece.start + piece.bytes.byteLength);
|
|
266
|
+
}
|
|
267
|
+
if (at >= to) {
|
|
268
|
+
const whole = new Uint8Array(to - from);
|
|
269
|
+
for (const piece of pieces) {
|
|
270
|
+
whole.set(piece.bytes.subarray(0, Math.min(piece.bytes.byteLength, to - piece.start)), piece.start - from);
|
|
271
|
+
}
|
|
272
|
+
this.#chunks[chunk] = whole;
|
|
273
|
+
this.#pieces.delete(chunk);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
this.#pieces.set(chunk, pieces);
|
|
277
|
+
}
|
|
135
278
|
}
|
|
136
279
|
}
|
|
137
280
|
/**
|
|
@@ -151,8 +294,11 @@ class RangeLoader {
|
|
|
151
294
|
const offset = match ? Number(match[1]) : Number.NaN;
|
|
152
295
|
return offset >= 0 && offset < this.length ? offset : null;
|
|
153
296
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
297
|
+
/**
|
|
298
|
+
* The chunk window to fetch for a miss at `chunk`, with read-ahead, and at least to `lastAhead`:
|
|
299
|
+
* what the engine said it's likely to read next.
|
|
300
|
+
*/
|
|
301
|
+
#window(chunk, lastNeeded, lastAhead) {
|
|
156
302
|
const start = chunk * this.chunkBytes;
|
|
157
303
|
const sequential = this.#lastFetchEnd >= 0 &&
|
|
158
304
|
start >= this.#lastFetchEnd - this.chunkBytes &&
|
|
@@ -161,16 +307,17 @@ class RangeLoader {
|
|
|
161
307
|
? Math.min(MAX_READ_AHEAD_BYTES, this.#readAhead * 2)
|
|
162
308
|
: this.chunkBytes;
|
|
163
309
|
const totalChunks = Math.ceil(this.length / this.chunkBytes);
|
|
164
|
-
const end = Math.min(totalChunks, Math.max(lastNeeded + 1, chunk + this.#readAhead / this.chunkBytes));
|
|
310
|
+
const end = Math.min(totalChunks, Math.max(lastNeeded + 1, lastAhead + 1, chunk + this.#readAhead / this.chunkBytes));
|
|
165
311
|
this.#lastFetchEnd = end * this.chunkBytes;
|
|
166
312
|
return [chunk, end];
|
|
167
313
|
}
|
|
168
314
|
/**
|
|
169
|
-
* Copies [offset, offset + out.length) into `out`, fetching what is missing first
|
|
170
|
-
*
|
|
171
|
-
*
|
|
315
|
+
* Copies [offset, offset + out.length) into `out`, fetching what is missing first, and if it
|
|
316
|
+
* fetches, `ahead` bytes past it too, as far as the budget allows: the engine's guess at what
|
|
317
|
+
* it reads next, which then costs no round trip of its own. On a failed fetch it records the
|
|
318
|
+
* failure and fails the read; the call that made it then throws the failure.
|
|
172
319
|
*/
|
|
173
|
-
read(offset, out) {
|
|
320
|
+
read(offset, out, ahead = 0) {
|
|
174
321
|
this.#reads += 1;
|
|
175
322
|
this.#readBytes += out.byteLength;
|
|
176
323
|
const end = offset + out.byteLength;
|
|
@@ -182,10 +329,12 @@ class RangeLoader {
|
|
|
182
329
|
}
|
|
183
330
|
const first = Math.floor(offset / this.chunkBytes);
|
|
184
331
|
const last = Math.floor((end - 1) / this.chunkBytes);
|
|
332
|
+
const spare = Math.max(0, this.#maxBytes - this.#bytesFetched - out.byteLength);
|
|
333
|
+
const lastAhead = Math.floor((Math.min(this.length, end + Math.min(ahead, spare)) - 1) / this.chunkBytes);
|
|
185
334
|
for (let chunk = first; chunk <= last; chunk++) {
|
|
186
335
|
if (!this.#chunks[chunk]) {
|
|
187
336
|
try {
|
|
188
|
-
this.fetchChunks(...this.#window(chunk, last));
|
|
337
|
+
this.fetchChunks(...this.#window(chunk, last, lastAhead));
|
|
189
338
|
}
|
|
190
339
|
catch (error) {
|
|
191
340
|
this.failure =
|
|
@@ -212,46 +361,34 @@ class RangeLoader {
|
|
|
212
361
|
}
|
|
213
362
|
}
|
|
214
363
|
/**
|
|
215
|
-
* Starts a ranged read of `source`: the head, then
|
|
216
|
-
*
|
|
364
|
+
* Starts a ranged read of `source`: the head and tail, then the last cross-reference section
|
|
365
|
+
* if the tail didn't hold it. Blocks while it fetches, except in a browser, where these
|
|
366
|
+
* rounds are `fetch` calls.
|
|
217
367
|
*/
|
|
218
|
-
export function startRangeLoader(source, options = {}) {
|
|
368
|
+
export async function startRangeLoader(source, options = {}) {
|
|
219
369
|
const positive = (name, value) => {
|
|
220
|
-
if (value !== undefined && !(value > 0)) {
|
|
370
|
+
if (value !== undefined && !(typeof value === "number" && value > 0)) {
|
|
221
371
|
throw new RangeError(`${name} must be above 0 (got ${value})`);
|
|
222
372
|
}
|
|
223
373
|
};
|
|
224
374
|
positive("chunkBytes", options.chunkBytes);
|
|
225
375
|
positive("maxBytes", options.maxBytes);
|
|
226
376
|
positive("timeoutMs", options.timeoutMs);
|
|
377
|
+
positive("totalTimeoutMs", options.totalTimeoutMs);
|
|
227
378
|
if (options.chunkBytes !== undefined && !Number.isSafeInteger(options.chunkBytes)) {
|
|
228
379
|
throw new RangeError(`chunkBytes must be a whole number (got ${options.chunkBytes})`);
|
|
229
380
|
}
|
|
230
381
|
if (options.signal?.aborted) {
|
|
231
382
|
throw new PdfError("aborted", "the signal was aborted", options.signal.reason);
|
|
232
383
|
}
|
|
233
|
-
const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
|
|
234
384
|
const loader = new RangeLoader(fetcherFor(source), {
|
|
235
|
-
chunkBytes,
|
|
385
|
+
chunkBytes: options.chunkBytes ?? DEFAULT_CHUNK_BYTES,
|
|
236
386
|
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
237
387
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
388
|
+
totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
|
|
238
389
|
signal: options.signal,
|
|
239
390
|
});
|
|
240
|
-
loader.
|
|
241
|
-
if (loader.length <= 0) {
|
|
242
|
-
throw new PdfError("invalid-file", "the file is empty");
|
|
243
|
-
}
|
|
244
|
-
// A file the head covered is read whole; any other one gets its tail.
|
|
245
|
-
const totalChunks = Math.ceil(loader.length / chunkBytes);
|
|
246
|
-
if (totalChunks > HEAD_CHUNKS) {
|
|
247
|
-
loader.fetchChunks(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks);
|
|
248
|
-
// The tail names the last cross-reference section (`startxref`). Fetch all of it in one
|
|
249
|
-
// round, rather than letting the parser discover it a chunk at a time.
|
|
250
|
-
const xrefAt = loader.startxref();
|
|
251
|
-
if (xrefAt !== null && loader.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
|
|
252
|
-
loader.fetchChunks(Math.floor(xrefAt / chunkBytes), totalChunks);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
391
|
+
await loader.begin(asyncFetcherFor(source));
|
|
255
392
|
return loader;
|
|
256
393
|
}
|
|
257
394
|
/**
|
|
@@ -276,19 +413,52 @@ export function ownedSource(source) {
|
|
|
276
413
|
function fetcherFor(source) {
|
|
277
414
|
if (typeof source !== "string") {
|
|
278
415
|
const bytes = source;
|
|
279
|
-
return (
|
|
280
|
-
start,
|
|
281
|
-
bytes: bytes.subarray(start,
|
|
282
|
-
|
|
283
|
-
etag: null,
|
|
284
|
-
}));
|
|
416
|
+
return (requests) => requests.map((request) => {
|
|
417
|
+
const { start, end } = asked(request, bytes.byteLength);
|
|
418
|
+
return { start, bytes: bytes.subarray(start, end), length: bytes.byteLength, etag: null, status: 206, received: end - start };
|
|
419
|
+
});
|
|
285
420
|
}
|
|
286
421
|
const url = source;
|
|
287
422
|
// Node's fetch cannot run under a blocked thread; in a browser worker a synchronous
|
|
288
423
|
// XMLHttpRequest needs no second thread.
|
|
289
|
-
return
|
|
290
|
-
? (
|
|
291
|
-
: (
|
|
424
|
+
return isNode()
|
|
425
|
+
? (requests, timeoutMs, limit) => fetchOverThread(url, requests, timeoutMs, limit)
|
|
426
|
+
: (requests, timeoutMs, limit) => fetchOverXhr(url, requests, timeoutMs, limit);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* How the rounds before the parse fetch, if not as the parse's do: in a browser, `fetch`, so a
|
|
430
|
+
* round's requests run in parallel, where synchronous requests run one after another. Node's
|
|
431
|
+
* fetcher thread already runs them in parallel.
|
|
432
|
+
*/
|
|
433
|
+
function asyncFetcherFor(source) {
|
|
434
|
+
if (typeof source !== "string" || isNode()) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
return (requests, timeoutMs, limit) => fetchOverFetch(source, requests, timeoutMs, limit);
|
|
438
|
+
}
|
|
439
|
+
function isNode() {
|
|
440
|
+
return Boolean(globalThis.process?.getBuiltinModule?.("node:worker_threads"));
|
|
441
|
+
}
|
|
442
|
+
/** The bytes `request` asks for of a file of `length`, or of a range with `yieldTail`, those it reads. */
|
|
443
|
+
function asked(request, length) {
|
|
444
|
+
if ("suffix" in request) {
|
|
445
|
+
return { start: Math.max(0, length - request.suffix), end: length };
|
|
446
|
+
}
|
|
447
|
+
const end = Math.min(request.end, length);
|
|
448
|
+
return { start: request.start, end: request.yieldTail === undefined ? end : Math.min(end, Math.max(request.start, length - request.yieldTail)) };
|
|
449
|
+
}
|
|
450
|
+
/** How much of a 206 answer to `request` to read, from its total; past it is the suffix's. */
|
|
451
|
+
function readsOf(request, contentRange) {
|
|
452
|
+
const size = "suffix" in request ? request.suffix : request.end - request.start;
|
|
453
|
+
const total = Number(/\/(\d+)$/u.exec(contentRange?.trim() ?? "")?.[1] ?? Number.NaN);
|
|
454
|
+
if ("suffix" in request || request.yieldTail === undefined || !(total >= 0)) {
|
|
455
|
+
return { most: size, exact: false };
|
|
456
|
+
}
|
|
457
|
+
const most = Math.max(0, Math.min(size, asked(request, total).end - request.start));
|
|
458
|
+
return { most, exact: most < size };
|
|
459
|
+
}
|
|
460
|
+
function rangeHeader(request) {
|
|
461
|
+
return "suffix" in request ? `bytes=-${request.suffix}` : `bytes=${request.start}-${request.end - 1}`;
|
|
292
462
|
}
|
|
293
463
|
function tooLarge(maxBytes) {
|
|
294
464
|
return new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
|
|
@@ -297,30 +467,46 @@ function tooLarge(maxBytes) {
|
|
|
297
467
|
* A server's answer as chunks to store, or a clear failure. Messages never carry the URL: a
|
|
298
468
|
* presigned query is a credential.
|
|
299
469
|
*/
|
|
300
|
-
function answerOf(range, status, contentRange, etag, bytes, limit) {
|
|
301
|
-
if (status === 200) {
|
|
470
|
+
function answerOf(range, status, contentRange, etag, bytes, limit, received = bytes.byteLength) {
|
|
471
|
+
if (status === 200 && !("suffix" in range)) {
|
|
302
472
|
// The server ignored Range and sent the whole file: use it, while the budget allows.
|
|
303
473
|
if (bytes.byteLength > limit) {
|
|
304
474
|
throw new PdfError("too-large", `the server does not answer Range requests, and the whole file is past the ${limit} bytes left of maxBytes`);
|
|
305
475
|
}
|
|
306
|
-
return { start: 0, bytes, length: bytes.byteLength, etag };
|
|
476
|
+
return { start: 0, bytes, length: bytes.byteLength, etag, status, received };
|
|
307
477
|
}
|
|
308
478
|
if (status !== 206) {
|
|
309
|
-
throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})
|
|
479
|
+
throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})`, undefined, status);
|
|
310
480
|
}
|
|
311
481
|
const match = /^bytes (\d+)-\d+\/(\d+)$/u.exec(contentRange?.trim() ?? "");
|
|
312
482
|
if (!match) {
|
|
313
483
|
throw new PdfError("fetch-failed", "the server's 206 answer has no usable Content-Range (with a total length). " +
|
|
314
|
-
"A cross-origin server must also send Access-Control-Expose-Headers: Content-Range.");
|
|
484
|
+
"A cross-origin server must also send Access-Control-Expose-Headers: Content-Range.", undefined, status);
|
|
315
485
|
}
|
|
316
486
|
const start = Number(match[1]);
|
|
317
487
|
const length = Number(match[2]);
|
|
318
488
|
// A short or shifted answer (a proxy capping ranges) would store a partial chunk.
|
|
319
|
-
|
|
320
|
-
|
|
489
|
+
const want = asked(range, length);
|
|
490
|
+
// A range that would stop where the suffix starts runs on to its end when the suffix failed.
|
|
491
|
+
const whole = "suffix" in range ? want : asked({ start: range.start, end: range.end }, length);
|
|
492
|
+
if (start !== want.start || (bytes.byteLength !== want.end - want.start && bytes.byteLength !== whole.end - whole.start)) {
|
|
493
|
+
throw new PdfError("fetch-failed", `the server answered a different range than asked (asked ${want.start}-${want.end - 1}, got ${bytes.byteLength} bytes from ${start})`, undefined, status);
|
|
321
494
|
}
|
|
322
|
-
return { start, bytes, length, etag };
|
|
495
|
+
return { start, bytes, length, etag, status, received };
|
|
323
496
|
}
|
|
497
|
+
/** A suffix's answer, or null if it isn't one: the tail is then asked for by its offset. */
|
|
498
|
+
function suffixAnswerOf(suffix, status, contentRange, etag, bytes, received = bytes.byteLength) {
|
|
499
|
+
if (status !== 206) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
return answerOf(suffix, status, contentRange, etag, bytes, Number.POSITIVE_INFINITY, received);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
|
|
324
510
|
function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
325
511
|
const deadline = performance.now() + timeoutMs;
|
|
326
512
|
return ranges.map((range) => {
|
|
@@ -328,7 +514,7 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
328
514
|
xhr.open("GET", url, false);
|
|
329
515
|
xhr.responseType = "arraybuffer";
|
|
330
516
|
xhr.timeout = Math.max(1, deadline - performance.now());
|
|
331
|
-
xhr.setRequestHeader("Range",
|
|
517
|
+
xhr.setRequestHeader("Range", rangeHeader(range));
|
|
332
518
|
try {
|
|
333
519
|
xhr.send();
|
|
334
520
|
}
|
|
@@ -336,11 +522,211 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
336
522
|
const timedOut = error.name === "TimeoutError";
|
|
337
523
|
throw new PdfError(timedOut ? "timeout" : "fetch-failed", timedOut
|
|
338
524
|
? `the ranged read timed out after ${timeoutMs} ms`
|
|
339
|
-
:
|
|
525
|
+
: NO_ANSWER, error);
|
|
526
|
+
}
|
|
527
|
+
// Firefox reports a refused cross-origin read as status 0, not a throw.
|
|
528
|
+
if (xhr.status === 0) {
|
|
529
|
+
throw new PdfError("fetch-failed", NO_ANSWER);
|
|
530
|
+
}
|
|
531
|
+
if ("suffix" in range) {
|
|
532
|
+
return suffixAnswerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response));
|
|
340
533
|
}
|
|
341
534
|
return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
|
|
342
535
|
});
|
|
343
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* The XMLHttpRequest path's answers and failures, from `fetch`, with a round's requests in
|
|
539
|
+
* parallel. Used before the parse starts, when this thread may await. A suffix still out when the
|
|
540
|
+
* ranges are in gets as long again as they took, then counts as unanswered.
|
|
541
|
+
*/
|
|
542
|
+
async function fetchOverFetch(url, requests, timeoutMs, limit) {
|
|
543
|
+
const started = performance.now();
|
|
544
|
+
const controllers = requests.map(() => new AbortController());
|
|
545
|
+
let timedOut = false;
|
|
546
|
+
const timer = setTimeout(() => {
|
|
547
|
+
timedOut = true;
|
|
548
|
+
for (const controller of controllers) {
|
|
549
|
+
controller.abort();
|
|
550
|
+
}
|
|
551
|
+
}, timeoutMs);
|
|
552
|
+
const failure = (error) => timedOut
|
|
553
|
+
? new PdfError("timeout", `the ranged read timed out after ${timeoutMs} ms`, error)
|
|
554
|
+
: new PdfError("fetch-failed", NO_ANSWER, error);
|
|
555
|
+
// What the suffix came to, for a head that stopped where it starts: kept short only if it's good.
|
|
556
|
+
let suffixDone;
|
|
557
|
+
const suffixAnswer = new Promise((resolve) => (suffixDone = resolve));
|
|
558
|
+
const suffixAt = requests.findIndex((request) => "suffix" in request);
|
|
559
|
+
const one = async (request, signal) => {
|
|
560
|
+
const suffix = "suffix" in request;
|
|
561
|
+
try {
|
|
562
|
+
const response = await fetch(url, { headers: { Range: rangeHeader(request) }, signal });
|
|
563
|
+
const contentRange = response.headers.get("Content-Range");
|
|
564
|
+
const etag = response.headers.get("ETag");
|
|
565
|
+
if (suffix && response.status !== 206) {
|
|
566
|
+
// Not a suffix: a whole file, or a refusal. Read none of it.
|
|
567
|
+
await response.body?.cancel();
|
|
568
|
+
suffixDone(null);
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
const reader = byteReader(response.body);
|
|
572
|
+
try {
|
|
573
|
+
if (response.status !== 206) {
|
|
574
|
+
// A whole file, used if it fits the budget: read no further than one byte past it.
|
|
575
|
+
const bytes = await reader.read(limit + 1);
|
|
576
|
+
return answerOf(request, response.status, contentRange, etag, bytes, limit, reader.received);
|
|
577
|
+
}
|
|
578
|
+
const { most, exact } = readsOf(request, contentRange);
|
|
579
|
+
let bytes = await reader.read(exact ? most : most + 1);
|
|
580
|
+
if (suffix) {
|
|
581
|
+
const answer = suffixAnswerOf(request, response.status, contentRange, etag, bytes, reader.received);
|
|
582
|
+
suffixDone(answer);
|
|
583
|
+
return answer;
|
|
584
|
+
}
|
|
585
|
+
if (exact && !("suffix" in request)) {
|
|
586
|
+
// Stopped where the suffix starts: the rest too, unless the suffix brings it.
|
|
587
|
+
const total = Number(contentRange?.trim().split("/").pop());
|
|
588
|
+
const answer = await Promise.race([suffixAnswer, delay(suffixGrace(performance.now() - started), null)]);
|
|
589
|
+
if (answer?.length !== total) {
|
|
590
|
+
controllers[suffixAt]?.abort();
|
|
591
|
+
const rest = await reader.read(request.end - request.start - most + 1);
|
|
592
|
+
bytes = concat(bytes, rest);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return answerOf(request, response.status, contentRange, etag, bytes, limit, reader.received);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await reader.cancel();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
// A suffix never fails the round: the tail is then asked for by its offset.
|
|
603
|
+
if (suffix) {
|
|
604
|
+
suffixDone(null);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
throw error instanceof PdfError ? error : failure(error);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
try {
|
|
611
|
+
const ranges = Promise.all(requests.map((request, i) => ("suffix" in request ? null : one(request, controllers[i].signal))));
|
|
612
|
+
const suffixes = requests.map((request, i) => {
|
|
613
|
+
if (!("suffix" in request)) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
return one(request, controllers[i].signal);
|
|
617
|
+
});
|
|
618
|
+
const answers = await ranges;
|
|
619
|
+
const grace = suffixGrace(performance.now() - started);
|
|
620
|
+
return await Promise.all(requests.map(async (request, i) => {
|
|
621
|
+
const pending = suffixes[i];
|
|
622
|
+
if (!pending) {
|
|
623
|
+
return answers[i];
|
|
624
|
+
}
|
|
625
|
+
let late;
|
|
626
|
+
const giveUp = new Promise((resolve) => {
|
|
627
|
+
late = setTimeout(() => {
|
|
628
|
+
controllers[i].abort();
|
|
629
|
+
resolve(null);
|
|
630
|
+
}, grace);
|
|
631
|
+
});
|
|
632
|
+
try {
|
|
633
|
+
return await Promise.race([pending, giveUp]);
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
clearTimeout(late);
|
|
637
|
+
}
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
clearTimeout(timer);
|
|
642
|
+
// A round that failed leaves nothing downloading.
|
|
643
|
+
for (const controller of controllers) {
|
|
644
|
+
controller.abort();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* How long a suffix may still take once the round's ranges are in, `took` after they went out:
|
|
650
|
+
* as long again, and at least a quarter second, since both went out together. Past it the tail
|
|
651
|
+
* is asked for by its offset, a round later, as a server that refuses suffixes gets it.
|
|
652
|
+
*/
|
|
653
|
+
function suffixGrace(took) {
|
|
654
|
+
return Math.max(took, 250);
|
|
655
|
+
}
|
|
656
|
+
function delay(ms, value) {
|
|
657
|
+
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
658
|
+
}
|
|
659
|
+
function concat(a, b) {
|
|
660
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
661
|
+
out.set(a);
|
|
662
|
+
out.set(b, a.byteLength);
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* A body read a given number of bytes at a time: exactly, by a reader that fills a buffer where the
|
|
667
|
+
* stream has one, or by parts, keeping what a part held past the count for the next read.
|
|
668
|
+
* `received` counts every byte taken off the stream.
|
|
669
|
+
*/
|
|
670
|
+
function byteReader(body) {
|
|
671
|
+
let byob = null;
|
|
672
|
+
try {
|
|
673
|
+
byob = body?.getReader({ mode: "byob" }) ?? null;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// Not a byte stream: read by parts.
|
|
677
|
+
}
|
|
678
|
+
const plain = byob || !body ? null : body.getReader();
|
|
679
|
+
let spare = new Uint8Array(0);
|
|
680
|
+
let done = !body;
|
|
681
|
+
let received = 0;
|
|
682
|
+
return {
|
|
683
|
+
get received() {
|
|
684
|
+
return received;
|
|
685
|
+
},
|
|
686
|
+
async read(n) {
|
|
687
|
+
// In parts of at most 1 MiB, so a large `n` (a budget) allocates only what arrives.
|
|
688
|
+
const parts = [spare.subarray(0, n)];
|
|
689
|
+
let got = parts[0].byteLength;
|
|
690
|
+
spare = spare.subarray(got);
|
|
691
|
+
while (got < n && !done) {
|
|
692
|
+
if (byob) {
|
|
693
|
+
const { value, done: end } = await byob.read(new Uint8Array(Math.min(n - got, 1 << 20)));
|
|
694
|
+
if (value) {
|
|
695
|
+
parts.push(value);
|
|
696
|
+
got += value.byteLength;
|
|
697
|
+
received += value.byteLength;
|
|
698
|
+
}
|
|
699
|
+
done = end;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
const { value, done: end } = await plain.read();
|
|
703
|
+
if (end) {
|
|
704
|
+
done = true;
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
received += value.byteLength;
|
|
708
|
+
const take = Math.min(n - got, value.byteLength);
|
|
709
|
+
parts.push(value.subarray(0, take));
|
|
710
|
+
got += take;
|
|
711
|
+
spare = value.subarray(take);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (parts.length === 1) {
|
|
715
|
+
return parts[0];
|
|
716
|
+
}
|
|
717
|
+
const out = new Uint8Array(got);
|
|
718
|
+
let at = 0;
|
|
719
|
+
for (const part of parts) {
|
|
720
|
+
out.set(part, at);
|
|
721
|
+
at += part.byteLength;
|
|
722
|
+
}
|
|
723
|
+
return out;
|
|
724
|
+
},
|
|
725
|
+
async cancel() {
|
|
726
|
+
await (byob ?? plain)?.cancel().catch(() => undefined);
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
344
730
|
// The fetcher thread: plain JS (eval), so it needs no loader or bundler entry. It answers
|
|
345
731
|
// each request on the port, then bumps `flag` so the waiting thread wakes. The body is read
|
|
346
732
|
// up to `limit`, for a server that answers a small range with a huge whole file.
|
|
@@ -348,9 +734,22 @@ const FETCHER_SOURCE = `
|
|
|
348
734
|
const { workerData } = require("node:worker_threads");
|
|
349
735
|
const { port, flag } = workerData;
|
|
350
736
|
const controllers = new Map();
|
|
737
|
+
const suffixes = new Map();
|
|
738
|
+
// What a group's suffix came to: its file's total if it's a good suffix, null otherwise.
|
|
739
|
+
function outcome(group) {
|
|
740
|
+
let entry = suffixes.get(group);
|
|
741
|
+
if (!entry) {
|
|
742
|
+
let resolve;
|
|
743
|
+
entry = { promise: new Promise((r) => (resolve = r)) };
|
|
744
|
+
entry.resolve = resolve;
|
|
745
|
+
suffixes.set(group, entry);
|
|
746
|
+
}
|
|
747
|
+
return entry;
|
|
748
|
+
}
|
|
351
749
|
async function get(m, signal) {
|
|
750
|
+
const started = performance.now();
|
|
352
751
|
const response = await fetch(m.url, {
|
|
353
|
-
headers: { Range:
|
|
752
|
+
headers: { Range: m.range, "Accept-Encoding": "identity" },
|
|
354
753
|
signal,
|
|
355
754
|
});
|
|
356
755
|
const reply = {
|
|
@@ -358,19 +757,61 @@ async function get(m, signal) {
|
|
|
358
757
|
contentRange: response.headers.get("content-range"),
|
|
359
758
|
etag: response.headers.get("etag"),
|
|
360
759
|
};
|
|
361
|
-
|
|
760
|
+
// A suffix answered with anything else (a whole file) isn't read.
|
|
761
|
+
if (!response.ok || (m.suffix && response.status !== 206)) {
|
|
362
762
|
if (response.body) await response.body.cancel();
|
|
363
|
-
|
|
763
|
+
if (m.suffix) outcome(m.group).resolve(null);
|
|
764
|
+
return { ...reply, bytes: new ArrayBuffer(0), received: 0 };
|
|
364
765
|
}
|
|
365
|
-
const
|
|
366
|
-
let
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
766
|
+
const reader = response.body ? response.body.getReader({ mode: "byob" }) : null;
|
|
767
|
+
let received = 0;
|
|
768
|
+
let done = !reader;
|
|
769
|
+
const read = async (n) => {
|
|
770
|
+
const parts = [];
|
|
771
|
+
let got = 0;
|
|
772
|
+
while (got < n && !done) {
|
|
773
|
+
const { value, done: end } = await reader.read(new Uint8Array(Math.min(n - got, 1 << 20)));
|
|
774
|
+
if (value) { parts.push(value); got += value.byteLength; received += value.byteLength; }
|
|
775
|
+
done = end;
|
|
776
|
+
}
|
|
777
|
+
return Buffer.concat(parts, got);
|
|
778
|
+
};
|
|
779
|
+
try {
|
|
780
|
+
// A 206 is read as far as the range runs (see readsOf), and a byte past to catch a longer one;
|
|
781
|
+
// a whole file, to a byte past the limit.
|
|
782
|
+
if (response.status !== 206) {
|
|
783
|
+
const bytes = await read(m.limit + 1);
|
|
784
|
+
if (received > m.limit) return { ...reply, overLimit: true, bytes: new ArrayBuffer(0), received };
|
|
785
|
+
return { ...reply, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), received };
|
|
786
|
+
}
|
|
787
|
+
const total = Number((reply.contentRange || "").trim().split("/").pop());
|
|
788
|
+
let most = m.size;
|
|
789
|
+
let exact = false;
|
|
790
|
+
if (m.yieldTail !== undefined && total >= 0) {
|
|
791
|
+
most = Math.max(0, Math.min(m.size, Math.min(m.end, Math.max(m.start, total - m.yieldTail)) - m.start));
|
|
792
|
+
exact = most < m.size;
|
|
793
|
+
}
|
|
794
|
+
let bytes = await read(exact ? most : most + 1);
|
|
795
|
+
if (m.suffix) {
|
|
796
|
+
const [from, , length] = (reply.contentRange || "").trim().replace("bytes ", "").split(/[-/]/).map(Number);
|
|
797
|
+
const good = from === Math.max(0, length - m.size) && bytes.byteLength === length - from;
|
|
798
|
+
outcome(m.group).resolve(good ? length : null);
|
|
799
|
+
} else if (exact) {
|
|
800
|
+
// Stopped where the suffix starts: the rest too, unless the suffix brings it.
|
|
801
|
+
const grace = Math.max(performance.now() - started, 250);
|
|
802
|
+
const late = new Promise((r) => setTimeout(() => r(null), grace));
|
|
803
|
+
if ((await Promise.race([outcome(m.group).promise, late])) !== total) {
|
|
804
|
+
const suffix = controllers.get(m.suffixId);
|
|
805
|
+
if (suffix) suffix.abort();
|
|
806
|
+
bytes = Buffer.concat([bytes, await read(m.size - most + 1)]);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { ...reply, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), received };
|
|
810
|
+
} finally {
|
|
811
|
+
if (reader) await reader.cancel().catch(() => {});
|
|
812
|
+
if (m.suffix) outcome(m.group).resolve(null);
|
|
813
|
+
if (!m.suffix) suffixes.delete(m.group);
|
|
371
814
|
}
|
|
372
|
-
const { buffer, byteOffset, byteLength } = Buffer.concat(parts);
|
|
373
|
-
return { ...reply, bytes: buffer.slice(byteOffset, byteOffset + byteLength) };
|
|
374
815
|
}
|
|
375
816
|
port.on("message", async (m) => {
|
|
376
817
|
if (m.abort) { const c = controllers.get(m.id); if (c) c.abort(); return; }
|
|
@@ -416,14 +857,37 @@ function startFetcher() {
|
|
|
416
857
|
/** Fetches `ranges` in parallel on the fetcher thread; blocks until all answer. */
|
|
417
858
|
function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
418
859
|
const current = (fetcher ??= startFetcher());
|
|
419
|
-
const
|
|
860
|
+
const started = performance.now();
|
|
861
|
+
const deadline = started + timeoutMs;
|
|
862
|
+
let suffixDeadline = Number.POSITIVE_INFINITY;
|
|
863
|
+
let expired = false;
|
|
420
864
|
const pending = new Map();
|
|
865
|
+
const order = new Map();
|
|
866
|
+
// A round's ranges and its suffix are one group: a range that stops where the suffix starts
|
|
867
|
+
// waits on what the suffix came to (see FETCHER_SOURCE).
|
|
868
|
+
const group = current.nextId;
|
|
869
|
+
const suffixId = current.nextId + ranges.findIndex((range) => "suffix" in range);
|
|
421
870
|
for (const range of ranges) {
|
|
422
871
|
const id = current.nextId++;
|
|
423
872
|
pending.set(id, range);
|
|
424
|
-
|
|
873
|
+
order.set(range, order.size);
|
|
874
|
+
// A suffix's answer is read no further than the suffix: past it, it isn't one.
|
|
875
|
+
const suffix = "suffix" in range;
|
|
876
|
+
current.port.postMessage({
|
|
877
|
+
id,
|
|
878
|
+
url,
|
|
879
|
+
range: rangeHeader(range),
|
|
880
|
+
suffix,
|
|
881
|
+
limit: suffix ? Math.min(range.suffix, limit) : limit,
|
|
882
|
+
size: suffix ? Math.min(range.suffix, limit) : range.end - range.start,
|
|
883
|
+
start: suffix ? 0 : range.start,
|
|
884
|
+
end: suffix ? 0 : range.end,
|
|
885
|
+
yieldTail: suffix ? undefined : range.yieldTail,
|
|
886
|
+
group,
|
|
887
|
+
suffixId,
|
|
888
|
+
});
|
|
425
889
|
}
|
|
426
|
-
const answers =
|
|
890
|
+
const answers = ranges.map(() => null);
|
|
427
891
|
let failure = null;
|
|
428
892
|
let seen = Atomics.load(current.flag, 0);
|
|
429
893
|
while (pending.size > 0) {
|
|
@@ -438,6 +902,13 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
438
902
|
continue; // a reply to a request that already timed out
|
|
439
903
|
}
|
|
440
904
|
pending.delete(reply.id);
|
|
905
|
+
if ("suffix" in range) {
|
|
906
|
+
answers[order.get(range)] =
|
|
907
|
+
reply.failed === undefined && !reply.overLimit
|
|
908
|
+
? suffixAnswerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), reply.received)
|
|
909
|
+
: null;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
441
912
|
try {
|
|
442
913
|
if (reply.failed !== undefined) {
|
|
443
914
|
throw new PdfError("fetch-failed", `the ranged read failed: ${reply.failed}`);
|
|
@@ -445,7 +916,7 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
445
916
|
if (reply.overLimit) {
|
|
446
917
|
throw tooLarge(limit);
|
|
447
918
|
}
|
|
448
|
-
answers.
|
|
919
|
+
answers[order.get(range)] = answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit, reply.received);
|
|
449
920
|
}
|
|
450
921
|
catch (error) {
|
|
451
922
|
failure ??= error;
|
|
@@ -454,13 +925,27 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
454
925
|
if (pending.size === 0) {
|
|
455
926
|
break;
|
|
456
927
|
}
|
|
457
|
-
|
|
458
|
-
|
|
928
|
+
// Only suffixes left: they get a grace, then count as unanswered (suffixGrace).
|
|
929
|
+
const now = performance.now();
|
|
930
|
+
if (suffixDeadline === Number.POSITIVE_INFINITY && [...pending.values()].every((request) => "suffix" in request)) {
|
|
931
|
+
suffixDeadline = now + suffixGrace(now - started);
|
|
932
|
+
}
|
|
933
|
+
const until = Math.min(suffixDeadline, deadline);
|
|
934
|
+
if (now < until && Atomics.wait(current.flag, 0, seen, until - now) !== "timed-out") {
|
|
935
|
+
seen = Atomics.load(current.flag, 0);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (expired) {
|
|
459
939
|
for (const id of pending.keys()) {
|
|
460
940
|
current.port.postMessage({ id, abort: true });
|
|
461
941
|
}
|
|
942
|
+
if ([...pending.values()].every((request) => "suffix" in request)) {
|
|
943
|
+
break; // the suffixes stay unanswered
|
|
944
|
+
}
|
|
462
945
|
throw new PdfError("timeout", `the ranged read timed out after ${timeoutMs} ms. This thread is blocked while it waits, so a server running on the same thread can never answer; run the read on a worker thread.`);
|
|
463
946
|
}
|
|
947
|
+
// Once more through what arrived, then give up.
|
|
948
|
+
expired = true;
|
|
464
949
|
seen = Atomics.load(current.flag, 0);
|
|
465
950
|
}
|
|
466
951
|
if (failure) {
|