Last active
September 10, 2025 09:26
-
-
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(cause) { | |
super( | |
'The custom provided executor-callback does throw. Please check the `cause`.', | |
{ cause } | |
); | |
this.name = 'AsyncAbortableExecuteError'; | |
} | |
} | |
class AsyncAbortError extends Error { | |
constructor(message, options) { | |
message = String(message ?? '').trim() || 'Pending async task aborted.'; | |
options = options ?? {}; | |
super( | |
message, | |
(Object.hasOwn(options, 'cause') && { cause: options.cause }) || void 0 | |
); | |
this.name = 'AsyncAbortError'; | |
} | |
} /* | |
function getSettledPath({ aborted, rejected, fulfilled }) { | |
return ( | |
(aborted && 'aborted') || | |
(rejected && 'rejected') || | |
(fulfilled && 'fulfilled') | |
); | |
}*/ | |
function statusFromBoundState() { | |
const { pending, aborted, rejected, fulfilled } = this; | |
return ( | |
(pending && 'pending') || | |
(aborted && 'aborted') || | |
(rejected && 'rejected') || | |
(fulfilled && 'fulfilled') | |
); | |
} | |
function handleAsyncAbort({ currentTarget: signal }) { | |
const { reject, state } = this; | |
// guard. | |
if (state.pending) { | |
const { reason } = signal; | |
const abortError = | |
(Error.isError(reason) && reason.name === 'AbortError' && reason) || | |
new AsyncAbortError(null, { cause: reason }); | |
if (!abortError.message || !String(abortError.message).trim()) { | |
abortError.message = 'Pending async task aborted.'; | |
} | |
// - `reject` is going to take care of removing | |
// the bound version of this handler-function | |
// from the related internal abort-controller. | |
reject(abortError); | |
} | |
} | |
/** | |
* 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 defineAsyncAbortable(executeCustom) { | |
const { promise, resolve, reject } = Promise.withResolvers(); | |
const asyncState = { | |
fulfilled: false, | |
rejected: false, | |
aborted: false, | |
pending: true, | |
}; | |
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) { | |
// let success = `already-settled::${getSettledPath(state)}`; | |
// guard. | |
if (state.pending) { | |
state.pending = false; | |
state.fulfilled = true; | |
// success = 'fulfilled'; | |
// listener controller. | |
controller.abort(); | |
proceed(value); | |
} | |
// return success; | |
}, | |
}['resolve']))(resolve, asyncState, listenerController); | |
const rejectPromise = ((proceed, state, controller) => | |
// create and return a concise generic `reject` resolver. | |
({ | |
reject(reason) { | |
// let success = `already-settled::${getSettledPath(state)}`; | |
// guard. | |
if (state.pending) { | |
state.pending = false; | |
state.rejected = true; | |
// success = 'rejected'; | |
// listener controller. | |
controller.abort(); | |
proceed(reason); | |
} | |
// return success; | |
}, | |
}['reject']))(reject, asyncState, listenerController); | |
const abortPromise = ((state, controller) => | |
// create and return a concise generic `abort` resolver. | |
({ | |
abort(reason) { | |
// let success = `already-settled::${getSettledPath(state)}`; | |
// guard. | |
if (state.pending && !state.aborted) { | |
// state.aborted = true; | |
// | |
// // success = 'aborted'; | |
const beforeAbortEvent = new Event('beforeabort', { | |
bubbles: true, | |
cancelable: true, | |
}); | |
// abort controller. | |
controller.signal.dispatchEvent(beforeAbortEvent); | |
// - up until here the execution/finalization of this | |
// `abort` resolver can still be cancelled through | |
// any registered 'beforeabort' listener via its | |
// handler function invoking `evt.preventDefault`. | |
// event based guard. | |
if (beforeAbortEvent.defaultPrevented === false) { | |
state.aborted = true; | |
// success = 'aborted'; | |
// abort controller. | |
controller.abort(reason); | |
} | |
} | |
// return success; | |
}, | |
}['abort']))(asyncState, abortController); | |
const abortHandler = handleAsyncAbort.bind({ | |
reject: rejectPromise, | |
state: asyncState, | |
}); | |
abortSignal.addEventListener('abort', abortHandler, { | |
signal: listenerController.signal, | |
}); | |
Object.defineProperty(promise, 'status', { | |
enumerable: false, | |
configurable: true, | |
get: statusFromBoundState.bind(asyncState), | |
}); | |
let result; | |
if (executeCustom) { | |
try { | |
executeCustom(resolvePromise, rejectPromise, abortPromise, abortSignal); | |
} catch (exception) { | |
rejectPromise(new AsyncAbortableExecuteError(exception)); | |
} | |
// abortable ... {Promise} | |
result = promise; | |
} else { | |
// with abort ... {AbortablePromiseWithResolversAndSignal} | |
result = { | |
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. | |
*/ | |
function abortable(executeCustom) { | |
if (typeof executeCustom !== 'function') { | |
throw new TypeError( | |
'The single mandatory argument must be a function type.' | |
); | |
} | |
return defineAsyncAbortable(executeCustom); | |
} | |
/** | |
* `Promise.withAbort()`` | |
* | |
* Returns `{ promise, resolve, reject, abort, signal }` so one | |
* can wire an abortable promise without an executor-callback. | |
* | |
* @returns {AbortablePromiseWithResolversAndSignal} | |
*/ | |
function withAbort() { | |
return defineAsyncAbortable(); | |
} | |
// apply minifier safe function names. | |
Object.defineProperty(abortable, 'name', { | |
enumerable: false, | |
writable: false, | |
configurable: true, | |
value: 'abortable', | |
}); | |
Object.defineProperty(withAbort, 'name', { | |
enumerable: false, | |
writable: false, | |
configurable: true, | |
value: 'withAbort', | |
}); | |
// introduce two new static `Promise` methods. | |
Object.defineProperty(Promise, 'abortable', { | |
enumerable: false, | |
writable: false, | |
configurable: true, | |
value: abortable, | |
}); | |
Object.defineProperty(Promise, 'withAbort', { | |
enumerable: false, | |
writable: false, | |
configurable: true, | |
value: withAbort, | |
}); |
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( | |
'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