UNPKG

real-cancellable-promise

Version:

A simple cancellable promise implementation that cancels the underlying HTTP call.

293 lines (287 loc) 10 kB
'use strict'; /** * If canceled, a [[`CancellablePromise`]] should throw an `Cancellation` object. */ class Cancellation extends Error { constructor(message = 'Promise canceled.') { super(message); } } /** @internal */ const noop = () => { }; /** * Determines if an arbitrary value is a thenable with a cancel method. */ function isPromiseWithCancel(value) { return ( // typeof null === "object" value != null && typeof value === 'object' && typeof value.then === 'function' && typeof value.cancel === 'function'); } /** * A promise with a `cancel` method. * * If canceled, the `CancellablePromise` will reject with a [[`Cancellation`]] * object. * * @typeParam T what the `CancellablePromise` resolves to */ class CancellablePromise { /** * @param promise a normal promise or thenable * @param cancel a function that cancels `promise`. **Calling `cancel` after * `promise` has resolved must be a no-op.** */ constructor(promise, cancel) { this.promise = Promise.resolve(promise); this.cancel = cancel; } /** * Analogous to `Promise.then`. * * `onFulfilled` on `onRejected` can return a value, a normal promise, or a * `CancellablePromise`. So you can make a chain a `CancellablePromise`s * like this: * * ``` * const overallPromise = cancellableAsyncFunction1() * .then(cancellableAsyncFunction2) * .then(cancellableAsyncFunction3) * .then(cancellableAsyncFunction4) * ``` * * Then if you call `overallPromise.cancel`, `cancel` is called on all * `CancellablePromise`s in the chain! In practice, this means that * whichever async operation is in progress will be canceled. * * @returns a new CancellablePromise */ then(onFulfilled, onRejected) { let fulfill; let reject; let callbackPromiseWithCancel; let canceled = false; if (onFulfilled) { fulfill = (value) => { const nextValue = onFulfilled(value); if (isPromiseWithCancel(nextValue)) { callbackPromiseWithCancel = nextValue; if (canceled) { // so we don't throw away the original result, only cancel the resulting promise nextValue.cancel(); } } return nextValue; }; } if (onRejected) { reject = (reason) => { const nextValue = onRejected(reason); if (isPromiseWithCancel(nextValue)) { callbackPromiseWithCancel = nextValue; if (canceled) { // so we don't throw away the original error, only cancel the resulting promise nextValue.cancel(); } } return nextValue; }; } const newPromise = this.promise.then(fulfill, reject); const newCancel = () => { this.cancel(); canceled = true; callbackPromiseWithCancel === null || callbackPromiseWithCancel === void 0 ? void 0 : callbackPromiseWithCancel.cancel(); }; return new CancellablePromise(newPromise, newCancel); } /* eslint-disable @typescript-eslint/no-explicit-any -- to match the types used for Promise in the official lib.d.ts */ /** * Analogous to `Promise.catch`. */ catch(onRejected) { return this.then(undefined, onRejected); } /* eslint-enable @typescript-eslint/no-explicit-any */ /** * Attaches a callback that is invoked when the Promise is settled * (fulfilled or rejected). The resolved value cannot be modified from the * callback. * @param onFinally The callback to execute when the Promise is settled * (fulfilled or rejected). * @returns A Promise for the completion of the callback. */ finally(onFinally) { return new CancellablePromise(this.promise.finally(onFinally), this.cancel); } /** * This is necessary to make `CancellablePromise` assignable to `Promise`. */ // eslint-disable-next-line class-methods-use-this get [Symbol.toStringTag]() { return 'CancellablePromise'; } static resolve(value) { return new CancellablePromise(Promise.resolve(value), noop); } /** * Analogous to `Promise.reject`. * * Like `CancellablePromise.resolve`, canceling the returned * `CancellablePromise` is a no-op. * * @param reason this should probably be an `Error` object */ static reject(reason) { return new CancellablePromise(Promise.reject(reason), noop); } /** * Analogous to `Promise.all`. * * @param values an array that may contain `CancellablePromise`s, promises, * thenables, and resolved values * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each * of the promises passed in to `CancellablePromise.all`. */ static all(values) { return new CancellablePromise(Promise.all(values), () => { for (const value of values) { if (isPromiseWithCancel(value)) value.cancel(); } }); } static allSettled(values) { const cancel = () => { for (const value of values) { if (isPromiseWithCancel(value)) { value.cancel(); } } }; return new CancellablePromise(Promise.allSettled(values), cancel); } /** * Creates a `CancellablePromise` that is resolved or rejected when any of * the provided `Promises` are resolved or rejected. * @param values An array of `Promises`. * @returns A new `CancellablePromise`. Canceling it cancels all of the input * promises. */ static race(values) { const cancel = () => { for (const value of values) { if (isPromiseWithCancel(value)) { value.cancel(); } } }; return new CancellablePromise(Promise.race(values), cancel); } // Promise.any is an ES2021 feature. Not yet implemented. // /** // * The any function returns a `CancellablePromise` that is fulfilled by the // * first given promise to be fulfilled, or rejected with an `AggregateError` // * containing an array of rejection reasons if all of the given promises are // * rejected. It resolves all elements of the passed iterable to promises as // * it runs this algorithm. // * @param values An array or iterable of Promises. // * @returns A new `CancellablePromise`. // */ // any<T>(values: (T | PromiseLike<T>)[] | Iterable<T | PromiseLike<T>>): CancellablePromise<T> { // return new CancellablePromise(Promise.any(values), cancel)) // } /** * @returns a `CancellablePromise` that resolves after `ms` milliseconds. */ static delay(ms) { let timer; let rejectFn = noop; const promise = new Promise((resolve, reject) => { timer = setTimeout(() => { resolve(); rejectFn = noop; }, ms); rejectFn = reject; }); return new CancellablePromise(promise, () => { if (timer) clearTimeout(timer); rejectFn(new Cancellation()); }); } } /** * Takes in a regular `Promise` and returns a `CancellablePromise`. If canceled, * the `CancellablePromise` will immediately reject with a `Cancellation`, but the asynchronous * operation will not truly be aborted. * * Analogous to * [make-cancellable-promise](https://www.npmjs.com/package/make-cancellable-promise). */ function pseudoCancellable(promise) { let canceled = false; let rejectFn = noop; const newPromise = new Promise((resolve, reject) => { rejectFn = reject; // eslint-disable-next-line promise/catch-or-return -- no catch method on PromiseLike promise.then((result) => { if (!canceled) { resolve(result); rejectFn = noop; } return undefined; }, (e) => { if (!canceled) reject(e); }); }); function cancel() { canceled = true; rejectFn(new Cancellation()); } return new CancellablePromise(newPromise, cancel); } /** * Used to build a single [[`CancellablePromise`]] from a multi-step asynchronous * flow. * * When the overall promise is canceled, each captured promise is canceled. In practice, * this means the active asynchronous operation is canceled. * * ``` * function query(id: number): CancellablePromise<QueryResult> { * return buildCancellablePromise(async capture => { * const result1 = await capture(api.method1(id)) * * // do some stuff * * const result2 = await capture(api.method2(result1.id)) * * return { result1, result2 } * }) * } * ``` * * @param innerFunc an async function that takes in a `capture` function and returns * a regular `Promise` */ function buildCancellablePromise(innerFunc) { const capturedPromises = new Set(); const capture = (promise) => { capturedPromises.add(promise); promise.finally(() => capturedPromises.delete(promise)).catch(() => { }); return promise; }; function cancel() { capturedPromises.forEach((p) => p.cancel()); } return new CancellablePromise(innerFunc(capture), cancel); } exports.CancellablePromise = CancellablePromise; exports.Cancellation = Cancellation; exports.buildCancellablePromise = buildCancellablePromise; exports.isPromiseWithCancel = isPromiseWithCancel; exports.pseudoCancellable = pseudoCancellable;