Last active
October 14, 2025 13:41
-
-
Save petsel/1d12c6b9fbdf39e9dd7d327e03ebc6d7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * This callback is the custom provided executor-function | |
| * which gets all of an abortable promise's specific resolver | |
| * functions and its abort signal passed into. | |
| * This callback defines how the created promise does settle. | |
| * | |
| * @callback abortablePromiseExecutor | |
| * @param {(value?: any) => void} resolve | |
| * Does resolve its promise with a result. | |
| * @param {(reason?: any) => void} reject | |
| * Does reject its promise with a reason. | |
| * @param {(reason?: any) => void} abort | |
| * Does abort its promise with a reason. | |
| * @param {AbortSignal} signal | |
| * Enables listening to its promise's `abort` event. | |
| */ | |
| /** | |
| * An object which features all of an abortable promise's resolver | |
| * functions, its abort signal and the abortable promise itself. | |
| * | |
| * @typedef {Object} AbortablePromiseWithResolversAndSignal | |
| * @property {Promise} promise | |
| * The abortable promise itself. | |
| * @property {(value?: any) => void} resolve | |
| * Does resolve its promise with a result. | |
| * @property {(reason?: any) => void} reject | |
| * Does reject its promise with a reason. | |
| * @property {(reason?: any) => void} abort | |
| * Does abort its promise with a reason. | |
| * @property {AbortSignal} signal | |
| * Enables listening to its promise's `abort` event. | |
| */ | |
| class AsyncAbortableExecuteError extends Error { | |
| constructor(message, options) { | |
| message = | |
| String(message ?? '').trim() || | |
| "`executor` callback failure; check the error's `cause`."; | |
| options = options ?? {}; | |
| super( | |
| message, | |
| (Object.hasOwn(options, 'cause') && { cause: options.cause }) || void 0 | |
| ); | |
| } | |
| get name() { | |
| return 'AsyncAbortableExecuteError'; | |
| } | |
| } | |
| class AsyncAbortError extends Error { | |
| constructor(message, options) { | |
| message = String(message ?? '').trim() || asyncAbortErrorDefaultMessage; | |
| options = options ?? {}; | |
| super( | |
| message, | |
| (Object.hasOwn(options, 'cause') && { cause: options.cause }) || void 0 | |
| ); | |
| } | |
| get name() { | |
| return 'AsyncAbortError'; | |
| } | |
| } | |
| const asyncAbortErrorDefaultMessage = 'Pending async task aborted.'; | |
| function readFromState(key) { | |
| return this[key]; | |
| } | |
| function handleResolve(resolve, value, state, listenerController) { | |
| if (state.status === 'pending') { | |
| listenerController.abort(); | |
| resolve(value); | |
| state.result = value; | |
| state.status = 'fulfilled'; | |
| } | |
| } | |
| function handleReject(reject, reason, state, listenerController) { | |
| if (state.status === 'pending') { | |
| listenerController.abort(); | |
| reject(reason); | |
| state.result = reason; | |
| state.status = 'rejected'; | |
| } | |
| } | |
| function handleAbort(reason, state, abortController) { | |
| if (state.status === 'pending') { | |
| const beforeAbortEvent = new Event('beforeabort', { | |
| bubbles: true, | |
| cancelable: true, | |
| }); | |
| abortController.signal.dispatchEvent(beforeAbortEvent); | |
| // - up until here the execution/finalization of this | |
| // `abort` resolver can still be cancelled through | |
| // any registered 'beforeabort' listener by letting | |
| // its handler-function call `evt.preventDefault()`. | |
| if (beforeAbortEvent.defaultPrevented === false) { | |
| abortController.abort(reason); | |
| } | |
| } | |
| } | |
| function handleAbortEvent(reject, state, { currentTarget: signal }) { | |
| const { reason: cause } = signal; | |
| const abortError = | |
| (Error.isError(cause) && cause.name.endsWith('AbortError') && cause) || | |
| new AsyncAbortError(null, { cause }); | |
| if (!abortError.message || !String(abortError.message).trim()) { | |
| abortError.message = asyncAbortErrorDefaultMessage; | |
| } | |
| reject(abortError); | |
| state.result = abortError; | |
| state.status = 'aborted'; | |
| } | |
| /** | |
| * Core functionality which defines and manages state | |
| * and control-flow of an abortable promise, be it a | |
| * promise defined by a custom executor-callback, or | |
| * a promise with resolvers. | |
| * | |
| * @param {abortablePromiseExecutor} [executeCustom] | |
| * A custom provided callback which defines | |
| * how the created promise settles. | |
| * @returns {Promise | AbortablePromiseWithResolversAndSignal} | |
| */ | |
| function defineAbortablePromise(executeCustom) { | |
| const { promise, resolve, reject } = Promise.withResolvers(); | |
| const asyncState = Object.assign(Object.create(null), { | |
| status: 'pending' /* without a `result` property */, | |
| }); | |
| const listenerController = new AbortController(); | |
| const abortController = new AbortController(); | |
| const abortSignal = abortController.signal; | |
| const resolvePromise = ((proceed, state, controller) => | |
| // create and return a concise generic `resolve` resolver. | |
| ({ | |
| resolve(value) { | |
| handleResolve(proceed, value, state, controller); | |
| }, | |
| }['resolve']))(resolve, asyncState, listenerController); | |
| const rejectPromise = ((proceed, state, controller) => | |
| // create and return a concise generic `reject` resolver. | |
| ({ | |
| reject(reason) { | |
| handleReject(proceed, reason, state, controller); | |
| }, | |
| }['reject']))(reject, asyncState, listenerController); | |
| const abortPromise = ((state, controller) => | |
| // create and return a concise generic `abort` resolver. | |
| ({ | |
| abort(reason) { | |
| handleAbort(reason, state, controller); | |
| }, | |
| }['abort']))(asyncState, abortController); | |
| const abortHandler = handleAbortEvent.bind(null, reject, asyncState); | |
| abortSignal.addEventListener('abort', abortHandler, { | |
| signal: listenerController.signal, // - deregistering by `abort()` via `handleResolve` or `handleReject`. | |
| once: true, // - deregistering by the one-time execution of `handleAbortEvent`. | |
| }); | |
| Object.defineProperty(promise, 'status', { | |
| get: readFromState.bind(asyncState, 'status'), | |
| }); | |
| Object.defineProperty(promise, 'result', { | |
| get: readFromState.bind(asyncState, 'result'), | |
| }); | |
| /** @type {Promise | AbortablePromiseWithResolversAndSignal} */ | |
| let result; | |
| if (executeCustom) { | |
| try { | |
| executeCustom(resolvePromise, rejectPromise, abortPromise, abortSignal); | |
| } catch (reason) { | |
| rejectPromise(new AsyncAbortableExecuteError(null, { cause: reason })); | |
| } | |
| result = /** @type {Promise} */ promise; | |
| } else { | |
| result = /** @type {AbortablePromiseWithResolversAndSignal} */ { | |
| promise, | |
| resolve: resolvePromise, | |
| reject: rejectPromise, | |
| abort: abortPromise, | |
| signal: abortSignal, | |
| }; | |
| } | |
| return result; | |
| } | |
| /** | |
| * `Promise.abortable(executor)` | |
| * | |
| * Creates an abortable promise; the custom provided executor-callback | |
| * ... `executeCustom(resolve, reject, abort, signal)` ... defines how | |
| * such a promise is going to settle. | |
| * | |
| * @param {abortablePromiseExecutor} executeCustom | |
| * A custom provided callback which defines how the created promise | |
| * settles. | |
| * @returns {Promise} | |
| */ | |
| /*export*/function create(executeCustom) { | |
| if (typeof executeCustom !== 'function') { | |
| throw new TypeError( | |
| 'The single mandatory argument must be a function type.' | |
| ); | |
| } | |
| return defineAbortablePromise(executeCustom); | |
| } | |
| /** | |
| * `Promise.withAbort()`` | |
| * | |
| * Returns `{ promise, resolve, reject, abort, signal }` so one | |
| * can wire an abortable promise without an executor-callback. | |
| * | |
| * @returns {AbortablePromiseWithResolversAndSignal} | |
| */ | |
| /*export*/function withResolversAndSignal() { | |
| return defineAbortablePromise(); | |
| } | |
| const abortablePropertyDescriptor = { | |
| enumerable: false, | |
| writable: false, | |
| configurable: true, | |
| }; | |
| // apply minifier safe function names. | |
| Object.defineProperty(create, 'name', { | |
| ...abortablePropertyDescriptor, | |
| value: 'create', | |
| }); | |
| Object.defineProperty(withResolversAndSignal, 'name', { | |
| ...abortablePropertyDescriptor, | |
| value: 'withResolversAndSignal', | |
| }); | |
| // introduce two new static `Promise` methods. | |
| Object.defineProperty(Promise, 'abortable', { | |
| ...abortablePropertyDescriptor, | |
| value: create, | |
| }); | |
| Object.defineProperty(Promise, 'withAbort', { | |
| ...abortablePropertyDescriptor, | |
| value: withResolversAndSignal, | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * test case 1 ... `Promise.abortable` | |
| */ | |
| const aborted = Promise.abortable((resolve, reject, abort /*, signal */) => { | |
| setTimeout(resolve, 300, 'smoothly fulfilled'); | |
| setTimeout(reject, 200, 'plainly rejected'); | |
| setTimeout(abort, 100, 'too long pending task'); // <== | |
| }); | |
| const rejected = Promise.abortable((resolve, reject, abort /*, signal */) => { | |
| setTimeout(resolve, 200, 'smoothly fulfilled'); | |
| setTimeout(reject, 100, 'plainly rejected'); // <== | |
| setTimeout(abort, 300, 'too long pending task'); | |
| }); | |
| const fulfilled = Promise.abortable((resolve, reject, abort /*, signal */) => { | |
| setTimeout(resolve, 100, 'smoothly fulfilled'); // <== | |
| setTimeout(reject, 100, 'plainly rejected'); | |
| setTimeout(abort, 100, 'too long pending task'); | |
| }); | |
| setTimeout(() => console.log({ aborted, rejected, fulfilled }), 200); | |
| /* | |
| * test case 2 ... `Promise.withAbort` | |
| */ | |
| async function safeAsyncResult(future, ...args) { | |
| let error = null; | |
| let value; | |
| // roughly implemented in order to mainly cover the test scenario. | |
| const promise = typeof future === 'function' ? future(...args) : future; | |
| try { | |
| value = await promise; | |
| } catch (exception) { | |
| error = exception; | |
| } | |
| // safe result tuple. | |
| return [error, value]; | |
| } | |
| function getRandomNonZeroPositiveInteger(maxInt) { | |
| return Math.ceil(Math.random() * Math.abs(parseInt(maxInt, 10))); | |
| } | |
| async function createAbortableRandomlySettlingPromise() { | |
| const { promise, resolve, reject, abort /*, signal */ } = Promise.withAbort(); | |
| // list of values of either ... 300, 400, 500. | |
| const randomDelayList = [ | |
| 200 + getRandomNonZeroPositiveInteger(3) * 100, | |
| 200 + getRandomNonZeroPositiveInteger(3) * 100, | |
| 200 + getRandomNonZeroPositiveInteger(3) * 100, | |
| ]; | |
| //debugger; | |
| setTimeout(resolve, randomDelayList.at(0), 'resolved with value "foo".'); | |
| setTimeout(reject, randomDelayList.at(1), 'rejected with reason "bar".'); | |
| setTimeout(abort, randomDelayList.at(2), 'running for too long'); | |
| return promise; | |
| } | |
| let resultList = await Promise.allSettled( | |
| Array.from({ length: 9 }, () => | |
| safeAsyncResult(createAbortableRandomlySettlingPromise) | |
| ) | |
| ); | |
| const safeResultList = resultList.map(({ value }) => value); | |
| console.log({ safeResultList }); | |
| resultList = await Promise.allSettled( | |
| Array.from({ length: 9 }, () => | |
| createAbortableRandomlySettlingPromise() | |
| ) | |
| ); | |
| const statusList = resultList.map(({ status, value, reason }) => { | |
| return ( | |
| (status === 'fulfilled' && { status, value }) || | |
| (reason.name?.endsWith?.('AbortError') && { status: 'aborted', reason }) || | |
| { status, reason } | |
| ); | |
| }); | |
| console.log({ statusList }); | |
| /* | |
| * test case 3 ... abortable task (factory) function | |
| * based on `Promise.withAbort` | |
| */ | |
| /* | |
| * Business logic implementation of how one intends to | |
| * manage the settled state scenarios - `'fulfilled'` | |
| * and `'rejected'` - of a custom async task. | |
| * In addition such a task is allowed to get aborted | |
| * as long as it remains in its pending state. | |
| * Functions of that kind need to get passed all the | |
| * necessary resolvers (resolving handlers) which are | |
| * in that order `'resolve'`, `'reject'` and `'abort'`. | |
| */ | |
| function longRunningTask(resolve, reject, abort, signal) { | |
| console.log('Time-consuming task started...'); | |
| setTimeout(resolve, 5_000, 'Success!'); | |
| // - `reject` as well, if needed. | |
| // - auto-`abort`, if necessary. | |
| // - utilize `signal` as it seems fit. | |
| } | |
| const { promise, resolve, reject, abort, signal } = Promise.withAbort(); | |
| signal.addEventListener( | |
| 'beforeabort', | |
| (evt) => { | |
| if (Boolean(Math.floor(Math.random() + .5))) { | |
| evt.preventDefault(); | |
| } | |
| console.log('beforeabort ...', { evt }); | |
| } | |
| ); | |
| signal.addEventListener( | |
| 'abort', | |
| (evt) => | |
| // e.g. the one-time cleanup-task. | |
| console.log('Time-consuming task aborted ...', { evt }), | |
| { | |
| once: true, | |
| } | |
| ); | |
| promise | |
| .then((result) => console.log('Time-consuming task fulfilled!')) | |
| .catch((reason) => | |
| // covers both `reject` and `abort`. | |
| console.log('Time-consuming task rejected ...', { reason }) | |
| ); | |
| // cancel/abort the pending long running task after 2 seconds. | |
| setTimeout( | |
| abort, | |
| 2_000, | |
| new DOMException('Exceeded pending threshold.', 'AbortError') | |
| ); | |
| // trigger/start the long running task. | |
| longRunningTask(resolve, reject, abort, signal); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment