UNPKG

@ayonli/jsext

Version:

A JavaScript extension package for building strong and modern applications.

447 lines (408 loc) 12.5 kB
/** * Functions for async/promise context handling. * @module */ import { unrefTimer } from "./runtime.ts"; import { Exception, TimeoutError } from "./error.ts"; /** A promise that can be resolved or rejected manually. */ export type AsyncTask<T> = Promise<T> & { resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: any) => void; }; /** * Creates a promise that can be resolved or rejected manually. * * This function is like `Promise.withResolvers` but less verbose. * * @example * ```ts * import { asyncTask } from "@ayonli/jsext/async"; * * const task = asyncTask<number>(); * * setTimeout(() => task.resolve(42), 1000); * * const result = await task; * console.log(result); // 42 * ``` */ export function asyncTask<T>(): AsyncTask<T> { let resolve: (value: T | PromiseLike<T>) => void; let reject: (reason?: any) => void; const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }) as AsyncTask<T>; return Object.assign(promise, { resolve: resolve!, reject: reject! }); } /** * Wraps an async iterable object with an abort signal. * * @example * ```ts * import { abortable, sleep } from "@ayonli/jsext/async"; * * async function* generate() { * yield "Hello"; * await sleep(1000); * yield "World"; * } * * const iterator = generate(); * const controller = new AbortController(); * * setTimeout(() => controller.abort(), 100); * * // prints "Hello" and throws AbortError after 100ms * for await (const value of abortable(iterator, controller.signal)) { * console.log(value); // "Hello" * } * ``` */ export function abortable<T>(task: AsyncIterable<T>, signal: AbortSignal): AsyncIterable<T>; /** * Try to resolve the promise with an abort signal. * * **NOTE:** This function does not cancel the task itself, it only prematurely * breaks the current routine when the signal is aborted. In order to support * cancellation, the task must be designed to handle the abort signal itself. * * @deprecated This signature is confusing and doesn't actually cancel the task, * use {@link select} instead. * * @example * ```ts * import { abortable, sleep } from "@ayonli/jsext/async"; * * const task = sleep(1000); * const controller = new AbortController(); * * setTimeout(() => controller.abort(), 100); * * await abortable(task, controller.signal); // throws AbortError after 100ms * ``` */ export function abortable<T>(task: PromiseLike<T>, signal: AbortSignal): Promise<T>; export function abortable<T>( task: PromiseLike<T> | AsyncIterable<T>, signal: AbortSignal ): Promise<T> | AsyncIterable<T> { if (typeof (task as any)[Symbol.asyncIterator] === "function" && typeof (task as any).then !== "function" // this skips ThenableAsyncGenerator ) { return abortableAsyncIterable(task as AsyncIterable<T>, signal); } else { return select([task as PromiseLike<T>], signal); } } async function* abortableAsyncIterable<T>( task: AsyncIterable<T>, signal: AbortSignal ): AsyncIterable<T> { if (signal.aborted) { throw signal.reason; } const aTask = asyncTask<never>(); const handleAbort = () => aTask.reject(signal.reason); signal.addEventListener("abort", handleAbort, { once: true }); const it = task[Symbol.asyncIterator](); while (true) { const race = Promise.race([it.next(), aTask]); race.catch(() => { signal.removeEventListener("abort", handleAbort); }); const { done, value } = await race; if (done) { signal.removeEventListener("abort", handleAbort); return value; // async iterator may return a value } else { yield value; } } } function createTimeoutError(ms: number): DOMException | Exception { return new TimeoutError(`Operation timeout after ${ms}ms`); } /** * Try to resolve the promise with a timeout limit. * * **NOTE:** It is recommended to use the `AbortSignal.timeout` API whenever * possible, as it is more efficient and allows for cancellation of the task * itself. This function is mainly for compatibility with existing code that * does not support abort signals. * * @example * ```ts * import { timeout, sleep } from "@ayonli/jsext/async"; * * const task = sleep(1000); * * await timeout(task, 500); // throws TimeoutError after 500ms * ``` */ export async function timeout<T>(task: PromiseLike<T>, ms: number): Promise<T> { const result = await Promise.race([ task, new Promise<T>((_, reject) => unrefTimer(setTimeout(() => { reject(createTimeoutError(ms)); }, ms))) ]); return result; } /** * Slows down and resolves the promise only after the given duration. * * @example * ```ts * import { pace } from "@ayonli/jsext/async"; * * const task = fetch("https://example.com") * const res = await pace(task, 1000); * * console.log(res); // the response will not be printed unless 1 second has passed * ``` */ export async function pace<T>(task: PromiseLike<T>, ms: number): Promise<T> { const [result] = await Promise.allSettled([ task, new Promise<void>(resolve => setTimeout(resolve, ms)) ]); if (result.status === "fulfilled") { return result.value; } else { throw result.reason; } } /** * @deprecated Use {@link pace} instead. */ export const after = pace; /** * Blocks the context for a given duration. * * @example * ```ts * import { sleep } from "@ayonli/jsext/async"; * * console.log("Hello"); * * await sleep(1000); * console.log("World"); // "World" will be printed after 1 second * ``` */ export async function sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Blocks the current routine until the test returns a truthy value, which is * not `false`, `null` or `undefined`. If the test throws an error, it will be * treated as a falsy value and the check continues. * * This functions returns the same result as the test function when passed. * * @example * ```ts * import { until } from "@ayonli/jsext/async"; * * // wait for the header element to be present in the DOM * const ele = await until(() => document.querySelector("header")); * ``` */ export async function until<T>( test: () => T | PromiseLike<T> ): Promise<T extends false | null | undefined ? never : T> { return new Promise((resolve) => { let ongoing = false; const timer = setInterval(async () => { if (ongoing) return; try { ongoing = true; const result = await test(); if (result !== false && result !== null && result !== undefined) { clearInterval(timer); resolve(result as any); } } catch { // ignore } finally { ongoing = false; } }, 1); }); } /** * Runs multiple tasks concurrently and returns the result of the first task that * completes. The rest of the tasks will be aborted. * * @param tasks An array of promises or functions that return promises. * @param signal A parent abort signal, if provided and aborted before any task * completes, the function will reject immediately with the abort reason and * cancel all tasks. * * @example * ```ts * // fetch example * import { select } from "@ayonli/jsext/async"; * * const res = await select([ * signal => fetch("https://example.com", { signal }), * signal => fetch("https://example.org", { signal }), * fetch("https://example.net"), // This task cannot actually be aborted, but ignored * ]); * * console.log(res); // the response from the first completed fetch * ``` * * @example * ```ts * // with parent signal * import { select } from "@ayonli/jsext/async"; * * const signal = AbortSignal.timeout(1000); * * try { * const res = await select([ * signal => fetch("https://example.com", { signal }), * signal => fetch("https://example.org", { signal }), * ], signal); * } catch (err) { * if ((err as Error).name === "TimeoutError") { * console.error(err); // Error: signal timed out * } else { * throw err; * } * } * ``` */ export async function select<T>( tasks: (PromiseLike<T> | ((signal: AbortSignal) => PromiseLike<T>))[], signal: AbortSignal | undefined = undefined ): Promise<T> { if (signal?.aborted) { throw signal.reason; } if (signal) { tasks = [ ...tasks, new Promise((_, reject) => { signal.addEventListener("abort", () => { reject(signal.reason); }, { once: true }); }) ]; } const controllers = new Map<number, AbortController>(); const result = await Promise.race(tasks.map((task, index) => { if (typeof task === "function") { const ctrl = new AbortController(); controllers.set(index, ctrl); task = task(ctrl.signal); } return Promise.resolve(task) .then(value => ({ index, value })) .catch(reason => ({ index, reason })); })); for (const [index, ctrl] of controllers) { if (index !== result.index) { ctrl.signal.aborted || ctrl.abort(); } } if ("reason" in result) { throw result.reason; } else { return result.value; } } /** * Options for {@link abortWith}. * * NOTE: Must provide a `parent` signal or a `timeout` value, or both. */ export interface AbortWithOptions { /** * The parent signal to be linked with the new abort signal. If the parent * signal is aborted, the new signal will be aborted with the same reason. */ parent?: AbortSignal | undefined; /** * If provided, the abort signal will be automatically aborted after the * given duration (in milliseconds) if it is not already aborted. */ timeout?: number | undefined; } /** * Creates a new abort controller with a `parent` signal, the new abort signal * will be aborted if the controller's `abort` method is called or when the * parent signal is aborted, whichever happens first. * * @example * ```ts * import { abortWith } from "@ayonli/jsext/async"; * * const parent = new AbortController(); * const child1 = abortWith(parent.signal); * const child2 = abortWith(parent.signal); * * child1.abort(); * * console.assert(child1.signal.aborted); * console.assert(!parent.signal.aborted); * * parent.abort(); * * console.assert(child2.signal.aborted); * console.assert(child2.signal.reason === parent.signal.reason); * ``` */ export function abortWith( parent: AbortSignal, options?: Omit<AbortWithOptions, "parent"> ): AbortController; export function abortWith( options: AbortWithOptions ): AbortController; export function abortWith( _parent: AbortSignal | AbortWithOptions, options: Omit<AbortWithOptions, "parent"> | undefined = undefined, ): AbortController { let parent: AbortSignal | undefined; let timeout: number | undefined; if (_parent instanceof AbortSignal) { parent = _parent; timeout = options?.timeout; } else if (_parent) { parent = _parent.parent; timeout = _parent.timeout; } if (!parent && !timeout) { throw new TypeError("Must provide a parent signal or a timeout value, or both."); } const ctrl = new AbortController(); const { signal } = ctrl; if (parent) { if (parent.aborted) { ctrl.abort(parent.reason); return ctrl; } const abort = () => { signal.aborted || ctrl.abort(parent.reason); }; parent.addEventListener("abort", abort, { once: true }); signal.addEventListener("abort", () => { parent.aborted || parent.removeEventListener("abort", abort); }); } if (timeout) { const timer = setTimeout(() => { signal.aborted || ctrl.abort(createTimeoutError(timeout)); }, timeout); unrefTimer(timer); signal.addEventListener("abort", () => { clearTimeout(timer); }, { once: true }); } return ctrl; }