Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- Added a new event `settled` to `EventListener` [#84](https://github.com/TENSIILE/saborter/pull/84/changes/34843174ea21c79f83142c328e326cd3dffff3ee)
- Added strict typing for the `isAbortError` function where the typeguard targets `AbortError` [#85](https://github.com/TENSIILE/saborter/pull/85/changes/efb8d5faa5029e580127b447c26ec860284f2fde)
- Added `Response` exception to the `catch` block when `response.ok` is `false` when using the short `fetch` format [#85](https://github.com/TENSIILE/saborter/pull/85/changes/d096d569aadd3ad6c7aa9e1b08a679e41fb0fe49)
- Added `debounce` option directly to `Aborter's` `try` method
- Added `debounce` option directly to `Aborter's` `try` method [#86](https://github.com/TENSIILE/saborter/pull/86/changes)

### Bug Fixes

Expand Down
12 changes: 7 additions & 5 deletions docs/event-listener.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

## 📖 Overview

The `EventListener` class provides an event management system for handling operation interruption events. It implements the observer pattern for events of types `aborted` and `cancelled`.
The `EventListener` class provides an event management system for handling system interruptions. It implements the observer pattern for events of a certain set of types.

## 📝 Event Types

Two event types are supported:
The following event types are supported:

- **`aborted`** - operation was aborted
- `listener: (error: AbortError): void`
Expand All @@ -16,6 +16,8 @@ Two event types are supported:
- `listener: (data: any): void`
- **`rejected`** - event triggered when an operation fails with an error
- `listener: (error: Error): void`
- **`settled`** - event is triggered when the operation completes, both with an error and successfully
- `listener: (value: { status: 'fulfilled'; value: any; } | { status: 'rejected'; reason: Error }): void`

## 🔧 API

Expand All @@ -27,7 +29,7 @@ Adds an event listener for the specified event type.

**Parameters:**

- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected'` - event type
- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected' | 'settled'` - event type
- `listener: (error: AbortError | Error | any): void` - event handler function

**Returns:** A function to remove the event listener (unsubscribe)
Expand All @@ -49,7 +51,7 @@ Removes an event listener for the specified event type.

**Parameters:**

- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected'` - event type
- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected' | 'settled'` - event type
- `listener: (error: AbortError | Error | any): void` - event handler function to remove

`dispatchEvent(type, error): void`
Expand All @@ -58,7 +60,7 @@ Dispatches an event of the specified type, calling all registered handlers.

**Parameters:**

- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected'` - event type to dispatch
- `type: 'aborted' | 'cancelled' | 'fulfilled' | 'rejected' | 'settled'` - event type to dispatch
- `event: AbortError | Error | any` - event data passed to handlers

**Special Note:** When dispatching `'aborted'` or `'cancelled'` events, the global `onabort` handler is also called.
Expand Down
111 changes: 59 additions & 52 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,28 +84,6 @@ We constantly encounter situations where an incipient request needs to be cancel

### Basic Usage

```typescript
import { Aborter } from 'saborter';

// Create an Aborter instance
const aborter = new Aborter();

// Use for the request
const fetchData = async () => {
try {
const result = await aborter.try(async () => {
const response = await fetch('/api/data');
return response.json();
});
console.log('Data received:', result);
} catch (error) {
console.error('Request error:', error);
}
};
```

### Interrupting requests through direct signal transmission

```javascript
import { Aborter } from 'saborter';

Expand Down Expand Up @@ -175,16 +153,19 @@ This allows it to work with libraries such as [`axios`](https://www.npmjs.com/pa
const aborter = new Aborter();

// Minimal fetch
const results = await aborter.try(() => fetch('/api/data'));
const results = await aborter.try(() => fetch('/api/data'), { provision: true });

// Fetch
const results = await aborter.try(async () => {
const response = await fetch('/api/data');
return response.json();
});
const results = await aborter.try(
async () => {
const response = await fetch('/api/data');
return response.json();
},
{ provision: true }
);

// Axios
const results = await aborter.try(() => axios.get('/api/users').then((res) => res.data));
const results = await aborter.try(() => axios.get('/api/users').then((res) => res.data), { provision: true });
```

### 4. Built-in debounce functionality
Expand Down Expand Up @@ -315,6 +296,16 @@ const aborter = new Aborter(options?: AborterOptions);
*/
onAbort?: OnAbortCallback;

/*
Callback function called when `aborted` events occur.
*/
onInterrupt?: OnAbortCallback;

/*
Callback function called when `cancelled` events occur.
*/
onCancel?: OnAbortCallback;

/*
A function called when the request state changes.
It takes the new state as an argument.
Expand Down Expand Up @@ -413,7 +404,7 @@ Executes an asynchronous request with the ability to cancel.
- `reason?: any` - A field storing the error reason.
- `metadata?: any` - Interrupt-related data. The best way to pass any data inside the error.
- `unpackData?: boolean` (Default is `true`) - Automatically unwraps JSON if the `try` method receives a `Response` instance, for example, returns `fetch()`.
- `provision?: boolean` (Default is `true`) - Enables or disables automatic injection of the `Aborter` context for `fetch` and `XMLHttpRequest` calls.
- `provision?: boolean` - Enables or disables automatic injection of the `Aborter` context for `fetch` and `XMLHttpRequest` calls.

**Returns:** `Promise<T>`

Expand Down Expand Up @@ -657,7 +648,7 @@ requestPromise.catch((error) => {
}
});

// Cancel
// Abort
aborter.abort(new AbortError('Custom AbortError message', { reason: 1 }));
```

Expand Down Expand Up @@ -726,9 +717,8 @@ import { AbortError } from 'saborter/errors';

// Create an Aborter instance
const aborter = new Aborter({
onAbort: (error) => {
if (error.type === 'cancelled') {
// handling request cancellation via a callback
onCancel: (error) => {
// handling request cancellation via a callback
}
});

Expand Down Expand Up @@ -787,13 +777,18 @@ const results = await aborter.try(

### Interrupting requests without direct signal transmission (Provision API)

> [!WARNING]
> This `API` works exclusively with one request inside the callback of the `try` method!

If you don't want to pass `Aborter` arguments to the `Fetch API` or `XMLHttpRequest API`, you don't have to. `Aborter` will automatically pass data from its context.

Overriding the `Provision API` for the entire Aborter instance via the class constructor. Configuration performed by this method is applied.

```javascript
import { Aborter } from 'saborter';

// Create an Aborter instance
const aborter = new Aborter();
const aborter = new Aborter({ provision: true });

// We don't get the signal from the argument
const result = await aborter.try(async () => {
Expand All @@ -815,11 +810,14 @@ const aborter = new Aborter();
const controller = new AbortController();

// We don't get the signal from the argument
const result = await aborter.try(async () => {
// Crossing signals under the hood
const response = await fetch('/api/posts', { signal: controller.signal });
return response.json();
});
const result = await aborter.try(
async () => {
// Crossing signals under the hood
const response = await fetch('/api/posts', { signal: controller.signal });
return response.json();
},
{ provision: true }
);
```

#### **Examples:**
Expand All @@ -829,33 +827,42 @@ const result = await aborter.try(async () => {
```typescript
import axios from 'axios';

const users = await aborter.try(async () => {
const response = await axios.get<User[]>('/api/users');
return response.data;
});
const users = await aborter.try(
async () => {
const response = await axios.get<User[]>('/api/users');
return response.data;
},
{ provision: true }
);
```

**Wretch:**

```typescript
import wretch from 'wretch';

const users = await aborter.try(async () => {
const users = await wretch('/api').get('/users').json<User[]>();
return users;
});
const users = await aborter.try(
async () => {
const users = await wretch('/api').get('/users').json<User[]>();
return users;
},
{ provision: true }
);
```

**Ky:**

```typescript
import ky from 'ky';

const users = await aborter.try(() => {
return ky('/api/users', {
retry: { shouldRetry: () => false }
}).json<User[]>();
});
const users = await aborter.try(
() => {
return ky('/api/users', {
retry: { shouldRetry: () => false }
}).json<User[]>();
},
{ provision: true }
);
```

### Resource Cleanup
Expand Down
4 changes: 2 additions & 2 deletions src/features/event-listener/event-listener.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ export interface EventListenerConstructorOptions {
*/
onStateChange?: OnStateChangeCallback;
/**
* Callback function called when 'aborted' events occur.
* Callback function called when `aborted` events occur.
*/
onInterrupt?: OnAbortCallback;
/**
* Callback function called when 'cancelled' events occur.
* Callback function called when `cancelled` events occur.
*/
onCancel?: OnAbortCallback;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const setTimeoutAsync = <T, A extends [unknown?, ...unknown[]] = []>(
return reject(copyAbortError(signal.reason, { initiator: setTimeoutAsync.name }));
}

const error = new AbortError(`${setTimeoutAsync.name} was interrupted`, {
const error = new AbortError(`The callback was interrupted`, {
initiator: setTimeoutAsync.name,
reason: signal.reason
});
Expand Down
41 changes: 29 additions & 12 deletions src/modules/aborter/aborter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RequestState, emitRequestState } from '../../features/state-observer';
import { AbortError, isAbortError } from '../../features/abort-error';
import { EventListener, clearEventListeners } from '../../features/event-listener';
import { injectAborterContextIntoHttpRequest, setAborterContextProvisionMode } from '../../features/lib/fetch';
import { debounce } from '../../features/lib/debounce';
import { debounce as debounceFn } from '../../features/lib/debounce/debounce.lib';
import { ServerBreaker } from '../../features/server-breaker';
import { Timeout, TimeoutError } from '../../features/timeout';
import { ErrorMessage, disposeSymbol } from './aborter.constants';
Expand Down Expand Up @@ -59,10 +59,18 @@ export class Aborter implements Types.AborterType {
*/
protected serverBreaker: ServerBreaker = new ServerBreaker();

/**
* Overriding the `Provision API` for the entire `Aborter` instance. Configuration via the method is not applied.
*/
protected provision?: boolean;

private debouncedFn?: (signal: AbortSignal) => any;

constructor(options?: Types.AborterOptions<Aborter>) {
this.listeners = new EventListener(options);

this.try = this.try.bind(this);
this.provision = options?.provision;

if (!options?.interruptionOnServer) {
this.serverBreaker.off();
Expand Down Expand Up @@ -111,14 +119,15 @@ export class Aborter implements Types.AborterType {
if ((['fulfilled', 'rejected', 'aborted'] as RequestState[]).indexOf(state) !== -1) {
this.timeout.clearTimeout();
this.isRequestInProgress = false;
this.debouncedFn = undefined;
}
};

private tryImpl<R>(
request: Types.AbortableRequest<any>,
{ isErrorNativeBehavior, timeout, unpackData, provision }: Types.FnTryOptions = {}
): Promise<R> {
setAborterContextProvisionMode(!!provision);
setAborterContextProvisionMode(provision !== undefined ? provision : !!this.provision);

if (this.isRequestInProgress) {
const cancelledAbortError = new AbortError(ErrorMessage.CancelRequest, {
Expand Down Expand Up @@ -202,6 +211,20 @@ export class Aborter implements Types.AborterType {
return promise;
}

private async tryDebounceImpl<R>(request: Types.AbortableRequest<any>, options: Types.FnTryOptions): Promise<R> {
if (this.debouncedFn && this.isRequestInProgress) {
this.abort();
}

this.abortController = new AbortController();

this.isRequestInProgress = true;

this.debouncedFn = debounceFn<R>(() => this.tryImpl(request, options), options.debounce);

return this.debouncedFn(this.abortController.signal);
}

/**
* Performs an asynchronous request with cancellation of the previous request, preventing the call of the catch block when the request is canceled and the subsequent finally block.
* @param request callback function
Expand All @@ -214,18 +237,12 @@ export class Aborter implements Types.AborterType {

public try<R>(
request: Types.AbortableRequest<any>,
{
isErrorNativeBehavior = false,
timeout,
unpackData = true,
provision = true,
debounce: debounceMs
}: Types.FnTryOptions = {}
{ isErrorNativeBehavior = false, timeout, unpackData = true, provision, debounce }: Types.FnTryOptions = {}
): Promise<R> {
const certainOptions = { isErrorNativeBehavior, timeout, unpackData, provision };
const certainOptions = { isErrorNativeBehavior, timeout, unpackData, provision, debounce };

if (debounceMs) {
return debounce<R>(() => this.tryImpl(request, certainOptions), debounceMs)(this.signal);
if (debounce) {
return this.tryDebounceImpl(request, certainOptions);
}

return this.tryImpl(request, certainOptions);
Expand Down
Loading
Loading