2023-06-06 22:45:16 +00:00
|
|
|
//@ts-check
|
|
|
|
|
|
|
|
import { createTrackedResponse } from "./createTrackedResponse.mjs";
|
|
|
|
import { decodeContentLength } from "./decodeContentLength.mjs";
|
|
|
|
import { retryPromise } from "./retryPromise.mjs";
|
|
|
|
import { makeSignal } from "./makeSignal.mjs";
|
2023-06-07 16:51:18 +00:00
|
|
|
import { fetchContentLength } from "./fetchContentLength.mjs";
|
|
|
|
import { metadata } from "./path.mjs";
|
2023-06-06 22:45:16 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {{
|
|
|
|
* total?: number,
|
|
|
|
* onProgress?:(data:ProgressData)=>void,
|
|
|
|
* signal?: AbortSignal
|
|
|
|
* }} FileLoaderOptions
|
|
|
|
*/
|
|
|
|
|
2023-06-07 16:51:18 +00:00
|
|
|
/**
|
|
|
|
* @template T
|
|
|
|
* @typedef {import("./makeSignal.mjs").Signal<T>} Signal<T>
|
|
|
|
*/
|
|
|
|
|
2023-06-06 22:45:16 +00:00
|
|
|
/**
|
|
|
|
* @typedef {ReturnType<typeof makeFileLoader>} FileLoader
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates an object that downloads files and allows you to track progress.
|
|
|
|
* You can optionally provide an initial file size; if not provided, the size
|
|
|
|
* will be attempt to be acquired through reading the headers when download starts.
|
|
|
|
*
|
|
|
|
* Downloads can be aborted with an AbortSignal, and the response can be obtained on
|
|
|
|
* `response`.
|
|
|
|
*
|
|
|
|
* `response` can be awaited even before the download starts.
|
|
|
|
*
|
|
|
|
* @typedef {{received: number, total:number}} ProgressData
|
|
|
|
* @param {string} path
|
|
|
|
* @param {FileLoaderOptions} options
|
|
|
|
*/
|
|
|
|
export const makeFileLoader = (
|
|
|
|
path,
|
|
|
|
{ total = 0, onProgress, signal } = {}
|
|
|
|
) => {
|
2023-06-07 16:51:18 +00:00
|
|
|
const progress = makeSignal({ path, received: 0, total: 0 });
|
|
|
|
/** @type {Signal<Response>} */
|
|
|
|
const done = makeSignal();
|
|
|
|
/** @type {Signal<Error>} */
|
|
|
|
const failed = makeSignal();
|
2023-06-06 22:45:16 +00:00
|
|
|
|
|
|
|
if (onProgress) {
|
|
|
|
progress.connect(onProgress, { signal });
|
|
|
|
}
|
|
|
|
|
2023-06-07 16:51:18 +00:00
|
|
|
const createResponse = () => {
|
|
|
|
const response = createTrackedResponse(
|
|
|
|
(received) => progress.emit({ path, received, total }),
|
2023-06-06 22:45:16 +00:00
|
|
|
signal
|
|
|
|
);
|
2023-06-07 16:51:18 +00:00
|
|
|
response.then(done.emit);
|
|
|
|
response.catch(failed.emit);
|
|
|
|
return response;
|
|
|
|
};
|
2023-06-06 22:45:16 +00:00
|
|
|
|
|
|
|
let responsePromise = createResponse();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Retrieves the file size if `total` was not provided.
|
|
|
|
*/
|
2023-06-07 16:51:18 +00:00
|
|
|
const fetchSize = (() => {
|
|
|
|
/**
|
|
|
|
* @type {Promise<number> | null}
|
|
|
|
*/
|
|
|
|
let totalPromise;
|
|
|
|
|
|
|
|
const fetchSize = () =>
|
|
|
|
(totalPromise = total
|
|
|
|
? Promise.resolve(total)
|
|
|
|
: fetchContentLength(path).then(
|
|
|
|
(fetchedTotal) => (total = fetchedTotal)
|
|
|
|
));
|
|
|
|
|
|
|
|
return fetchSize;
|
|
|
|
})();
|
2023-06-06 22:45:16 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Fetches a file, dispatching progress events while doing so.
|
|
|
|
*/
|
|
|
|
const start = () =>
|
|
|
|
fetch(path, { signal }).then((initialResponse) => {
|
|
|
|
if (!initialResponse.body || !initialResponse.ok) {
|
|
|
|
throw new Error(`Failed loading file '${path}'`);
|
|
|
|
}
|
|
|
|
total = total || decodeContentLength(initialResponse.headers);
|
|
|
|
|
|
|
|
const reader = initialResponse.body.getReader();
|
|
|
|
// this triggers the deferred promise and starts the loading process
|
|
|
|
responsePromise.start(reader);
|
|
|
|
return responsePromise.response;
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads a file, _if it isn't already loaded_.
|
|
|
|
* If it is loaded, or if the loading has started, then this results in a
|
|
|
|
* no-op.
|
|
|
|
* It's therefore safe to use multiple times.
|
|
|
|
* This function returns as soon as the download is triggered. To await the
|
|
|
|
* download, await the `response` property and/or listen to the success event.
|
|
|
|
*
|
|
|
|
* @param {number} retries amount of time to try again in case of failure.
|
|
|
|
* Defaults to 4.
|
|
|
|
* @returns {Promise<Response>}
|
|
|
|
*/
|
|
|
|
const load = (retries = 4) =>
|
|
|
|
new Promise((ok, no) => {
|
|
|
|
if (responsePromise.isStarted) {
|
|
|
|
return ok(responsePromise.response);
|
|
|
|
}
|
|
|
|
if (responsePromise.isFailed) {
|
|
|
|
return no();
|
|
|
|
}
|
|
|
|
return retryPromise(start, retries).then(ok).catch(no);
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* clears loaded file from memory (if there are no more references to it)
|
|
|
|
*/
|
|
|
|
const unload = () => {
|
|
|
|
responsePromise = createResponse();
|
|
|
|
};
|
|
|
|
|
|
|
|
return {
|
2023-06-07 16:51:18 +00:00
|
|
|
...metadata(path),
|
2023-06-06 22:45:16 +00:00
|
|
|
fetchSize,
|
|
|
|
load,
|
|
|
|
unload,
|
2023-06-07 16:51:18 +00:00
|
|
|
done,
|
|
|
|
failed,
|
2023-06-06 22:45:16 +00:00
|
|
|
progress,
|
|
|
|
get path() {
|
|
|
|
return path;
|
|
|
|
},
|
|
|
|
get total() {
|
|
|
|
return total;
|
|
|
|
},
|
2023-06-07 16:51:18 +00:00
|
|
|
set total(newTotal) {
|
|
|
|
total = newTotal;
|
|
|
|
},
|
2023-06-06 22:45:16 +00:00
|
|
|
get received() {
|
|
|
|
return responsePromise.received;
|
|
|
|
},
|
|
|
|
get isComplete() {
|
|
|
|
return total > 0 && responsePromise.received === total;
|
|
|
|
},
|
|
|
|
get response() {
|
|
|
|
return responsePromise.response;
|
|
|
|
},
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Allows to track a group of file loaders
|
|
|
|
* @param {FileLoader[]} files
|
|
|
|
*/
|
|
|
|
export const makeFileLoadersTracker = (files) => {
|
|
|
|
const done = makeSignal();
|
|
|
|
const progress = makeSignal({ received: 0, total: 0 });
|
|
|
|
let total = 0;
|
|
|
|
let received = 0;
|
|
|
|
let amount = files.length;
|
|
|
|
|
|
|
|
const decrease = () => {
|
|
|
|
if (--amount === 0) {
|
|
|
|
done.emit();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Called every time any file's status changes.
|
|
|
|
* Updates the total and received amounts.
|
|
|
|
*/
|
2023-06-07 16:51:18 +00:00
|
|
|
const update = (props) => {
|
2023-06-06 22:45:16 +00:00
|
|
|
let _total = 0;
|
|
|
|
let _received = 0;
|
2023-06-07 16:51:18 +00:00
|
|
|
files.forEach((fileLoader) => {
|
|
|
|
_total += fileLoader.total;
|
|
|
|
_received += fileLoader.received;
|
2023-06-06 22:45:16 +00:00
|
|
|
});
|
|
|
|
if (total != _total || received != _received) {
|
|
|
|
total = _total;
|
|
|
|
received = _received;
|
|
|
|
progress.emit({ total, received });
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-06-07 16:51:18 +00:00
|
|
|
files.forEach((fileLoader) => {
|
|
|
|
fileLoader.done.connect(decrease);
|
|
|
|
fileLoader.progress.connect(update);
|
2023-06-06 22:45:16 +00:00
|
|
|
});
|
|
|
|
|
2023-06-07 16:51:18 +00:00
|
|
|
/**
|
|
|
|
* Runs `fetchSize` on all files to ensure all have a size
|
|
|
|
*/
|
2023-06-06 22:45:16 +00:00
|
|
|
const ensureSize = () =>
|
|
|
|
Promise.all(files.map(({ fetchSize }) => fetchSize()));
|
|
|
|
|
2023-06-07 16:51:18 +00:00
|
|
|
/**
|
|
|
|
* Loads all files. Loading happens once per file, so if it was called before, this is a
|
|
|
|
* no-op (for each particular file).
|
|
|
|
* If a file does not have a total set, then a small header fetch can optionally happen
|
|
|
|
* to fetch the initial size.
|
|
|
|
* @param {boolean} [andEnsureSize] set it to `false` to skip fetching sizes for files
|
|
|
|
* without a specified total
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
const loadAll = (andEnsureSize = true) =>
|
|
|
|
(andEnsureSize ? ensureSize() : Promise.resolve()).then(() =>
|
|
|
|
Promise.all(files.map(({ load }) => load()))
|
|
|
|
);
|
2023-06-06 22:45:16 +00:00
|
|
|
|
|
|
|
return { done, progress, ensureSize, loadAll };
|
|
|
|
};
|
|
|
|
|
|
|
|
export default makeFileLoader;
|