diff --git a/modules/utils/UnreachableCaseError.mjs b/modules/utils/UnreachableCaseError.mjs new file mode 100644 index 0000000..901faf6 --- /dev/null +++ b/modules/utils/UnreachableCaseError.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/createTrackedResponse.mjs b/modules/utils/createTrackedResponse.mjs new file mode 100644 index 0000000..4e59171 --- /dev/null +++ b/modules/utils/createTrackedResponse.mjs @@ -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>} */ + let readerPromise = deferredPromise(); + /** @type {DeferredPromise} */ + 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; + }, + }); +}; diff --git a/modules/utils/decodeContentLength.mjs b/modules/utils/decodeContentLength.mjs new file mode 100644 index 0000000..deee6ef --- /dev/null +++ b/modules/utils/decodeContentLength.mjs @@ -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; diff --git a/modules/utils/deferredPromise.d.ts b/modules/utils/deferredPromise.d.ts new file mode 100644 index 0000000..e69498d --- /dev/null +++ b/modules/utils/deferredPromise.d.ts @@ -0,0 +1,4 @@ +interface DeferredPromise extends Promise{ + resolve: (value: T) => void + reject: (reason?: any) => void +} \ No newline at end of file diff --git a/modules/utils/deferredPromise.mjs b/modules/utils/deferredPromise.mjs new file mode 100644 index 0000000..51980e8 --- /dev/null +++ b/modules/utils/deferredPromise.mjs @@ -0,0 +1,27 @@ +//@ts-check +/// + +/** + * Returns a promise that can be resolved externally. + * @template {any} DeferredPromiseType + * @returns {DeferredPromise} + */ +export const deferredPromise = () => { + /** @type {(value: DeferredPromiseType) => void} */ + let resolve + /** @type {(reason?: any) => void} */ + let reject; + + /** + * @type {Promise} + */ + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + // @ts-ignore + return Object.assign(promise, {resolve, reject}); +} + +export default DeferredPromise \ No newline at end of file diff --git a/modules/utils/fetchContentLength.mjs b/modules/utils/fetchContentLength.mjs new file mode 100644 index 0000000..35a89f8 --- /dev/null +++ b/modules/utils/fetchContentLength.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/fetchHeaders.mjs b/modules/utils/fetchHeaders.mjs new file mode 100644 index 0000000..8b6b54b --- /dev/null +++ b/modules/utils/fetchHeaders.mjs @@ -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; diff --git a/modules/utils/index.mjs b/modules/utils/index.mjs index 47a8d5f..d75e474 100644 --- a/modules/utils/index.mjs +++ b/modules/utils/index.mjs @@ -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, }; diff --git a/modules/utils/makeEventEmitter.d.ts b/modules/utils/makeEventEmitter.d.ts new file mode 100644 index 0000000..7b5e04d --- /dev/null +++ b/modules/utils/makeEventEmitter.d.ts @@ -0,0 +1,47 @@ +/** + * Base type that a CustomEventEmitter may receive as a generic type + * to discriminate possible events. + */ +type CustomEventMap = Record; + +/** + * 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 = string & keyof EvtMap; + +/** + * Any function or object that can be used to listen to an event. + */ +type CustomEventListenerOrEventListenerObject = + | { handleEvent(event: CustomEvent): void } + | ((event: CustomEvent) => void); + +/** + * An event emitter that can be mixed in to other objects. + */ +interface CustomEventEmitter { + addEventListener>( + eventName: K, + fn: CustomEventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean + ): void; + removeEventListener>( + eventName: K, + fn: CustomEventListenerOrEventListenerObject, + options?: EventListenerOptions | boolean + ): void; + dispatchEvent>( + eventName: K, + detail: EvtMap[K] + ): void; + signal: AbortSignal; + abort: AbortController["abort"]; +} \ No newline at end of file diff --git a/modules/utils/makeEventEmitter.mjs b/modules/utils/makeEventEmitter.mjs new file mode 100644 index 0000000..2b38ffe --- /dev/null +++ b/modules/utils/makeEventEmitter.mjs @@ -0,0 +1,55 @@ +//@ts-check +/// + +/** + * 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} + */ +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["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 \ No newline at end of file diff --git a/modules/utils/makeFileLoader.mjs b/modules/utils/makeFileLoader.mjs new file mode 100644 index 0000000..756f6a2 --- /dev/null +++ b/modules/utils/makeFileLoader.mjs @@ -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} 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} + */ + 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; diff --git a/modules/utils/makeFileSizeFetcher.mjs b/modules/utils/makeFileSizeFetcher.mjs new file mode 100644 index 0000000..d32dd1c --- /dev/null +++ b/modules/utils/makeFileSizeFetcher.mjs @@ -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} a function that always returns the same promise + */ +export const makeFileSizeFetcher = (filePath, estimatedTotal = 0) => { + /** + * @type {Promise | null} + */ + let totalPromise = estimatedTotal ? Promise.resolve(estimatedTotal) : null; + + const fetchSize = () => + (totalPromise = + totalPromise || + fetchContentLength(filePath).then( + (fetchedTotal) => (estimatedTotal = fetchedTotal) + )); + + return fetchSize; +}; + +export default makeFileSizeFetcher; diff --git a/modules/utils/makeSignal.mjs b/modules/utils/makeSignal.mjs new file mode 100644 index 0000000..ea8a68d --- /dev/null +++ b/modules/utils/makeSignal.mjs @@ -0,0 +1,59 @@ +//@ts-check + +/** + * @template T + * @typedef {(args:T) => void} Listener + */ +/** + * @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>} */ + const listeners = new Set(); + let enabled = true + /** + * + * @param {Listener} 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} 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 \ No newline at end of file diff --git a/modules/utils/noOp.mjs b/modules/utils/noOp.mjs new file mode 100644 index 0000000..8c185b1 --- /dev/null +++ b/modules/utils/noOp.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/percentFromProgress.mjs b/modules/utils/percentFromProgress.mjs new file mode 100644 index 0000000..447cca2 --- /dev/null +++ b/modules/utils/percentFromProgress.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/retryPromise.mjs b/modules/utils/retryPromise.mjs new file mode 100644 index 0000000..d52b6b2 --- /dev/null +++ b/modules/utils/retryPromise.mjs @@ -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} promiseProviderFunc + * @param {number} [max] + * @returns {Promise} + */ +export const retryPromise = (promiseProviderFunc, max = 5) => { + if(max <= 0){ + return promiseProviderFunc() + } + /** @type {Promise} */ + let promise = Promise.reject(); + + for (let i = 0; i < max; i++) { + promise = promise.catch(promiseProviderFunc); + } + return promise; +}; + +export default retryPromise \ No newline at end of file