@2018kguo/recto 0.3.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/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 fetch reads the head
13
- // (and learns the size); a file it does not cover gets its tail next, since an ordinary
14
- // file's cross-reference table is at its end. A miss that continues the previous fetch (a
15
- // long xref table or page run read front to back) doubles the read-ahead, so a sequential
16
- // scan of n chunks costs O(log n) round trips.
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;
@@ -75,16 +85,78 @@ class RangeLoader {
75
85
  }
76
86
  /** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
77
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) {
78
142
  const runs = [];
79
- let wanted = 0;
80
143
  for (let chunk = firstChunk; chunk < endChunk; chunk++) {
81
144
  if (this.#chunks[chunk]) {
82
145
  continue;
83
146
  }
84
- const start = chunk * this.chunkBytes;
85
- const end = this.length > 0
147
+ let start = chunk * this.chunkBytes;
148
+ let end = this.length > 0
86
149
  ? Math.min(this.length, start + this.chunkBytes)
87
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
+ }
88
160
  const last = runs.at(-1);
89
161
  if (last && last.end === start) {
90
162
  last.end = end;
@@ -92,39 +164,59 @@ class RangeLoader {
92
164
  else {
93
165
  runs.push({ start, end });
94
166
  }
95
- wanted += end - start;
96
- }
97
- if (runs.length === 0) {
98
- return;
99
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) {
100
175
  if (this.#signal?.aborted) {
101
176
  throw new PdfError("aborted", "the signal was aborted", this.#signal.reason);
102
177
  }
103
178
  // Before the length is known the head's size is a guess, and the file may be far smaller;
104
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);
105
181
  if (this.length > 0 && this.#bytesFetched + wanted > this.#maxBytes) {
106
182
  throw tooLarge(this.#maxBytes);
107
183
  }
108
- const passedTotal = `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
109
184
  const left = this.#deadline - performance.now();
110
185
  if (left <= 0) {
111
- throw new PdfError("timeout", passedTotal);
186
+ throw new PdfError("timeout", this.#passedTotal());
112
187
  }
113
188
  this.#rounds += 1;
114
189
  const passesTotal = left < this.#timeoutMs;
115
- let answers;
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) {
116
197
  try {
117
- answers = this.#fetch(runs, passesTotal ? Math.ceil(left) : this.#timeoutMs, this.#maxBytes - this.#bytesFetched);
198
+ return fetch();
118
199
  }
119
200
  catch (error) {
120
201
  if (passesTotal && error.code === "timeout") {
121
- throw new PdfError("timeout", passedTotal, error);
202
+ throw new PdfError("timeout", this.#passedTotal(), error);
122
203
  }
123
204
  throw error;
124
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) {
125
214
  for (const answer of answers) {
215
+ if (!answer) {
216
+ continue;
217
+ }
126
218
  this.#requests += 1;
127
- this.#bytesFetched += answer.bytes.byteLength;
219
+ this.#bytesFetched += answer.received;
128
220
  if (this.length > 0 && answer.length !== this.length) {
129
221
  throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})`, undefined, answer.status);
130
222
  }
@@ -141,16 +233,48 @@ class RangeLoader {
141
233
  }
142
234
  }
143
235
  #store(answer) {
144
- // Answers start on a chunk boundary (we asked for one). Views, not copies.
145
- const { bytes } = answer;
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;
146
239
  if (this.#chunks.length === 0) {
147
240
  this.#chunks = Array.from({
148
241
  length: Math.ceil(answer.length / this.chunkBytes),
149
242
  });
150
243
  }
151
- for (let at = 0; at < bytes.byteLength; at += this.chunkBytes) {
152
- const chunk = (answer.start + at) / this.chunkBytes;
153
- this.#chunks[chunk] ??= bytes.subarray(at, Math.min(bytes.byteLength, at + this.chunkBytes));
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
+ }
154
278
  }
155
279
  }
156
280
  /**
@@ -170,8 +294,11 @@ class RangeLoader {
170
294
  const offset = match ? Number(match[1]) : Number.NaN;
171
295
  return offset >= 0 && offset < this.length ? offset : null;
172
296
  }
173
- /** The chunk window to fetch for a miss at `chunk`, with read-ahead. */
174
- #window(chunk, lastNeeded) {
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) {
175
302
  const start = chunk * this.chunkBytes;
176
303
  const sequential = this.#lastFetchEnd >= 0 &&
177
304
  start >= this.#lastFetchEnd - this.chunkBytes &&
@@ -180,16 +307,17 @@ class RangeLoader {
180
307
  ? Math.min(MAX_READ_AHEAD_BYTES, this.#readAhead * 2)
181
308
  : this.chunkBytes;
182
309
  const totalChunks = Math.ceil(this.length / this.chunkBytes);
183
- 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));
184
311
  this.#lastFetchEnd = end * this.chunkBytes;
185
312
  return [chunk, end];
186
313
  }
187
314
  /**
188
- * Copies [offset, offset + out.length) into `out`, fetching what is missing first. On a
189
- * failed fetch it records the failure and fails the read; the call that made it then
190
- * throws the failure.
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.
191
319
  */
192
- read(offset, out) {
320
+ read(offset, out, ahead = 0) {
193
321
  this.#reads += 1;
194
322
  this.#readBytes += out.byteLength;
195
323
  const end = offset + out.byteLength;
@@ -201,10 +329,12 @@ class RangeLoader {
201
329
  }
202
330
  const first = Math.floor(offset / this.chunkBytes);
203
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);
204
334
  for (let chunk = first; chunk <= last; chunk++) {
205
335
  if (!this.#chunks[chunk]) {
206
336
  try {
207
- this.fetchChunks(...this.#window(chunk, last));
337
+ this.fetchChunks(...this.#window(chunk, last, lastAhead));
208
338
  }
209
339
  catch (error) {
210
340
  this.failure =
@@ -231,10 +361,11 @@ class RangeLoader {
231
361
  }
232
362
  }
233
363
  /**
234
- * Starts a ranged read of `source`: the head, then (for a larger file) the tail and its last
235
- * cross-reference section. Blocks while it fetches.
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.
236
367
  */
237
- export function startRangeLoader(source, options = {}) {
368
+ export async function startRangeLoader(source, options = {}) {
238
369
  const positive = (name, value) => {
239
370
  if (value !== undefined && !(typeof value === "number" && value > 0)) {
240
371
  throw new RangeError(`${name} must be above 0 (got ${value})`);
@@ -250,29 +381,14 @@ export function startRangeLoader(source, options = {}) {
250
381
  if (options.signal?.aborted) {
251
382
  throw new PdfError("aborted", "the signal was aborted", options.signal.reason);
252
383
  }
253
- const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
254
384
  const loader = new RangeLoader(fetcherFor(source), {
255
- chunkBytes,
385
+ chunkBytes: options.chunkBytes ?? DEFAULT_CHUNK_BYTES,
256
386
  maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
257
387
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
258
388
  totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
259
389
  signal: options.signal,
260
390
  });
261
- loader.fetchChunks(0, HEAD_CHUNKS);
262
- if (loader.length <= 0) {
263
- throw new PdfError("invalid-file", "the file is empty");
264
- }
265
- // A file the head covered is read whole; any other one gets its tail.
266
- const totalChunks = Math.ceil(loader.length / chunkBytes);
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
- }
275
- }
391
+ await loader.begin(asyncFetcherFor(source));
276
392
  return loader;
277
393
  }
278
394
  /**
@@ -297,20 +413,52 @@ export function ownedSource(source) {
297
413
  function fetcherFor(source) {
298
414
  if (typeof source !== "string") {
299
415
  const bytes = source;
300
- return (ranges) => ranges.map(({ start, end }) => ({
301
- start,
302
- bytes: bytes.subarray(start, Math.min(end, bytes.byteLength)),
303
- length: bytes.byteLength,
304
- etag: null,
305
- status: 206,
306
- }));
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
+ });
307
420
  }
308
421
  const url = source;
309
422
  // Node's fetch cannot run under a blocked thread; in a browser worker a synchronous
310
423
  // XMLHttpRequest needs no second thread.
311
- return globalThis.process?.getBuiltinModule?.("node:worker_threads")
312
- ? (ranges, timeoutMs, limit) => fetchOverThread(url, ranges, timeoutMs, limit)
313
- : (ranges, timeoutMs, limit) => fetchOverXhr(url, ranges, timeoutMs, limit);
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}`;
314
462
  }
315
463
  function tooLarge(maxBytes) {
316
464
  return new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
@@ -319,13 +467,13 @@ function tooLarge(maxBytes) {
319
467
  * A server's answer as chunks to store, or a clear failure. Messages never carry the URL: a
320
468
  * presigned query is a credential.
321
469
  */
322
- function answerOf(range, status, contentRange, etag, bytes, limit) {
323
- if (status === 200) {
470
+ function answerOf(range, status, contentRange, etag, bytes, limit, received = bytes.byteLength) {
471
+ if (status === 200 && !("suffix" in range)) {
324
472
  // The server ignored Range and sent the whole file: use it, while the budget allows.
325
473
  if (bytes.byteLength > limit) {
326
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`);
327
475
  }
328
- return { start: 0, bytes, length: bytes.byteLength, etag, status };
476
+ return { start: 0, bytes, length: bytes.byteLength, etag, status, received };
329
477
  }
330
478
  if (status !== 206) {
331
479
  throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})`, undefined, status);
@@ -338,10 +486,25 @@ function answerOf(range, status, contentRange, etag, bytes, limit) {
338
486
  const start = Number(match[1]);
339
487
  const length = Number(match[2]);
340
488
  // A short or shifted answer (a proxy capping ranges) would store a partial chunk.
341
- if (start !== range.start || bytes.byteLength !== Math.min(range.end, length) - range.start) {
342
- throw new PdfError("fetch-failed", `the server answered a different range than asked (asked ${range.start}-${range.end - 1}, got ${bytes.byteLength} bytes from ${start})`, undefined, status);
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);
494
+ }
495
+ return { start, bytes, length, etag, status, received };
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;
343
507
  }
344
- return { start, bytes, length, etag, status };
345
508
  }
346
509
  const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
347
510
  function fetchOverXhr(url, ranges, timeoutMs, limit) {
@@ -351,7 +514,7 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
351
514
  xhr.open("GET", url, false);
352
515
  xhr.responseType = "arraybuffer";
353
516
  xhr.timeout = Math.max(1, deadline - performance.now());
354
- xhr.setRequestHeader("Range", `bytes=${range.start}-${range.end - 1}`);
517
+ xhr.setRequestHeader("Range", rangeHeader(range));
355
518
  try {
356
519
  xhr.send();
357
520
  }
@@ -365,9 +528,205 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
365
528
  if (xhr.status === 0) {
366
529
  throw new PdfError("fetch-failed", NO_ANSWER);
367
530
  }
531
+ if ("suffix" in range) {
532
+ return suffixAnswerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response));
533
+ }
368
534
  return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
369
535
  });
370
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
+ }
371
730
  // The fetcher thread: plain JS (eval), so it needs no loader or bundler entry. It answers
372
731
  // each request on the port, then bumps `flag` so the waiting thread wakes. The body is read
373
732
  // up to `limit`, for a server that answers a small range with a huge whole file.
@@ -375,9 +734,22 @@ const FETCHER_SOURCE = `
375
734
  const { workerData } = require("node:worker_threads");
376
735
  const { port, flag } = workerData;
377
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
+ }
378
749
  async function get(m, signal) {
750
+ const started = performance.now();
379
751
  const response = await fetch(m.url, {
380
- headers: { Range: "bytes=" + m.start + "-" + (m.end - 1), "Accept-Encoding": "identity" },
752
+ headers: { Range: m.range, "Accept-Encoding": "identity" },
381
753
  signal,
382
754
  });
383
755
  const reply = {
@@ -385,19 +757,61 @@ async function get(m, signal) {
385
757
  contentRange: response.headers.get("content-range"),
386
758
  etag: response.headers.get("etag"),
387
759
  };
388
- if (!response.ok) {
760
+ // A suffix answered with anything else (a whole file) isn't read.
761
+ if (!response.ok || (m.suffix && response.status !== 206)) {
389
762
  if (response.body) await response.body.cancel();
390
- return { ...reply, bytes: new ArrayBuffer(0) };
763
+ if (m.suffix) outcome(m.group).resolve(null);
764
+ return { ...reply, bytes: new ArrayBuffer(0), received: 0 };
391
765
  }
392
- const parts = [];
393
- let size = 0;
394
- for await (const part of response.body ?? []) {
395
- size += part.byteLength;
396
- if (size > m.limit) return { ...reply, overLimit: true, bytes: new ArrayBuffer(0) };
397
- parts.push(part);
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);
398
814
  }
399
- const { buffer, byteOffset, byteLength } = Buffer.concat(parts);
400
- return { ...reply, bytes: buffer.slice(byteOffset, byteOffset + byteLength) };
401
815
  }
402
816
  port.on("message", async (m) => {
403
817
  if (m.abort) { const c = controllers.get(m.id); if (c) c.abort(); return; }
@@ -443,14 +857,37 @@ function startFetcher() {
443
857
  /** Fetches `ranges` in parallel on the fetcher thread; blocks until all answer. */
444
858
  function fetchOverThread(url, ranges, timeoutMs, limit) {
445
859
  const current = (fetcher ??= startFetcher());
446
- const deadline = performance.now() + timeoutMs;
860
+ const started = performance.now();
861
+ const deadline = started + timeoutMs;
862
+ let suffixDeadline = Number.POSITIVE_INFINITY;
863
+ let expired = false;
447
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);
448
870
  for (const range of ranges) {
449
871
  const id = current.nextId++;
450
872
  pending.set(id, range);
451
- current.port.postMessage({ id, url, start: range.start, end: range.end, limit });
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
+ });
452
889
  }
453
- const answers = [];
890
+ const answers = ranges.map(() => null);
454
891
  let failure = null;
455
892
  let seen = Atomics.load(current.flag, 0);
456
893
  while (pending.size > 0) {
@@ -465,6 +902,13 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
465
902
  continue; // a reply to a request that already timed out
466
903
  }
467
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
+ }
468
912
  try {
469
913
  if (reply.failed !== undefined) {
470
914
  throw new PdfError("fetch-failed", `the ranged read failed: ${reply.failed}`);
@@ -472,7 +916,7 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
472
916
  if (reply.overLimit) {
473
917
  throw tooLarge(limit);
474
918
  }
475
- answers.push(answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit));
919
+ answers[order.get(range)] = answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit, reply.received);
476
920
  }
477
921
  catch (error) {
478
922
  failure ??= error;
@@ -481,13 +925,27 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
481
925
  if (pending.size === 0) {
482
926
  break;
483
927
  }
484
- const remaining = deadline - performance.now();
485
- if (remaining <= 0 || Atomics.wait(current.flag, 0, seen, remaining) === "timed-out") {
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) {
486
939
  for (const id of pending.keys()) {
487
940
  current.port.postMessage({ id, abort: true });
488
941
  }
942
+ if ([...pending.values()].every((request) => "suffix" in request)) {
943
+ break; // the suffixes stay unanswered
944
+ }
489
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.`);
490
946
  }
947
+ // Once more through what arrived, then give up.
948
+ expired = true;
491
949
  seen = Atomics.load(current.flag, 0);
492
950
  }
493
951
  if (failure) {