//@ts-check import { createTrackedResponse } from "./createTrackedResponse.mjs"; import { decodeContentLength } from "./decodeContentLength.mjs"; import { retryPromise } from "./retryPromise.mjs"; import { makeSignal } from "./makeSignal.mjs"; import { fetchContentLength } from "./fetchContentLength.mjs"; import { metadata } from "./path.mjs"; /** * @typedef {{ * total?: number, * onProgress?:(data:ProgressData)=>void, * signal?: AbortSignal * }} FileLoaderOptions */ /** * @template T * @typedef {import("./makeSignal.mjs").Signal} Signal */ /** * @typedef {ReturnType} 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 } = {} ) => { const progress = makeSignal({ path, received: 0, total: 0 }); /** @type {Signal} */ const done = makeSignal(); /** @type {Signal} */ const failed = makeSignal(); if (onProgress) { progress.connect(onProgress, { signal }); } const createResponse = () => { const response = createTrackedResponse( (received) => progress.emit({ path, received, total }), signal ); response.then(done.emit); response.catch(failed.emit); return response; }; let responsePromise = createResponse(); /** * Retrieves the file size if `total` was not provided. */ const fetchSize = (() => { /** * @type {Promise | null} */ let totalPromise; const fetchSize = () => (totalPromise = total ? Promise.resolve(total) : fetchContentLength(path).then( (fetchedTotal) => (total = fetchedTotal) )); return fetchSize; })(); /** * 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} */ 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 { ...metadata(path), fetchSize, load, unload, done, failed, progress, get path() { return path; }, get total() { return total; }, set total(newTotal) { total = newTotal; }, 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. */ const update = (props) => { let _total = 0; let _received = 0; files.forEach((fileLoader) => { _total += fileLoader.total; _received += fileLoader.received; }); if (total != _total || received != _received) { total = _total; received = _received; progress.emit({ total, received }); } }; files.forEach((fileLoader) => { fileLoader.done.connect(decrease); fileLoader.progress.connect(update); }); /** * Runs `fetchSize` on all files to ensure all have a size */ const ensureSize = () => Promise.all(files.map(({ fetchSize }) => fetchSize())); /** * 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())) ); return { done, progress, ensureSize, loadAll }; }; export default makeFileLoader;