Skip to content

Instantly share code, notes, and snippets.

@petsel
Last active September 10, 2025 09:26
Show Gist options
  • Save petsel/1d12c6b9fbdf39e9dd7d327e03ebc6d7 to your computer and use it in GitHub Desktop.
Save petsel/1d12c6b9fbdf39e9dd7d327e03ebc6d7 to your computer and use it in GitHub Desktop.
/**
* 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,
});
/*
* 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