race-cancellation
Version:
Utilities for using Promise.race([task, cancellation]) for async/await code.
257 lines (245 loc) • 10.7 kB
JavaScript
(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