pdf.js/src/display/network.js
Jonas Jenwald 75cba72ca6 [api-major] Replace MissingPDFException and UnexpectedResponseException with one exception
These old exceptions have a fair amount of overlap given how/where they are being used, which is likely because they were introduced at different points in time, hence we can shorten and simplify the code by replacing them with a more general `ResponseException` instead.

Besides an error message, the new `ResponseException` instances also include:
 - A numeric `status` field containing the server response status, similar to the old `UnexpectedResponseException`.

 - A boolean `missing` field, to allow easily detecting the situations where `MissingPDFException` was previously thrown.
2025-01-16 22:51:05 +01:00

506 lines
14 KiB
JavaScript

/* Copyright 2012 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { assert, stringToBytes, warn } from "../shared/util.js";
import {
createHeaders,
createResponseError,
extractFilenameFromHeader,
getResponseOrigin,
validateRangeRequestCapabilities,
} from "./network_utils.js";
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error(
'Module "./network.js" shall not be used with MOZCENTRAL builds.'
);
}
const OK_RESPONSE = 200;
const PARTIAL_CONTENT_RESPONSE = 206;
function getArrayBuffer(xhr) {
const data = xhr.response;
if (typeof data !== "string") {
return data;
}
return stringToBytes(data).buffer;
}
class NetworkManager {
_responseOrigin = null;
constructor({ url, httpHeaders, withCredentials }) {
this.url = url;
this.isHttp = /^https?:/i.test(url);
this.headers = createHeaders(this.isHttp, httpHeaders);
this.withCredentials = withCredentials || false;
this.currXhrId = 0;
this.pendingRequests = Object.create(null);
}
request(args) {
const xhr = new XMLHttpRequest();
const xhrId = this.currXhrId++;
const pendingRequest = (this.pendingRequests[xhrId] = { xhr });
xhr.open("GET", this.url);
xhr.withCredentials = this.withCredentials;
for (const [key, val] of this.headers) {
xhr.setRequestHeader(key, val);
}
if (this.isHttp && "begin" in args && "end" in args) {
xhr.setRequestHeader("Range", `bytes=${args.begin}-${args.end - 1}`);
pendingRequest.expectedStatus = PARTIAL_CONTENT_RESPONSE;
} else {
pendingRequest.expectedStatus = OK_RESPONSE;
}
xhr.responseType = "arraybuffer";
assert(args.onError, "Expected `onError` callback to be provided.");
xhr.onerror = () => {
args.onError(xhr.status);
};
xhr.onreadystatechange = this.onStateChange.bind(this, xhrId);
xhr.onprogress = this.onProgress.bind(this, xhrId);
pendingRequest.onHeadersReceived = args.onHeadersReceived;
pendingRequest.onDone = args.onDone;
pendingRequest.onError = args.onError;
pendingRequest.onProgress = args.onProgress;
xhr.send(null);
return xhrId;
}
onProgress(xhrId, evt) {
const pendingRequest = this.pendingRequests[xhrId];
if (!pendingRequest) {
return; // Maybe abortRequest was called...
}
pendingRequest.onProgress?.(evt);
}
onStateChange(xhrId, evt) {
const pendingRequest = this.pendingRequests[xhrId];
if (!pendingRequest) {
return; // Maybe abortRequest was called...
}
const xhr = pendingRequest.xhr;
if (xhr.readyState >= 2 && pendingRequest.onHeadersReceived) {
pendingRequest.onHeadersReceived();
delete pendingRequest.onHeadersReceived;
}
if (xhr.readyState !== 4) {
return;
}
if (!(xhrId in this.pendingRequests)) {
// The XHR request might have been aborted in onHeadersReceived()
// callback, in which case we should abort request.
return;
}
delete this.pendingRequests[xhrId];
// Success status == 0 can be on ftp, file and other protocols.
if (xhr.status === 0 && this.isHttp) {
pendingRequest.onError(xhr.status);
return;
}
const xhrStatus = xhr.status || OK_RESPONSE;
// From http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35.2:
// "A server MAY ignore the Range header". This means it's possible to
// get a 200 rather than a 206 response from a range request.
const ok_response_on_range_request =
xhrStatus === OK_RESPONSE &&
pendingRequest.expectedStatus === PARTIAL_CONTENT_RESPONSE;
if (
!ok_response_on_range_request &&
xhrStatus !== pendingRequest.expectedStatus
) {
pendingRequest.onError(xhr.status);
return;
}
const chunk = getArrayBuffer(xhr);
if (xhrStatus === PARTIAL_CONTENT_RESPONSE) {
const rangeHeader = xhr.getResponseHeader("Content-Range");
const matches = /bytes (\d+)-(\d+)\/(\d+)/.exec(rangeHeader);
if (matches) {
pendingRequest.onDone({
begin: parseInt(matches[1], 10),
chunk,
});
} else {
warn(`Missing or invalid "Content-Range" header.`);
pendingRequest.onError(0);
}
} else if (chunk) {
pendingRequest.onDone({
begin: 0,
chunk,
});
} else {
pendingRequest.onError(xhr.status);
}
}
getRequestXhr(xhrId) {
return this.pendingRequests[xhrId].xhr;
}
isPendingRequest(xhrId) {
return xhrId in this.pendingRequests;
}
abortRequest(xhrId) {
const xhr = this.pendingRequests[xhrId].xhr;
delete this.pendingRequests[xhrId];
xhr.abort();
}
}
/** @implements {IPDFStream} */
class PDFNetworkStream {
constructor(source) {
this._source = source;
this._manager = new NetworkManager(source);
this._rangeChunkSize = source.rangeChunkSize;
this._fullRequestReader = null;
this._rangeRequestReaders = [];
}
_onRangeRequestReaderClosed(reader) {
const i = this._rangeRequestReaders.indexOf(reader);
if (i >= 0) {
this._rangeRequestReaders.splice(i, 1);
}
}
getFullReader() {
assert(
!this._fullRequestReader,
"PDFNetworkStream.getFullReader can only be called once."
);
this._fullRequestReader = new PDFNetworkStreamFullRequestReader(
this._manager,
this._source
);
return this._fullRequestReader;
}
getRangeReader(begin, end) {
const reader = new PDFNetworkStreamRangeRequestReader(
this._manager,
begin,
end
);
reader.onClosed = this._onRangeRequestReaderClosed.bind(this);
this._rangeRequestReaders.push(reader);
return reader;
}
cancelAllRequests(reason) {
this._fullRequestReader?.cancel(reason);
for (const reader of this._rangeRequestReaders.slice(0)) {
reader.cancel(reason);
}
}
}
/** @implements {IPDFStreamReader} */
class PDFNetworkStreamFullRequestReader {
constructor(manager, source) {
this._manager = manager;
this._url = source.url;
this._fullRequestId = manager.request({
onHeadersReceived: this._onHeadersReceived.bind(this),
onDone: this._onDone.bind(this),
onError: this._onError.bind(this),
onProgress: this._onProgress.bind(this),
});
this._headersCapability = Promise.withResolvers();
this._disableRange = source.disableRange || false;
this._contentLength = source.length; // Optional
this._rangeChunkSize = source.rangeChunkSize;
if (!this._rangeChunkSize && !this._disableRange) {
this._disableRange = true;
}
this._isStreamingSupported = false;
this._isRangeSupported = false;
this._cachedChunks = [];
this._requests = [];
this._done = false;
this._storedError = undefined;
this._filename = null;
this.onProgress = null;
}
_onHeadersReceived() {
const fullRequestXhrId = this._fullRequestId;
const fullRequestXhr = this._manager.getRequestXhr(fullRequestXhrId);
this._manager._responseOrigin = getResponseOrigin(
fullRequestXhr.responseURL
);
const rawResponseHeaders = fullRequestXhr.getAllResponseHeaders();
const responseHeaders = new Headers(
rawResponseHeaders
? rawResponseHeaders
.trimStart()
.replace(/[^\S ]+$/, "") // Not `trimEnd`, to keep regular spaces.
.split(/[\r\n]+/)
.map(x => {
const [key, ...val] = x.split(": ");
return [key, val.join(": ")];
})
: []
);
const { allowRangeRequests, suggestedLength } =
validateRangeRequestCapabilities({
responseHeaders,
isHttp: this._manager.isHttp,
rangeChunkSize: this._rangeChunkSize,
disableRange: this._disableRange,
});
if (allowRangeRequests) {
this._isRangeSupported = true;
}
// Setting right content length.
this._contentLength = suggestedLength || this._contentLength;
this._filename = extractFilenameFromHeader(responseHeaders);
if (this._isRangeSupported) {
// NOTE: by cancelling the full request, and then issuing range
// requests, there will be an issue for sites where you can only
// request the pdf once. However, if this is the case, then the
// server should not be returning that it can support range requests.
this._manager.abortRequest(fullRequestXhrId);
}
this._headersCapability.resolve();
}
_onDone(data) {
if (data) {
if (this._requests.length > 0) {
const requestCapability = this._requests.shift();
requestCapability.resolve({ value: data.chunk, done: false });
} else {
this._cachedChunks.push(data.chunk);
}
}
this._done = true;
if (this._cachedChunks.length > 0) {
return;
}
for (const requestCapability of this._requests) {
requestCapability.resolve({ value: undefined, done: true });
}
this._requests.length = 0;
}
_onError(status) {
this._storedError = createResponseError(status, this._url);
this._headersCapability.reject(this._storedError);
for (const requestCapability of this._requests) {
requestCapability.reject(this._storedError);
}
this._requests.length = 0;
this._cachedChunks.length = 0;
}
_onProgress(evt) {
this.onProgress?.({
loaded: evt.loaded,
total: evt.lengthComputable ? evt.total : this._contentLength,
});
}
get filename() {
return this._filename;
}
get isRangeSupported() {
return this._isRangeSupported;
}
get isStreamingSupported() {
return this._isStreamingSupported;
}
get contentLength() {
return this._contentLength;
}
get headersReady() {
return this._headersCapability.promise;
}
async read() {
await this._headersCapability.promise;
if (this._storedError) {
throw this._storedError;
}
if (this._cachedChunks.length > 0) {
const chunk = this._cachedChunks.shift();
return { value: chunk, done: false };
}
if (this._done) {
return { value: undefined, done: true };
}
const requestCapability = Promise.withResolvers();
this._requests.push(requestCapability);
return requestCapability.promise;
}
cancel(reason) {
this._done = true;
this._headersCapability.reject(reason);
for (const requestCapability of this._requests) {
requestCapability.resolve({ value: undefined, done: true });
}
this._requests.length = 0;
if (this._manager.isPendingRequest(this._fullRequestId)) {
this._manager.abortRequest(this._fullRequestId);
}
this._fullRequestReader = null;
}
}
/** @implements {IPDFStreamRangeReader} */
class PDFNetworkStreamRangeRequestReader {
constructor(manager, begin, end) {
this._manager = manager;
this._url = manager.url;
this._requestId = manager.request({
begin,
end,
onHeadersReceived: this._onHeadersReceived.bind(this),
onDone: this._onDone.bind(this),
onError: this._onError.bind(this),
onProgress: this._onProgress.bind(this),
});
this._requests = [];
this._queuedChunk = null;
this._done = false;
this._storedError = undefined;
this.onProgress = null;
this.onClosed = null;
}
_onHeadersReceived() {
const responseOrigin = getResponseOrigin(
this._manager.getRequestXhr(this._requestId)?.responseURL
);
if (responseOrigin !== this._manager._responseOrigin) {
this._storedError = new Error(
`Expected range response-origin "${responseOrigin}" to match "${this._manager._responseOrigin}".`
);
this._onError(0);
}
}
_close() {
this.onClosed?.(this);
}
_onDone(data) {
const chunk = data.chunk;
if (this._requests.length > 0) {
const requestCapability = this._requests.shift();
requestCapability.resolve({ value: chunk, done: false });
} else {
this._queuedChunk = chunk;
}
this._done = true;
for (const requestCapability of this._requests) {
requestCapability.resolve({ value: undefined, done: true });
}
this._requests.length = 0;
this._close();
}
_onError(status) {
this._storedError ??= createResponseError(status, this._url);
for (const requestCapability of this._requests) {
requestCapability.reject(this._storedError);
}
this._requests.length = 0;
this._queuedChunk = null;
}
_onProgress(evt) {
if (!this.isStreamingSupported) {
this.onProgress?.({ loaded: evt.loaded });
}
}
get isStreamingSupported() {
return false;
}
async read() {
if (this._storedError) {
throw this._storedError;
}
if (this._queuedChunk !== null) {
const chunk = this._queuedChunk;
this._queuedChunk = null;
return { value: chunk, done: false };
}
if (this._done) {
return { value: undefined, done: true };
}
const requestCapability = Promise.withResolvers();
this._requests.push(requestCapability);
return requestCapability.promise;
}
cancel(reason) {
this._done = true;
for (const requestCapability of this._requests) {
requestCapability.resolve({ value: undefined, done: true });
}
this._requests.length = 0;
if (this._manager.isPendingRequest(this._requestId)) {
this._manager.abortRequest(this._requestId);
}
this._close();
}
}
export { PDFNetworkStream };