diff --git a/core/stdlib/src/async/cancellablePromise/index.test.ts b/core/stdlib/src/async/cancellablePromise/index.test.ts new file mode 100644 index 0000000..e87788a --- /dev/null +++ b/core/stdlib/src/async/cancellablePromise/index.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { cancellablePromise, CancelledError } from '.'; + +describe('cancellablePromise', () => { + it('resolve the promise normally when not cancelled', async () => { + const { promise } = cancellablePromise(Promise.resolve('data')); + + await expect(promise).resolves.toBe('data'); + }); + + it('reject the promise normally when not cancelled', async () => { + const error = new Error('test-error'); + const { promise } = cancellablePromise(Promise.reject(error)); + + await expect(promise).rejects.toThrow(error); + }); + + it('reject with CancelledError when cancelled before resolve', async () => { + const { promise, cancel } = cancellablePromise( + new Promise((resolve) => setTimeout(() => resolve('data'), 100)), + ); + + cancel(); + + await expect(promise).rejects.toBeInstanceOf(CancelledError); + await expect(promise).rejects.toThrow('Promise was cancelled'); + }); + + it('reject with CancelledError with custom reason', async () => { + const { promise, cancel } = cancellablePromise( + new Promise((resolve) => setTimeout(() => resolve('data'), 100)), + ); + + cancel('Request aborted'); + + await expect(promise).rejects.toBeInstanceOf(CancelledError); + await expect(promise).rejects.toThrow('Request aborted'); + }); + + it('cancel prevents onSuccess from being called', async () => { + const onSuccess = vi.fn(); + + const { promise, cancel } = cancellablePromise( + new Promise((resolve) => setTimeout(() => resolve('data'), 100)), + ); + + cancel(); + + try { + await promise; + } + catch { + // expected + } + + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('CancelledError has correct name property', () => { + const error = new CancelledError(); + + expect(error.name).toBe('CancelledError'); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Promise was cancelled'); + }); + + it('CancelledError accepts custom message', () => { + const error = new CancelledError('Custom reason'); + + expect(error.message).toBe('Custom reason'); + }); +}); diff --git a/core/stdlib/src/async/cancellablePromise/index.ts b/core/stdlib/src/async/cancellablePromise/index.ts new file mode 100644 index 0000000..272874f --- /dev/null +++ b/core/stdlib/src/async/cancellablePromise/index.ts @@ -0,0 +1,49 @@ +export class CancelledError extends Error { + constructor(reason?: string) { + super(reason ?? 'Promise was cancelled'); + this.name = 'CancelledError'; + } +} + +export interface CancellablePromise { + promise: Promise; + cancel: (reason?: string) => void; +} + +/** + * @name cancellablePromise + * @category Async + * @description Wraps a promise with a cancel capability, allowing the promise to be rejected with a CancelledError + * + * @param {Promise} promise - The promise to make cancellable + * @returns {CancellablePromise} - An object with the wrapped promise and a cancel function + * + * @example + * const { promise, cancel } = cancellablePromise(fetch('/api/data')); + * cancel(); // Rejects with CancelledError + * + * @example + * const { promise, cancel } = cancellablePromise(longRunningTask()); + * setTimeout(() => cancel('Timeout'), 5000); + * const [error] = await tryIt(() => promise)(); + * + * @since 0.0.10 + */ +export function cancellablePromise(promise: Promise): CancellablePromise { + let rejectPromise: (reason: CancelledError) => void; + + const wrappedPromise = new Promise((resolve, reject) => { + rejectPromise = reject; + + promise.then(resolve, reject); + }); + + const cancel = (reason?: string) => { + rejectPromise(new CancelledError(reason)); + }; + + return { + promise: wrappedPromise, + cancel, + }; +} diff --git a/core/stdlib/src/async/index.ts b/core/stdlib/src/async/index.ts index 1d96a50..35af1b2 100644 --- a/core/stdlib/src/async/index.ts +++ b/core/stdlib/src/async/index.ts @@ -1,2 +1,3 @@ +export * from './cancellablePromise'; export * from './sleep'; export * from './tryIt';