testing moving the functionality into small modules #5

Open
xananax wants to merge 10 commits from split-into-modules into main
16 changed files with 658 additions and 0 deletions
Showing only changes of commit eadca78080 - Show all commits

View 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

View 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;
},
});
};

View 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
View File

@ -0,0 +1,4 @@
interface DeferredPromise<T> extends Promise<T>{
resolve: (value: T) => void
reject: (reason?: any) => void
}

View 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

View 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

View 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;

View File

@ -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
View 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"];
}

View 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

View 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;

View 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;

View 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
View 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

View 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

View 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