From f65f930298df1f62feed8b5d4a8554161d03fa9a Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Fri, 29 Oct 2021 23:45:14 +0200 Subject: [PATCH] Add cancelable decorator --- README.md | 78 ++++++++++++---- package.json | 5 +- src/cancelable.spec.ts | 204 +++++++++++++++++++++++++++++++++++++++-- src/cancelable.ts | 101 +++++++++++++++++++- src/index.ts | 1 + 5 files changed, 360 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d7db014..55be782 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ -# TypeScript Async Method Decorators +# TypeScript Async Method Decorators 🧰 [![NPM](https://img.shields.io/npm/v/ts-async-decorators.svg)](https://www.npmjs.com/package/ts-async-decorators) [![Downloads](https://img.shields.io/npm/dm/ts-async-decorators)](https://www.npmjs.com/package/ts-async-decorators) [![Tests](https://github.com/dokmic/ts-async-decorators/actions/workflows/tests.yaml/badge.svg?branch=master)](https://github.com/dokmic/ts-async-decorators/actions/workflows/tests.yaml) [![Code Coverage](https://codecov.io/gh/dokmic/ts-async-decorators/badge.svg?branch=master)](https://codecov.io/gh/dokmic/ts-async-decorators?branch=master) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -This package provides a collection of asynchronous method decorators. +This package provides a collection of asynchronous method decorators with an elegant declarative API. ## What's Inside? - `after` - post action. - `before` - pre action. +- `cancelable` - canceling execution. - `debounce` - delaying execution by timeout since the last call. - `retry` - retrying on fail. - `semaphore` - limiting the number of simultaneous calls. @@ -64,6 +65,54 @@ class SomeClass { } ``` +### `cancelable` +Wraps a call inside a cancelable promise. + +```typescript +cancelable({ onCancel = undefined }): Decorator +``` +- `onCancel: (instance) => void` - The action to call on canceling the returned promise. + +There is also an option to set the cancelation callback from within the decorated method. + +```typescript +onCancel(callback): void +``` +- `callback: (instance) => void` - The callback that will be called on promise cancelation. + +#### Example using parameters +```typescript +import { cancelable } from 'ts-async-decorators'; + +class SomeClass { + @cancelable({ onCancel() { this.stop(); } }) + start() { + // ... + } + + stop() { + // ... + } +} +``` + +#### Example using `onCancel` +```typescript +import { cancelable, onCancel } from 'ts-async-decorators'; + +class SomeClass { + @cancelable() + fetch() { + const controller = new AbortController(); + const { signal } = controller; + onCancel(() => controller.abort()); + + return fetch('http://example.com', { signal }); + } +} +``` + + ### `debounce` Delays execution by timeout since the last call. @@ -164,29 +213,20 @@ timeout({ timeout, reason = 'Operation timeout.' }): Decorator #### Example ```typescript -import PCancelable from 'p-cancelable'; -import { timeout } from 'ts-async-decorators'; +import { cancelable, timeout, onCancel } from 'ts-async-decorators'; class SomeClass { @timeout({ timeout: 10000, reason = 'Fetch timeout.' }) - fetchOne() { + @cancelable() + fetch() { const controller = new AbortController(); const { signal } = controller; + onCancel(() => controller.abort()); - const promise = fetch('http://example.com', { signal }); - - return Object.assign(promise, { cancel: () => controller.abort() }); - } - - @timeout({ timeout: 10000, reason = 'Fetch timeout.' }) - fetchTwo() { - return new PCancelable((resolve, reject, onCancel) => { - const controller = new AbortController(); - const { signal } = controller; - - fetch('http://example.com', { signal }).then(resolve, reject); - onCancel(() => controller.abort()); - }); + return fetch('http://example.com', { signal }); } } ``` + +## Examples +- [Bluetooth Low-Energy Peripheral Device](https://github.com/dokmic/bluetooth-device). diff --git a/package.json b/package.json index 0949fda..787362a 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "ts-async-decorators", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript Async Method Decorators", "keywords": [ "typescript", "decorator", "async", "promise", + "abortable", + "cancelable", "debounce", "retry", + "mutex", "semaphore", "throttle", "timeout" diff --git a/src/cancelable.spec.ts b/src/cancelable.spec.ts index 39bee6b..30bd53e 100644 --- a/src/cancelable.spec.ts +++ b/src/cancelable.spec.ts @@ -1,22 +1,212 @@ +// eslint-disable-next-line max-classes-per-file import PCancelable from 'p-cancelable'; -import { isCancelable } from './cancelable'; +import { cancelable, isCancelable, onCancel, CancelError } from './cancelable'; describe('isCancelable', () => { it('should return true for a cancelable promise', () => { - const cancelable = new PCancelable((resolve) => resolve()); + const promise = new PCancelable((resolve) => resolve()); - expect(isCancelable(cancelable)).toBeTrue(); + expect(isCancelable(promise)).toBeTrue(); }); it('should return true for a custom cancelable promise', () => { - const cancelable = Object.assign(Promise.resolve(), { cancel: jest.fn() }); + const promise = Object.assign(Promise.resolve(), { cancel: jest.fn() }); - expect(isCancelable(cancelable)).toBeTrue(); + expect(isCancelable(promise)).toBeTrue(); }); it('should return false for a non-promise value', () => { - const cancelable = { cancel: jest.fn() }; + const promise = { cancel: jest.fn() }; - expect(isCancelable(cancelable)).toBeFalse(); + expect(isCancelable(promise)).toBeFalse(); + }); +}); + +describe('cancelable', () => { + const callback = jest.fn(); + const payload = jest.fn(); + + class TestClass { + @cancelable({ onCancel: callback }) + method1() { + return payload(); + } + + @cancelable() + method2() { + return payload(); + } + } + + let instance: TestClass; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + instance = new TestClass(); + }); + + it('should return a cancelable promise', () => { + const result = instance.method1(); + + expect(result).toBeInstanceOf(Promise); + expect(result).toHaveProperty('cancel', expect.any(Function)); + }); + + it('should resolve with the decorated method result', async () => { + payload.mockReturnValueOnce('something'); + + await expect(instance.method1()).resolves.toBe('something'); + }); + + it('should reject with the thrown value', async () => { + payload.mockImplementationOnce(() => { + throw new Error('something'); + }); + + await expect(instance.method1()).rejects.toEqual(new Error('something')); + }); + + it('should wrap a returned promise', async () => { + payload.mockResolvedValueOnce('something'); + + await expect(instance.method1()).resolves.toBe('something'); + }); + + it('should call a callback on cancelation', async () => { + payload.mockReturnValueOnce(new Promise(jest.fn())); + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(callback).toHaveBeenCalled(); + }); + + it('should cancel a wrapped cancelable promise', async () => { + const cancel = jest.fn(); + const promise = Object.assign(new Promise(jest.fn()), { cancel }); + + payload.mockReturnValueOnce(promise); + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(cancel).toHaveBeenCalled(); + }); + + it('should not fail when there is no cancelation callback', async () => { + payload.mockReturnValueOnce(new Promise(jest.fn())); + const result = instance.method2(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + }); +}); + +describe('onCancel', () => { + const payload = jest.fn(); + + class TestClass { + @cancelable() + method1() { + return payload(); + } + + @cancelable() + method2() { + return payload(); + } + } + + let instance: TestClass; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + instance = new TestClass(); + }); + + it('should set cancelation callback from within a cancelable method', async () => { + const callback = jest.fn(); + payload.mockImplementation(() => onCancel(callback)); + + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(callback).toHaveBeenCalled(); + }); + + it('should set cancelation callback from within an asynchronous method', async () => { + const cancel = jest.fn(); + payload.mockImplementation( + async () => + new Promise(() => { + onCancel(cancel); + }), + ); + + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(cancel).toHaveBeenCalled(); + }); + + it('should throw if called outside a cancelable method', () => { + expect(() => onCancel(jest.fn())).toThrow(); + }); + + it('should keep context for nested calls', async () => { + const cancel1 = jest.fn(); + const cancel2 = jest.fn(); + + payload.mockImplementationOnce(() => { + instance.method2(); + onCancel(cancel1); + }); + payload.mockImplementationOnce(() => { + onCancel(cancel2); + }); + + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(cancel2).toHaveBeenCalledAfter(cancel1); + }); + + it('should not lose context when a method throws', async () => { + const cancel = jest.fn(); + + payload.mockImplementationOnce(() => { + instance.method2().catch(jest.fn()); + onCancel(cancel); + }); + payload.mockImplementationOnce(() => { + throw new Error(); + }); + + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); + expect(cancel).toHaveBeenCalled(); + }); + + it('should not reject after cancelation', async () => { + payload.mockImplementation( + () => + new Promise((resolve, reject) => { + onCancel(() => reject(new Error())); + }), + ); + + const result = instance.method1(); + result.cancel(); + + await expect(result).rejects.toBeInstanceOf(CancelError); }); }); diff --git a/src/cancelable.ts b/src/cancelable.ts index 8308c53..2bbbab1 100644 --- a/src/cancelable.ts +++ b/src/cancelable.ts @@ -1,7 +1,104 @@ -import type PCancelable from 'p-cancelable'; +import PCancelable, { CancelError } from 'p-cancelable'; +import { Decorator, decorate } from './decorate'; -type Cancelable = Promise & Pick, 'cancel'>; +export { CancelError }; +/** + * Cancelable promise. + * The interface is compatible with the most popular promise libraries. + */ +export interface Cancelable extends Promise { + /** + * Cancels the promise. + * @param reason - The cancelation reason. + */ + cancel(reason?: string): void; +} + +/** + * Checks whether a value is a cancelable promise. + * @param value - The value to check. + */ export function isCancelable(value: unknown): value is Cancelable { return value instanceof Promise && typeof (value as Partial).cancel === 'function'; } + +/** + * The `cancelable` decorator parameters. + */ +interface Parameters { + /** + * The action to call on canceling the returned promise. + * @param instance - The decorated method object's instance. + */ + onCancel?(this: T, instance: T): void; +} + +type Callback = Required>['onCancel']; + +// eslint-disable-next-line no-use-before-define +let context: typeof onCancel | undefined; + +/** + * Sets the cancelation callback. + * @param callback - The callback that will be called on promise cancelation. + * @example + * cancelable() + * fetch() { + * const controller = new AbortController(); + * const { signal } = controller; + * onCancel(() => controller.abort()); + * + * return fetch('http://example.com', { signal }); + * } + */ +export function onCancel(callback: Callback): void { + if (!context) { + throw new Error('The cancelation callback cannot be set outside the cancelable method.'); + } + + return context(callback); +} + +/** + * Wraps a call inside a cancelable promise. + * @example + * cancelable({ cancel() { this.stop(); } }) + * start() { + * // ... + * } + * @param parameters - The decorator parameters. + * @returns The method decorator. + */ +// eslint-disable-next-line no-shadow +export function cancelable({ onCancel }: Parameters = {}): Decorator { + return decorate( + ({ callback, instance }) => + new PCancelable(async (resolve, reject, onWrapperCancel) => { + let promise: unknown; + + onWrapperCancel(() => { + onCancel?.call(instance, instance); + + if (isCancelable(promise)) { + promise.cancel(); + } + }); + + try { + const previousContext = context; + try { + // eslint-disable-next-line no-return-assign, no-param-reassign + context = (value) => (onCancel = value as unknown as typeof onCancel); + promise = callback(); + } finally { + context = previousContext; + } + + resolve(await promise); + } catch (error) { + reject(error); + } + }), + ); +} diff --git a/src/index.ts b/src/index.ts index 3efa8a7..715814e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './after'; export * from './before'; +export * from './cancelable'; export * from './debounce'; export * from './retry'; export * from './semaphore';