diff --git a/.eslintrc b/.eslintrc index 17849f2..63ecd76 100644 --- a/.eslintrc +++ b/.eslintrc @@ -32,6 +32,7 @@ "import/no-extraneous-dependencies": ["off"], "@typescript-eslint/no-unused-vars": ["off", {}], "no-unused-vars": ["off"], + "dot-notation": "off", "prettier/prettier": [ "error", { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7a8d21b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + pull_request: + push: + branches: + - master + - develop + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm ci + - run: npm test -- --coverage --coverageReporters=lcov + - uses: coverallsapp/github-action@v2 + with: + file: coverage/lcov.info diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c62238..def3b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Saborter Changelog +## v2.2.0 (Match 31th, 2026) + +### New Features + +- Added the `abortSignalAny` utility function [#60](https://github.com/TENSIILE/saborter/pull/60) +- Added integration functionality with the `@saborter/server` package [#60](https://github.com/TENSIILE/saborter/pull/60) +- Improved JSdoc documentation for `Aborter` [#60](https://github.com/TENSIILE/saborter/pull/60) + +### Bug Fixes + +- Fixed a bug in the `debounce` and `setTimeoutAsync` utilities with overriding the `initiator` field in the `AbortError` error [#60](https://github.com/TENSIILE/saborter/pull/60) + ## v2.1.0 (March 18th, 2026) ### New Features diff --git a/cspell.json b/cspell.json index aa74856..8548834 100644 --- a/cspell.json +++ b/cspell.json @@ -1,7 +1,7 @@ { "version": "0.2", "language": "en,ru", - "words": ["Saborter", "saborter", "Laptev", "Vladislav", "tgz", "Сalls"], + "words": ["Сalls", "Laptev", "saborter", "Saborter", "tgz", "Vladislav", "yxxx", "TENSIILE"], "flagWords": [], "ignorePaths": [ "node_modules/**", diff --git a/docs/libs.md b/docs/libs.md index 8107bf5..8284617 100644 --- a/docs/libs.md +++ b/docs/libs.md @@ -334,6 +334,93 @@ const processItems = (items: unknown[], signal: AbortSignal) => { }; ``` +### `abortSignalAny` + +Combines multiple abort signals into a single signal that aborts when any of the input signals aborts. This is useful when you need to cancel an operation if any of several independent signals (e.g., from different sources) become aborted. + +**Signature:** + +```typescript +export const abortSignalAny = (...args: T[]): AbortSignal +``` + +Where `AbortSignalLike = AbortSignal | null | undefined`. + +**Parameters:** + +- `...args` – A rest parameter that accepts any number of arguments. Each argument can be: + - A single `AbortSignal` (or `null`/`undefined` – these are ignored). + - An array of `AbortSignalLike`. + +This function accepts either an unlimited number of signals or an array of signals. + +**Returns:** + +A new `AbortController.signal` that will be aborted when `any` of the input signals aborts. If any input signal is already aborted when the function is called, the returned signal is immediately aborted. + +**Description:** + +The function works as follows: + +1. Flattens the provided arguments into a single array of signals (ignoring `null` or `undefined`). +2. Creates a new `AbortController`. +3. For each signal in the flattened list: + +- If the signal is already aborted, the controller is aborted immediately with a custom `AbortError` (see error handling below) and no further listeners are attached. +- Otherwise, it attaches a one‑time `'abort'` event listener to that signal. When the signal aborts, the handler is called, which: + - Aborts the controller using the same `AbortError` created from the aborting signal. + - Cleans up all listeners from all other signals (removes the `'abort'` event handlers) to prevent memory leaks. + +The function ensures that the controller is aborted only once, even if multiple signals abort simultaneously or in quick succession. + +**Error Handling:** + +The function creates a consistent `AbortError` object when aborting the controller. It uses the helper `createAbortError`, which: + +- If the signal’s `reason` is already an `AbortError`, that error is reused. +- If the signal’s `reason` is a `DOMException` with the name `'AbortError'` (as in browser‑native abort), it stores the original reason under a `cause` property. +- Otherwise, it creates a new `AbortError` with the message `'The operation was aborted'` and attaches the original reason under a `reason` property. + +In all cases, the resulting error has an `initiator` property set to `'abortSignalAny'` to help trace the source of the abort. + +**Examples:** + +#### Basic usage with two signals: + +```typescript +import { abortSignalAny } from '.saborter/lib'; + +const ac1 = new AbortController(); +const ac2 = new AbortController(); +const combined = abortSignalAny(ac1.signal, ac2.signal); + +combined.addEventListener('abort', () => console.log('Combined signal aborted!')); +ac1.abort(); // triggers combined abort +``` + +#### Using arrays: + +```typescript +const signals = [ac1.signal, ac2.signal]; +const combined = abortSignalAny(signals); +``` + +#### Handling already aborted signals: + +```typescript +const ac = new AbortController(); +ac.abort(); // signal is already aborted + +const combined = abortSignalAny(ac.signal); // returns an already aborted signal +combined.aborted; // true +``` + +#### Ignoring `null` or `undefined`: + +```typescript +const combined = abortSignalAny(null, undefined, ac.signal); // only ac.signal is considered +``` + ### `timeInMilliseconds` Converts a configuration object containing time components (hours, minutes, seconds, milliseconds) into a total number of milliseconds. All components are optional and default to `0` if not provided. diff --git a/package.json b/package.json index a205593..12c8863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "saborter", - "version": "2.1.0", + "version": "2.2.0", "description": "A simple and efficient library for canceling asynchronous requests using AbortController", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/readme.md b/readme.md index 2d690c0..b86d87f 100644 --- a/readme.md +++ b/readme.md @@ -11,8 +11,11 @@ - - + + +Coverage Status + + @@ -24,6 +27,8 @@ **Saborter** is a lightweight, dependency-free, simple, yet incredibly powerful JavaScript/TypeScript library for managing asynchronous cancellation. It builds on top of its own [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) but fully exploits its shortcomings, providing a clean, inexpensive, and convenient API. +Add a 🌟 and follow me to support the project! + ## 📚 Documentation The documentation is divided into several sections: @@ -123,7 +128,7 @@ The `Aborter` class makes it easy to cancel running requests after a period of t const aborter = new Aborter(); // Start a long-running request and cancel the request after 2 seconds -const results = aborter.try( +const results = await aborter.try( (signal) => { return fetch('/api/long-task', { signal }); }, @@ -142,14 +147,29 @@ import { debounce } from 'saborter/lib'; const aborter = new Aborter(); // The request will be delayed for 2 seconds and then executed. -const results = aborter.try( +const results = await aborter.try( debounce((signal) => { return fetch('/api/long-task', { signal }); }, 2000) ); ``` -### 4. Interrupting promises without a signal +### 4. Canceling a request on the server + +For the server to support interrupts via the `saborter`, it is necessary to use the [@saborter/server](https://github.com/TENSIILE/saborter-server) package on the server side: + +```javascript +// Create an Aborter instance +const aborter = new Aborter(); + +// The request will be cancelled on the server side +// if the request fails to complete either successfully or with an error. +const results = await aborter.try((signal, { headers }) => { + return fetch('/api/posts', { signal, headers }); +}); +``` + +### 5. Interrupting promises without a signal If you want to cancel a task with a promise: @@ -164,7 +184,7 @@ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // The callback function can be restarted each time (by calling .try() again), which will interrupt the previous call and start it again. // Or the `.abort()` method can be used to abort the callback function entirely. -const results = aborter.try( +const results = await aborter.try( async () => { await delay(2000); return Promise.resolve({ done: true }); @@ -172,7 +192,7 @@ const results = aborter.try( ); ``` -### 5. Multiple request aborts through a single `ReusableAborter` instance +### 6. Multiple request aborts through a single `ReusableAborter` instance The `ReusableAborter` class allows you to easily cancel requests an unlimited number of times while preserving all listeners: @@ -193,7 +213,7 @@ reusableAborter.abort(); // call of the listener -> console.log('aborted', e) reusableAborter.abort(); // listener recall -> console.log('aborted', e) ``` -### 6. Working with Multiple Requests +### 7. Working with Multiple Requests You can create separate instances for different groups of requests: @@ -402,6 +422,24 @@ try { > [!NOTE] > In this case, the wait for the request to be executed will be interrupted, but the request itself will still be executed. +**Canceling a request on the server:** + +To automatically abort a server-side operation when a client aborts a request, +you must use [@saborter/server](https://github.com/TENSIILE/saborter-server) on the server and pass headers on the client. + +```typescript +try { + const users = await aborter.try(async (signal, { headers }) => { + const response = await fetch('/api/users', { signal, headers }); + return response.json(); + }); +} catch (error) { + if (error instanceof AbortError) { + console.log('interrupt error handling'); + } +} +``` + **Examples using automatic cancellation after a time:** ```javascript @@ -541,6 +579,7 @@ fetchData(); The `saborter` package contains additional features out of the box that can help you: - [**@saborter/react**](https://github.com/TENSIILE/saborter-react) - a standalone library with `Saborter` and `React` integration. +- [**@saborter/server**](https://github.com/TENSIILE/saborter-server) - library that automatically cancels server-side operations when the client aborts a request. - [**saborter/lib**](./docs/libs.md) - auxiliary functions. - [**saborter/errors**](./docs/errors.md) - package errors. - [**AbortError**](./docs/errors.md#aborterror) - custom error for working with Aborter. diff --git a/src/features/abort-error/abort-error.lib.ts b/src/features/abort-error/abort-error.lib.ts index 6390e4c..d7300f1 100644 --- a/src/features/abort-error/abort-error.lib.ts +++ b/src/features/abort-error/abort-error.lib.ts @@ -53,3 +53,44 @@ export const isAbortError = (error: any): error is Error => { return !!checkErrorCause(error); }; + +const CANNOT_BE_OVERRIDDEN = ['cause', 'timestamp', 'stack', 'name'] satisfies Array; + +/** + * Creates a new `AbortError` instance that is a copy of the original, + * allowing selective override of its properties. + * + * The original error is set as the `cause` of the new error, preserving + * the error chain. This is useful when you need to augment or transform + * an abort error without losing the original context. + * + * @param abortError - The original `AbortError` to copy. + * @param override - An object with properties to override on the new error. + * The following properties cannot be overridden: + * - `cause` – always set to the original error. + * - `timestamp` – always the creation time of the new error. + * - `stack` – automatically generated. + * - `name` – always `'AbortError'`. + * All other properties of `AbortError` can be overridden. + * @returns A new `AbortError` instance with the same properties as the original, + * except those specified in `override`. + * + * @example + * const original = new AbortError('Operation aborted', { type: 'cancelled', initiator: 'user' }); + * const copy = copyAbortError(original, { message: 'Custom message', metadata: { retry: false } }); + * console.log(copy.message); // 'Custom message' + * console.log(copy.type); // 'cancelled' (from original) + * console.log(copy.cause); // original (the original error) + */ +export const copyAbortError = ( + abortError: AbortError, + override: Omit<{ [key in keyof AbortError]?: AbortError[key] }, (typeof CANNOT_BE_OVERRIDDEN)[any]> = {} +) => { + const foundOverriddenField = CANNOT_BE_OVERRIDDEN.find((key) => Object.prototype.hasOwnProperty.call(override, key)); + + if (foundOverriddenField) { + throw new TypeError(`The '${foundOverriddenField}' field cannot be overridden!`); + } + + return new AbortError(override?.message ?? abortError.message, { ...abortError, ...override, cause: abortError }); +}; diff --git a/src/features/abort-error/abort-error.test.ts b/src/features/abort-error/abort-error.test.ts index c7db1a5..4c27682 100644 --- a/src/features/abort-error/abort-error.test.ts +++ b/src/features/abort-error/abort-error.test.ts @@ -1,6 +1,6 @@ /* eslint-disable dot-notation */ /* eslint-disable no-import-assign */ -import { isAbortError } from './abort-error.lib'; +import { isAbortError, copyAbortError } from './abort-error.lib'; import { AbortError } from './abort-error'; import { ABORT_ERROR_NAME } from './abort-error.constants'; @@ -9,35 +9,35 @@ describe('isAbortError', () => { jest.clearAllMocks(); }); - describe('Проверка через instanceof AbortError', () => { - it('должна возвращать true для экземпляра AbortError', () => { + describe('Checking via instanceof AbortError', () => { + it('must return true for an AbortError instance', () => { const abortError = new AbortError('test abort'); expect(isAbortError(abortError)).toBe(true); }); - it('должна возвращать false для обычной ошибки', () => { + it('should return false for a normal error', () => { const error = new Error('regular error'); expect(isAbortError(error)).toBe(false); }); }); - describe('Проверка через Utils.isObject и совпадение имени', () => { - it('должна возвращать true для объекта с правильным name', () => { + describe('checking via Utils.isObject and name matching', () => { + it('should return true for an object with the correct name', () => { const fakeAbort = { name: 'AbortError', message: 'fake' }; expect(isAbortError(fakeAbort)).toBe(true); }); - it('должна возвращать false, если у объекта нет свойства name', () => { + it('should return false if the object does not have a name property', () => { const fakeAbort = { message: 'no name' }; expect(isAbortError(fakeAbort)).toBe(false); }); - it('должна возвращать false, если name не совпадает с ABORT_ERROR_NAME', () => { + it('should return false if name does not match ABORT_ERROR_NAME', () => { const fakeAbort = { name: 'NotAbortError' }; expect(isAbortError(fakeAbort)).toBe(false); }); - it('должна использовать глобальную константу ABORT_ERROR_NAME', () => { + it('must use the global constant ABORT_ERROR_NAME', () => { const fakeAbort = { name: 'CustomAbort' }; const originalName = ABORT_ERROR_NAME; @@ -51,8 +51,8 @@ describe('isAbortError', () => { }); }); - describe('Проверка через подстроку', () => { - it('должна возвращать true, если в error.message есть подстрока "abort"', () => { + describe('Checking via substring', () => { + it('should return true if the error.message contains the substring "abort"', () => { const errorWithShortMessage = new Error(' aborting '); expect(isAbortError(errorWithShortMessage)).toBeTruthy(); @@ -63,7 +63,7 @@ describe('isAbortError', () => { expect(isAbortError(error)).toBeTruthy(); }); - it('должна возвращать false, если error.message отсутствует "abort"', () => { + it('should return false if error.message is missing "abort"', () => { const error = { message: undefined }; expect(isAbortError(error)).toBe(false); @@ -75,24 +75,24 @@ describe('isAbortError', () => { }); }); - describe('Проверка через checkErrorCause', () => { - it('должна возвращать результат checkErrorCause, если предыдущие проверки не сработали', () => { + describe('Checking via checkErrorCause', () => { + it('should return the result of checkErrorCause if the previous checks failed', () => { const error = new Error('some error'); error['cause'] = new Error('abort'); expect(isAbortError(error)).toBe(true); }); - it('должна возвращать false, если checkErrorCause возвращает false', () => { + it('should return false if checkErrorCause returns false', () => { const error = new Error('some error'); expect(isAbortError(error)).toBe(false); }); - it('должна возвращать false, если error.message содержит часть слова "abort"', () => { + it('should return false if error.message contains part of the word "abort"', () => { const error = new Error('abo'); expect(isAbortError(error)).toBe(false); }); - it('не должна вызывать checkErrorCause, если одна из предыдущих проверок уже вернула true', () => { + it('should not call checkErrorCause if one of the previous checks has already returned true', () => { const abortError = new AbortError('abort'); isAbortError(abortError); @@ -104,18 +104,18 @@ describe('isAbortError', () => { }); }); - describe('Интеграционные тесты (комбинации)', () => { - it('должна корректно обрабатывать объекты, проходящие несколько проверок', () => { + describe('Integration tests', () => { + it('should correctly handle objects passing multiple checks', () => { const abortError = new AbortError('test'); expect(isAbortError(abortError)).toBe(true); }); - it('должна обрабатывать null и undefined без ошибок', () => { + it('should handle null and undefined without errors', () => { expect(isAbortError(null)).toBe(false); expect(isAbortError(undefined)).toBe(false); }); - it('должна обрабатывать примитивные значения', () => { + it('primitive values ​​must be processed', () => { expect(isAbortError({})).toBe(false); expect(isAbortError([])).toBe(false); expect(isAbortError(() => {})).toBe(false); @@ -125,4 +125,88 @@ describe('isAbortError', () => { expect(isAbortError(Symbol('sym'))).toBe(false); }); }); + + describe('copyAbortError', () => { + let originalError: AbortError; + + beforeEach(() => { + jest.clearAllMocks(); + originalError = new AbortError('Original message', { + type: 'cancelled', + initiator: 'user', + metadata: { id: 1 }, + reason: 'test reason' + }); + }); + + it('should create a new AbortError with the original message when no override provided', () => { + const copy = copyAbortError(originalError); + + expect(copy.message).toEqual(originalError.message); + expect(copy.cause).toEqual(originalError); + }); + + it('should override the message when provided', () => { + const copy = copyAbortError(originalError, { message: 'New message' }); + + expect(copy.message).toEqual('New message'); + expect(copy.cause).toEqual(originalError); + }); + + it('should override other properties when provided', () => { + const override = { + type: 'aborted', + initiator: 'timeout', + metadata: { new: true }, + reason: 'new reason' + } as const; + + const copy = copyAbortError(originalError, override); + + expect(copy.message).toEqual(originalError.message); + expect(copy.cause).toEqual(originalError); + expect(copy.type).toEqual(override.type); + expect(copy.initiator).toEqual(override.initiator); + expect(copy.metadata).toEqual(override.metadata); + expect(copy.reason).toEqual(override.reason); + }); + + it('should not allow overriding cause, timestamp, stack, or name', () => { + const override = { + cause: new Error('fake cause'), + timestamp: 12345, + stack: 'fake stack', + name: 'FakeError' + }; + + expect(() => copyAbortError(originalError, override as any)).toThrow(TypeError); + }); + + it('should preserve all original properties not overridden', () => { + const copy = copyAbortError(originalError, { metadata: { new: true } }); + + expect(copy.type).toBe(originalError.type); + expect(copy.initiator).toBe(originalError.initiator); + expect(copy.reason).toBe(originalError.reason); + expect(copy.metadata).toEqual({ new: true }); + expect(copy.cause).toBe(originalError); + }); + + it('should work with minimal AbortError (no extra properties)', () => { + const minimalError = new AbortError('Minimal'); + + const copy = copyAbortError(minimalError); + + expect(copy.message).toEqual(minimalError.message); + expect(copy.cause).toEqual(minimalError); + }); + + it('should handle undefined override', () => { + const copy = copyAbortError(originalError, undefined); + + expect(copy.cause).toEqual(originalError); + expect(copy.message).toEqual(originalError.message); + expect(copy.type).toEqual(originalError.type); + }); + }); }); diff --git a/src/features/index.ts b/src/features/index.ts index 2921990..82c81e9 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,2 +1,3 @@ export * from './abort-error'; export * from './timeout'; +export * from './server-breaker'; diff --git a/src/features/lib/abort-signal-any/abort-signal-any.lib.ts b/src/features/lib/abort-signal-any/abort-signal-any.lib.ts new file mode 100644 index 0000000..f45fb7d --- /dev/null +++ b/src/features/lib/abort-signal-any/abort-signal-any.lib.ts @@ -0,0 +1,66 @@ +import { AbortError } from '../../abort-error'; +import { ABORT_ERROR_NAME } from '../../abort-error/abort-error.constants'; + +type AbortSignalLike = AbortSignal | null | undefined; + +const createAbortError = (signal: AbortSignal) => { + const isReasonDOMException = signal.reason instanceof DOMException && signal.reason.name === ABORT_ERROR_NAME; + + const reasonKey = isReasonDOMException ? 'cause' : 'reason'; + + return signal.reason instanceof AbortError + ? signal.reason + : new AbortError('The operation was aborted', { [reasonKey]: signal.reason, initiator: 'abortSignalAny' }); +}; + +/** + * Combines multiple abort signals into a single signal that aborts when any of the input signals aborts. + * If any of the provided signals is already aborted when the function is called, the resulting signal + * will be immediately aborted. + * + * @template T - The type of the arguments (allows mixing of single signals and arrays). + * @param {...(T | T[])} args - A list of abort signals (or arrays of signals) to combine. + * @returns {AbortSignal} A new AbortSignal that will be aborted when any of the input signals aborts. + * + * @example + * // Combine two signals + * const ctrl1 = new AbortController(); + * const ctrl2 = new AbortController(); + * const combined = abortSignalAny(ctrl1.signal, ctrl2.signal); + * combined.addEventListener('abort', () => console.log('Aborted!')); + * ctrl1.abort(); // triggers combined abort + * + * @example + * // Using arrays + * const signals = [ctrl1.signal, ctrl2.signal]; + * const combined = abortSignalAny(signals); + * + * @example + * // Signal transmission in the rest of the format + * const combined = abortSignalAny(ctrl1.signal, ctrl2.signal, ctrl3.signal); + */ +export const abortSignalAny = (...args: T[]): AbortSignal => { + const signals = args.flat(); + + const controller = new AbortController(); + + signals.forEach((signal) => { + if (signal?.aborted) { + controller.abort(createAbortError(signal)); + } + + const handler = () => { + if (signal) { + controller.abort(createAbortError(signal)); + } + + signals.forEach((sign) => { + sign?.removeEventListener('abort', handler); + }); + }; + + signal?.addEventListener('abort', handler, { once: true }); + }); + + return controller.signal; +}; diff --git a/src/features/lib/abort-signal-any/abort-signal-any.test.ts b/src/features/lib/abort-signal-any/abort-signal-any.test.ts new file mode 100644 index 0000000..aa4a091 --- /dev/null +++ b/src/features/lib/abort-signal-any/abort-signal-any.test.ts @@ -0,0 +1,118 @@ +import { abortSignalAny } from './abort-signal-any.lib'; +import { AbortError } from '../../abort-error'; + +describe('abortSignalAny', () => { + let controller1: AbortController; + let controller2: AbortController; + let controller3: AbortController; + + beforeEach(() => { + controller1 = new AbortController(); + controller2 = new AbortController(); + controller3 = new AbortController(); + }); + + describe('basic behavior', () => { + it('must create a new AbortSignal', () => { + const signal = abortSignalAny(controller1.signal); + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + it('must create a new AbortSignal', () => { + const signal = abortSignalAny(controller1.signal, controller2.signal); + expect(signal.aborted).toBe(false); + + controller1.abort('reason1'); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBeInstanceOf(AbortError); + expect(signal.reason.reason).toBe('reason1'); + }); + + it('must respond to the first triggered signal', () => { + const signal = abortSignalAny(controller1.signal, controller2.signal); + + controller2.abort('reason2'); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBeInstanceOf(AbortError); + expect(signal.reason.reason).toBe('reason2'); + }); + + it('must be aborted immediately if any of the signals are already cancelled', () => { + controller1.abort('pre'); + + const signal = abortSignalAny(controller1.signal, controller2.signal); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBeInstanceOf(AbortError); + expect(signal.reason.reason).toBe('pre'); + }); + + it('must correctly handle null and undefined signals', () => { + expect(() => abortSignalAny(null, undefined, controller1.signal)).not.toThrow(); + + const signal = abortSignalAny(null, controller1.signal); + + controller1.abort('ok'); + + expect(signal.aborted).toBe(true); + }); + }); + + describe('working with arrays', () => { + it('must receive an array of signals', () => { + const signal = abortSignalAny([controller1.signal, controller2.signal]); + + controller1.abort(); + + expect(signal.aborted).toBe(true); + }); + + it('must accept a set of signals in rest format', () => { + const signal = abortSignalAny(controller1.signal, controller2.signal); + + controller2.abort(); + + expect(signal.aborted).toBe(true); + }); + }); + + describe('cleaning handlers', () => { + it('must remove handlers after cancellation (to avoid leaks)', () => { + const removeSpy1 = jest.spyOn(controller1.signal, 'removeEventListener'); + const removeSpy2 = jest.spyOn(controller2.signal, 'removeEventListener'); + + const signal = abortSignalAny(controller1.signal, controller2.signal); + controller1.abort(); + + expect(removeSpy1).toHaveBeenCalled(); + expect(removeSpy2).toHaveBeenCalled(); + }); + }); + + describe('working with the reason for cancellation', () => { + it('must transmit the reason for cancellation from the first triggered signal', () => { + const reason = { code: 'USER_CANCELLED' }; + + const signal = abortSignalAny(controller1.signal, controller2.signal); + + controller1.abort(reason); + + expect(signal.reason).toBeInstanceOf(AbortError); + expect(signal.reason.reason).toEqual(reason); + }); + + it('must pass a reason even if it is undefined', () => { + const signal = abortSignalAny(controller1.signal); + + controller1.abort(); + + expect(signal.reason).toBeInstanceOf(AbortError); + expect(signal.reason.reason).toBeUndefined(); + expect(signal.reason.cause).toBeInstanceOf(DOMException); + }); + }); +}); diff --git a/src/features/lib/abort-signal-any/index.ts b/src/features/lib/abort-signal-any/index.ts new file mode 100644 index 0000000..f4aaa36 --- /dev/null +++ b/src/features/lib/abort-signal-any/index.ts @@ -0,0 +1 @@ +export * from './abort-signal-any.lib'; diff --git a/src/features/lib/debounce/debounce.lib.ts b/src/features/lib/debounce/debounce.lib.ts index 2920162..674e8e1 100644 --- a/src/features/lib/debounce/debounce.lib.ts +++ b/src/features/lib/debounce/debounce.lib.ts @@ -1,5 +1,6 @@ import { setTimeoutAsync } from '../set-timeout-async'; import { AbortError } from '../../abort-error'; +import { copyAbortError } from '../../abort-error/abort-error.lib'; /** * Creates a debounced function that delays invoking the provided handler @@ -54,10 +55,7 @@ export const debounce = ( return setTimeoutAsync(handler, delay, { signal, args }); } catch (error) { if (error instanceof AbortError) { - error.cause = new AbortError(error.message, { ...error, cause: error }); - error.initiator = debounce.name; - - throw error; + throw copyAbortError(error, { initiator: debounce.name }); } throw error; diff --git a/src/features/lib/debounce/debounce.test.ts b/src/features/lib/debounce/debounce.test.ts index 1abb4e6..1fdad4a 100644 --- a/src/features/lib/debounce/debounce.test.ts +++ b/src/features/lib/debounce/debounce.test.ts @@ -51,8 +51,9 @@ describe('debounce', () => { expect((error as any).initiator).toBeUndefined(); }); - it('должна обогащать AbortError: устанавливать cause и initiator = "debounce"', async () => { - const originalAbortError = new AbortError('Signal aborted'); + it('должна возвращать новый AbortError: initiator = "debounce" и сохранение истории ошибок в cause', async () => { + const originalAbortError = new AbortError('Signal aborted', { initiator: setTimeoutAsync.name }); + (setTimeoutAsync as jest.Mock).mockImplementation(() => { throw originalAbortError; }); @@ -60,36 +61,55 @@ describe('debounce', () => { const debouncedFn = debounce(jest.fn(), 100); const { signal } = new AbortController(); - expect(() => debouncedFn(signal)).toThrow(AbortError); - - // We check that the original error has been modified - expect(originalAbortError.cause).toBeInstanceOf(AbortError); - expect((originalAbortError.cause as Error).message).toBe('Signal aborted'); - expect(originalAbortError.cause).not.toBe(originalAbortError); // there must be a new copy - expect(originalAbortError.initiator).toBe('debounce'); + const fn = () => { + try { + debouncedFn(signal); + } catch (error) { + return error; + } + }; + + expect(fn()).toEqual( + new AbortError('Signal aborted', { + initiator: debounce.name, + cause: originalAbortError + }) + ); }); - it('должна корректно обогащать AbortError с дополнительными полями (reason, metadata и т.д.)', async () => { + it('должна возвращать новый AbortError с дополнительными полями (reason, metadata и т.д.)', async () => { const originalAbortError = new AbortError('Timeout', { reason: 'User cancelled', metadata: { id: 42 } }); + (setTimeoutAsync as jest.Mock).mockImplementation(() => { throw originalAbortError; }); const debouncedFn = debounce(jest.fn(), 100); - expect(() => debouncedFn(new AbortController().signal)).toThrow(AbortError); - expect(originalAbortError.cause).toBeInstanceOf(AbortError); - expect((originalAbortError.cause as AbortError).reason).toBe('User cancelled'); - expect((originalAbortError.cause as AbortError).metadata).toEqual({ id: 42 }); - expect(originalAbortError.initiator).toBe('debounce'); + const fn = () => { + try { + debouncedFn(new AbortController().signal); + } catch (error) { + return error; + } + }; + + expect(fn()).toEqual( + new AbortError('Timeout', { + initiator: debounce.name, + reason: originalAbortError.reason, + metadata: originalAbortError.metadata, + cause: originalAbortError + }) + ); }); }); describe('синхронные ошибки из setTimeoutAsync', () => { - it('должна перехватывать синхронно выброшенный AbortError и обогащать его', () => { + it('должна перехватывать синхронно выброшенный AbortError и возращать новый экземпляр', () => { const originalAbortError = new AbortError('sync abort'); (setTimeoutAsync as jest.Mock).mockImplementation(() => { @@ -99,9 +119,20 @@ describe('debounce', () => { const debouncedFn = debounce(jest.fn(), 100); const { signal } = new AbortController(); - expect(() => debouncedFn(signal)).toThrow(AbortError); - expect(originalAbortError.cause).toBeInstanceOf(AbortError); - expect(originalAbortError.initiator).toBe('debounce'); + const fn = () => { + try { + debouncedFn(signal); + } catch (error) { + return error; + } + }; + + expect(fn()).toEqual( + new AbortError('sync abort', { + initiator: debounce.name, + cause: originalAbortError + }) + ); }); it('должна пробрасывать синхронную ошибку, не являющуюся AbortError', () => { diff --git a/src/features/lib/index.ts b/src/features/lib/index.ts index bea9853..64e8791 100644 --- a/src/features/lib/index.ts +++ b/src/features/lib/index.ts @@ -5,3 +5,4 @@ export * from './set-timeout-async'; export * from './throw-if-aborted'; export * from './time-in-milliseconds'; export * from './debounce'; +export * from './abort-signal-any'; diff --git a/src/features/lib/set-timeout-async/set-timeout-async.lib.ts b/src/features/lib/set-timeout-async/set-timeout-async.lib.ts index d226d6d..8362620 100644 --- a/src/features/lib/set-timeout-async/set-timeout-async.lib.ts +++ b/src/features/lib/set-timeout-async/set-timeout-async.lib.ts @@ -1,4 +1,5 @@ import { AbortError } from '../../abort-error'; +import { copyAbortError } from '../../abort-error/abort-error.lib'; import { logger } from '../../../shared/logger'; /** @@ -60,10 +61,7 @@ export const setTimeoutAsync = ( clearTimeout(timeoutId); if (signal.reason instanceof AbortError) { - signal.reason.cause = new AbortError(signal.reason.message, { ...signal.reason, cause: signal.reason }); - signal.reason.initiator = setTimeoutAsync.name; - - return reject(signal.reason); + return reject(copyAbortError(signal.reason, { initiator: setTimeoutAsync.name })); } const error = new AbortError(`${setTimeoutAsync.name} was interrupted`, { diff --git a/src/features/server-breaker/index.ts b/src/features/server-breaker/index.ts new file mode 100644 index 0000000..d6bbecc --- /dev/null +++ b/src/features/server-breaker/index.ts @@ -0,0 +1 @@ +export * from './server-breaker'; diff --git a/src/features/server-breaker/server-breaker.test.ts b/src/features/server-breaker/server-breaker.test.ts new file mode 100644 index 0000000..dcbecb7 --- /dev/null +++ b/src/features/server-breaker/server-breaker.test.ts @@ -0,0 +1,54 @@ +import { ServerBreaker } from './server-breaker'; +import { createHeaders } from './server-breaker.utils'; + +jest.mock('./server-breaker.utils', () => ({ + createHeaders: jest.fn() +})); + +describe('ServerBreaker', () => { + const mockHeaders = { + 'x-request-id': 'test-uuid-123', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + }; + + beforeEach(() => { + jest.clearAllMocks(); + (createHeaders as jest.Mock).mockReturnValue(mockHeaders); + }); + + describe('constructor', () => { + it('should call createHeaders once', () => { + // eslint-disable-next-line no-new + new ServerBreaker(); + expect(createHeaders).toHaveBeenCalledTimes(1); + }); + + it('should store headers in meta.headers', () => { + const breaker = new ServerBreaker(); + expect(breaker['meta'].headers).toBe(mockHeaders); + }); + }); + + describe('headers getter', () => { + it('should return headers created in the constructor', () => { + const breaker = new ServerBreaker(); + expect(breaker.headers).toBe(mockHeaders); + }); + + it('should return undefined if createHeaders returns undefined', () => { + (createHeaders as jest.Mock).mockReturnValue(undefined); + const breaker = new ServerBreaker(); + expect(breaker.headers).toBeUndefined(); + }); + }); + + describe('integration with createHeaders', () => { + it('should keep the same object that createHeaders returned', () => { + const customHeaders = { 'x-request-id': 'custom' }; + (createHeaders as jest.Mock).mockReturnValue(customHeaders); + const breaker = new ServerBreaker(); + expect(breaker.headers).toBe(customHeaders); + }); + }); +}); diff --git a/src/features/server-breaker/server-breaker.ts b/src/features/server-breaker/server-breaker.ts new file mode 100644 index 0000000..598ae5d --- /dev/null +++ b/src/features/server-breaker/server-breaker.ts @@ -0,0 +1,27 @@ +import * as Types from './server-breaker.types'; +import { createHeaders } from './server-breaker.utils'; + +/** + * Manages server‑side interruption notification for abortable requests. + */ +export class ServerBreaker { + /** + * Metadata storage for request‑related data (e.g., headers). + * @protected + */ + protected meta: Types.RequestMeta = {}; + + constructor() { + this.meta.headers = createHeaders(); + } + + /** + * Returns the request headers that should be sent with the abortable request. + * Headers are only created if `interruptionsOnServer` is configured. + * + * @returns {Types.RequestHeaders | undefined} - The headers object, or `undefined` if interruption is disabled. + */ + public get headers(): Types.RequestHeaders | undefined { + return this.meta.headers; + } +} diff --git a/src/features/server-breaker/server-breaker.types.ts b/src/features/server-breaker/server-breaker.types.ts new file mode 100644 index 0000000..b75fc4d --- /dev/null +++ b/src/features/server-breaker/server-breaker.types.ts @@ -0,0 +1,28 @@ +/** + * Headers sent with every abortable fetch request. + * Includes a unique request ID and cache-control headers. + */ +export interface RequestHeaders extends Record { + /** + * Unique identifier for the request, generated on the client. + */ + 'x-request-id': string; + /** + * Disables caching. + */ + 'Cache-Control': 'no-cache'; + /** + * Disables caching for HTTP/1.0. + */ + Pragma: 'no-cache'; +} + +/** + * Metadata describing a request. + */ +export interface RequestMeta { + /** + * Headers used in the request. + */ + headers?: RequestHeaders; +} diff --git a/src/features/server-breaker/server-breaker.utils.ts b/src/features/server-breaker/server-breaker.utils.ts new file mode 100644 index 0000000..b52aa14 --- /dev/null +++ b/src/features/server-breaker/server-breaker.utils.ts @@ -0,0 +1,12 @@ +import { RequestHeaders } from './server-breaker.types'; +import { generateUuid } from '../../shared/utils'; + +/** + * Creates a set of headers to be sent with each request. + * Includes a unique request ID and cache-control headers. + * + * @returns {RequestHeaders} The headers object. + */ +export const createHeaders = (): RequestHeaders => { + return { 'x-request-id': generateUuid(), 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; +}; diff --git a/src/modules/aborter/aborter.test.ts b/src/modules/aborter/aborter.test.ts index badd0cc..1519c2c 100644 --- a/src/modules/aborter/aborter.test.ts +++ b/src/modules/aborter/aborter.test.ts @@ -4,6 +4,7 @@ import { AbortError } from '../../features/abort-error'; import { EventListener } from '../../features/event-listener'; import { emitMethodSymbol } from '../../features/state-observer/state-observer.constants'; import { ErrorMessage } from './aborter.constants'; +import { createHeaders } from '../../features/server-breaker/server-breaker.utils'; class MockResponse { public body: any; @@ -33,12 +34,15 @@ describe('Aborter', () => { let aborter: Aborter; let mockRequest: jest.Mock; let mockSignal: AbortSignal; + let headers; beforeEach(() => { jest.clearAllMocks(); + headers = createHeaders(); aborter = new Aborter(); mockRequest = jest.fn(); + aborter['serverBreaker']['meta']['headers'] = headers; mockSignal = { aborted: false, @@ -72,11 +76,12 @@ describe('Aborter', () => { describe('Метод try', () => { it('должен выполнять запрос и возвращать результат', async () => { const expectedResult = { data: 'test' }; + mockRequest.mockResolvedValue(expectedResult); const result = await aborter.try(mockRequest); - expect(mockRequest).toHaveBeenCalledWith(mockSignal); + expect(mockRequest).toHaveBeenCalledWith(mockSignal, { headers }); expect(result).toEqual(expectedResult); }); diff --git a/src/modules/aborter/aborter.ts b/src/modules/aborter/aborter.ts index d6df3a7..d42a11d 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -2,6 +2,7 @@ import { RequestState, emitRequestState } from '../../features/state-observer'; import { AbortError, isAbortError } from '../../features/abort-error'; import { EventListener, clearEventListeners } from '../../features/event-listener'; +import { ServerBreaker } from '../../features/server-breaker'; import { Timeout, TimeoutError } from '../../features/timeout'; import { ErrorMessage, disposeSymbol } from './aborter.constants'; import * as Utils from './aborter.utils'; @@ -51,6 +52,11 @@ export class Aborter { */ public listeners: EventListener; + /** + * Manages server‑side interruption notification for abortable requests. + */ + protected serverBreaker: ServerBreaker = new ServerBreaker(); + constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); @@ -72,6 +78,25 @@ export class Aborter { return this.abortController?.signal; } + /** + * Returns the request options to be used when making an abortable request. + * + * Currently, this includes only the `headers` property retrieved from the + * `serverBreaker` instance. If `serverBreaker.headers` is `undefined`, + * the returned object will have an empty `headers` property. + * + * @returns {Types.AbortableRequestOptions} An object containing the request options, + * specifically the headers for the request. + * + * @example + * // Inside a class that uses this getter + * const options = this.requestOptions; + * // options = { headers: { 'x-request-id': '...' } } + */ + protected get requestOptions(): Types.AbortableRequestOptions { + return { headers: this.serverBreaker.headers }; + } + private setRequestState = (state: RequestState): void => { emitRequestState(this.listeners.state, state); @@ -107,7 +132,7 @@ export class Aborter { this.abortController = new AbortController(); - const promise: Promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { this.isRequestInProgress = true; const { timeoutMs, timeoutOptions } = Utils.getTimeoutOptions(timeout); @@ -125,7 +150,7 @@ export class Aborter { queueMicrotask(() => this.setRequestState('pending')); Promise.race([ - request(this.abortController.signal), + request(this.abortController.signal, this.requestOptions), Utils.createAbortablePromise(this.abortController.signal, { isErrorNativeBehavior }) ]) .then((response) => { diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index aedc929..e075225 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -1,23 +1,63 @@ import { EventListenerConstructorOptions } from '../../features/event-listener/event-listener.types'; import { TimeoutErrorOptions } from '../../features/timeout'; +import { RequestHeaders } from '../../features/server-breaker/server-breaker.types'; -export type AbortableRequest = (signal: AbortSignal) => Promise; +/** + * Options that can be passed to an abortable request. + */ +export interface AbortableRequestOptions { + /** + * Optional headers to include in the request. + */ + headers?: RequestHeaders; +} + +/** + * An abortable request function that receives an `AbortSignal` and request options, + * and returns a `Promise` resolving to the response data of type `T`. + * + * @typeParam T - The expected type of the resolved data. + * @param signal - An `AbortSignal` that can be used to cancel the request. + * @param options - Additional options for the request (e.g., headers). + * @returns A `Promise` that resolves with the data of type `T` or rejects with an error. + * + * @example + * const fetchUsers: AbortableRequest = (signal, options) => { + * return fetch('/api/users', { signal, headers: options.headers }) + * .then(res => res.json()); + * }; + */ +export type AbortableRequest = (signal: AbortSignal, options: AbortableRequestOptions) => Promise; +/** + * Options for configuring a request attempt via the `try` method. + */ export interface FnTryOptions { /** - * Returns the ability to catch a canceled request error in a catch block. + * When set to `true`, allows catching a canceled request error in a catch block. + * When `false` (default), abort errors are handled internally and do not propagate + * to the promise rejection (unless they are of type `'aborted'`). + * * @default false */ isErrorNativeBehavior?: boolean; /** - * Automatic request cancellation setting field. + * Request timeout configuration. + * - If a number is provided, it is interpreted as milliseconds. + * - If a `TimeoutErrorOptions` object is provided, it allows additional metadata. */ timeout?: number | TimeoutErrorOptions; /** - * Automatically unwraps JSON if the `try` method receives a `Response` instance, for example, returns `fetch()`. + * If `true` (default) and the request returns a `Response` object (e.g., from `fetch`), + * the response body is automatically parsed as JSON and the promise resolves with + * the parsed data. If `false`, the raw `Response` is returned. + * * @default true */ unpackData?: boolean; } +/** + * Configuration options for creating an `Aborter` instance. + */ export interface AborterOptions extends Pick {} diff --git a/src/modules/aborter/aborter.utils.ts b/src/modules/aborter/aborter.utils.ts index 82c9b21..3d19baf 100644 --- a/src/modules/aborter/aborter.utils.ts +++ b/src/modules/aborter/aborter.utils.ts @@ -2,6 +2,9 @@ import { TimeoutErrorOptions } from '../../features/timeout/timeout-error'; import { AbortError } from '../../features/abort-error'; import { ErrorMessage } from './aborter.constants'; +/** + * @internal + */ export const getAbortErrorByReason = (reason?: any): AbortError => { if (reason instanceof AbortError) { return reason; diff --git a/src/shared/utils/generate-uuid/generate-uuid.constants.ts b/src/shared/utils/generate-uuid/generate-uuid.constants.ts new file mode 100644 index 0000000..d90d1ff --- /dev/null +++ b/src/shared/utils/generate-uuid/generate-uuid.constants.ts @@ -0,0 +1,26 @@ +/** + * Number of hex characters for timestamp part. + */ +export const TIMESTAMP_HEX_LENGTH = 12; + +/** + * Number of hex characters for counter part. + */ +export const COUNTER_HEX_LENGTH = 4; + +/** + * Number of hex characters for random part. + */ +export const RANDOM_HEX_LENGTH = 16; + +/** + * Length of each UUID block in hex digits. + */ +export const UUID_BLOCK_LENGTHS = [8, 4, 4, 4, 12]; + +/** + * The third block must start with '4' to comply with UUID v4. + */ +export const UUID_V4_VERSION_SYMBOL = '4'; + +export const DASH_SYMBOL = '-'; diff --git a/src/shared/utils/generate-uuid/generate-uuid.test.ts b/src/shared/utils/generate-uuid/generate-uuid.test.ts new file mode 100644 index 0000000..9661933 --- /dev/null +++ b/src/shared/utils/generate-uuid/generate-uuid.test.ts @@ -0,0 +1,49 @@ +import { generateUuid } from './generate-uuid'; + +describe('generateUuid', () => { + test('should return a string of length 36', () => { + const uuid = generateUuid(); + + expect(uuid).toHaveLength(36); + }); + + test('should match the UUID v4 format', () => { + const uuid = generateUuid(); + + const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + expect(uuid).toMatch(regex); + }); + + test('should have version 4 (third block starts with "4")', () => { + const uuid = generateUuid(); + + const versionChar = uuid[14]; + + expect(versionChar).toBe('4'); + }); + + test('should have RFC 4122 variant (fourth block starts with 8,9,a,b)', () => { + const uuid = generateUuid(); + + const variantChar = uuid[19]; + + expect(variantChar).toMatch(/[89ab]/i); + }); + + test('should generate unique values for many calls', () => { + const uuids = new Set(); + + const ITERATIONS = 1_000_000; + + for (let i = 0; i < ITERATIONS; i++) { + uuids.add(generateUuid()); + } + + expect(uuids.size).toBe(ITERATIONS); + }); + + test('should not throw any exceptions', () => { + expect(() => generateUuid()).not.toThrow(); + }); +}); diff --git a/src/shared/utils/generate-uuid/generate-uuid.ts b/src/shared/utils/generate-uuid/generate-uuid.ts new file mode 100644 index 0000000..2c591cf --- /dev/null +++ b/src/shared/utils/generate-uuid/generate-uuid.ts @@ -0,0 +1,56 @@ +import * as Constants from './generate-uuid.constants'; + +let counter = 0; + +/** + * Returns a random hex character (0 – f). + */ +const getRandomHex = (): string => { + return Math.floor(Math.random() * 16).toString(16); +}; + +/** + * Generates a UUID version 4 using Date.now(), a counter, and Math.random. + * + * @returns {string} UUID in format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + */ +export const generateUuid = (): string => { + // eslint-disable-next-line no-bitwise + counter = (counter + 1) & 0xffff; + + const timestamp = Date.now(); + + const timeHex = timestamp + .toString(16) + .padStart(Constants.TIMESTAMP_HEX_LENGTH, '0') + .slice(0, Constants.TIMESTAMP_HEX_LENGTH); + + const counterHex = counter.toString(16).padStart(Constants.COUNTER_HEX_LENGTH, '0'); + + const randomHex = Array.from({ length: Constants.RANDOM_HEX_LENGTH }, () => getRandomHex()).join(''); + + const fullHex = timeHex + counterHex + randomHex; + + const parts: string[] = []; + let start = 0; + + for (let i = 0; i < Constants.UUID_BLOCK_LENGTHS.length; i++) { + parts.push(fullHex.slice(start, start + (Constants.UUID_BLOCK_LENGTHS[i] ?? 0))); + start += Constants.UUID_BLOCK_LENGTHS[i] ?? 0; + } + + parts[2] = `${Constants.UUID_V4_VERSION_SYMBOL}${parts[2]?.slice(1)}`; + + const variantDigit = (Math.floor(Math.random() * 4) + 8).toString(16); + parts[3] = variantDigit + (parts[3]?.slice(1) ?? ''); + + let result = ''; + + for (let i = 0; i < parts.length; i++) { + if (i > 0) result += Constants.DASH_SYMBOL; + + result += parts[i]; + } + + return result; +}; diff --git a/src/shared/utils/generate-uuid/index.ts b/src/shared/utils/generate-uuid/index.ts new file mode 100644 index 0000000..a5ce0dd --- /dev/null +++ b/src/shared/utils/generate-uuid/index.ts @@ -0,0 +1 @@ +export * from './generate-uuid'; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 589ff7a..ed1d45f 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -1,2 +1,3 @@ export * from './get'; export * from './is-object'; +export * from './generate-uuid'; diff --git a/src/types.ts b/src/types.ts index cd0fac3..546293b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,3 +2,5 @@ export type { AbortInitiator, AbortType, AbortErrorOptions } from './features/ab export type { OnAbortCallback } from './features/event-listener/event-listener.types'; export type { RequestState, OnStateChangeCallback } from './features/state-observer/state-observer.types'; export type { ReusableAborterProps, AttractListeners } from './modules/reusable-aborter/reusable-aborter.types'; +export type { RequestHeaders } from './features/server-breaker/server-breaker.types'; +export type { AbortableRequest, AbortableRequestOptions } from './modules/aborter/aborter.types';