@2018kguo/recto 0.3.0 → 0.5.1
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.js +32 -52
- package/dist/document.d.ts +100 -4
- package/dist/document.js +72 -39
- package/dist/engine.d.ts +7 -0
- package/dist/engine.js +19 -13
- package/dist/index.d.ts +1 -1
- package/dist/pool-worker.d.ts +1 -0
- package/dist/pool-worker.js +2 -2
- package/dist/pool.d.ts +7 -1
- package/dist/pool.js +14 -6
- package/dist/ranged.d.ts +41 -11
- package/dist/ranged.js +579 -97
- package/dist/reading-order.d.ts +7 -0
- package/dist/reading-order.js +15 -6
- package/dist/text-items.js +1 -6
- package/package.json +1 -1
- package/wasm/pins.mjs +4 -4
- 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 +1 -1
- package/wasm/recto.gen.mjs +100 -12
- package/wasm/recto.types.d.mts +39 -5
- package/wasm/recto.wasm +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;
|
|
@@ -73,18 +83,70 @@ class RangeLoader {
|
|
|
73
83
|
fileBytes: this.length,
|
|
74
84
|
};
|
|
75
85
|
}
|
|
76
|
-
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
|
|
77
|
-
fetchChunks(firstChunk, endChunk) {
|
|
86
|
+
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet: a read's miss, so it blocks. */
|
|
87
|
+
#fetchChunks(firstChunk, endChunk) {
|
|
88
|
+
const runs = this.#missing(firstChunk, endChunk);
|
|
89
|
+
if (runs.length > 0) {
|
|
90
|
+
this.#absorb(this.#round(runs, (limit, timeoutMs) => this.#fetch(runs, timeoutMs, limit)));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** A round before the parse: through `fetchAsync` when there is one, so a browser need not block. */
|
|
94
|
+
async #fetchAhead(requests, fetchAsync) {
|
|
95
|
+
if (requests.length > 0) {
|
|
96
|
+
this.#absorb(await this.#round(requests, (limit, timeoutMs) => fetchAsync ? fetchAsync(requests, timeoutMs, limit) : this.#fetch(requests, timeoutMs, limit)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The first round: the head and, in parallel, the tail as a suffix, then the last
|
|
101
|
+
* cross-reference section if the tail didn't hold it. A file the head covers needs no more;
|
|
102
|
+
* a server that doesn't answer the suffix gets the tail by offset in a round of its own.
|
|
103
|
+
*/
|
|
104
|
+
async begin(fetchAsync) {
|
|
105
|
+
const tail = TAIL_CHUNKS * this.chunkBytes;
|
|
106
|
+
// The head stops where the suffix starts, in a file shorter than the two.
|
|
107
|
+
const head = { start: 0, end: HEAD_CHUNKS * this.chunkBytes, yieldTail: tail };
|
|
108
|
+
await this.#fetchAhead([head, { suffix: tail }], fetchAsync);
|
|
109
|
+
if (this.length <= 0) {
|
|
110
|
+
throw new PdfError("invalid-file", "the file is empty");
|
|
111
|
+
}
|
|
112
|
+
if (this.#bytesFetched > this.#maxBytes) {
|
|
113
|
+
throw tooLarge(this.#maxBytes);
|
|
114
|
+
}
|
|
115
|
+
// Without the suffix's answer, the head and the last chunk by their offsets, as one round.
|
|
116
|
+
const totalChunks = Math.ceil(this.length / this.chunkBytes);
|
|
117
|
+
await this.#fetchAhead([...this.#missing(0, Math.min(HEAD_CHUNKS, totalChunks)), ...this.#missing(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks)], fetchAsync);
|
|
118
|
+
if (totalChunks > HEAD_CHUNKS) {
|
|
119
|
+
// The tail names the last cross-reference section (`startxref`). Fetch all of it in one
|
|
120
|
+
// round, rather than letting the parser discover it a chunk at a time.
|
|
121
|
+
const xrefAt = this.startxref();
|
|
122
|
+
if (xrefAt !== null && this.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
|
|
123
|
+
await this.#fetchAhead(this.#missing(Math.floor(xrefAt / this.chunkBytes), totalChunks), fetchAsync);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The runs of bytes in chunks `firstChunk..endChunk` not held yet. A chunk a range brought
|
|
129
|
+
* the start or end of needs only the rest.
|
|
130
|
+
*/
|
|
131
|
+
#missing(firstChunk, endChunk) {
|
|
78
132
|
const runs = [];
|
|
79
|
-
let wanted = 0;
|
|
80
133
|
for (let chunk = firstChunk; chunk < endChunk; chunk++) {
|
|
81
134
|
if (this.#chunks[chunk]) {
|
|
82
135
|
continue;
|
|
83
136
|
}
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
let start = chunk * this.chunkBytes;
|
|
138
|
+
let end = this.length > 0
|
|
86
139
|
? Math.min(this.length, start + this.chunkBytes)
|
|
87
140
|
: start + this.chunkBytes;
|
|
141
|
+
for (const piece of this.#pieces.get(chunk) ?? []) {
|
|
142
|
+
const pieceEnd = piece.start + piece.bytes.byteLength;
|
|
143
|
+
if (piece.start <= start) {
|
|
144
|
+
start = Math.max(start, pieceEnd);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
end = Math.min(end, piece.start);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
88
150
|
const last = runs.at(-1);
|
|
89
151
|
if (last && last.end === start) {
|
|
90
152
|
last.end = end;
|
|
@@ -92,39 +154,49 @@ class RangeLoader {
|
|
|
92
154
|
else {
|
|
93
155
|
runs.push({ start, end });
|
|
94
156
|
}
|
|
95
|
-
wanted += end - start;
|
|
96
|
-
}
|
|
97
|
-
if (runs.length === 0) {
|
|
98
|
-
return;
|
|
99
157
|
}
|
|
158
|
+
return runs;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One round trip for `requests`: the signal, the budget and the deadline checked first, then
|
|
162
|
+
* `fetch` with what's left of each. A timeout that's the deadline's says so.
|
|
163
|
+
*/
|
|
164
|
+
#round(requests, fetch) {
|
|
100
165
|
if (this.#signal?.aborted) {
|
|
101
166
|
throw new PdfError("aborted", "the signal was aborted", this.#signal.reason);
|
|
102
167
|
}
|
|
103
168
|
// Before the length is known the head's size is a guess, and the file may be far smaller;
|
|
104
169
|
// the fetch itself stops at the budget.
|
|
170
|
+
const wanted = requests.reduce((sum, request) => sum + ("suffix" in request ? request.suffix : request.end - request.start), 0);
|
|
105
171
|
if (this.length > 0 && this.#bytesFetched + wanted > this.#maxBytes) {
|
|
106
172
|
throw tooLarge(this.#maxBytes);
|
|
107
173
|
}
|
|
108
|
-
const passedTotal = `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
109
174
|
const left = this.#deadline - performance.now();
|
|
110
175
|
if (left <= 0) {
|
|
111
|
-
throw new PdfError("timeout", passedTotal);
|
|
176
|
+
throw new PdfError("timeout", this.#passedTotal());
|
|
112
177
|
}
|
|
113
178
|
this.#rounds += 1;
|
|
114
179
|
const passesTotal = left < this.#timeoutMs;
|
|
115
|
-
|
|
180
|
+
const said = (error) => passesTotal && error.code === "timeout" ? new PdfError("timeout", this.#passedTotal(), error) : error;
|
|
116
181
|
try {
|
|
117
|
-
answers = this.#
|
|
182
|
+
const answers = fetch(this.#maxBytes - this.#bytesFetched, passesTotal ? Math.ceil(left) : this.#timeoutMs);
|
|
183
|
+
return (answers instanceof Promise ? answers.catch((error) => Promise.reject(said(error))) : answers);
|
|
118
184
|
}
|
|
119
185
|
catch (error) {
|
|
120
|
-
|
|
121
|
-
throw new PdfError("timeout", passedTotal, error);
|
|
122
|
-
}
|
|
123
|
-
throw error;
|
|
186
|
+
throw said(error);
|
|
124
187
|
}
|
|
188
|
+
}
|
|
189
|
+
#passedTotal() {
|
|
190
|
+
return `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
191
|
+
}
|
|
192
|
+
/** Checks and keeps a round's answers; a suffix nobody answered is skipped. */
|
|
193
|
+
#absorb(answers) {
|
|
125
194
|
for (const answer of answers) {
|
|
195
|
+
if (!answer) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
126
198
|
this.#requests += 1;
|
|
127
|
-
this.#bytesFetched += answer.
|
|
199
|
+
this.#bytesFetched += answer.received;
|
|
128
200
|
if (this.length > 0 && answer.length !== this.length) {
|
|
129
201
|
throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})`, undefined, answer.status);
|
|
130
202
|
}
|
|
@@ -141,17 +213,70 @@ class RangeLoader {
|
|
|
141
213
|
}
|
|
142
214
|
}
|
|
143
215
|
#store(answer) {
|
|
144
|
-
//
|
|
145
|
-
|
|
216
|
+
// Whole chunks are kept as views, not copies; a chunk the answer has only part of is kept
|
|
217
|
+
// as a piece until the rest arrives.
|
|
218
|
+
const { bytes, start } = answer;
|
|
146
219
|
if (this.#chunks.length === 0) {
|
|
147
220
|
this.#chunks = Array.from({
|
|
148
221
|
length: Math.ceil(answer.length / this.chunkBytes),
|
|
149
222
|
});
|
|
150
223
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.#chunks[chunk]
|
|
224
|
+
const end = start + bytes.byteLength;
|
|
225
|
+
for (let chunk = Math.floor(start / this.chunkBytes); chunk * this.chunkBytes < end; chunk++) {
|
|
226
|
+
if (this.#chunks[chunk]) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const from = chunk * this.chunkBytes;
|
|
230
|
+
const to = Math.min(from + this.chunkBytes, answer.length);
|
|
231
|
+
const part = bytes.subarray(Math.max(from, start) - start, Math.min(to, end) - start);
|
|
232
|
+
if (from >= start && to <= end) {
|
|
233
|
+
this.#chunks[chunk] = part;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const pieces = this.#pieces.get(chunk) ?? [];
|
|
237
|
+
pieces.push({ start: Math.max(from, start), bytes: part });
|
|
238
|
+
pieces.sort((a, b) => a.start - b.start);
|
|
239
|
+
// Whole once its pieces meet end to end.
|
|
240
|
+
let at = from;
|
|
241
|
+
for (const piece of pieces) {
|
|
242
|
+
if (piece.start > at) {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
at = Math.max(at, piece.start + piece.bytes.byteLength);
|
|
246
|
+
}
|
|
247
|
+
if (at >= to) {
|
|
248
|
+
const whole = new Uint8Array(to - from);
|
|
249
|
+
for (const piece of pieces) {
|
|
250
|
+
whole.set(piece.bytes.subarray(0, Math.min(piece.bytes.byteLength, to - piece.start)), piece.start - from);
|
|
251
|
+
}
|
|
252
|
+
this.#chunks[chunk] = whole;
|
|
253
|
+
this.#pieces.delete(chunk);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
this.#pieces.set(chunk, pieces);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* `failure`, or when what's held shows the file isn't a PDF (see `notPdfBytes`), that it isn't: an
|
|
262
|
+
* error or sign-in page fails to parse, or runs into `maxBytes`, and neither says why. Only when
|
|
263
|
+
* enough of the first KiB is held to tell.
|
|
264
|
+
*/
|
|
265
|
+
explain(failure) {
|
|
266
|
+
if (!(failure instanceof PdfError && (failure.code === "invalid-file" || overBudget.has(failure)))) {
|
|
267
|
+
return failure;
|
|
268
|
+
}
|
|
269
|
+
// The chunks held from the start on, at least the first KiB's worth when chunks are smaller.
|
|
270
|
+
const parts = [];
|
|
271
|
+
let held = 0;
|
|
272
|
+
for (let chunk = 0; held < Math.max(HEADER_BYTES, this.chunkBytes) && this.#chunks[chunk]; chunk++) {
|
|
273
|
+
parts.push(this.#chunks[chunk]);
|
|
274
|
+
held += this.#chunks[chunk].byteLength;
|
|
154
275
|
}
|
|
276
|
+
const head = parts.length > 0 ? concat(...parts) : this.#pieces.get(0)?.find((piece) => piece.start === 0)?.bytes;
|
|
277
|
+
return head && head.byteLength >= Math.min(HEADER_BYTES, this.length) && notPdfBytes(head, this.#chunks.at(-1))
|
|
278
|
+
? notPdf(failure)
|
|
279
|
+
: failure;
|
|
155
280
|
}
|
|
156
281
|
/**
|
|
157
282
|
* The `startxref` offset in the fetched last chunk, if it names one. Null for a linearized
|
|
@@ -160,18 +285,19 @@ class RangeLoader {
|
|
|
160
285
|
startxref() {
|
|
161
286
|
const first = this.#chunks[0];
|
|
162
287
|
const last = this.#chunks.at(-1);
|
|
163
|
-
if (!first ||
|
|
164
|
-
!last ||
|
|
165
|
-
new TextDecoder("latin1").decode(first.subarray(0, 1024)).includes("/Linearized")) {
|
|
288
|
+
if (!first || !last || latin1(first.subarray(0, HEADER_BYTES)).includes("/Linearized")) {
|
|
166
289
|
return null;
|
|
167
290
|
}
|
|
168
|
-
const tail =
|
|
291
|
+
const tail = latin1(last.subarray(-1024));
|
|
169
292
|
const match = /startxref\s+(\d+)\s+%%EOF\s*$/u.exec(tail);
|
|
170
293
|
const offset = match ? Number(match[1]) : Number.NaN;
|
|
171
294
|
return offset >= 0 && offset < this.length ? offset : null;
|
|
172
295
|
}
|
|
173
|
-
/**
|
|
174
|
-
|
|
296
|
+
/**
|
|
297
|
+
* The chunk window to fetch for a miss at `chunk`, with read-ahead, and at least to `lastAhead`:
|
|
298
|
+
* what the engine said it's likely to read next.
|
|
299
|
+
*/
|
|
300
|
+
#window(chunk, lastNeeded, lastAhead) {
|
|
175
301
|
const start = chunk * this.chunkBytes;
|
|
176
302
|
const sequential = this.#lastFetchEnd >= 0 &&
|
|
177
303
|
start >= this.#lastFetchEnd - this.chunkBytes &&
|
|
@@ -180,16 +306,17 @@ class RangeLoader {
|
|
|
180
306
|
? Math.min(MAX_READ_AHEAD_BYTES, this.#readAhead * 2)
|
|
181
307
|
: this.chunkBytes;
|
|
182
308
|
const totalChunks = Math.ceil(this.length / this.chunkBytes);
|
|
183
|
-
const end = Math.min(totalChunks, Math.max(lastNeeded + 1, chunk + this.#readAhead / this.chunkBytes));
|
|
309
|
+
const end = Math.min(totalChunks, Math.max(lastNeeded + 1, lastAhead + 1, chunk + this.#readAhead / this.chunkBytes));
|
|
184
310
|
this.#lastFetchEnd = end * this.chunkBytes;
|
|
185
311
|
return [chunk, end];
|
|
186
312
|
}
|
|
187
313
|
/**
|
|
188
|
-
* Copies [offset, offset + out.length) into `out`, fetching what is missing first
|
|
189
|
-
*
|
|
190
|
-
*
|
|
314
|
+
* Copies [offset, offset + out.length) into `out`, fetching what is missing first, and if it
|
|
315
|
+
* fetches, `ahead` bytes past it too, as far as the budget allows: the engine's guess at what
|
|
316
|
+
* it reads next, which then costs no round trip of its own. On a failed fetch it records the
|
|
317
|
+
* failure and fails the read; the call that made it then throws the failure.
|
|
191
318
|
*/
|
|
192
|
-
read(offset, out) {
|
|
319
|
+
read(offset, out, ahead = 0) {
|
|
193
320
|
this.#reads += 1;
|
|
194
321
|
this.#readBytes += out.byteLength;
|
|
195
322
|
const end = offset + out.byteLength;
|
|
@@ -201,10 +328,12 @@ class RangeLoader {
|
|
|
201
328
|
}
|
|
202
329
|
const first = Math.floor(offset / this.chunkBytes);
|
|
203
330
|
const last = Math.floor((end - 1) / this.chunkBytes);
|
|
331
|
+
const spare = Math.max(0, this.#maxBytes - this.#bytesFetched - out.byteLength);
|
|
332
|
+
const lastAhead = Math.floor((Math.min(this.length, end + Math.min(ahead, spare)) - 1) / this.chunkBytes);
|
|
204
333
|
for (let chunk = first; chunk <= last; chunk++) {
|
|
205
334
|
if (!this.#chunks[chunk]) {
|
|
206
335
|
try {
|
|
207
|
-
this
|
|
336
|
+
this.#fetchChunks(...this.#window(chunk, last, lastAhead));
|
|
208
337
|
}
|
|
209
338
|
catch (error) {
|
|
210
339
|
this.failure =
|
|
@@ -231,10 +360,11 @@ class RangeLoader {
|
|
|
231
360
|
}
|
|
232
361
|
}
|
|
233
362
|
/**
|
|
234
|
-
* Starts a ranged read of `source`: the head, then
|
|
235
|
-
*
|
|
363
|
+
* Starts a ranged read of `source`: the head and tail, then the last cross-reference section
|
|
364
|
+
* if the tail didn't hold it. Blocks while it fetches, except in a browser, where these
|
|
365
|
+
* rounds are `fetch` calls.
|
|
236
366
|
*/
|
|
237
|
-
export function startRangeLoader(source, options = {}) {
|
|
367
|
+
export async function startRangeLoader(source, options = {}) {
|
|
238
368
|
const positive = (name, value) => {
|
|
239
369
|
if (value !== undefined && !(typeof value === "number" && value > 0)) {
|
|
240
370
|
throw new RangeError(`${name} must be above 0 (got ${value})`);
|
|
@@ -250,28 +380,18 @@ export function startRangeLoader(source, options = {}) {
|
|
|
250
380
|
if (options.signal?.aborted) {
|
|
251
381
|
throw new PdfError("aborted", "the signal was aborted", options.signal.reason);
|
|
252
382
|
}
|
|
253
|
-
const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
|
|
254
383
|
const loader = new RangeLoader(fetcherFor(source), {
|
|
255
|
-
chunkBytes,
|
|
384
|
+
chunkBytes: options.chunkBytes ?? DEFAULT_CHUNK_BYTES,
|
|
256
385
|
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
257
386
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
258
387
|
totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
|
|
259
388
|
signal: options.signal,
|
|
260
389
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
throw new PdfError("invalid-file", "the file is empty");
|
|
390
|
+
try {
|
|
391
|
+
await loader.begin(asyncFetcherFor(source));
|
|
264
392
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (totalChunks > HEAD_CHUNKS) {
|
|
268
|
-
loader.fetchChunks(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks);
|
|
269
|
-
// The tail names the last cross-reference section (`startxref`). Fetch all of it in one
|
|
270
|
-
// round, rather than letting the parser discover it a chunk at a time.
|
|
271
|
-
const xrefAt = loader.startxref();
|
|
272
|
-
if (xrefAt !== null && loader.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
|
|
273
|
-
loader.fetchChunks(Math.floor(xrefAt / chunkBytes), totalChunks);
|
|
274
|
-
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
throw loader.explain(error);
|
|
275
395
|
}
|
|
276
396
|
return loader;
|
|
277
397
|
}
|
|
@@ -297,38 +417,97 @@ export function ownedSource(source) {
|
|
|
297
417
|
function fetcherFor(source) {
|
|
298
418
|
if (typeof source !== "string") {
|
|
299
419
|
const bytes = source;
|
|
300
|
-
return (
|
|
301
|
-
start,
|
|
302
|
-
bytes: bytes.subarray(start,
|
|
303
|
-
|
|
304
|
-
etag: null,
|
|
305
|
-
status: 206,
|
|
306
|
-
}));
|
|
420
|
+
return (requests) => requests.map((request) => {
|
|
421
|
+
const { start, end } = asked(request, bytes.byteLength);
|
|
422
|
+
return { start, bytes: bytes.subarray(start, end), length: bytes.byteLength, etag: null, status: 206, received: end - start };
|
|
423
|
+
});
|
|
307
424
|
}
|
|
308
425
|
const url = source;
|
|
309
426
|
// Node's fetch cannot run under a blocked thread; in a browser worker a synchronous
|
|
310
427
|
// XMLHttpRequest needs no second thread.
|
|
311
|
-
return
|
|
312
|
-
? (
|
|
313
|
-
: (
|
|
428
|
+
return isNode()
|
|
429
|
+
? (requests, timeoutMs, limit) => fetchOverThread(url, requests, timeoutMs, limit)
|
|
430
|
+
: (requests, timeoutMs, limit) => fetchOverXhr(url, requests, timeoutMs, limit);
|
|
314
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* How the rounds before the parse fetch, if not as the parse's do: in a browser, `fetch`, so a
|
|
434
|
+
* round's requests run in parallel, where synchronous requests run one after another. Node's
|
|
435
|
+
* fetcher thread already runs them in parallel.
|
|
436
|
+
*/
|
|
437
|
+
function asyncFetcherFor(source) {
|
|
438
|
+
if (typeof source !== "string" || isNode()) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
return (requests, timeoutMs, limit) => fetchOverFetch(source, requests, timeoutMs, limit);
|
|
442
|
+
}
|
|
443
|
+
function isNode() {
|
|
444
|
+
return Boolean(globalThis.process?.getBuiltinModule?.("node:worker_threads"));
|
|
445
|
+
}
|
|
446
|
+
/** The bytes `request` asks for of a file of `length`, or of a range with `yieldTail`, those it reads. */
|
|
447
|
+
function asked(request, length) {
|
|
448
|
+
if ("suffix" in request) {
|
|
449
|
+
return { start: Math.max(0, length - request.suffix), end: length };
|
|
450
|
+
}
|
|
451
|
+
const end = Math.min(request.end, length);
|
|
452
|
+
return { start: request.start, end: request.yieldTail === undefined ? end : Math.min(end, Math.max(request.start, length - request.yieldTail)) };
|
|
453
|
+
}
|
|
454
|
+
/** How much of a 206 answer to `request` to read, from its total; past it is the suffix's. */
|
|
455
|
+
function readsOf(request, contentRange) {
|
|
456
|
+
const size = "suffix" in request ? request.suffix : request.end - request.start;
|
|
457
|
+
const total = Number(/\/(\d+)$/u.exec(contentRange?.trim() ?? "")?.[1] ?? Number.NaN);
|
|
458
|
+
if ("suffix" in request || request.yieldTail === undefined || !(total >= 0)) {
|
|
459
|
+
return { most: size, exact: false };
|
|
460
|
+
}
|
|
461
|
+
const most = Math.max(0, Math.min(size, asked(request, total).end - request.start));
|
|
462
|
+
return { most, exact: most < size };
|
|
463
|
+
}
|
|
464
|
+
function rangeHeader(request) {
|
|
465
|
+
return "suffix" in request ? `bytes=-${request.suffix}` : `bytes=${request.start}-${request.end - 1}`;
|
|
466
|
+
}
|
|
467
|
+
/** The budget's failures, which bytes that aren't a PDF explain better; not the address space's. */
|
|
468
|
+
const overBudget = new WeakSet();
|
|
315
469
|
function tooLarge(maxBytes) {
|
|
316
|
-
|
|
470
|
+
const error = new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
|
|
471
|
+
overBudget.add(error);
|
|
472
|
+
return error;
|
|
473
|
+
}
|
|
474
|
+
/** How far into a file a reader looks for its `%PDF` header. */
|
|
475
|
+
const HEADER_BYTES = 1024;
|
|
476
|
+
function latin1(bytes) {
|
|
477
|
+
return new TextDecoder("latin1").decode(bytes);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether a file's first and last bytes show it isn't a PDF: no `%PDF` where a reader looks for it,
|
|
481
|
+
* and no `endobj` or `startxref` either, which a damaged PDF that lost its header still has.
|
|
482
|
+
*/
|
|
483
|
+
function notPdfBytes(head, tail) {
|
|
484
|
+
const syntax = (bytes) => /endobj|startxref/u.test(latin1(bytes));
|
|
485
|
+
return !latin1(head.subarray(0, HEADER_BYTES)).includes("%PDF") && !syntax(head) && !(tail && syntax(tail));
|
|
486
|
+
}
|
|
487
|
+
function notPdf(cause) {
|
|
488
|
+
return new PdfError("invalid-file", `not a PDF: no %PDF header in its first ${HEADER_BYTES} bytes (an error or sign-in page?)`, cause);
|
|
489
|
+
}
|
|
490
|
+
/** A non-2xx answer: its status says more than anything read from its body would. */
|
|
491
|
+
function httpFailure(status) {
|
|
492
|
+
return new PdfError("fetch-failed", `the server answered HTTP ${status}, not the file`, undefined, status);
|
|
317
493
|
}
|
|
318
494
|
/**
|
|
319
495
|
* A server's answer as chunks to store, or a clear failure. Messages never carry the URL: a
|
|
320
496
|
* presigned query is a credential.
|
|
321
497
|
*/
|
|
322
|
-
function answerOf(range, status, contentRange, etag, bytes, limit) {
|
|
323
|
-
if (status === 200) {
|
|
498
|
+
function answerOf(range, status, contentRange, etag, bytes, limit, received = bytes.byteLength) {
|
|
499
|
+
if (status === 200 && !("suffix" in range)) {
|
|
324
500
|
// The server ignored Range and sent the whole file: use it, while the budget allows.
|
|
325
501
|
if (bytes.byteLength > limit) {
|
|
502
|
+
if (notPdfBytes(bytes.subarray(0, 1 << 16), bytes.subarray(-(1 << 16)))) {
|
|
503
|
+
throw notPdf();
|
|
504
|
+
}
|
|
326
505
|
throw new PdfError("too-large", `the server does not answer Range requests, and the whole file is past the ${limit} bytes left of maxBytes`);
|
|
327
506
|
}
|
|
328
|
-
return { start: 0, bytes, length: bytes.byteLength, etag, status };
|
|
507
|
+
return { start: 0, bytes, length: bytes.byteLength, etag, status, received };
|
|
329
508
|
}
|
|
330
509
|
if (status !== 206) {
|
|
331
|
-
throw
|
|
510
|
+
throw httpFailure(status);
|
|
332
511
|
}
|
|
333
512
|
const match = /^bytes (\d+)-\d+\/(\d+)$/u.exec(contentRange?.trim() ?? "");
|
|
334
513
|
if (!match) {
|
|
@@ -338,12 +517,28 @@ function answerOf(range, status, contentRange, etag, bytes, limit) {
|
|
|
338
517
|
const start = Number(match[1]);
|
|
339
518
|
const length = Number(match[2]);
|
|
340
519
|
// A short or shifted answer (a proxy capping ranges) would store a partial chunk.
|
|
341
|
-
|
|
342
|
-
|
|
520
|
+
const want = asked(range, length);
|
|
521
|
+
// A range that would stop where the suffix starts runs on to its end when the suffix failed.
|
|
522
|
+
const whole = "suffix" in range ? want : asked({ start: range.start, end: range.end }, length);
|
|
523
|
+
if (start !== want.start || (bytes.byteLength !== want.end - want.start && bytes.byteLength !== whole.end - whole.start)) {
|
|
524
|
+
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);
|
|
525
|
+
}
|
|
526
|
+
return { start, bytes, length, etag, status, received };
|
|
527
|
+
}
|
|
528
|
+
/** A suffix's answer, or null if it isn't one: the tail is then asked for by its offset. */
|
|
529
|
+
function suffixAnswerOf(suffix, status, contentRange, etag, bytes, received = bytes.byteLength) {
|
|
530
|
+
if (status !== 206) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
return answerOf(suffix, status, contentRange, etag, bytes, Number.POSITIVE_INFINITY, received);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return null;
|
|
343
538
|
}
|
|
344
|
-
return { start, bytes, length, etag, status };
|
|
345
539
|
}
|
|
346
540
|
const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
|
|
541
|
+
/** A read's miss in a browser worker. The rounds with a suffix go through `fetch`, before the parse. */
|
|
347
542
|
function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
348
543
|
const deadline = performance.now() + timeoutMs;
|
|
349
544
|
return ranges.map((range) => {
|
|
@@ -351,7 +546,7 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
351
546
|
xhr.open("GET", url, false);
|
|
352
547
|
xhr.responseType = "arraybuffer";
|
|
353
548
|
xhr.timeout = Math.max(1, deadline - performance.now());
|
|
354
|
-
xhr.setRequestHeader("Range",
|
|
549
|
+
xhr.setRequestHeader("Range", rangeHeader(range));
|
|
355
550
|
try {
|
|
356
551
|
xhr.send();
|
|
357
552
|
}
|
|
@@ -368,6 +563,198 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
368
563
|
return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
|
|
369
564
|
});
|
|
370
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* The XMLHttpRequest path's answers and failures, from `fetch`, with a round's requests in
|
|
568
|
+
* parallel. Used before the parse starts, when this thread may await. A suffix still out when the
|
|
569
|
+
* ranges are in gets as long again as they took, then counts as unanswered.
|
|
570
|
+
*/
|
|
571
|
+
async function fetchOverFetch(url, requests, timeoutMs, limit) {
|
|
572
|
+
const started = performance.now();
|
|
573
|
+
const controllers = requests.map(() => new AbortController());
|
|
574
|
+
let timedOut = false;
|
|
575
|
+
const timer = setTimeout(() => {
|
|
576
|
+
timedOut = true;
|
|
577
|
+
for (const controller of controllers) {
|
|
578
|
+
controller.abort();
|
|
579
|
+
}
|
|
580
|
+
}, timeoutMs);
|
|
581
|
+
const failure = (error) => timedOut
|
|
582
|
+
? new PdfError("timeout", `the ranged read timed out after ${timeoutMs} ms`, error)
|
|
583
|
+
: new PdfError("fetch-failed", NO_ANSWER, error);
|
|
584
|
+
// What the suffix came to, for a head that stopped where it starts: kept short only if it's good.
|
|
585
|
+
let suffixDone;
|
|
586
|
+
const suffixAnswer = new Promise((resolve) => (suffixDone = resolve));
|
|
587
|
+
const suffixAt = requests.findIndex((request) => "suffix" in request);
|
|
588
|
+
const one = async (request, signal) => {
|
|
589
|
+
const suffix = "suffix" in request;
|
|
590
|
+
try {
|
|
591
|
+
const response = await fetch(url, { headers: { Range: rangeHeader(request) }, signal });
|
|
592
|
+
const contentRange = response.headers.get("Content-Range");
|
|
593
|
+
const etag = response.headers.get("ETag");
|
|
594
|
+
if (suffix && response.status !== 206) {
|
|
595
|
+
// Not a suffix: a whole file, or a refusal. Read none of it.
|
|
596
|
+
await response.body?.cancel();
|
|
597
|
+
suffixDone(null);
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
// An error page: its status is the answer, and its body isn't worth downloading.
|
|
602
|
+
await response.body?.cancel();
|
|
603
|
+
throw httpFailure(response.status);
|
|
604
|
+
}
|
|
605
|
+
const reader = byteReader(response.body);
|
|
606
|
+
try {
|
|
607
|
+
if (response.status !== 206) {
|
|
608
|
+
// A whole file, used if it fits the budget: read no further than one byte past it.
|
|
609
|
+
const bytes = await reader.read(limit + 1);
|
|
610
|
+
return answerOf(request, response.status, contentRange, etag, bytes, limit, reader.received);
|
|
611
|
+
}
|
|
612
|
+
const { most, exact } = readsOf(request, contentRange);
|
|
613
|
+
let bytes = await reader.read(exact ? most : most + 1);
|
|
614
|
+
if (suffix) {
|
|
615
|
+
const answer = suffixAnswerOf(request, response.status, contentRange, etag, bytes, reader.received);
|
|
616
|
+
suffixDone(answer);
|
|
617
|
+
return answer;
|
|
618
|
+
}
|
|
619
|
+
if (exact && !("suffix" in request)) {
|
|
620
|
+
// Stopped where the suffix starts: the rest too, unless the suffix brings it.
|
|
621
|
+
const total = Number(contentRange?.trim().split("/").pop());
|
|
622
|
+
const answer = await Promise.race([suffixAnswer, delay(suffixGrace(performance.now() - started), null)]);
|
|
623
|
+
if (answer?.length !== total) {
|
|
624
|
+
controllers[suffixAt]?.abort();
|
|
625
|
+
const rest = await reader.read(request.end - request.start - most + 1);
|
|
626
|
+
bytes = concat(bytes, rest);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return answerOf(request, response.status, contentRange, etag, bytes, limit, reader.received);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
await reader.cancel();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
// A suffix never fails the round: the tail is then asked for by its offset.
|
|
637
|
+
if (suffix) {
|
|
638
|
+
suffixDone(null);
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
throw error instanceof PdfError ? error : failure(error);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
try {
|
|
645
|
+
const ranges = Promise.all(requests.map((request, i) => ("suffix" in request ? null : one(request, controllers[i].signal))));
|
|
646
|
+
const suffixes = requests.map((request, i) => {
|
|
647
|
+
if (!("suffix" in request)) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
return one(request, controllers[i].signal);
|
|
651
|
+
});
|
|
652
|
+
const answers = await ranges;
|
|
653
|
+
const grace = suffixGrace(performance.now() - started);
|
|
654
|
+
return await Promise.all(requests.map(async (request, i) => {
|
|
655
|
+
const pending = suffixes[i];
|
|
656
|
+
if (!pending) {
|
|
657
|
+
return answers[i];
|
|
658
|
+
}
|
|
659
|
+
let late;
|
|
660
|
+
const giveUp = new Promise((resolve) => {
|
|
661
|
+
late = setTimeout(() => {
|
|
662
|
+
controllers[i].abort();
|
|
663
|
+
resolve(null);
|
|
664
|
+
}, grace);
|
|
665
|
+
});
|
|
666
|
+
try {
|
|
667
|
+
return await Promise.race([pending, giveUp]);
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
clearTimeout(late);
|
|
671
|
+
}
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
clearTimeout(timer);
|
|
676
|
+
// A round that failed leaves nothing downloading.
|
|
677
|
+
for (const controller of controllers) {
|
|
678
|
+
controller.abort();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* How long a suffix may still take once the round's ranges are in, `took` after they went out:
|
|
684
|
+
* as long again, and at least a quarter second, since both went out together. Past it the tail
|
|
685
|
+
* is asked for by its offset, a round later, as a server that refuses suffixes gets it.
|
|
686
|
+
*/
|
|
687
|
+
function suffixGrace(took) {
|
|
688
|
+
return Math.max(took, 250);
|
|
689
|
+
}
|
|
690
|
+
function delay(ms, value) {
|
|
691
|
+
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
692
|
+
}
|
|
693
|
+
function concat(...parts) {
|
|
694
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0));
|
|
695
|
+
let at = 0;
|
|
696
|
+
for (const part of parts) {
|
|
697
|
+
out.set(part, at);
|
|
698
|
+
at += part.byteLength;
|
|
699
|
+
}
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* A body read a given number of bytes at a time: exactly, by a reader that fills a buffer where the
|
|
704
|
+
* stream has one, or by parts, keeping what a part held past the count for the next read.
|
|
705
|
+
* `received` counts every byte taken off the stream.
|
|
706
|
+
*/
|
|
707
|
+
function byteReader(body) {
|
|
708
|
+
let byob = null;
|
|
709
|
+
try {
|
|
710
|
+
byob = body?.getReader({ mode: "byob" }) ?? null;
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// Not a byte stream: read by parts.
|
|
714
|
+
}
|
|
715
|
+
const plain = byob || !body ? null : body.getReader();
|
|
716
|
+
let spare = new Uint8Array(0);
|
|
717
|
+
let done = !body;
|
|
718
|
+
let received = 0;
|
|
719
|
+
return {
|
|
720
|
+
get received() {
|
|
721
|
+
return received;
|
|
722
|
+
},
|
|
723
|
+
async read(n) {
|
|
724
|
+
// In parts of at most 1 MiB, so a large `n` (a budget) allocates only what arrives.
|
|
725
|
+
const parts = [spare.subarray(0, n)];
|
|
726
|
+
let got = parts[0].byteLength;
|
|
727
|
+
spare = spare.subarray(got);
|
|
728
|
+
while (got < n && !done) {
|
|
729
|
+
if (byob) {
|
|
730
|
+
const { value, done: end } = await byob.read(new Uint8Array(Math.min(n - got, 1 << 20)));
|
|
731
|
+
if (value) {
|
|
732
|
+
parts.push(value);
|
|
733
|
+
got += value.byteLength;
|
|
734
|
+
received += value.byteLength;
|
|
735
|
+
}
|
|
736
|
+
done = end;
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
const { value, done: end } = await plain.read();
|
|
740
|
+
if (end) {
|
|
741
|
+
done = true;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
received += value.byteLength;
|
|
745
|
+
const take = Math.min(n - got, value.byteLength);
|
|
746
|
+
parts.push(value.subarray(0, take));
|
|
747
|
+
got += take;
|
|
748
|
+
spare = value.subarray(take);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return parts.length === 1 ? parts[0] : concat(...parts);
|
|
752
|
+
},
|
|
753
|
+
async cancel() {
|
|
754
|
+
await (byob ?? plain)?.cancel().catch(() => undefined);
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
}
|
|
371
758
|
// The fetcher thread: plain JS (eval), so it needs no loader or bundler entry. It answers
|
|
372
759
|
// each request on the port, then bumps `flag` so the waiting thread wakes. The body is read
|
|
373
760
|
// up to `limit`, for a server that answers a small range with a huge whole file.
|
|
@@ -375,9 +762,22 @@ const FETCHER_SOURCE = `
|
|
|
375
762
|
const { workerData } = require("node:worker_threads");
|
|
376
763
|
const { port, flag } = workerData;
|
|
377
764
|
const controllers = new Map();
|
|
765
|
+
const suffixes = new Map();
|
|
766
|
+
// What a group's suffix came to: its file's total if it's a good suffix, null otherwise.
|
|
767
|
+
function outcome(group) {
|
|
768
|
+
let entry = suffixes.get(group);
|
|
769
|
+
if (!entry) {
|
|
770
|
+
let resolve;
|
|
771
|
+
entry = { promise: new Promise((r) => (resolve = r)) };
|
|
772
|
+
entry.resolve = resolve;
|
|
773
|
+
suffixes.set(group, entry);
|
|
774
|
+
}
|
|
775
|
+
return entry;
|
|
776
|
+
}
|
|
378
777
|
async function get(m, signal) {
|
|
778
|
+
const started = performance.now();
|
|
379
779
|
const response = await fetch(m.url, {
|
|
380
|
-
headers: { Range:
|
|
780
|
+
headers: { Range: m.range, "Accept-Encoding": "identity" },
|
|
381
781
|
signal,
|
|
382
782
|
});
|
|
383
783
|
const reply = {
|
|
@@ -385,19 +785,60 @@ async function get(m, signal) {
|
|
|
385
785
|
contentRange: response.headers.get("content-range"),
|
|
386
786
|
etag: response.headers.get("etag"),
|
|
387
787
|
};
|
|
388
|
-
|
|
788
|
+
// A suffix answered with anything else (a whole file) isn't read.
|
|
789
|
+
if (!response.ok || (m.suffix && response.status !== 206)) {
|
|
389
790
|
if (response.body) await response.body.cancel();
|
|
390
|
-
|
|
791
|
+
if (m.suffix) outcome(m.group).resolve(null);
|
|
792
|
+
return { ...reply, bytes: new ArrayBuffer(0), received: 0 };
|
|
391
793
|
}
|
|
392
|
-
const
|
|
393
|
-
let
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
794
|
+
const reader = response.body ? response.body.getReader({ mode: "byob" }) : null;
|
|
795
|
+
let received = 0;
|
|
796
|
+
let done = !reader;
|
|
797
|
+
const read = async (n) => {
|
|
798
|
+
const parts = [];
|
|
799
|
+
let got = 0;
|
|
800
|
+
while (got < n && !done) {
|
|
801
|
+
const { value, done: end } = await reader.read(new Uint8Array(Math.min(n - got, 1 << 20)));
|
|
802
|
+
if (value) { parts.push(value); got += value.byteLength; received += value.byteLength; }
|
|
803
|
+
done = end;
|
|
804
|
+
}
|
|
805
|
+
return Buffer.concat(parts, got);
|
|
806
|
+
};
|
|
807
|
+
try {
|
|
808
|
+
// A 206 is read as far as the range runs (see readsOf), and a byte past to catch a longer one;
|
|
809
|
+
// a whole file, to a byte past the limit (answerOf says which).
|
|
810
|
+
if (response.status !== 206) {
|
|
811
|
+
const bytes = await read(m.limit + 1);
|
|
812
|
+
return { ...reply, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), received };
|
|
813
|
+
}
|
|
814
|
+
const total = Number((reply.contentRange || "").trim().split("/").pop());
|
|
815
|
+
let most = m.size;
|
|
816
|
+
let exact = false;
|
|
817
|
+
if (m.yieldTail !== undefined && total >= 0) {
|
|
818
|
+
most = Math.max(0, Math.min(m.size, Math.min(m.end, Math.max(m.start, total - m.yieldTail)) - m.start));
|
|
819
|
+
exact = most < m.size;
|
|
820
|
+
}
|
|
821
|
+
let bytes = await read(exact ? most : most + 1);
|
|
822
|
+
if (m.suffix) {
|
|
823
|
+
const [from, , length] = (reply.contentRange || "").trim().replace("bytes ", "").split(/[-/]/).map(Number);
|
|
824
|
+
const good = from === Math.max(0, length - m.size) && bytes.byteLength === length - from;
|
|
825
|
+
outcome(m.group).resolve(good ? length : null);
|
|
826
|
+
} else if (exact) {
|
|
827
|
+
// Stopped where the suffix starts: the rest too, unless the suffix brings it.
|
|
828
|
+
const grace = Math.max(performance.now() - started, 250);
|
|
829
|
+
const late = new Promise((r) => setTimeout(() => r(null), grace));
|
|
830
|
+
if ((await Promise.race([outcome(m.group).promise, late])) !== total) {
|
|
831
|
+
const suffix = controllers.get(m.suffixId);
|
|
832
|
+
if (suffix) suffix.abort();
|
|
833
|
+
bytes = Buffer.concat([bytes, await read(m.size - most + 1)]);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return { ...reply, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), received };
|
|
837
|
+
} finally {
|
|
838
|
+
if (reader) await reader.cancel().catch(() => {});
|
|
839
|
+
if (m.suffix) outcome(m.group).resolve(null);
|
|
840
|
+
if (!m.suffix) suffixes.delete(m.group);
|
|
398
841
|
}
|
|
399
|
-
const { buffer, byteOffset, byteLength } = Buffer.concat(parts);
|
|
400
|
-
return { ...reply, bytes: buffer.slice(byteOffset, byteOffset + byteLength) };
|
|
401
842
|
}
|
|
402
843
|
port.on("message", async (m) => {
|
|
403
844
|
if (m.abort) { const c = controllers.get(m.id); if (c) c.abort(); return; }
|
|
@@ -443,14 +884,37 @@ function startFetcher() {
|
|
|
443
884
|
/** Fetches `ranges` in parallel on the fetcher thread; blocks until all answer. */
|
|
444
885
|
function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
445
886
|
const current = (fetcher ??= startFetcher());
|
|
446
|
-
const
|
|
887
|
+
const started = performance.now();
|
|
888
|
+
const deadline = started + timeoutMs;
|
|
889
|
+
let suffixDeadline = Number.POSITIVE_INFINITY;
|
|
890
|
+
let expired = false;
|
|
447
891
|
const pending = new Map();
|
|
892
|
+
const order = new Map();
|
|
893
|
+
// A round's ranges and its suffix are one group: a range that stops where the suffix starts
|
|
894
|
+
// waits on what the suffix came to (see FETCHER_SOURCE).
|
|
895
|
+
const group = current.nextId;
|
|
896
|
+
const suffixId = current.nextId + ranges.findIndex((range) => "suffix" in range);
|
|
448
897
|
for (const range of ranges) {
|
|
449
898
|
const id = current.nextId++;
|
|
450
899
|
pending.set(id, range);
|
|
451
|
-
|
|
900
|
+
order.set(range, order.size);
|
|
901
|
+
// A suffix's answer is read no further than the suffix: past it, it isn't one.
|
|
902
|
+
const suffix = "suffix" in range;
|
|
903
|
+
current.port.postMessage({
|
|
904
|
+
id,
|
|
905
|
+
url,
|
|
906
|
+
range: rangeHeader(range),
|
|
907
|
+
suffix,
|
|
908
|
+
limit: suffix ? Math.min(range.suffix, limit) : limit,
|
|
909
|
+
size: suffix ? Math.min(range.suffix, limit) : range.end - range.start,
|
|
910
|
+
start: suffix ? 0 : range.start,
|
|
911
|
+
end: suffix ? 0 : range.end,
|
|
912
|
+
yieldTail: suffix ? undefined : range.yieldTail,
|
|
913
|
+
group,
|
|
914
|
+
suffixId,
|
|
915
|
+
});
|
|
452
916
|
}
|
|
453
|
-
const answers =
|
|
917
|
+
const answers = ranges.map(() => null);
|
|
454
918
|
let failure = null;
|
|
455
919
|
let seen = Atomics.load(current.flag, 0);
|
|
456
920
|
while (pending.size > 0) {
|
|
@@ -465,14 +929,18 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
465
929
|
continue; // a reply to a request that already timed out
|
|
466
930
|
}
|
|
467
931
|
pending.delete(reply.id);
|
|
932
|
+
if ("suffix" in range) {
|
|
933
|
+
answers[order.get(range)] =
|
|
934
|
+
reply.failed === undefined
|
|
935
|
+
? suffixAnswerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), reply.received)
|
|
936
|
+
: null;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
468
939
|
try {
|
|
469
940
|
if (reply.failed !== undefined) {
|
|
470
941
|
throw new PdfError("fetch-failed", `the ranged read failed: ${reply.failed}`);
|
|
471
942
|
}
|
|
472
|
-
|
|
473
|
-
throw tooLarge(limit);
|
|
474
|
-
}
|
|
475
|
-
answers.push(answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit));
|
|
943
|
+
answers[order.get(range)] = answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit, reply.received);
|
|
476
944
|
}
|
|
477
945
|
catch (error) {
|
|
478
946
|
failure ??= error;
|
|
@@ -481,13 +949,27 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
481
949
|
if (pending.size === 0) {
|
|
482
950
|
break;
|
|
483
951
|
}
|
|
484
|
-
|
|
485
|
-
|
|
952
|
+
// Only suffixes left: they get a grace, then count as unanswered (suffixGrace).
|
|
953
|
+
const now = performance.now();
|
|
954
|
+
if (suffixDeadline === Number.POSITIVE_INFINITY && [...pending.values()].every((request) => "suffix" in request)) {
|
|
955
|
+
suffixDeadline = now + suffixGrace(now - started);
|
|
956
|
+
}
|
|
957
|
+
const until = Math.min(suffixDeadline, deadline);
|
|
958
|
+
if (now < until && Atomics.wait(current.flag, 0, seen, until - now) !== "timed-out") {
|
|
959
|
+
seen = Atomics.load(current.flag, 0);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (expired) {
|
|
486
963
|
for (const id of pending.keys()) {
|
|
487
964
|
current.port.postMessage({ id, abort: true });
|
|
488
965
|
}
|
|
966
|
+
if ([...pending.values()].every((request) => "suffix" in request)) {
|
|
967
|
+
break; // the suffixes stay unanswered
|
|
968
|
+
}
|
|
489
969
|
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.`);
|
|
490
970
|
}
|
|
971
|
+
// Once more through what arrived, then give up.
|
|
972
|
+
expired = true;
|
|
491
973
|
seen = Atomics.load(current.flag, 0);
|
|
492
974
|
}
|
|
493
975
|
if (failure) {
|