tickle/modules/utils/makeFileLoader.mjs

225 lines
5.9 KiB
JavaScript
Raw Permalink Normal View History

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;