UNPKG

abort-controller-x

Version:
657 lines (471 loc) 15.7 kB
# Abort Controller Extras [![npm version][npm-image]][npm-url] Abortable async function primitives and combinators. - [Installation](#installation) - [Abort Controller](#abort-controller) - [Abortable Functions](#abortable-functions) - [Composing Abortable Functions](#composing-abortable-functions) - [Companion Packages](#companion-packages) - [API](#api) - [`all`](#all) - [`race`](#race) - [`delay`](#delay) - [`waitForEvent`](#waitforevent) - [`forever`](#forever) - [`spawn`](#spawn) - [`retry`](#retry) - [`proactiveRetry`](#proactive-retry) - [`execute`](#execute) - [`abortable`](#abortable) - [`run`](#run) - [`AbortError`](#aborterror) - [`isAbortError`](#isaborterror) - [`throwIfAborted`](#throwifaborted) - [`rethrowAbortError`](#rethrowaborterror) - [`catchAbortError`](#catchaborterror) ## Installation ``` yarn add abort-controller-x ``` ## Abort Controller See [`AbortController` MDN page](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). AbortController is [available in NodeJS](https://nodejs.org/api/globals.html#class-abortcontroller) since 15.0.0, NodeJS 14.17+ requires the [--experimental-abortcontroller](https://nodejs.org/docs/latest-v14.x/api/cli.html#cli_experimental_abortcontroller) flag. A [polyfill](https://www.npmjs.com/package/abort-controller) is available for older NodeJS versions and browsers. ## Abortable Functions We define _abortable function_ as a function that obeys following rules: - It must accept `AbortSignal` in its arguments. - It must return a `Promise`. - It must add [`abort`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event) event listener to the `AbortSignal`. Once the `AbortSignal` is aborted, the returned `Promise` must reject with `AbortError` either immediately, or after doing any async cleanup. It's also possible to reject with other errors that happen during cleanup. - Once the returned `Promise` is fulfilled or rejected, it must remove [`abort`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event) event listener. An example of _abortable function_ is the standard [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) function. ## Composing Abortable Functions This library provides a way to build complex abortable functions using standard `async`/`await` syntax, without the burden of manually managing [`abort`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event) event listeners. You can reuse a single `AbortSignal` between many operations inside a parent function: ```ts /** * Make requests repeatedly with a delay between consecutive requests */ async function makeRequests(signal: AbortSignal): Promise<never> { while (true) { await fetch('...', {signal}); await delay(signal, 1000); } } const abortController = new AbortController(); makeRequests(abortController.signal).catch(catchAbortError); process.on('SIGTERM', () => { abortController.abort(); }); ``` The above example can be rewritten in a more ergonomic way using [`run`](#run) helper. Usually you should only create `AbortController` somewhere on the top level, and in regular code use `async`/`await` and pass `AbortSignal` to abortable functions provided by this library or custom ones composed of other abortable functions. ## Companion Packages - [`abort-controller-x-rxjs`](https://github.com/deeplay-io/abort-controller-x-rxjs)Abortable helpers for RxJS. ## API ### `all` ```ts function all<T>( signal: AbortSignal, executor: (innerSignal: AbortSignal) => readonly PromiseLike<T>[], ): Promise<T[]>; ``` Abortable version of `Promise.all`. Creates new inner `AbortSignal` and passes it to `executor`. That signal is aborted when `signal` is aborted or any of the promises returned from `executor` are rejected. Returns a promise that fulfills with an array of results when all of the promises returned from `executor` fulfill, rejects when any of the promises returned from `executor` are rejected, and rejects with `AbortError` when `signal` is aborted. The promises returned from `executor` must be abortable, i.e. once `innerSignal` is aborted, they must reject with `AbortError` either immediately, or after doing any async cleanup. Example: ```ts const [result1, result2] = await all(signal, signal => [ makeRequest(signal, params1), makeRequest(signal, params2), ]); ``` ### `race` ```ts function race<T>( signal: AbortSignal, executor: (innerSignal: AbortSignal) => readonly PromiseLike<T>[], ): Promise<T>; ``` Abortable version of `Promise.race`. Creates new inner `AbortSignal` and passes it to `executor`. That signal is aborted when `signal` is aborted or any of the promises returned from `executor` are fulfilled or rejected. Returns a promise that fulfills or rejects when any of the promises returned from `executor` are fulfilled or rejected, and rejects with `AbortError` when `signal` is aborted. The promises returned from `executor` must be abortable, i.e. once `innerSignal` is aborted, they must reject with `AbortError` either immediately, or after doing any async cleanup. Example: ```ts const result = await race(signal, signal => [ delay(signal, 1000).then(() => ({status: 'timeout'})), makeRequest(signal, params).then(value => ({status: 'success', value})), ]); if (result.status === 'timeout') { // request timed out } else { const response = result.value; } ``` ### `delay` ```ts function delay(signal: AbortSignal, dueTime: number | Date): Promise<void>; ``` Return a promise that resolves after delay and rejects with `AbortError` once `signal` is aborted. The delay time is specified as a `Date` object or as an integer denoting milliseconds to wait. Example: ```ts // Make a request repeatedly with a delay between consecutive requests while (true) { await makeRequest(signal, params); await delay(signal, 1000); } ``` Example: ```ts // Make a request repeatedly with a fixed interval import {addMilliseconds} from 'date-fns'; let date = new Date(); while (true) { await makeRequest(signal, params); date = addMilliseconds(date, 1000); await delay(signal, date); } ``` ### `waitForEvent` ```ts function waitForEvent<T>( signal: AbortSignal, target: EventTargetLike<T>, eventName: string, options?: EventListenerOptions, ): Promise<T>; ``` Returns a promise that fulfills when an event of specific type is emitted from given event target and rejects with `AbortError` once `signal` is aborted. Example: ```ts // Create a WebSocket and wait for connection const webSocket = new WebSocket(url); const openEvent = await race(signal, signal => [ waitForEvent<WebSocketEventMap['open']>(signal, webSocket, 'open'), waitForEvent<WebSocketEventMap['close']>(signal, webSocket, 'close').then( event => { throw new Error(`Failed to connect to ${url}: ${event.reason}`); }, ), ]); ``` ### `forever` ```ts function forever(signal: AbortSignal): Promise<never>; ``` Return a promise that never fulfills and only rejects with `AbortError` once `signal` is aborted. ### `spawn` ```ts function spawn<T>( signal: AbortSignal, fn: (signal: AbortSignal, effects: SpawnEffects) => Promise<T>, ): Promise<T>; type SpawnEffects = { defer(fn: () => void | Promise<void>): void; fork<T>(fn: (signal: AbortSignal) => Promise<T>): ForkTask<T>; }; type ForkTask<T> = { abort(): void; join(): Promise<T>; }; ``` Run an abortable function with `fork` and `defer` effects attached to it. `spawn` allows to write Go-style coroutines. - `SpawnEffects.defer` Schedules a function to run after spawned function finishes. Deferred functions run serially in last-in-first-out order. Promise returned from `spawn` resolves or rejects only after all deferred functions finish. - `SpawnEffects.fork` Executes an abortable function in background. If a forked function throws an exception, spawned function and other forks are aborted and promise returned from `spawn` rejects with that exception. When spawned function finishes, all forks are aborted. - `ForkTask.abort` Abort a forked function. - `ForkTask.join` Returns a promise returned from a forked function. Example: ```ts // Connect to a database, then start a server, then block until abort. // On abort, gracefully shutdown the server, and once done, disconnect // from the database. spawn(signal, async (signal, {defer}) => { const db = await connectToDb(); defer(async () => { await db.close(); }); const server = await startServer(db); defer(async () => { await server.close(); }); await forever(signal); }); ``` Example: ```ts // Connect to a database, then start an infinite polling loop. // On abort, disconnect from the database. spawn(signal, async (signal, {defer}) => { const db = await connectToDb(); defer(async () => { await db.close(); }); while (true) { await poll(signal, db); await delay(signal, 5000); } }); ``` Example: ```ts // Acquire a lock and execute a function. // Extend the lock while the function is running. // Once the function finishes or the signal is aborted, stop extending // the lock and release it. import Redlock = require('redlock'); const lockTtl = 30_000; function withLock<T>( signal: AbortSignal, redlock: Redlock, key: string, fn: (signal: AbortSignal) => Promise<T>, ): Promise<T> { return spawn(signal, async (signal, {fork, defer}) => { const lock = await redlock.lock(key, lockTtl); defer(() => lock.unlock()); fork(async signal => { while (true) { await delay(signal, lockTtl / 10); await lock.extend(lockTtl); } }); return await fn(signal); }); } const redlock = new Redlock([redis], { retryCount: -1, }); await withLock(signal, redlock, 'the-lock-key', async signal => { // ... }); ``` ### `retry` ```ts function retry<T>( signal: AbortSignal, fn: (signal: AbortSignal, attempt: number, reset: () => void) => Promise<T>, options?: RetryOptions, ): Promise<T>; type RetryOptions = { baseMs?: number; maxDelayMs?: number; maxAttempts?: number; onError?: (error: unknown, attempt: number, delayMs: number) => void; }; ``` Retry a function with exponential backoff. - `fn` A function that will be called and retried in case of error. It receives: - `signal` `AbortSignal` that is aborted when the signal passed to `retry` is aborted. - `attempt` Attempt number starting with 0. - `reset` Function that sets attempt number to -1 so that the next attempt will be made without delay. - `RetryOptions.baseMs` Starting delay before first retry attempt in milliseconds. Defaults to 1000. Example: if `baseMs` is 100, then retries will be attempted in 100ms, 200ms, 400ms etc (not counting jitter). - `RetryOptions.maxDelayMs` Maximum delay between attempts in milliseconds. Defaults to 30 seconds. Example: if `baseMs` is 1000 and `maxDelayMs` is 3000, then retries will be attempted in 1000ms, 2000ms, 3000ms, 3000ms etc (not counting jitter). - `RetryOptions.maxAttempts` Maximum for the total number of attempts. Defaults to `Infinity`. - `RetryOptions.onError` Called after each failed attempt before setting delay timer. Rethrow error from this callback to prevent further retries. ### `proactiveRetry` ```ts function proactiveRetry<T>( signal: AbortSignal, fn: (signal: AbortSignal, attempt: number) => Promise<T>, options?: ProactiveRetryOptions, ): Promise<T>; type ProactiveRetryOptions = { baseMs?: number; maxAttempts?: number; onError?: (error: unknown, attempt: number) => void; }; ``` Proactively retry a function with exponential backoff. Also known as hedging. The function will be called multiple times in parallel until it succeeds, in which case all the other calls will be aborted. - `fn` A function that will be called multiple times in parallel until it succeeds. It receives: - `signal` `AbortSignal` that is aborted when the signal passed to `retry` is aborted, or when the function succeeds. - `attempt` Attempt number starting with 0. - `ProactiveRetryOptions.baseMs` Base delay between attempts in milliseconds. Defaults to 1000. Example: if `baseMs` is 100, then retries will be attempted in 100ms, 200ms, 400ms etc (not counting jitter). - `ProactiveRetryOptions.maxAttempts` Maximum for the total number of attempts. Defaults to `Infinity`. - `ProactiveRetryOptions.onError` Called after each failed attempt. Rethrow error from this callback to prevent further retries. ### `execute` ```ts function execute<T>( signal: AbortSignal, executor: ( resolve: (value: T) => void, reject: (reason?: any) => void, ) => () => void | PromiseLike<void>, ): Promise<T>; ``` Similar to `new Promise(executor)`, but allows executor to return abort callback that is called once `signal` is aborted. Returned promise rejects with `AbortError` once `signal` is aborted. Callback can return a promise, e.g. for doing any async cleanup. In this case, the promise returned from `execute` rejects with `AbortError` after that promise fulfills. ### `abortable` ```ts function abortable<T>(signal: AbortSignal, promise: PromiseLike<T>): Promise<T>; ``` Wrap a promise to reject with `AbortError` once `signal` is aborted. Useful to wrap non-abortable promises. Note that underlying process will NOT be aborted. ### `run` ```ts function run(fn: (signal: AbortSignal) => Promise<void>): () => Promise<void>; ``` Invokes an abortable function with implicitly created `AbortSignal`. Returns a function that aborts that signal and waits until passed function finishes. Any error other than `AbortError` thrown from passed function will result in unhandled promise rejection. Example: ```ts const stop = run(async signal => { try { while (true) { await delay(signal, 1000); console.log('tick'); } } finally { await doCleanup(); } }); // abort and wait until cleanup is done await stop(); ``` This function is also useful with React `useEffect` hook: ```ts // make requests periodically while the component is mounted useEffect( () => run(async signal => { while (true) { await makeRequest(signal); await delay(signal, 1000); } }), [], ); ``` ### `AbortError` ```ts class AbortError extends Error ``` Thrown when an abortable function was aborted. **Warning**: do not use `instanceof` with this class. Instead, use `isAbortError` function. ### `isAbortError` ```ts function isAbortError(error: unknown): boolean; ``` Checks whether given `error` is an `AbortError`. ### `throwIfAborted` ```ts function throwIfAborted(signal: AbortSignal): void; ``` If `signal` is aborted, throws `AbortError`. Otherwise does nothing. ### `rethrowAbortError` ```ts function rethrowAbortError(error: unknown): void; ``` If `error` is `AbortError`, throws it. Otherwise does nothing. Useful for `try/catch` blocks around abortable code: ```ts try { await somethingAbortable(signal); } catch (err) { rethrowAbortError(err); // do normal error handling } ``` ### `catchAbortError` ```ts function catchAbortError(error: unknown): void; ``` If `error` is `AbortError`, does nothing. Otherwise throws it. Useful for invoking top-level abortable functions: ```ts somethingAbortable(signal).catch(catchAbortError); ``` Without `catchAbortError`, aborting would result in unhandled promise rejection. [npm-image]: https://badge.fury.io/js/abort-controller-x.svg [npm-url]: https://badge.fury.io/js/abort-controller-x