Skip to content

Instantly share code, notes, and snippets.

@andrewcourtice
Last active April 21, 2024 09:08
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andrewcourtice/ef1b8f14935b409cfe94901558ba5594 to your computer and use it in GitHub Desktop.
Save andrewcourtice/ef1b8f14935b409cfe94901558ba5594 to your computer and use it in GitHub Desktop.
Async cancellation using promise extension and abort controller
/*
This a basic implementation of task cancellation using a Promise extension
combined with an AbortController. There are 3 major benefits to this implementation:
1. Because it's just an extension of a Promise the Task is fully
compatible with the async/await syntax.
2. By using the abort controller as a native cancellation token
fetch requests and certain DOM operations can be cancelled inside the task.
3. By passing the controller from parent tasks to new child tasks an entire
async chain can be cancelled using a single AbortController.
*/
type Product<T = any> = (...args: any[]) => T;
type TaskAbortCallback = (reason?: any) => void;
type TaskExecutor<T> = (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => any,
controller: AbortController,
onAbort: (callback: TaskAbortCallback) => void
) => void;
function safeRun<T = any>(bodyInvokee: Product<T>, finallyInvokee: Product<void>): Product<T> {
return (...args: any[]) => {
try {
return bodyInvokee(...args);
} finally {
finallyInvokee();
}
};
}
class Task<T = void> extends Promise<T> {
private controller: AbortController;
private abortReason: any;
constructor(executor: TaskExecutor<T>, controller: AbortController = new AbortController()) {
if (controller.signal.aborted) {
throw new Error('Cannot attach task to an already aborted controller');
}
const listeners = new Set<Product<void>>();
const addListener = (listener: Product<void>) => {
listeners.add(listener);
controller.signal.addEventListener('abort', listener);
};
const removeListener = (listener: Product<void>) => {
listeners.delete(listener);
controller.signal.removeEventListener('abort', listener);
};
const cleanup = () => {
if (listeners.size > 0) {
listeners.forEach(removeListener);
}
};
super((_resolve, _reject) => {
const resolve = safeRun(_resolve, cleanup);
const reject = safeRun(_reject, cleanup);
const onAbort = (callback: TaskAbortCallback) => {
const listener = safeRun(
() => callback(this.abortReason),
() => removeListener(listener)
);
addListener(listener);
};
executor(resolve, reject, controller, onAbort);
});
this.controller = controller;
}
public get signal(): AbortSignal {
return this.controller.signal;
}
public get hasAborted(): boolean {
return this.signal.aborted;
}
public abort(reason?: any): this {
this.abortReason = reason;
this.controller.abort();
return this;
}
}
function createTask(message: string, timeout: number = 1000): Task<string> {
// Create a new task the same way you create a promise
return new Task((resolve, reject, controller, onAbort) => {
const handle = window.setTimeout(() => resolve(message), 1000);
/*
Register an onAbort handler to instruct the task how to handle cancellation.
This is also handy for cleaning up resources such as timer handles
*/
onAbort(() => {
window.clearTimeout(handle);
reject();
});
/*
The controller could be passed to any number of child tasks
to synchronise the cancellation all the way through a chain
of async operations.
eg. new Task((resolve, reject, controller, onAbort) => {
// Do some nested async operation
}, controller);
Note the second parameter to the Task constructor is an
existing AbortController. Handy for passing controllers
down to child tasks.
*/
});
}
async function run(): Promise<void> {
const task = createTask('hello');
// Abort the task before it has a chance to complete
window.setTimeout(() => task.abort(), 500);
try {
const result = await task;
console.log(result);
} catch {
console.log('aborted');
}
}
function getUserData(id: string): Task<object> {
// Create a new task the same way you create a promise
return new Task(async (resolve, reject, controller) => {
try {
/*
Pass the abort controller signal into the fetch api
to cancel the request when the task is aborted
*/
const response = await window.fetch(`/api/users/${id}`, {
signal: controller.signal
});
const data = await response.json();
resolve(data);
} catch (error) {
reject(error)
}
});
}
async function run(): Promise<void> {
const task = getUserData('some-id');
// Abort the task before it has a chance to complete
window.setTimeout(() => task.abort(), 100);
try {
const result = await task;
console.log(result);
} catch {
console.log('aborted');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment