testing moving the functionality into small modules #5
32
modules/utils/UnreachableCaseError.mjs
Normal file
32
modules/utils/UnreachableCaseError.mjs
Normal file
@ -0,0 +1,32 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Utility: use it at the end of switch statements to make sure all matches are covered.
|
||||
* Only useful in an editor that supports JSDOC strong typing.
|
||||
*
|
||||
* Use it like so
|
||||
* ```js
|
||||
* switch(data):
|
||||
* case A:
|
||||
* doThing();
|
||||
* break;
|
||||
* case B:
|
||||
* doOtherThing();
|
||||
* break;
|
||||
* default:
|
||||
* throw new UnreachableCaseError(state);
|
||||
* ```
|
||||
* It `data` may be more options, then `state` will be underlined with the error
|
||||
* ```
|
||||
* Argument of type 'T' is not assignable to parameter of type 'never'
|
||||
* ```
|
||||
* Where `T` is the type of `data`.
|
||||
* To remove the error, handle all cases.
|
||||
*/
|
||||
export class UnreachableCaseError extends Error {
|
||||
constructor(/** @type {never} */ value) {
|
||||
super(`Unreachable case: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default UnreachableCaseError
|
93
modules/utils/createTrackedResponse.mjs
Normal file
93
modules/utils/createTrackedResponse.mjs
Normal file
@ -0,0 +1,93 @@
|
||||
//@ts-check
|
||||
import { deferredPromise } from "./deferredPromise.mjs";
|
||||
|
||||
/**
|
||||
* Creates a response object that can dispatch progress.
|
||||
* @param {(received: number) => void} onProgress
|
||||
* @param {AbortSignal} [signal]
|
||||
* @param {number} [maxPackets] a maximum amount of packets to receive
|
||||
*/
|
||||
export const createTrackedResponse = (
|
||||
onProgress,
|
||||
signal,
|
||||
maxPackets = 99999
|
||||
) => {
|
||||
/** @type {DeferredPromise<ReadableStreamDefaultReader<Uint8Array>>} */
|
||||
let readerPromise = deferredPromise();
|
||||
/** @type {DeferredPromise<Response>} */
|
||||
const successPromise = deferredPromise();
|
||||
|
||||
let received = 0;
|
||||
let started = false;
|
||||
let failed = false;
|
||||
let success = false;
|
||||
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const onError = (/** @type {Error} */ error) => {
|
||||
failed = true;
|
||||
success = false;
|
||||
controller.close();
|
||||
controller.error(error);
|
||||
successPromise.reject(error);
|
||||
};
|
||||
const onSuccess = () => {
|
||||
failed = false;
|
||||
success = true;
|
||||
successPromise.resolve(response);
|
||||
};
|
||||
signal &&
|
||||
signal.addEventListener("abort", () =>
|
||||
onError(new Error(`Stream aborted`))
|
||||
);
|
||||
try {
|
||||
const reader = await readerPromise;
|
||||
started = true;
|
||||
try {
|
||||
while (true && maxPackets-- > 0) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
onProgress(received);
|
||||
break;
|
||||
}
|
||||
received += value.byteLength;
|
||||
controller.enqueue(value);
|
||||
onProgress(received);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
} catch (readerError) {
|
||||
onError(readerError);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const start = readerPromise.resolve;
|
||||
|
||||
return Object.assign(successPromise, {
|
||||
start,
|
||||
get response() {
|
||||
return response;
|
||||
},
|
||||
get received() {
|
||||
return received;
|
||||
},
|
||||
get isStarted() {
|
||||
return started;
|
||||
},
|
||||
get isFailed() {
|
||||
return failed;
|
||||
},
|
||||
get isSuccess() {
|
||||
return success;
|
||||
},
|
||||
get isUnknown() {
|
||||
return success === false && failed === false;
|
||||
},
|
||||
});
|
||||
};
|
20
modules/utils/decodeContentLength.mjs
Normal file
20
modules/utils/decodeContentLength.mjs
Normal file
@ -0,0 +1,20 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Does a best guess attempt at finding out the size of a file from a request headers.
|
||||
* To access headers, server must send CORS header
|
||||
* `Access-Control-Expose-Headers: content-encoding, content-length x-file-size`
|
||||
* server must send the custom `x-file-size` header if gzip or other content-encoding is used.
|
||||
* @param {Headers} headers
|
||||
*/
|
||||
export const decodeContentLength = (headers) => {
|
||||
const contentEncoding = headers.get("content-encoding");
|
||||
const contentLength =
|
||||
headers.get(contentEncoding ? "x-file-size" : "content-length") ||
|
||||
(headers.has("content-range") &&
|
||||
/**@type {string}*/ (headers.get("content-range")).split("/")[1]) ||
|
||||
"0";
|
||||
return parseInt(contentLength, 10);
|
||||
};
|
||||
|
||||
export default decodeContentLength;
|
4
modules/utils/deferredPromise.d.ts
vendored
Normal file
4
modules/utils/deferredPromise.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
interface DeferredPromise<T> extends Promise<T>{
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: any) => void
|
||||
}
|
27
modules/utils/deferredPromise.mjs
Normal file
27
modules/utils/deferredPromise.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
//@ts-check
|
||||
/// <reference path="./deferredPromise.d.ts"/>
|
||||
|
||||
/**
|
||||
* Returns a promise that can be resolved externally.
|
||||
* @template {any} DeferredPromiseType
|
||||
* @returns {DeferredPromise<DeferredPromiseType>}
|
||||
*/
|
||||
export const deferredPromise = () => {
|
||||
/** @type {(value: DeferredPromiseType) => void} */
|
||||
let resolve
|
||||
/** @type {(reason?: any) => void} */
|
||||
let reject;
|
||||
|
||||
/**
|
||||
* @type {Promise<DeferredPromiseType>}
|
||||
*/
|
||||
const promise = new Promise((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
return Object.assign(promise, {resolve, reject});
|
||||
}
|
||||
|
||||
export default DeferredPromise
|
14
modules/utils/fetchContentLength.mjs
Normal file
14
modules/utils/fetchContentLength.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
//@ts-check
|
||||
import {decodeContentLength} from './decodeContentLength.mjs'
|
||||
import {fetchHeaders} from './fetchHeaders.mjs'
|
||||
|
||||
/**
|
||||
* Attempts to retrieve the size of an object represented by a URL with a
|
||||
* limited fetch request.
|
||||
* @see {decodeContentLength}
|
||||
* @param {string} path
|
||||
*/
|
||||
export const fetchContentLength = (path) =>
|
||||
fetchHeaders(path).then(decodeContentLength);
|
||||
|
||||
export default fetchContentLength
|
22
modules/utils/fetchHeaders.mjs
Normal file
22
modules/utils/fetchHeaders.mjs
Normal file
@ -0,0 +1,22 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Limited fetch request that retrieves only headers.
|
||||
* @param {string} path
|
||||
*/
|
||||
export const fetchHeaders = async (path) => {
|
||||
const response = await fetch(path, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
Range: "bytes=0-0",
|
||||
"X-HTTP-Method-Override": "HEAD",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed loading file '${path}'`);
|
||||
}
|
||||
return response.headers;
|
||||
};
|
||||
|
||||
export default fetchHeaders;
|
@ -2,8 +2,13 @@
|
||||
|
||||
import { changeTitle } from "./changeTitle.mjs";
|
||||
//import { createCustomElement } from "./createCustomElement.mjs";
|
||||
import { createTrackedResponse } from "./createTrackedResponse.mjs";
|
||||
import { decodeContentLength } from "./decodeContentLength.mjs";
|
||||
import { deferredPromise } from "./deferredPromise.mjs";
|
||||
import { documentMode } from "./documentMode.mjs";
|
||||
import { documentState } from "./documentState.mjs";
|
||||
import { fetchContentLength } from "./fetchContentLength.mjs";
|
||||
import { fetchHeaders } from "./fetchHeaders.mjs";
|
||||
import { fetchMarkdown } from "./fetchMarkdown.mjs";
|
||||
import { fetchText } from "./fetchText.mjs";
|
||||
import { generateDomFromString } from "./generateDomFromString.mjs";
|
||||
@ -20,30 +25,43 @@ import { html } from "./html.mjs";
|
||||
import { isExternalUrl } from "./isExternalUrl.mjs";
|
||||
import { isLocalHost } from "./isLocalHost.mjs";
|
||||
import { isNotNull } from "./isNotNull.mjs";
|
||||
import { makeEventEmitter } from "./makeEventEmitter.mjs";
|
||||
import { makeFileLoader, makeFileLoadersTracker } from "./makeFileLoader.mjs";
|
||||
import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs";
|
||||
import { makeSignal } from "./makeSignal.mjs";
|
||||
import { markdownToMarkup } from "./markdownToMarkup.mjs";
|
||||
import { markupToDom } from "./markupToDom.mjs";
|
||||
import { memoize } from "./memoize.mjs";
|
||||
import { noOp } from "./noOp.mjs";
|
||||
import { not } from "./not.mjs";
|
||||
import {
|
||||
onDocumentKeyUp,
|
||||
onDocumentKeyDown,
|
||||
onDocumentKey,
|
||||
} from "./onDocumentKey.mjs";
|
||||
import { percentFromProgress } from "./percentFromProgress.mjs";
|
||||
import { print, makeTemplate } from "./print.mjs";
|
||||
import {
|
||||
querySelectorDoc,
|
||||
querySelectorParent,
|
||||
querySelectorAll,
|
||||
} from "./querySelectorAll.mjs";
|
||||
import { retryPromise } from "./retryPromise.mjs";
|
||||
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
|
||||
import { today } from "./today.mjs";
|
||||
import { UnreachableCaseError } from "./UnreachableCaseError.mjs";
|
||||
import { wait } from "./wait.mjs";
|
||||
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
|
||||
|
||||
export {
|
||||
changeTitle,
|
||||
createTrackedResponse,
|
||||
decodeContentLength,
|
||||
deferredPromise,
|
||||
documentMode,
|
||||
documentState,
|
||||
fetchContentLength,
|
||||
fetchHeaders,
|
||||
fetchMarkdown,
|
||||
fetchText,
|
||||
generateDomFromString,
|
||||
@ -59,20 +77,29 @@ export {
|
||||
isExternalUrl,
|
||||
isLocalHost,
|
||||
isNotNull,
|
||||
makeEventEmitter,
|
||||
makeFileLoader,
|
||||
makeFileLoadersTracker,
|
||||
makeFileSizeFetcher,
|
||||
makeSignal,
|
||||
markdownToMarkup,
|
||||
markupToDom,
|
||||
memoize,
|
||||
not,
|
||||
noOp,
|
||||
onDocumentKeyUp,
|
||||
onDocumentKeyDown,
|
||||
onDocumentKey,
|
||||
percentFromProgress,
|
||||
print,
|
||||
makeTemplate,
|
||||
querySelectorDoc,
|
||||
querySelectorParent,
|
||||
querySelectorAll,
|
||||
retryPromise,
|
||||
rewriteLocalUrls,
|
||||
today,
|
||||
UnreachableCaseError,
|
||||
wait,
|
||||
waitIfLocalHost,
|
||||
};
|
||||
|
47
modules/utils/makeEventEmitter.d.ts
vendored
Normal file
47
modules/utils/makeEventEmitter.d.ts
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Base type that a CustomEventEmitter may receive as a generic type
|
||||
* to discriminate possible events.
|
||||
*/
|
||||
type CustomEventMap = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Extracts the keys from a CustomEventMap so they can be used as event types.
|
||||
* For example, for the CustomEventMap
|
||||
* ```ts
|
||||
* {
|
||||
* "longpress": {position: number[]}
|
||||
* "shortpress": {position: number[]}
|
||||
* }
|
||||
* ```
|
||||
* This type will infer `"longpress" | "shortpress"`.
|
||||
*/
|
||||
type CustomEventKey<EvtMap extends CustomEventMap> = string & keyof EvtMap;
|
||||
|
||||
/**
|
||||
* Any function or object that can be used to listen to an event.
|
||||
*/
|
||||
type CustomEventListenerOrEventListenerObject<T> =
|
||||
| { handleEvent(event: CustomEvent<T>): void }
|
||||
| ((event: CustomEvent<T>) => void);
|
||||
|
||||
/**
|
||||
* An event emitter that can be mixed in to other objects.
|
||||
*/
|
||||
interface CustomEventEmitter<EvtMap extends CustomEventMap> {
|
||||
addEventListener<K extends CustomEventKey<EvtMap>>(
|
||||
eventName: K,
|
||||
fn: CustomEventListenerOrEventListenerObject<EvtMap[K]>,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
): void;
|
||||
removeEventListener<K extends CustomEventKey<EvtMap>>(
|
||||
eventName: K,
|
||||
fn: CustomEventListenerOrEventListenerObject<EvtMap[K]>,
|
||||
options?: EventListenerOptions | boolean
|
||||
): void;
|
||||
dispatchEvent<K extends CustomEventKey<EvtMap>>(
|
||||
eventName: K,
|
||||
detail: EvtMap[K]
|
||||
): void;
|
||||
signal: AbortSignal;
|
||||
abort: AbortController["abort"];
|
||||
}
|
55
modules/utils/makeEventEmitter.mjs
Normal file
55
modules/utils/makeEventEmitter.mjs
Normal file
@ -0,0 +1,55 @@
|
||||
//@ts-check
|
||||
/// <reference path="makeEventEmitter.d.ts"/>
|
||||
|
||||
/**
|
||||
* Returns a native browser event target that is properly typed.
|
||||
* Three major differences with a classical event target:
|
||||
*
|
||||
* 1. The emitter's methods are bound and can be passed to other objects
|
||||
* 2. The emitter has an `abort` property and a `signal` property that can be
|
||||
* used to abort all listeners (you have to explicitely pass it though, it's
|
||||
* not automatic)
|
||||
* 3. `dispatchEvent` has a different signature `(type, event)` rather than just
|
||||
* `event`. This is because there is no way to enforce a string & details
|
||||
* tuple on a CustomEvent using Typescript or JSDocs.
|
||||
* @template {CustomEventMap} EvtMap
|
||||
* @returns {CustomEventEmitter<EvtMap>}
|
||||
*/
|
||||
export const makeEventEmitter = () => {
|
||||
let abortController = new AbortController();
|
||||
const eventEmitter = new EventTarget();
|
||||
const addEventListener = eventEmitter.addEventListener.bind(eventEmitter);
|
||||
const removeEventListener =
|
||||
eventEmitter.removeEventListener.bind(eventEmitter);
|
||||
|
||||
/**
|
||||
* Dispatches a custom event to all listeners of that event.
|
||||
* @type {CustomEventEmitter<EvtMap>["dispatchEvent"]}
|
||||
*/
|
||||
const dispatchEvent = (type, detail) => {
|
||||
const event = new CustomEvent(type, { detail });
|
||||
eventEmitter.dispatchEvent(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Aborts any eventListener, fetch, or other process that received the signal.
|
||||
* resets the abort controller and signal (they are new instances)
|
||||
* @param {any} reason
|
||||
*/
|
||||
const abort = (reason) => {
|
||||
abortController.abort(reason);
|
||||
abortController = new AbortController();
|
||||
};
|
||||
|
||||
return {
|
||||
dispatchEvent,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
abort,
|
||||
get signal() {
|
||||
return abortController.signal;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default makeEventEmitter
|
177
modules/utils/makeFileLoader.mjs
Normal file
177
modules/utils/makeFileLoader.mjs
Normal file
@ -0,0 +1,177 @@
|
||||
//@ts-check
|
||||
|
||||
import { createTrackedResponse } from "./createTrackedResponse.mjs";
|
||||
import { decodeContentLength } from "./decodeContentLength.mjs";
|
||||
import { retryPromise } from "./retryPromise.mjs";
|
||||
import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs";
|
||||
import { makeSignal } from "./makeSignal.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* total?: number,
|
||||
* onProgress?:(data:ProgressData)=>void,
|
||||
* signal?: AbortSignal
|
||||
* }} FileLoaderOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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 } = {}
|
||||
) => {
|
||||
const progress = makeSignal({ received: 0, total: 0 });
|
||||
|
||||
if (onProgress) {
|
||||
progress.connect(onProgress, { signal });
|
||||
}
|
||||
|
||||
const createResponse = () =>
|
||||
createTrackedResponse(
|
||||
(received) => progress.emit({ received, total }),
|
||||
signal
|
||||
);
|
||||
|
||||
let responsePromise = createResponse();
|
||||
|
||||
/**
|
||||
* Retrieves the file size if `total` was not provided.
|
||||
*/
|
||||
const fetchSize = makeFileSizeFetcher(path, total);
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
fetchSize,
|
||||
load,
|
||||
unload,
|
||||
progress,
|
||||
get path() {
|
||||
return path;
|
||||
},
|
||||
get total() {
|
||||
return total;
|
||||
},
|
||||
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 = () => {
|
||||
let _total = 0;
|
||||
let _received = 0;
|
||||
|
||||
files.forEach((thing) => {
|
||||
_total += thing.total;
|
||||
_received += thing.received;
|
||||
console.log(thing.path, thing.received, thing.total);
|
||||
});
|
||||
if (total != _total || received != _received) {
|
||||
total = _total;
|
||||
received = _received;
|
||||
progress.emit({ total, received });
|
||||
}
|
||||
};
|
||||
|
||||
files.forEach((loader) => {
|
||||
loader.then(decrease);
|
||||
loader.progress.connect(update);
|
||||
});
|
||||
|
||||
const ensureSize = () =>
|
||||
Promise.all(files.map(({ fetchSize }) => fetchSize()));
|
||||
|
||||
const loadAll = () => Promise.all(files.map(({ load }) => load()));
|
||||
|
||||
return { done, progress, ensureSize, loadAll };
|
||||
};
|
||||
|
||||
export default makeFileLoader;
|
39
modules/utils/makeFileSizeFetcher.mjs
Normal file
39
modules/utils/makeFileSizeFetcher.mjs
Normal file
@ -0,0 +1,39 @@
|
||||
//@ts-check
|
||||
|
||||
import { fetchContentLength } from "./fetchContentLength.mjs";
|
||||
|
||||
/**
|
||||
* Conditional size fetcher, which can be skipped by providing an intial size.
|
||||
* This function is only useful when used as part of a larger loading framework, when
|
||||
* files loading may or may not have access to size information, and you want a
|
||||
* consistent behavior regardless.
|
||||
*
|
||||
* If `estimatedTotal` is passed, this is a no-op.
|
||||
* If `estimatedTotal` is not passed, the created function does a limited `fetch`
|
||||
* to attempt to retrieve the file size.
|
||||
* Repeated calls to the function will not repeat the fetch request.
|
||||
* The function is not guaranteed to succeed, the server has to play along by
|
||||
* sending the correct headers.
|
||||
* Ideally, `total` is passed instead to avoid this.
|
||||
* @see {fetchContentLength} decodeContentLength
|
||||
* @param {string} filePath
|
||||
* @param {number} estimatedTotal
|
||||
* @returns {()=>Promise<number>} a function that always returns the same promise
|
||||
*/
|
||||
export const makeFileSizeFetcher = (filePath, estimatedTotal = 0) => {
|
||||
/**
|
||||
* @type {Promise<number> | null}
|
||||
*/
|
||||
let totalPromise = estimatedTotal ? Promise.resolve(estimatedTotal) : null;
|
||||
|
||||
const fetchSize = () =>
|
||||
(totalPromise =
|
||||
totalPromise ||
|
||||
fetchContentLength(filePath).then(
|
||||
(fetchedTotal) => (estimatedTotal = fetchedTotal)
|
||||
));
|
||||
|
||||
return fetchSize;
|
||||
};
|
||||
|
||||
export default makeFileSizeFetcher;
|
59
modules/utils/makeSignal.mjs
Normal file
59
modules/utils/makeSignal.mjs
Normal file
@ -0,0 +1,59 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {(args:T) => void} Listener<T>
|
||||
*/
|
||||
/**
|
||||
* @typedef {{signal?: AbortSignal, once?: boolean}} ListenerOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns an event emitter for a specific signal
|
||||
* The initial passed value is optional, discarded, and only used to provide
|
||||
* automatic typing where applicable.
|
||||
* @template T
|
||||
* @param {T} [_initial]
|
||||
* @returns
|
||||
*/
|
||||
export const makeSignal = (_initial) => {
|
||||
/** @type {Set<Listener<T>>} */
|
||||
const listeners = new Set();
|
||||
let enabled = true
|
||||
/**
|
||||
*
|
||||
* @param {Listener<T>} fn
|
||||
* @param {ListenerOptions} [options]
|
||||
*/
|
||||
const connect = (fn, { once, signal } = {}) => {
|
||||
if (once) {
|
||||
const _bound = fn;
|
||||
fn = (args) => {
|
||||
listeners.delete(fn);
|
||||
_bound(args);
|
||||
};
|
||||
}
|
||||
listeners.add(fn);
|
||||
const _disconnect = () => disconnect(fn);
|
||||
signal && signal.addEventListener("abort", _disconnect);
|
||||
return _disconnect;
|
||||
};
|
||||
/**
|
||||
* @param {Listener<T>} fn
|
||||
* @returns
|
||||
*/
|
||||
const disconnect = (fn) => listeners.delete(fn);
|
||||
|
||||
/**
|
||||
* @param {T} [args]
|
||||
* @returns
|
||||
*/
|
||||
// @ts-ignore
|
||||
const emit = (args) => enabled && listeners.forEach((fn) => fn(args));
|
||||
|
||||
const disable = () => {enabled = false}
|
||||
|
||||
return { connect, disconnect, emit, disable };
|
||||
};
|
||||
|
||||
export default makeSignal
|
8
modules/utils/noOp.mjs
Normal file
8
modules/utils/noOp.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Does absolutely nothing. Use it when you need a function that does nothing
|
||||
* at all.
|
||||
*/
|
||||
export const noOp = () => {}
|
||||
export default noOp
|
10
modules/utils/percentFromProgress.mjs
Normal file
10
modules/utils/percentFromProgress.mjs
Normal file
@ -0,0 +1,10 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Returns a formatted percent from a given fraction.
|
||||
* @param {number} fraction any fractional number, e.g, 5/10
|
||||
*/
|
||||
export const percentFromProgress = (fraction) =>
|
||||
/** @type {`${string}%`} */ (Math.round(fraction * 100) + "%");
|
||||
|
||||
export default percentFromProgress
|
24
modules/utils/retryPromise.mjs
Normal file
24
modules/utils/retryPromise.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
//@ts-check
|
||||
|
||||
/**
|
||||
* Retries a promise N times, allowing it to fail by silently swallowing
|
||||
* errors, until `N` has run out.
|
||||
* @template {any} T
|
||||
* @param {()=>Promise<T>} promiseProviderFunc
|
||||
* @param {number} [max]
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export const retryPromise = (promiseProviderFunc, max = 5) => {
|
||||
if(max <= 0){
|
||||
return promiseProviderFunc()
|
||||
}
|
||||
/** @type {Promise<T>} */
|
||||
let promise = Promise.reject();
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
promise = promise.catch(promiseProviderFunc);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
export default retryPromise
|
Loading…
Reference in New Issue
Block a user