Skip to content

Instantly share code, notes, and snippets.

@petsel
Last active October 14, 2025 13:41
Show Gist options
  • Select an option

  • Save petsel/1d12c6b9fbdf39e9dd7d327e03ebc6d7 to your computer and use it in GitHub Desktop.

Select an option

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(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,
});
/*
* 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