temp mid-work backup commit
This commit is contained in:
parent
3d348710e0
commit
eadca78080
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 { changeTitle } from "./changeTitle.mjs";
|
||||||
//import { createCustomElement } from "./createCustomElement.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 { documentMode } from "./documentMode.mjs";
|
||||||
import { documentState } from "./documentState.mjs";
|
import { documentState } from "./documentState.mjs";
|
||||||
|
import { fetchContentLength } from "./fetchContentLength.mjs";
|
||||||
|
import { fetchHeaders } from "./fetchHeaders.mjs";
|
||||||
import { fetchMarkdown } from "./fetchMarkdown.mjs";
|
import { fetchMarkdown } from "./fetchMarkdown.mjs";
|
||||||
import { fetchText } from "./fetchText.mjs";
|
import { fetchText } from "./fetchText.mjs";
|
||||||
import { generateDomFromString } from "./generateDomFromString.mjs";
|
import { generateDomFromString } from "./generateDomFromString.mjs";
|
||||||
@ -20,30 +25,43 @@ import { html } from "./html.mjs";
|
|||||||
import { isExternalUrl } from "./isExternalUrl.mjs";
|
import { isExternalUrl } from "./isExternalUrl.mjs";
|
||||||
import { isLocalHost } from "./isLocalHost.mjs";
|
import { isLocalHost } from "./isLocalHost.mjs";
|
||||||
import { isNotNull } from "./isNotNull.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 { markdownToMarkup } from "./markdownToMarkup.mjs";
|
||||||
import { markupToDom } from "./markupToDom.mjs";
|
import { markupToDom } from "./markupToDom.mjs";
|
||||||
import { memoize } from "./memoize.mjs";
|
import { memoize } from "./memoize.mjs";
|
||||||
|
import { noOp } from "./noOp.mjs";
|
||||||
import { not } from "./not.mjs";
|
import { not } from "./not.mjs";
|
||||||
import {
|
import {
|
||||||
onDocumentKeyUp,
|
onDocumentKeyUp,
|
||||||
onDocumentKeyDown,
|
onDocumentKeyDown,
|
||||||
onDocumentKey,
|
onDocumentKey,
|
||||||
} from "./onDocumentKey.mjs";
|
} from "./onDocumentKey.mjs";
|
||||||
|
import { percentFromProgress } from "./percentFromProgress.mjs";
|
||||||
import { print, makeTemplate } from "./print.mjs";
|
import { print, makeTemplate } from "./print.mjs";
|
||||||
import {
|
import {
|
||||||
querySelectorDoc,
|
querySelectorDoc,
|
||||||
querySelectorParent,
|
querySelectorParent,
|
||||||
querySelectorAll,
|
querySelectorAll,
|
||||||
} from "./querySelectorAll.mjs";
|
} from "./querySelectorAll.mjs";
|
||||||
|
import { retryPromise } from "./retryPromise.mjs";
|
||||||
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
|
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
|
||||||
import { today } from "./today.mjs";
|
import { today } from "./today.mjs";
|
||||||
|
import { UnreachableCaseError } from "./UnreachableCaseError.mjs";
|
||||||
import { wait } from "./wait.mjs";
|
import { wait } from "./wait.mjs";
|
||||||
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
|
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
changeTitle,
|
changeTitle,
|
||||||
|
createTrackedResponse,
|
||||||
|
decodeContentLength,
|
||||||
|
deferredPromise,
|
||||||
documentMode,
|
documentMode,
|
||||||
documentState,
|
documentState,
|
||||||
|
fetchContentLength,
|
||||||
|
fetchHeaders,
|
||||||
fetchMarkdown,
|
fetchMarkdown,
|
||||||
fetchText,
|
fetchText,
|
||||||
generateDomFromString,
|
generateDomFromString,
|
||||||
@ -59,20 +77,29 @@ export {
|
|||||||
isExternalUrl,
|
isExternalUrl,
|
||||||
isLocalHost,
|
isLocalHost,
|
||||||
isNotNull,
|
isNotNull,
|
||||||
|
makeEventEmitter,
|
||||||
|
makeFileLoader,
|
||||||
|
makeFileLoadersTracker,
|
||||||
|
makeFileSizeFetcher,
|
||||||
|
makeSignal,
|
||||||
markdownToMarkup,
|
markdownToMarkup,
|
||||||
markupToDom,
|
markupToDom,
|
||||||
memoize,
|
memoize,
|
||||||
not,
|
not,
|
||||||
|
noOp,
|
||||||
onDocumentKeyUp,
|
onDocumentKeyUp,
|
||||||
onDocumentKeyDown,
|
onDocumentKeyDown,
|
||||||
onDocumentKey,
|
onDocumentKey,
|
||||||
|
percentFromProgress,
|
||||||
print,
|
print,
|
||||||
makeTemplate,
|
makeTemplate,
|
||||||
querySelectorDoc,
|
querySelectorDoc,
|
||||||
querySelectorParent,
|
querySelectorParent,
|
||||||
querySelectorAll,
|
querySelectorAll,
|
||||||
|
retryPromise,
|
||||||
rewriteLocalUrls,
|
rewriteLocalUrls,
|
||||||
today,
|
today,
|
||||||
|
UnreachableCaseError,
|
||||||
wait,
|
wait,
|
||||||
waitIfLocalHost,
|
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