Search / static /reader /pdf /reader-pdf-network.mjs
vomebook's picture
fix(pdf): deliver completed demands before unrelated hint remainder
0a1327b verified
Raw History Blame Contribute Delete
28.4 kB
// A search-tab-owned, bounded memory cache. Every opening still fetches its
// fresh range to establish a strong ETag/total before any reuse. No disk,
// Service Worker body cache or change to the PDF HTTP no-store policy is involved.
export function createPdfRangeCache({maxBytes = 8 * 1024 * 1024, maxEntries = 64,
maxAgeMs = 600000, now = () => Date.now()} = {}) {
const entries = new Map();
let bytes = 0;
const remove = key => { const row = entries.get(key); if (row) { bytes -= row.bytes.byteLength; entries.delete(key); } };
const valid = (url, version) => typeof url === "string" && url.length <= 4096 &&
/^"[^"\r\n]+"$/.test(version?.etag || "") && version.etag.length <= 256 &&
Number.isSafeInteger(version.total) && version.total > 0;
const prune = () => {
for (const [key, row] of entries) if (now() - row.at >= maxAgeMs) remove(key);
};
return Object.freeze({
version(url, begin, end) {
if (!Number.isSafeInteger(begin) || !Number.isSafeInteger(end) || begin < 0 || end <= begin) return null;
prune();
for (const row of entries.values()) {
if (row.url === url && begin >= row.begin && Math.min(end, row.total) <= row.end)
return {etag:row.etag, total:row.total};
}
return null;
},
get(url, begin, end, version) {
if (!valid(url, version) || !Number.isSafeInteger(begin) || !Number.isSafeInteger(end) ||
begin < 0 || end <= begin || end > version.total) return null;
prune();
for (const [key, row] of entries) {
if (row.url !== url || row.etag !== version.etag || row.total !== version.total ||
begin < row.begin || end > row.end) continue;
entries.delete(key); entries.set(key, row);
// PDF.js transfers ArrayBuffers. Never let it detach our retained bytes.
return row.bytes.slice(begin - row.begin, end - row.begin);
}
return null;
},
put(url, begin, value, version) {
if (!valid(url, version) || value.byteLength > 2 * 1024 * 1024 || value.byteLength > maxBytes) return;
prune();
const end = begin + value.byteLength;
if (!Number.isSafeInteger(begin) || begin < 0 || end > version.total) return;
for (const [key, row] of entries) {
if (row.url === url && (row.etag !== version.etag || row.total !== version.total ||
(row.begin >= begin && row.end <= end))) remove(key);
}
const key = JSON.stringify([url, version.etag, version.total, begin, end]);
remove(key);
const retained = value.slice();
entries.set(key, {url, begin, end, etag:version.etag, total:version.total, bytes:retained, at:now()});
bytes += retained.byteLength;
while (bytes > maxBytes || entries.size > maxEntries) remove(entries.keys().next().value);
},
get byteLength() { return bytes; },
get size() { return entries.size; }
});
}
function sharedPdfRangeCache() {
if (typeof window === "undefined") return null;
let owner = window;
try {
if (parent !== window && parent.location.origin === location.origin &&
[...parent.document.querySelectorAll("iframe.reader-overlay")].some(frame => frame.contentWindow === window)) owner = parent;
} catch (_) {}
const key = Symbol.for("voice-reader-pdf-ranges-v1");
try {
if (!Object.prototype.hasOwnProperty.call(owner, key))
Object.defineProperty(owner, key, {value:createPdfRangeCache()});
return owner[key];
} catch (_) { return null; }
}
function abortable(promise, signal) {
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, {once:true});
if (signal.aborted) abort();
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
async function readPdfRange(options, begin, end, {signal, readBytes, limit, rangeCache}, expected = null, onHeaders = null, partialOnly = false) {
signal.throwIfAborted();
const key = new URL(options.url, globalThis.location?.href).href;
let cached = null;
try { if (expected) cached = rangeCache?.get(key, begin, Math.min(end, expected.total), expected); }
catch (_) { /* Cache allocation failure must retain normal network reading. */ }
if (cached) return {bytes:cached, total:expected.total, etag:expected.etag};
const response = await fetch(options.url, {
headers: {Range: `bytes=${begin}-${end-1}`}, signal, credentials:"same-origin"
});
const invalid = () => new Error("PDF_RANGE_INVALID");
try {
if (!response.ok) throw Object.assign(new Error(`server response (${response.status})`), {status:response.status});
if (!expected && response.status === 200) {
if (partialOnly) { await response.body?.cancel(); return null; }
const bytes = await readBytes(response, limit);
return {bytes, complete:true};
}
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("Content-Range") || "");
if (response.status !== 206 || !match || match.slice(1).some(value=>!Number.isSafeInteger(Number(value))))
throw invalid();
const [start, last, total] = match.slice(1).map(Number);
if (total > limit) throw Object.assign(new Error("READER_RESOURCE_LIMIT"), {code:"READER_RESOURCE_LIMIT"});
if (start !== begin || last !== Math.min(end,total)-1 || total <= start) throw invalid();
const etag = response.headers.get("ETag"), encoding = response.headers.get("Content-Encoding");
if (encoding && encoding !== "identity") throw invalid();
if (expected && (total !== expected.total || etag !== expected.etag)) throw new Error("PDF_SOURCE_CHANGED");
if (!expected && !/^"[^"\r\n]+"$/.test(etag || "") && last+1 < total) {
await response.body?.cancel();
return null;
}
onHeaders?.({total, etag});
const bytes = await readBytes(response, last-start+1);
if (bytes.byteLength !== last-start+1) throw invalid();
signal.throwIfAborted();
try { rangeCache?.put(key, start, bytes, {total, etag}); }
catch (_) { /* Retaining an optional copy must never fail the document. */ }
return {bytes, total, etag, complete:bytes.byteLength === total};
} catch (error) {
await response.body?.cancel().catch(() => {});
throw error;
}
}
function preparePdfRangeHint(options, begin, end, hooks, expected) {
const waiters = new Set();
let bytes = null, loaded = 0, complete = false, failed = false;
let published = false, failure = null, failureHandler = null, notified = false;
const notifyFailure = () => {
if (!failed || !published || notified || !failureHandler || hooks.signal.aborted) return;
notified = true;
failureHandler(failure);
};
const flush = () => {
for (const waiter of waiters) {
if (!failed && (!bytes || loaded < waiter.end - begin || (!complete && waiter.end === end))) continue;
waiters.delete(waiter);
if (failed) { waiter.resolve(null); continue; }
published = true;
// Each PDF.js range reader accepts one complete, exact requested buffer.
// A proper subrange must not wait for unrelated trailing hint bytes/EOF.
waiter.resolve({bytes:bytes.slice(waiter.begin - begin, waiter.end - begin)});
}
};
const promise = readPdfRange(options, begin, end, {
...hooks,
readBytes: async (response, length) => {
const declared = response.headers.get("Content-Length");
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) !== length))
throw new Error("PDF_RANGE_INVALID");
if (!response.body?.getReader) return hooks.readBytes(response, length);
const reader = response.body.getReader();
try {
bytes = new Uint8Array(length);
while (true) {
const {value, done} = await abortable(reader.read(), hooks.signal);
hooks.signal.throwIfAborted();
if (done) break;
if (!value?.byteLength) continue;
if (loaded + value.byteLength > length) throw new Error("PDF_RANGE_INVALID");
bytes.set(value, loaded);
loaded += value.byteLength;
flush();
}
if (loaded !== length) throw new Error("PDF_RANGE_INVALID");
return bytes;
} catch (error) {
await reader.cancel(error).catch(() => {});
throw error;
} finally { reader.releaseLock(); }
}
}, expected).then(result => {
// readPdfRange retains only an exact, version-checked complete response.
bytes = result.bytes;
loaded = bytes.byteLength;
complete = true;
flush();
return result;
}, error => {
failure = error;
failed = true;
flush();
// If a proper subrange was already used, a malformed/truncated remainder
// must invalidate that document, not silently leave an accepted bad stream.
notifyFailure();
return null;
});
return {
begin, end, promise,
read(from, last) {
if (from < begin || last > end || last <= from) return Promise.reject(new Error("PDF_RANGE_INVALID"));
const pending = Promise.withResolvers();
waiters.add({begin:from, end:last, resolve:pending.resolve});
flush();
return pending.promise;
},
onFailure(handler) { failureHandler = handler; notifyFailure(); }
};
}
function firstPageSectionEnd(bytes, total, chunkSize) {
// A bounded scheduling hint, not a PDF parser. Only a direct, internally
// consistent first-object linearization dictionary can justify first-page
// lookahead. PDF.js remains the authority for all content and rendering.
// Keep this section disjoint from the already scheduled two-chunk tail.
if (total <= chunkSize * 6) return 0;
const header = new TextDecoder("latin1").decode(bytes.subarray(0, 4096));
const object = /\b\d+\s+\d+\s+obj\s*<<([^]*?)>>/.exec(header);
if (!object || object.index > 1024 || !/\/Linearized\s+1(?:\.0+)?(?=[\s/>]|$)/.test(object[1])) return 0;
const integer = name => {
const match = new RegExp("/" + name + "\\s+(\\d+)(?=[\\s/>]|$)").exec(object[1]);
if (!match || /^\s+\d+\s+R\b/.test(object[1].slice(match.index + match[0].length))) return NaN;
return Number(match[1]);
};
const length = integer("L"), end = integer("E"), firstPage = /\/P\s/.test(object[1]) ? integer("P") : 0;
if (length !== total || firstPage !== 0 || !Number.isSafeInteger(end) || end <= chunkSize ||
end >= total || end > chunkSize * 4) return 0;
return Math.min(total, Math.ceil(end / chunkSize) * chunkSize);
}
// The first demand overlaps engine preparation. After its complete validation,
// a qualified page-one file may overlap at most three more existing chunks.
export async function preparePdfInitialRange(options, hooks) {
hooks = {...hooks, rangeCache:hooks.rangeCache === undefined ? sharedPdfRangeCache() : hooks.rangeCache};
const owner = new AbortController();
const signal = AbortSignal.any([hooks.signal, owner.signal]);
let tail = null;
try {
const onHeaders = metadata => {
if (metadata.total <= options.rangeChunkSize * 4) return;
// Start the existing two-chunk tail on validated headers, not after the
// first body finishes. This changes scheduling, not the fetched range.
const begin = Math.floor((metadata.total - 1) / options.rangeChunkSize) * options.rangeChunkSize - options.rangeChunkSize;
tail = preparePdfRangeHint(options, begin, metadata.total, {...hooks, signal}, metadata);
};
let first = null;
// A complete retained prefix may be reused only after a fresh no-store
// one-byte 206 proves its exact strong ETag and whole-document length.
// A changed/weak source or origin ignoring Range takes the ordinary path.
const key = new URL(options.url, globalThis.location?.href).href;
let previous = null;
try { previous = hooks.rangeCache?.version?.(key, 0, options.rangeChunkSize); } catch (_) {}
if (previous) {
const checked = await readPdfRange(options, 0, 1, {...hooks, signal, rangeCache:null}, null, null, true);
if (checked && checked.etag === previous.etag && checked.total === previous.total) {
let bytes = null;
try { bytes = hooks.rangeCache?.get(key, 0, Math.min(options.rangeChunkSize, checked.total), checked); } catch (_) {}
if (bytes) {
first = {bytes, total:checked.total, etag:checked.etag, complete:bytes.byteLength === checked.total};
if (!first.complete) onHeaders(first);
}
}
}
first ||= await readPdfRange(options, 0, options.rangeChunkSize, {...hooks, signal}, null, onHeaders);
if (!first) { owner.abort(); return first; }
const openingRanges = [];
const end = !first.complete && hooks.canPrefetchFirstPage?.()
? firstPageSectionEnd(first.bytes, first.total, options.rangeChunkSize) : 0;
for (let begin = options.rangeChunkSize; begin < end; begin += options.rangeChunkSize * 2) {
const last = Math.min(end, begin + options.rangeChunkSize * 2);
openingRanges.push(preparePdfRangeHint(options, begin, last, {...hooks, signal}, first));
}
return {...first, tail, openingRanges, cancelInitial:reason => owner.abort(reason)};
} catch (error) {
owner.abort(error);
throw error;
}
}
export async function openPdfRangeTask(pdfjs, options, {signal, readBytes, limit, onError, initial = null,
rangeCache = sharedPdfRangeCache(), canPrefetchFirstPage = () => false, canPrefetchOpening = () => false}) {
if (typeof pdfjs.PDFDataRangeTransport !== "function") return pdfjs.getDocument(options);
const chunkSize = options.rangeChunkSize;
const owner = new AbortController();
const active = AbortSignal.any([signal, owner.signal]);
let task = null, stopped = false, prepared = null;
const failure = Promise.withResolvers();
failure.promise.catch(() => {});
const stop = reason => {
if (stopped) return;
stopped = true;
owner.abort(reason);
prepared?.cancelInitial?.(reason);
signal.removeEventListener("abort", cancelled);
};
const cancelled = () => stop(signal.reason);
signal.addEventListener("abort", cancelled, {once:true});
const read = (begin, end, expected = null) => readPdfRange(options, begin, end,
{signal:active, readBytes, limit, rangeCache}, expected);
try {
const first = prepared = await (initial || preparePdfInitialRange(options,
{signal:active, readBytes, limit, rangeCache, canPrefetchFirstPage}));
active.throwIfAborted();
if (!first) {
stop();
return pdfjs.getDocument(options);
}
if (first.complete) {
stop();
return pdfjs.getDocument({...options, data:first.bytes});
}
// PDF.js owns parsing and range demand; the bootstrap is the first real range.
const transport = new pdfjs.PDFDataRangeTransport(first.total, first.bytes, true);
transport.abort = () => stop(new DOMException("PDF transport closed", "AbortError"));
// PDF.js parses non-linearized files from their end (trailer, then the
// cross-reference table), demanding those chunks one round trip at a
// time. Fetch the last two aligned chunks once, alongside its startup.
let tail = first.tail || null;
if (!tail && first.total > chunkSize * 4) {
const begin = Math.floor((first.total - 1) / chunkSize) * chunkSize - chunkSize;
tail = preparePdfRangeHint(options, begin, first.total, {signal:active, readBytes, limit, rangeCache}, first);
}
// A conservative read-ahead hint, not a PDF parser: only a direct Length
// before the first stream, in the bounded opening header, can qualify.
// Missing/indirect/complete streams retain ordinary PDF.js range demand.
const header = new TextDecoder("latin1").decode(first.bytes.subarray(0, 4096));
const stream = /\bstream(?:\r\n|\r|\n)/.exec(header);
const length = stream && /\/Length\s+(\d+)(?:\s+\d+\s+R)?(?=[\s/>])/.exec(header.slice(0, stream.index));
const streamEnd = length && stream.index + stream[0].length + Number(length[1]);
const openingStreamContinues = length && !/\sR$/.test(length[0]) && Number.isSafeInteger(streamEnd) &&
streamEnd > first.bytes.byteLength && streamEnd <= first.total;
// One chunk only; deep restorations and non-v3/native URLs do not opt in.
const openingRanges = first.openingRanges || [];
const prefix = first.total > chunkSize * 4 && openingStreamContinues && canPrefetchOpening() &&
!openingRanges.some(range => range.begin <= chunkSize && range.end >= chunkSize * 2)
? {begin:chunkSize, end:chunkSize * 2,
promise:read(chunkSize, chunkSize * 2, first).catch(() => null)}
: null;
const fetchOrdinaryRange = (begin, end) => prefix && begin >= prefix.begin && end <= prefix.end
? prefix.promise.then(result => result
? {bytes:result.bytes.slice(begin - prefix.begin, end - prefix.begin)}
: read(begin, end, first))
: tail && begin >= tail.begin && end <= first.total
? tail.read(begin, end).then(result => result || read(begin, end, first))
: read(begin, end, first);
const fetchRange = (begin, end) => {
const parts = [];
let offset = begin;
for (const range of openingRanges) {
if (offset < range.begin || offset >= range.end) continue;
const last = Math.min(end, range.end);
const from = offset;
parts.push({range, from, last});
offset = last;
if (offset === end) break;
}
if (offset !== end) return fetchOrdinaryRange(begin, end);
return Promise.all(parts.map(({range, from, last}) => range.read(from, last).then(result => result
? result.bytes
: read(from, last, first).then(row => row.bytes)))).then(values => {
const bytes = new Uint8Array(end - begin);
let at = 0;
for (const value of values) { bytes.set(value, at); at += value.byteLength; }
return {bytes};
});
};
const fail = error => {
if (stopped) return;
failure.reject(error);
stop(error);
task?.destroy()?.catch?.(() => {});
onError?.(error);
};
for (const hint of [tail, ...openingRanges]) hint?.onFailure(fail);
transport.requestDataRange = (begin, end) => {
fetchRange(begin,end).then(result => {
if (!stopped) transport.onDataRange(begin,result.bytes);
}).catch(fail);
};
task = pdfjs.getDocument({...options, range:transport});
task.promise.catch(error => stop(error));
return {
promise:Promise.race([task.promise,failure.promise]),
destroy() {transport.abort();return task.destroy();}
};
} catch (error) {
stop(error);
throw error;
}
}
export function createPdfFetchPolicy({idleMs = 15000, attemptMs = 45000,
initialIdleMs = 10000, initialTotalMs = 120000} = {}) {
const documents = new Set();
const versions = new Map(), timings = [];
const controller = new AbortController();
const nativeFetch = globalThis.fetch;
let version = null, inconsistent = false;
const remember = (response, url) => {
const etag = response.headers.get("ETag");
const range = /^bytes \d+-\d+\/(\d+)$/.exec(response.headers.get("Content-Range") || "");
const size = Number(range?.[1] || response.headers.get("Content-Length"));
if (!/^"[^"\r\n]+"$/.test(etag || "") || !Number.isSafeInteger(size) || size < 1 ||
(response.headers.get("Content-Encoding") && response.headers.get("Content-Encoding") !== "identity")) {
if (versions.has(url)) inconsistent = true;
return;
}
const identity = JSON.stringify([etag, size]);
if (versions.has(url) && versions.get(url) !== identity) {
inconsistent = true;
throw new Error("PDF_SOURCE_CHANGED");
}
versions.set(url, identity);
version = identity;
};
const pdfFetch = async (input, init) => {
let url;
try { url = new URL(input instanceof Request ? input.url : input, location.href).href; }
catch (_) { return nativeFetch.call(globalThis, input, init); }
if (!documents.has(url)) return nativeFetch.call(globalThis, input, init);
const callerSignal = init?.signal || (input instanceof Request ? input.signal : null);
const signal = callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal;
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
const match = /^bytes=(\d+)-(\d+)$/.exec(headers.get("Range") || "");
const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
const start = Number(match?.[1]), end = Number(match?.[2]);
const conditional = ["If-Range","If-None-Match","If-Match","If-Modified-Since","If-Unmodified-Since"]
.some(name=>headers.has(name));
const eligible = method === "GET" && match && Number.isSafeInteger(start) && Number.isSafeInteger(end) &&
start <= end && end - start < 2 * 1024 * 1024 && !conditional;
const initialRecovery = method === "GET" && !headers.has("Range") && !conditional;
const initialAt = performance.now();
let attempt = 0, record, response, reader, length = 0, delivered = 0, validator = null, stopped = false;
function finish(error = null) {
if (!record || record.finished) return;
record.finished = true;
clearTimeout(record.idle); clearTimeout(record.deadline);
const row = record.row;
if (error) {
row.error = signal.aborted ? "cancelled" : error.name === "TimeoutError" ? "timeout" : "failure";
record.owner.abort(error);
}
if (reader) {
const owned = reader; reader = null;
owned.cancel().catch(() => {}).finally(() => owned.releaseLock());
} else if (error) response?.body?.cancel().catch(() => {});
row.totalMs = performance.now()-row.at;
if (record.bodyAt) row.bodyMs = performance.now()-record.bodyAt;
else if (!row.status) row.headersMs = row.totalMs;
if (!controller.signal.aborted) timings.push(row);
if (timings.length > 64) timings.shift();
}
function arm() {
clearTimeout(record.idle); clearTimeout(record.deadline);
const owner = record.owner;
const timeout = () => owner.abort(new DOMException("PDF request stalled", "TimeoutError"));
if (!initialRecovery || attempt === 0)
record.idle = setTimeout(timeout,initialRecovery ? initialIdleMs : idleMs);
record.deadline = setTimeout(timeout,Math.max(0,initialRecovery
? initialTotalMs-(performance.now()-initialAt) : attemptMs-(performance.now()-record.row.at)));
}
function retryable(error) {
return !signal.aborted && attempt === 0 && (initialRecovery
? performance.now()-initialAt < initialTotalMs : eligible && (!delivered || validator)) &&
(error?.retryable || error?.name === "TimeoutError" || error instanceof TypeError);
}
async function open() {
signal.throwIfAborted();
response = null;
const owner = new AbortController();
record = {owner, active:AbortSignal.any([signal,owner.signal]),
row:{at:performance.now(),attempt,range:match?[start,end]:null,headersMs:0,bodyMs:0,bytes:0}};
if (eligible || initialRecovery) arm();
response = await abortable(Promise.resolve(nativeFetch.call(globalThis,input,{...init,cache:"no-store",signal:record.active}))
.then(value => {
if (owner.signal.aborted || signal.aborted) {value.body?.cancel().catch(() => {});throw record.active.reason;}
return value;
}),record.active);
record.row.headersMs = performance.now()-record.row.at;
record.row.status = response.status;
record.row.serverTiming = (response.headers.get("Server-Timing") || "").slice(0,512);
if ((eligible || initialRecovery) && [408,429,500,502,503,504].includes(response.status))
throw Object.assign(new Error("PDF temporary upstream failure"),{retryable:true});
// PDF.js needs headers immediately to cancel the full GET and demand ranges.
// Initial recovery must not read or wait for this potentially large body.
if (response.ok) remember(response,url);
if (!eligible || response.status !== 206) {
if (delivered) throw new Error("PDF_RANGE_INVALID");
return false;
}
const actual = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("Content-Range") || "");
if (!actual || actual.slice(1).some(value=>!Number.isSafeInteger(Number(value))) ||
Number(actual[1]) !== start || Number(actual[2]) !== Math.min(end,Number(actual[3])-1) || Number(actual[3]) <= start)
throw new Error("PDF_RANGE_INVALID");
const size = Number(actual[2])-start+1, etag = response.headers.get("ETag");
if (delivered && (etag !== validator || size !== length)) throw new Error("PDF_SOURCE_CHANGED");
length = size;
validator = /^"[^"\r\n]+"$/.test(etag || "") ? etag : null;
reader = response.body.getReader();
record.bodyAt = performance.now();
clearTimeout(record.idle); clearTimeout(record.deadline);
return true;
}
let streaming;
while (true) {
try {streaming = await open();break;}
catch(error) {const retry = retryable(error);finish(error);if (!retry) throw error;attempt++;}
}
if (!streaming) {finish();return response;}
const firstResponse = response;
let output;
const abort = () => {
if (stopped) return;
stopped = true;finish(signal.reason);output.error(signal.reason);
signal.removeEventListener("abort",abort);
};
const stream = new ReadableStream({
start(control) {output=control;signal.addEventListener("abort",abort,{once:true});if (signal.aborted) abort();},
async pull(control) {
while (!stopped) {
try {
arm();
const chunk = await abortable(reader.read(),record.active);
clearTimeout(record.idle); clearTimeout(record.deadline);
if (stopped) return;
if (chunk.done) {
if (record.row.bytes !== length) throw Object.assign(new Error("PDF_RANGE_TRUNCATED"),{retryable:true});
stopped=true;finish();signal.removeEventListener("abort",abort);control.close();return;
}
const offset = record.row.bytes;
record.row.bytes += chunk.value.byteLength;
if (record.row.bytes > length) throw new Error("PDF_RANGE_INVALID");
// A validated retry replays only bytes not already delivered.
const skip = Math.min(chunk.value.byteLength,Math.max(0,delivered-offset));
if (skip === chunk.value.byteLength) continue;
// PDF.js consumes value.buffer, ignoring typed-array offsets.
const value = skip || chunk.value.byteOffset || chunk.value.byteLength !== chunk.value.buffer.byteLength
? chunk.value.slice(skip) : chunk.value;
delivered += value.byteLength;control.enqueue(value);return;
} catch(error) {
if (stopped) return;
const retry = retryable(error);finish(error);
if (retry) {
attempt++;
try {
if (await open()) continue;
error = new Error("PDF_RANGE_INVALID");finish(error);
} catch(next) {error=next;finish(error);}
}
stopped=true;signal.removeEventListener("abort",abort);control.error(error);return;
}
}
},
cancel(reason) {stopped=true;signal.removeEventListener("abort",abort);finish(reason || new DOMException("PDF read cancelled","AbortError"));}
},{highWaterMark:0});
const result = new Response(stream,{status:firstResponse.status,statusText:firstResponse.statusText,headers:firstResponse.headers});
Object.defineProperty(result,"url",{value:firstResponse.url});
return result;
};
globalThis.fetch = pdfFetch;
return {
add(urls) {
for (const url of urls) documents.add(new URL(url, location.href).href);
},
get identity() { return inconsistent ? null : version; },
get timings() { return timings.map(row => ({...row,range:row.range?.slice() || null})); },
dispose() {
controller.abort(new DOMException("Reader closed", "AbortError"));
documents.clear();
versions.clear(); timings.length = 0;
if (globalThis.fetch === pdfFetch) globalThis.fetch = nativeFetch;
}
};
}