temp mid-work backup commit
This commit is contained in:
		
							
								
								
									
										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
 | 
			
		||||
		Reference in New Issue
	
	Block a user