UNPKG

race-cancellation

Version:

Utilities for using Promise.race([task, cancellation]) for async/await code.

257 lines (245 loc) 10.7 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = global || self, factory(global.RaceCancellation = {})); }(this, (function (exports) { 'use strict'; /** * A no-op implemenation of a {@link RaceCancelFn} function. * * @remarks * Allows an async function to add cancellation support in a backwards compatible way by * adding optional param by providing this as a default. * * @param asyncFnOrPromise - an {@link AsyncFn} or `PromiseLike` that will be resolved. * @public */ const noopRaceCancel = (asyncFnOrPromise) => typeof asyncFnOrPromise === "function" ? Promise.resolve().then(asyncFnOrPromise) : Promise.resolve(asyncFnOrPromise); /** * Creates a cancellable Promise from an executor that returns a {@link DisposeFn} function and a {@link RaceCancelFn} function. * * @param disposableExecutor - a {@link DisposableExecutorFn} function (this will not be run if already cancelled). * @param raceCancel - a {@link RaceCancelFn} function to race against the disposable promise against. * @public */ async function cancellablePromise(disposableExecutor, raceCancel = noopRaceCancel) { let dispose; let result; try { result = await raceCancel(() => new Promise((resolve, reject) => { dispose = disposableExecutor(resolve, reject); })); } finally { if (dispose !== undefined) { dispose(); } } return result; } /** * Returns a {@link RaceCancelFn} function that is the composition of the two RaceCancel functions. * * @remarks * For convenience of writing methods that take cancellations, the params * are optional. If a is undefined, then b is retuned, if b is undefined then a * is returned, and if they both are undefined a noop race that just invokes * the task is returned. * * The outer scope function is expected to be a wider concern like ctrl-C and the inner a narrower * concern like timeout. This way if you catch a error then try to say sleep before a retrying the narrower * concern the outer will bail out without even starting the timeout because it is already cancelled. * * This isn't a hard requirement just things are optimized with that assunmption. * * @param inner - a {@link RaceCancelFn} function that is the inner function of the composition, in general its cancellation scope should be narrower than the outer scope. * @param outer - a {@link RaceCancelFn} function that is the outer function, its scope should be wider or equal to the inner scope. * @public */ function composeRaceCancel(inner, outer) { return outer === undefined ? inner === undefined ? noopRaceCancel : inner : inner === undefined ? outer : (asyncFnOrPromise) => typeof asyncFnOrPromise === "function" ? outer(() => inner(asyncFnOrPromise)) // lazy if function : outer(inner(asyncFnOrPromise)); } // we are casting the sentinel value to a unique symbol // to get === union narrowing const sentinelValue = { toString: () => "[cancel sentinel]", }; /** * Create a {@link RaceCancelFn} function. * * @param isCancelled - a function that returns whether or not we are currently cancelled * @param executor - a callback to resolve the cancellation * @param cancelReason - an optional reason for the cancellation * @public */ function newRaceCancel(isCancelled, executor, cancelReason) { // the async cancel executor should only run 0 or 1 times const memoizedAsyncCancel = memoizeAsyncCancel(isCancelled, executor); return async (asyncFnOrPromise) => throwIfCancelled(await raceCancel(asyncFnOrPromise, isCancelled, memoizedAsyncCancel), cancelReason); } function raceCancel(asyncFnOrPromise, isCancelled, memoizedAsyncCancel) { return typeof asyncFnOrPromise === "function" ? isCancelled() ? memoizedAsyncCancel() : Promise.race([asyncFnOrPromise(), memoizedAsyncCancel()]) : Promise.race([asyncFnOrPromise, memoizedAsyncCancel()]); } function throwIfCancelled(result, cancelReason) { if (result === sentinelValue) { throw intoCancelError(cancelReason); } return result; } function memoizeAsyncCancel(isCancelled, executor) { let promise; return () => { if (promise === undefined) { promise = isCancelled() ? Promise.resolve(sentinelValue) : waitForCancel(executor); } return promise; }; } async function waitForCancel(cancelExecutor) { await new Promise(cancelExecutor); return sentinelValue; } function intoCancelError(cancelReason) { if (typeof cancelReason === "function") { cancelReason = cancelReason(); } if (cancelReason === undefined || typeof cancelReason === "string") { return newCancelError(cancelReason); } return cancelReason; } function newCancelError(message = "The operation was cancelled") { const cancelError = new Error(message); cancelError.name = "CancelError"; cancelError.isCancelled = true; return cancelError; } /** * Run a {@link CancellableAsyncFn} function with a {@link RaceCancelFn} adapted from a {@link DisposableCancelExecutorFn} function. * * @param cancellableAsync - a {@link CancellableAsyncFn} function * @param disposableCancelExecutor - a {@link DisposableCancelExecutorFn} function that will only be called once * @param outerRaceCancel - an optional outer {@link RaceCancelFn} function that will be composed with the {@link RaceCancelFn} function before being passed to the {@link CancellableAsyncFn} function. * @public */ async function withCancel(cancellableAsync, disposableCancelExecutor, outerRaceCancel) { let cancelled = false; let cancelReason; let dispose; const raceCancel = newRaceCancel(() => cancelled, (resolveCancel) => { dispose = disposableCancelExecutor((reason) => { cancelled = true; cancelReason = reason; resolveCancel(); }); }, () => cancelReason); let result; try { result = await cancellableAsync(composeRaceCancel(raceCancel, outerRaceCancel)); } finally { if (dispose !== undefined) { dispose(); } } return result; } /** * Create a tuple of {@link RaceCancelFn} and {@link ResolveCancelFn} functions. * * @remarks * In general, it is better to use {@link race-cancellation#withCancel} to scope cancellation * to an async task so that the cancellation concern is cleaned up. * * If the cancellation concern will be GC'ed with the cancel already and no cleanup * needed like removing an event listener than this method can be simpler. * @public */ function deferCancel() { let cancelled = false; let onCancel; let cancelReason; const raceCancel = newRaceCancel(() => cancelled, (cancel) => (onCancel = cancel), () => cancelReason); const cancel = (reason) => { cancelled = true; cancelReason = reason; if (onCancel !== undefined) onCancel(); }; return [raceCancel, cancel]; } /** * Wrap a cancellable async function with a timeout. * * @remarks * * @example * ```js * async function fetchWithTimeout(url, timeoutMs, raceCancel) { * return await withTimeout((raceTimeout) => cancellableFetch(url, raceTimeout), timeoutMs, raceCancel); * } * ``` * * @param cancellableAsync - a {@link CancellableAsyncFn} function * @param milliseconds - a timeout in miliseconds * @param raceCancel - an optional outer scope {@link RaceCancelFn} function that will be combined with the timeout race before being passed to the {@link CancellableAsyncFn} function * @public */ function withTimeout(cancellableAsync, milliseconds, raceCancel) { return withCancel(cancellableAsync, (resolve) => { const id = setTimeout(() => resolve(newTimeoutError(milliseconds)), milliseconds); return () => clearTimeout(id); }, raceCancel); } function newTimeoutError(milliseconds) { const timeoutError = new Error(`The operation timed out after taking longer than ${milliseconds}ms`); timeoutError.name = "TimeoutError"; timeoutError.isCancelled = true; timeoutError.isTimeout = true; return timeoutError; } /** * Wrap a {@link CancellableAsyncFn} function to cancel still pending concurrent child {@link CancellableAsyncFn} * functions when another concurrent promise either rejected in a Promise.all() or won in a Promise.race(). * * @param cancellableAsync - a {@link CancellableAsyncFn} function to run with cancel pending * @param outerRaceCancel - an optional outer {@link RaceCancelFn} function * @public */ async function withCancelPending(cancellableAsync, outerRaceCancel) { const [raceSettled, settled] = deferCancel(); let result; try { result = await cancellableAsync(composeRaceCancel(raceSettled, outerRaceCancel)); } finally { settled("The operation was cancelled because it was still pending when another concurrent promise either rejected in a Promise.all() or won in a Promise.race()."); } return result; } exports.cancellablePromise = cancellablePromise; exports.composeRaceCancel = composeRaceCancel; exports.deferCancel = deferCancel; exports.newRaceCancel = newRaceCancel; exports.noopRaceCancel = noopRaceCancel; exports.withCancel = withCancel; exports.withCancelPending = withCancelPending; exports.withTimeout = withTimeout; Object.defineProperty(exports, '__esModule', { value: true }); }))); //# sourceMappingURL=index.cjs.map