ipull
Version:
The only file downloader you'll ever need. For node.js and the browser, CLI and library for fast and reliable file downloads.
162 lines • 7.22 kB
JavaScript
import BaseDownloadEngineFetchStream, { MIN_LENGTH_FOR_MORE_INFO_REQUEST, STREAM_NOT_RESPONDING_TIMEOUT } from "./base-download-engine-fetch-stream.js";
import InvalidContentLengthError from "./errors/invalid-content-length-error.js";
import SmartChunkSplit from "./utils/smart-chunk-split.js";
import { parseContentDisposition } from "./utils/content-disposition.js";
import StatusCodeError from "./errors/status-code-error.js";
import { parseHttpContentRange } from "./utils/httpRange.js";
import { browserCheck } from "./utils/browserCheck.js";
import { EmptyStreamTimeoutError } from "./errors/EmptyStreamTimeoutError.js";
import prettyMilliseconds from "pretty-ms";
export default class DownloadEngineFetchStreamFetch extends BaseDownloadEngineFetchStream {
_fetchDownloadInfoWithHEAD = false;
_activeController;
transferAction = "Downloading";
supportDynamicStreamLength = true;
withSubState(state) {
const fetchStream = new DownloadEngineFetchStreamFetch(this.options);
return this.cloneState(state, fetchStream);
}
async fetchWithoutRetryChunks(callback) {
const headers = {
accept: "*/*",
...this.options.headers
};
if (this.state.rangeSupport) {
headers.range = `bytes=${this._startSize}-${this._endSize - 1}`;
}
if (!this._activeController?.signal.aborted) {
this._activeController?.abort();
}
let response = null;
this._activeController = new AbortController();
this.on("aborted", () => {
if (!response) {
this._activeController?.abort();
}
});
response = await fetch(this.appendToURL(this.state.url), {
headers,
signal: this._activeController.signal
});
if (response.status < 200 || response.status >= 300) {
throw new StatusCodeError(this.state.url, response.status, response.statusText, headers);
}
const contentLength = parseHttpContentRange(response.headers.get("content-range"))?.length ?? parseInt(response.headers.get("content-length"));
const expectedContentLength = this._endSize - this._startSize;
if (this.state.rangeSupport && contentLength !== expectedContentLength) {
throw new InvalidContentLengthError(expectedContentLength, contentLength);
}
const reader = response.body.getReader();
return await this.chunkGenerator(callback, () => reader.read());
}
async fetchDownloadInfoWithoutRetry(url) {
if (this._fetchDownloadInfoWithHEAD) {
try {
return this.fetchDownloadInfoWithoutRetryByMethod(url, "HEAD");
}
catch (error) {
if (!(error instanceof StatusCodeError)) {
throw error;
}
this._fetchDownloadInfoWithHEAD = false;
}
}
return this.fetchDownloadInfoWithoutRetryByMethod(url, "GET");
}
async fetchDownloadInfoWithoutRetryByMethod(url, method = "HEAD") {
const response = await fetch(url, {
method: method,
headers: {
"Accept-Encoding": "identity",
...this.options.headers
}
});
if (response.status < 200 || response.status >= 300) {
throw new StatusCodeError(url, response.status, response.statusText, this.options.headers, DownloadEngineFetchStreamFetch.convertHeadersToRecord(response.headers));
}
const acceptRange = this.options.acceptRangeIsKnown ?? response.headers.get("accept-ranges") === "bytes";
const fileName = parseContentDisposition(response.headers.get("content-disposition"));
let length = parseInt(response.headers.get("content-length")) || 0;
const contentEncoding = response.headers.get("content-encoding");
if (contentEncoding && contentEncoding !== "identity") {
length = 0; // If content is encoded, we cannot determine the length reliably
}
if (acceptRange && length === 0 && browserCheck() && MIN_LENGTH_FOR_MORE_INFO_REQUEST < length) {
length = await this.fetchDownloadInfoWithoutRetryContentRange(url, method === "GET" ? response : undefined);
}
return {
length,
acceptRange,
newURL: response.url,
fileName
};
}
async fetchDownloadInfoWithoutRetryContentRange(url, response) {
const responseGet = response ?? await fetch(url, {
method: "GET",
headers: {
accept: "*/*",
...this.options.headers,
range: "bytes=0-0"
}
});
const contentRange = responseGet.headers.get("content-range");
return parseHttpContentRange(contentRange)?.size || 0;
}
async chunkGenerator(callback, getNextChunk) {
const smartSplit = new SmartChunkSplit(callback, this.state);
// eslint-disable-next-line no-constant-condition
while (true) {
const chunkInfo = await this._wrapperStreamNotResponding(getNextChunk());
await this.paused;
if (!chunkInfo || this.aborted || chunkInfo.done)
break;
smartSplit.addChunk(chunkInfo.value);
this.state.onProgress?.(smartSplit.savedLength);
}
smartSplit.closeAndSendLeftoversIfLengthIsUnknown();
}
_wrapperStreamNotResponding(promise) {
if (!(promise instanceof Promise)) {
return promise;
}
return new Promise((resolve, reject) => {
let streamNotRespondedInTime = false;
let timeoutMaxStreamWaitThrows = false;
const timeoutNotResponding = setTimeout(() => {
streamNotRespondedInTime = true;
this.emit("streamNotRespondingOn");
}, STREAM_NOT_RESPONDING_TIMEOUT);
const timeoutMaxStreamWait = setTimeout(() => {
timeoutMaxStreamWaitThrows = true;
reject(new EmptyStreamTimeoutError(`Stream timeout after ${prettyMilliseconds(this.options.maxStreamWait)}`));
this._activeController?.abort();
}, this.options.maxStreamWait);
this.addListener("aborted", resolve);
promise
.then(resolve)
.catch(error => {
if (timeoutMaxStreamWaitThrows || this.aborted) {
return;
}
reject(error);
})
.finally(() => {
clearTimeout(timeoutNotResponding);
clearTimeout(timeoutMaxStreamWait);
if (streamNotRespondedInTime) {
this.emit("streamNotRespondingOff");
}
this.removeListener("aborted", resolve);
});
});
}
static convertHeadersToRecord(headers) {
const headerObj = {};
headers.forEach((value, key) => {
headerObj[key] = value;
});
return headerObj;
}
}
//# sourceMappingURL=download-engine-fetch-stream-fetch.js.map