From fd9c0a327a2150e925c31ccccad5ead60a779c6f Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 20 Mar 2026 22:15:01 +0300 Subject: [PATCH 01/28] feat(aborter): adds fetcher installation functionality --- src/modules/aborter/aborter.ts | 29 +++++++++++++++++++++++++--- src/modules/aborter/aborter.types.ts | 24 ++++++++++++++++++++++- src/modules/aborter/aborter.utils.ts | 25 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/modules/aborter/aborter.ts b/src/modules/aborter/aborter.ts index d5dbe7a..109794f 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -24,7 +24,7 @@ import { logger } from '../../shared'; * { timeout: 5000 } * ); */ -export class Aborter { +export class Aborter { /** * Internal abort controller for the current request. * @@ -51,9 +51,15 @@ export class Aborter { */ public listeners: EventListener; - constructor(options?: Types.AborterOptions) { + protected fetcherFactory: Types.FetcherFactory; + + protected meta: Types.AbortableMeta = {}; + + constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); + this.fetcherFactory = (options?.fetcher ?? Utils.defaultFetcher) as Types.FetcherFactory; + this.try = this.try.bind(this); } @@ -81,6 +87,23 @@ export class Aborter { } }; + public fetcher = (...args: Args): Result extends false ? Return : Promise => { + const innerFn = this.fetcherFactory(...args); + const context = this.createContext(); + + return innerFn(context) as any; + }; + + protected createContext = (): Types.AbortableFetcherContext => { + return { + save: (data: any) => { + this.meta.response = data; + }, + headers: this.meta.headers, + signal: this.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 @@ -107,7 +130,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 = typeof timeout === 'number' ? timeout : timeout?.ms; diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index aedc929..5fe681f 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -20,4 +20,26 @@ export interface FnTryOptions { unpackData?: boolean; } -export interface AborterOptions extends Pick {} +export interface AbortableFetcherContext { + save: (data: any) => void; + signal: AbortSignal; + headers?: HeadersInit; +} + +export interface AbortableMeta { + headers?: HeadersInit; + response?: Response; +} + +export type FetcherFactory = ( + ...args: Args +) => (context: AbortableFetcherContext) => Return; + +export type DefaultFetcherFactoryArgs = [url: string, init?: RequestInit]; + +export interface AborterOptions extends Pick< + EventListenerConstructorOptions, + 'onAbort' | 'onStateChange' +> { + fetcher?: FetcherFactory; +} diff --git a/src/modules/aborter/aborter.utils.ts b/src/modules/aborter/aborter.utils.ts index 1d05076..555d7dd 100644 --- a/src/modules/aborter/aborter.utils.ts +++ b/src/modules/aborter/aborter.utils.ts @@ -1,5 +1,7 @@ import { AbortError } from '../../features/abort-error'; import { ErrorMessage } from './aborter.constants'; +import { AbortableFetcherContext, FetcherFactory, DefaultFetcherFactoryArgs } from './aborter.types'; +import { abortSignalAny } from '../../features/lib'; export const getAbortErrorByReason = (reason?: any): AbortError => { if (reason instanceof AbortError) { @@ -12,3 +14,26 @@ export const getAbortErrorByReason = (reason?: any): AbortError => { initiator: 'user' }); }; + +export const defaultFetcher: FetcherFactory = (url: string, init?: RequestInit) => { + return async (context: AbortableFetcherContext) => { + const signal = abortSignalAny(init?.signal, context.signal); + + const response = await fetch(url, { + ...init, + signal, + headers: { ...init?.headers, ...context.headers } + }); + + context.save(response); + + if (!response.ok) { + const error = new Error('The request failed'); + (error as any).response = response; + } + + const data = await response.json(); + + return data; + }; +}; From 43228d844d0952973aa43cfe1acf3ccc6d4e2971 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 20 Mar 2026 22:16:23 +0300 Subject: [PATCH 02/28] feat(aborter): adds the abortSignalAny lib function --- .../abort-signal-any/abort-signal-any.lib.ts | 21 +++++++++++++++++++ src/features/lib/abort-signal-any/index.ts | 1 + src/features/lib/index.ts | 1 + 3 files changed, 23 insertions(+) create mode 100644 src/features/lib/abort-signal-any/abort-signal-any.lib.ts create mode 100644 src/features/lib/abort-signal-any/index.ts 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..82a4f71 --- /dev/null +++ b/src/features/lib/abort-signal-any/abort-signal-any.lib.ts @@ -0,0 +1,21 @@ +type AbortSignalLike = AbortSignal | null | undefined; + +export const abortSignalAny = (...args: T[]): AbortSignal => { + const signals = args.flat(); + + const controller = new AbortController(); + + signals.forEach((signal) => { + const handler = () => { + controller.abort(signal?.reason); + + 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/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/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'; From ccc18dc2a2cd9cc9ab89eef7a32d79a59a649b82 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Sat, 21 Mar 2026 02:57:57 +0300 Subject: [PATCH 03/28] feat: fetcherFactory was moved to a separate module --- .../fetcher-factory/fetcher-factory.lib.ts | 34 ++++++++++++++ .../fetcher-factory/fetcher-factory.ts | 46 +++++++++++++++++++ .../fetcher-factory/fetcher-factory.types.ts | 24 ++++++++++ src/features/fetcher-factory/index.ts | 1 + src/features/index.ts | 1 + src/modules/aborter/aborter.ts | 37 ++++++--------- src/modules/aborter/aborter.types.ts | 22 ++------- src/modules/aborter/aborter.utils.ts | 28 ++--------- 8 files changed, 126 insertions(+), 67 deletions(-) create mode 100644 src/features/fetcher-factory/fetcher-factory.lib.ts create mode 100644 src/features/fetcher-factory/fetcher-factory.ts create mode 100644 src/features/fetcher-factory/fetcher-factory.types.ts create mode 100644 src/features/fetcher-factory/index.ts diff --git a/src/features/fetcher-factory/fetcher-factory.lib.ts b/src/features/fetcher-factory/fetcher-factory.lib.ts new file mode 100644 index 0000000..54a753d --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.lib.ts @@ -0,0 +1,34 @@ +import { FetcherFactory, DefaultFetcherFactoryArgs, AbortableFetcherContext } from './fetcher-factory.types'; +import { abortSignalAny } from '../lib'; + +export const defaultFetcher: FetcherFactory = (url: string, init?: RequestInit) => { + return async (context: AbortableFetcherContext) => { + const signal = abortSignalAny(init?.signal, context.signal); + + const response = await fetch(url, { + ...init, + signal, + headers: { ...init?.headers, ...context.headers } + }); + + context.save(response); + + if (!response.ok) { + const error = new Error('The request failed'); + (error as any).response = response; + } + + const data = await response.json(); + + return data; + }; +}; + +export const overrideSymbol = Symbol('Fetcher.override'); + +export const override = any>(callback: C): C & typeof overrideSymbol => { + // eslint-disable-next-line no-param-reassign + (callback as any)[overrideSymbol] = overrideSymbol; + + return callback as C & typeof overrideSymbol; +}; diff --git a/src/features/fetcher-factory/fetcher-factory.ts b/src/features/fetcher-factory/fetcher-factory.ts new file mode 100644 index 0000000..c68b9a9 --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.ts @@ -0,0 +1,46 @@ +import * as Types from './fetcher-factory.types'; +import { defaultFetcher, overrideSymbol, override } from './fetcher-factory.lib'; + +export class FetcherFactory< + Factory extends Types.FetcherFactory<[any?, ...any[]]> = Types.FetcherFactory +> { + protected fetcherFactory: Factory; + + protected meta: Types.AbortableMeta = {}; + + protected signal: AbortSignal; + + constructor(props: Types.FetcherFactoryProps) { + this.fetcherFactory = (props.fetcher ?? defaultFetcher) as Factory; + this.signal = props.signal; + } + + protected createContext = (): Types.AbortableFetcherContext => { + return { + save: (data: any) => { + this.meta.response = data; + }, + headers: this.meta.headers, + signal: this.signal + }; + }; + + public setAbortSignal = (signal: AbortSignal): void => { + this.signal = signal; + }; + + public get fetcher(): Factory extends typeof overrideSymbol + ? ReturnType> + : (...args: Parameters) => Result { + const context = this.createContext(); + + if (!this.fetcherFactory[overrideSymbol]) { + // @ts-expect-error + return (...args: Parameters) => { + return this.fetcherFactory(...args)(context); + }; + } + + return this.fetcherFactory([])(context); + } +} diff --git a/src/features/fetcher-factory/fetcher-factory.types.ts b/src/features/fetcher-factory/fetcher-factory.types.ts new file mode 100644 index 0000000..8dfee3f --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.types.ts @@ -0,0 +1,24 @@ +import { overrideSymbol } from './fetcher-factory.lib'; + +export interface AbortableFetcherContext { + save: (data: any) => void; + signal: AbortSignal; + headers?: HeadersInit; +} + +export interface AbortableMeta { + headers?: HeadersInit; + response?: Response; +} + +export interface FetcherFactory { + (...args: Args): (context: AbortableFetcherContext) => any extends infer P ? P : never; + [overrideSymbol]?: typeof overrideSymbol; +} + +export type DefaultFetcherFactoryArgs = [url: string, init?: RequestInit]; + +export interface FetcherFactoryProps> { + signal: AbortSignal; + fetcher?: Factory; +} diff --git a/src/features/fetcher-factory/index.ts b/src/features/fetcher-factory/index.ts new file mode 100644 index 0000000..f0b7640 --- /dev/null +++ b/src/features/fetcher-factory/index.ts @@ -0,0 +1 @@ +export * from './fetcher-factory'; diff --git a/src/features/index.ts b/src/features/index.ts index 8198a32..c3b3c68 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,3 +1,4 @@ export * from './abort-error'; export * from './timeout'; export * from './abortable-promise'; +export * from './fetcher-factory'; diff --git a/src/modules/aborter/aborter.ts b/src/modules/aborter/aborter.ts index 109794f..3d1d6f4 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -2,6 +2,11 @@ import { RequestState, emitRequestState } from '../../features/state-observer'; import { AbortError, isAbortError } from '../../features/abort-error'; import { EventListener, clearEventListeners } from '../../features/event-listener'; +import { FetcherFactory } from '../../features/fetcher-factory'; +import { + FetcherFactory as IFetcherFactory, + DefaultFetcherFactoryArgs +} from '../../features/fetcher-factory/fetcher-factory.types'; import { Timeout, TimeoutError } from '../../features/timeout'; import { ErrorMessage, disposeSymbol } from './aborter.constants'; import * as Utils from './aborter.utils'; @@ -24,7 +29,7 @@ import { logger } from '../../shared'; * { timeout: 5000 } * ); */ -export class Aborter { +export class Aborter = IFetcherFactory> { /** * Internal abort controller for the current request. * @@ -51,14 +56,12 @@ export class Aborter; + protected fetcherFactory: FetcherFactory; - protected meta: Types.AbortableMeta = {}; - - constructor(options?: Types.AborterOptions) { + constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); - this.fetcherFactory = (options?.fetcher ?? Utils.defaultFetcher) as Types.FetcherFactory; + this.fetcherFactory = new FetcherFactory({ fetcher: options?.fetcher, signal: this.signal }); this.try = this.try.bind(this); } @@ -78,6 +81,10 @@ export class Aborter { emitRequestState(this.listeners.state, state); @@ -87,23 +94,6 @@ export class Aborter(...args: Args): Result extends false ? Return : Promise => { - const innerFn = this.fetcherFactory(...args); - const context = this.createContext(); - - return innerFn(context) as any; - }; - - protected createContext = (): Types.AbortableFetcherContext => { - return { - save: (data: any) => { - this.meta.response = data; - }, - headers: this.meta.headers, - signal: this.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 @@ -129,6 +119,7 @@ export class Aborter((resolve, reject) => { this.isRequestInProgress = true; diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index 5fe681f..0ff2755 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -1,5 +1,6 @@ import { EventListenerConstructorOptions } from '../../features/event-listener/event-listener.types'; import { TimeoutErrorOptions } from '../../features/timeout'; +import { FetcherFactory } from '../../features/fetcher-factory/fetcher-factory.types'; export type AbortableRequest = (signal: AbortSignal) => Promise; @@ -20,26 +21,9 @@ export interface FnTryOptions { unpackData?: boolean; } -export interface AbortableFetcherContext { - save: (data: any) => void; - signal: AbortSignal; - headers?: HeadersInit; -} - -export interface AbortableMeta { - headers?: HeadersInit; - response?: Response; -} - -export type FetcherFactory = ( - ...args: Args -) => (context: AbortableFetcherContext) => Return; - -export type DefaultFetcherFactoryArgs = [url: string, init?: RequestInit]; - -export interface AborterOptions extends Pick< +export interface AborterOptions> extends Pick< EventListenerConstructorOptions, 'onAbort' | 'onStateChange' > { - fetcher?: FetcherFactory; + fetcher?: Fetcher; } diff --git a/src/modules/aborter/aborter.utils.ts b/src/modules/aborter/aborter.utils.ts index 555d7dd..fab1064 100644 --- a/src/modules/aborter/aborter.utils.ts +++ b/src/modules/aborter/aborter.utils.ts @@ -1,8 +1,9 @@ import { AbortError } from '../../features/abort-error'; import { ErrorMessage } from './aborter.constants'; -import { AbortableFetcherContext, FetcherFactory, DefaultFetcherFactoryArgs } from './aborter.types'; -import { abortSignalAny } from '../../features/lib'; +/** + * @internal + */ export const getAbortErrorByReason = (reason?: any): AbortError => { if (reason instanceof AbortError) { return reason; @@ -14,26 +15,3 @@ export const getAbortErrorByReason = (reason?: any): AbortError => { initiator: 'user' }); }; - -export const defaultFetcher: FetcherFactory = (url: string, init?: RequestInit) => { - return async (context: AbortableFetcherContext) => { - const signal = abortSignalAny(init?.signal, context.signal); - - const response = await fetch(url, { - ...init, - signal, - headers: { ...init?.headers, ...context.headers } - }); - - context.save(response); - - if (!response.ok) { - const error = new Error('The request failed'); - (error as any).response = response; - } - - const data = await response.json(); - - return data; - }; -}; From 7236db3c625441fb42c155b3eecbd0ca1d1b109c Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 24 Mar 2026 15:43:28 +0300 Subject: [PATCH 04/28] feat: adds a utility for generating a unique id (#59) --- .../generate-uuid/generate-uuid.constants.ts | 26 +++++++++ .../utils/generate-uuid/generate-uuid.test.ts | 49 ++++++++++++++++ .../utils/generate-uuid/generate-uuid.ts | 56 +++++++++++++++++++ src/shared/utils/generate-uuid/index.ts | 1 + src/shared/utils/index.ts | 1 + 5 files changed, 133 insertions(+) create mode 100644 src/shared/utils/generate-uuid/generate-uuid.constants.ts create mode 100644 src/shared/utils/generate-uuid/generate-uuid.test.ts create mode 100644 src/shared/utils/generate-uuid/generate-uuid.ts create mode 100644 src/shared/utils/generate-uuid/index.ts 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'; From 67f2bf3a15518d517abb7aee2a1547eaa3d7b15f Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 24 Mar 2026 15:44:25 +0300 Subject: [PATCH 05/28] adds exceptions to cspell (#59) --- cspell.json | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/cspell.json b/cspell.json index aa74856..418a613 100644 --- a/cspell.json +++ b/cspell.json @@ -1,7 +1,15 @@ { "version": "0.2", "language": "en,ru", - "words": ["Saborter", "saborter", "Laptev", "Vladislav", "tgz", "Сalls"], + "words": [ + "Сalls", + "Laptev", + "saborter", + "Saborter", + "tgz", + "Vladislav", + "yxxx" + ], "flagWords": [], "ignorePaths": [ "node_modules/**", @@ -15,7 +23,13 @@ "./cspell.json", "./integrations" ], - "dictionaries": ["typescript", "node", "softwareTerms", "en_US", "ru_RU"], + "dictionaries": [ + "typescript", + "node", + "softwareTerms", + "en_US", + "ru_RU" + ], "useGitignore": true, "patterns": [ { @@ -29,7 +43,12 @@ "description": "Ignore HTML entities" } ], - "ignoreRegExpList": ["/\\[.*\\]\\(.*\\)/g", "/&[a-z]+;/g", "/0x[a-fA-F0-9]+/g", "/\\$[^{][\\w.]+/g"], + "ignoreRegExpList": [ + "/\\[.*\\]\\(.*\\)/g", + "/&[a-z]+;/g", + "/0x[a-fA-F0-9]+/g", + "/\\$[^{][\\w.]+/g" + ], "caseSensitive": false, "allowCompoundWords": true, "minWordLength": 3 From 5f3fca25de5e4da2286e7071cfb0917d6b2d07db Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:12:19 +0300 Subject: [PATCH 06/28] feat: adds the abortSignalAny function (#59) --- .../abort-signal-any/abort-signal-any.lib.ts | 47 ++++++- .../abort-signal-any/abort-signal-any.test.ts | 118 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/features/lib/abort-signal-any/abort-signal-any.test.ts 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 index 82a4f71..f45fb7d 100644 --- a/src/features/lib/abort-signal-any/abort-signal-any.lib.ts +++ b/src/features/lib/abort-signal-any/abort-signal-any.lib.ts @@ -1,13 +1,58 @@ +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 = () => { - controller.abort(signal?.reason); + if (signal) { + controller.abort(createAbortError(signal)); + } signals.forEach((sign) => { sign?.removeEventListener('abort', handler); 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..d5fe2c3 --- /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('должен создавать новый 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); + }); + }); +}); From 8fb7e673cce39c65f3e62197dddb0fa1d3dbff43 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:16:44 +0300 Subject: [PATCH 07/28] feat: adds fetcher factory functionality (#59) --- .../fetcher-factory.constants.ts | 1 + .../fetcher-factory.lib.test.ts | 160 ++++++++++++ .../fetcher-factory/fetcher-factory.lib.ts | 56 ++++- .../fetcher-factory/fetcher-factory.test.ts | 233 ++++++++++++++++++ .../fetcher-factory/fetcher-factory.ts | 174 ++++++++++++- .../fetcher-factory/fetcher-factory.types.ts | 143 ++++++++++- .../fetcher-factory/fetcher-factory.utils.ts | 28 +++ 7 files changed, 768 insertions(+), 27 deletions(-) create mode 100644 src/features/fetcher-factory/fetcher-factory.constants.ts create mode 100644 src/features/fetcher-factory/fetcher-factory.lib.test.ts create mode 100644 src/features/fetcher-factory/fetcher-factory.test.ts create mode 100644 src/features/fetcher-factory/fetcher-factory.utils.ts diff --git a/src/features/fetcher-factory/fetcher-factory.constants.ts b/src/features/fetcher-factory/fetcher-factory.constants.ts new file mode 100644 index 0000000..242bbb0 --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.constants.ts @@ -0,0 +1 @@ +export const overrideSymbol = Symbol('Fetcher.override'); diff --git a/src/features/fetcher-factory/fetcher-factory.lib.test.ts b/src/features/fetcher-factory/fetcher-factory.lib.test.ts new file mode 100644 index 0000000..598fdac --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.lib.test.ts @@ -0,0 +1,160 @@ +import { defaultFetcher, makeFetchGetter } from './fetcher-factory.lib'; +import { abortSignalAny } from '../lib/abort-signal-any'; +import { overrideSymbol } from './fetcher-factory.constants'; + +jest.mock('../lib/abort-signal-any', () => ({ + abortSignalAny: jest.fn() +})); + +jest.mock('./fetcher-factory.constants', () => ({ + overrideSymbol: Symbol('override') +})); + +describe('defaultFetcher', () => { + let mockAbortSignalAny; + let mockContext; + let mockFetch; + let url: string; + let init: RequestInit; + + beforeEach(() => { + mockAbortSignalAny = jest.fn().mockReturnValue({ aborted: false }); + (abortSignalAny as jest.Mock).mockImplementation(mockAbortSignalAny); + + mockFetch = jest.fn(); + global.fetch = mockFetch; + + url = '/api/test'; + init = { method: 'POST', headers: { 'X-Custom': 'value' }, signal: { aborted: false } as AbortSignal }; + mockContext = { + headers: { 'X-Context': 'ctx' }, + signal: { aborted: false }, + save: jest.fn() + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return a function that takes the context', () => { + const fetcher = defaultFetcher(url, init); + expect(typeof fetcher).toBe('function'); + }); + + it('should call abortSignalAny with signals from init and context', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ data: 'ok' }), + url: 'https://example.com/api/test' + }); + const fetcher = defaultFetcher(url, init); + await fetcher(mockContext); + expect(abortSignalAny).toHaveBeenCalledWith(init.signal, mockContext.signal); + }); + + it('should fetch with the combined signal and headers', async () => { + const combinedSignal = { aborted: false }; + mockAbortSignalAny.mockReturnValue(combinedSignal); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ data: 'ok' }), + url: 'https://example.com/api/test' + }); + + const fetcher = defaultFetcher(url, init); + await fetcher(mockContext); + + expect(mockFetch).toHaveBeenCalledWith(url, { + method: init.method, + signal: combinedSignal, + headers: { ...init.headers, ...mockContext.headers } + }); + }); + + it('should parse JSON and return data', async () => { + const responseData = { id: 1 }; + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(responseData), + url: 'https://example.com/api/test' + }); + + const fetcher = defaultFetcher(url, init); + const result = await fetcher(mockContext); + expect(result).toEqual(responseData); + }); + + it('must call context.save with request metadata', async () => { + const responseUrl = 'https://example.com/api/test'; + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + url: responseUrl + }); + + const fetcher = defaultFetcher(url, init); + await fetcher(mockContext); + + expect(mockContext.save).toHaveBeenCalledWith({ + url: responseUrl, + method: init.method + }); + }); + + it('should use the default "get" method if init.method is not specified', async () => { + const fetcher = defaultFetcher(url, undefined); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + url: 'https://example.com/api/test' + }); + + await fetcher(mockContext); + + expect(mockContext.save).toHaveBeenCalledWith({ + url: expect.any(String), + method: 'get' + }); + }); + + it('should throw an error with the response property if response.ok === false', async () => { + const errorResponse = { ok: false, status: 404, statusText: 'Not Found' }; + mockFetch.mockResolvedValue(errorResponse); + + const fetcher = defaultFetcher(url, init); + await expect(fetcher(mockContext)).rejects.toThrow('The request failed'); + + try { + await fetcher(mockContext); + } catch (err) { + expect(err.response).toBe(errorResponse); + } + }); +}); + +describe('makeFetchGetter', () => { + it('must return the same function it received', () => { + const callback = () => {}; + const result = makeFetchGetter(callback); + expect(result).toBe(callback); + }); + + it('must add the overrideSymbol property to the function', () => { + const callback = () => {}; + const result = makeFetchGetter(callback); + expect(result[overrideSymbol]).toBe(overrideSymbol); + }); + + it('must preserve the return type', () => { + const callback = (a: number) => a + 1; + const result = makeFetchGetter(callback); + expect(result(5)).toBe(6); + }); + + it('should work correctly with asynchronous functions', async () => { + const asyncCallback = async (x: number) => x * 2; + const result = makeFetchGetter(asyncCallback); + await expect(result(3)).resolves.toBe(6); + }); +}); diff --git a/src/features/fetcher-factory/fetcher-factory.lib.ts b/src/features/fetcher-factory/fetcher-factory.lib.ts index 54a753d..de3ab67 100644 --- a/src/features/fetcher-factory/fetcher-factory.lib.ts +++ b/src/features/fetcher-factory/fetcher-factory.lib.ts @@ -1,8 +1,31 @@ -import { FetcherFactory, DefaultFetcherFactoryArgs, AbortableFetcherContext } from './fetcher-factory.types'; +import { FetcherFactory, DefaultFetcherFactoryArgs, FetcherFactoryContext } from './fetcher-factory.types'; import { abortSignalAny } from '../lib'; +import { overrideSymbol } from './fetcher-factory.constants'; +/** + * Default fetcher factory that creates an abortable fetch function. + * + * This factory produces a function that: + * - Merges the provided signal with the context signal using `abortSignalAny`. + * - Performs a `fetch` request with the combined signal. + * - If the response is not ok, throws an error with the `response` property attached. + * - Parses the response as JSON. + * - Saves the request metadata (url and method) via `context.save`. + * + * @type {FetcherFactory} + * + * @param {string} url - The request URL. + * @param {RequestInit} [init] - Optional fetch options. + * @returns {(context: FetcherFactoryContext) => Promise} + * A function that accepts a context and returns a promise resolving to the parsed JSON. + * + * @example + * const fetcher = defaultFetcher('/api/users', { method: 'GET' }); + * const context = { headers: {}, signal: controller.signal, save: console.log }; + * fetcher(context).then(data => console.log(data)); + */ export const defaultFetcher: FetcherFactory = (url: string, init?: RequestInit) => { - return async (context: AbortableFetcherContext) => { + return async (context: FetcherFactoryContext) => { const signal = abortSignalAny(init?.signal, context.signal); const response = await fetch(url, { @@ -11,22 +34,41 @@ export const defaultFetcher: FetcherFactory = (url: s headers: { ...init?.headers, ...context.headers } }); - context.save(response); - if (!response.ok) { const error = new Error('The request failed'); (error as any).response = response; + + throw error; } const data = await response.json(); + context.save({ url: response.url, method: init?.method ?? 'get' }); + return data; }; }; -export const overrideSymbol = Symbol('Fetcher.override'); - -export const override = any>(callback: C): C & typeof overrideSymbol => { +/** + * Marks a callback as overridable by attaching a unique symbol property. + * + * This function is used internally to indicate that a fetcher factory + * should be treated specially (e.g., when it is intended to be overridden + * by a custom implementation). The resulting function receives the + * `overrideSymbol` property, which is used by the `FetcherFactory` class + * to differentiate between standard and overridden factories. + * + * @template C - The type of the callback function. + * @param {C} callback - The callback function to mark. + * @returns {C & typeof overrideSymbol} The same function with the `overrideSymbol` property attached. + * + * @example + * const myFetcher = makeFetchGetter((url, init) => { + * return (context) => fetch(url, { ...init, signal: context.signal }); + * }); + * // `myFetcher[overrideSymbol]` is now truthy. + */ +export const makeFetchGetter = any>(callback: C): C & typeof overrideSymbol => { // eslint-disable-next-line no-param-reassign (callback as any)[overrideSymbol] = overrideSymbol; diff --git a/src/features/fetcher-factory/fetcher-factory.test.ts b/src/features/fetcher-factory/fetcher-factory.test.ts new file mode 100644 index 0000000..4f831cd --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.test.ts @@ -0,0 +1,233 @@ +import { FetcherFactory } from './fetcher-factory'; +import { defaultFetcher } from './fetcher-factory.lib'; +import { overrideSymbol } from './fetcher-factory.constants'; +import { Utils } from '../../shared'; + +jest.mock('../../shared', () => ({ + Utils: { + generateUuid: jest.fn().mockReturnValue('mock-uuid') + } +})); + +jest.mock('./fetcher-factory.lib', () => ({ + defaultFetcher: jest.fn() +})); + +jest.mock('./fetcher-factory.constants', () => ({ + overrideSymbol: Symbol('override') +})); + +describe('FetcherFactory', () => { + let mockSignal: AbortSignal; + let mockController: AbortController; + let originalWindow: any; + let originalNavigator: any; + + beforeAll(() => { + originalWindow = global.window; + originalNavigator = global.navigator; + }); + + afterAll(() => { + global.window = originalWindow; + global.navigator = originalNavigator; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockController = new AbortController(); + mockSignal = mockController.signal; + + delete (global as any).window; + delete (global as any).navigator; + }); + + describe('конструктор', () => { + it('должен использовать defaultFetcher, если fetcher не передан', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + expect(factory['fetcherFactory']).toBe(defaultFetcher); + }); + + it('должен сохранять переданный fetcher', () => { + const customFetcher = jest.fn(); + const factory = new FetcherFactory({ fetcher: customFetcher, signal: mockSignal }); + expect(factory['fetcherFactory']).toBe(customFetcher); + }); + + it('должен сохранять переданный сигнал', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + expect(factory['signal']).toBe(mockSignal); + }); + + it('должен устанавливать interruptionsOnServer с дефолтными значениями, если options.interruptionsOnServer не передан', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + expect(factory['interruptionsOnServer']).not.toHaveProperty('hasInterruptRequests'); + }); + }); + + describe('метод createHeaders', () => { + it('должен генерировать уникальный x-request-id', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const headers = factory['createHeaders'](); + expect(Utils.generateUuid).toHaveBeenCalled(); + expect(headers['x-request-id']).toBe('mock-uuid'); + }); + + it('должен устанавливать Cache-Control и Pragma', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const headers = factory['createHeaders'](); + expect(headers['Cache-Control']).toBe('no-cache'); + expect(headers.Pragma).toBe('no-cache'); + }); + }); + + describe('метод createContext', () => { + it('должен вызывать createHeaders и сохранять их в meta.headers', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const createHeadersSpy = jest.spyOn(factory as any, 'createHeaders'); + const context = factory['createContext'](); + + expect(createHeadersSpy).toHaveBeenCalled(); + expect(factory['meta'].headers).toBeDefined(); + expect(context.headers).toBe(factory['meta'].headers); + }); + + it('должен возвращать объект с save, headers и signal', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const context = factory['createContext'](); + + expect(context).toHaveProperty('save'); + expect(typeof context.save).toBe('function'); + expect(context.headers).toBeDefined(); + expect(context.signal).toBe(mockSignal); + }); + + it('метод save должен сохранять данные в meta.url', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const context = factory['createContext'](); + const testUrl = 'https://'; + context.save({ url: testUrl, method: 'post' }); + expect(factory['meta'].url).toBe(testUrl); + }); + }); + + describe('метод setAbortSignal', () => { + it('должен обновлять сигнал', () => { + const factory = new FetcherFactory({ signal: mockSignal }); + const newController = new AbortController(); + const newSignal = newController.signal; + factory.setAbortSignal(newSignal); + expect(factory['signal']).toBe(newSignal); + }); + }); + + describe('геттер fetcher', () => { + let factory: FetcherFactory; + let mockContext: any; + + beforeEach(() => { + factory = new FetcherFactory({ signal: mockSignal }); + mockContext = { + headers: { 'x-request-id': 'uuid' }, + signal: mockSignal, + save: jest.fn() + }; + jest.spyOn(factory as any, 'createContext').mockReturnValue(mockContext); + }); + + it('должен возвращать функцию, которая вызывает fetcherFactory с переданными аргументами, если нет overrideSymbol', () => { + const mockFetcher = jest.fn().mockReturnValue(jest.fn()); + factory['fetcherFactory'] = mockFetcher; + delete factory['fetcherFactory'][overrideSymbol]; + + // eslint-disable-next-line prefer-destructuring + const fetcher = factory.fetcher; + expect(typeof fetcher).toBe('function'); + + fetcher('/api/test', { method: 'GET' }); + expect(mockFetcher).toHaveBeenCalledWith('/api/test', { method: 'GET' }); + const returnedFn = mockFetcher.mock.results[0].value; + expect(returnedFn).toHaveBeenCalledWith(mockContext); + }); + + it('должен возвращать результат вызова fetcherFactory([]) с контекстом, если есть overrideSymbol', () => { + const mockFactoryReturn = jest.fn().mockReturnValue(jest.fn()); + const mockFetcherFactory = jest.fn().mockReturnValue(mockFactoryReturn); + factory['fetcherFactory'] = mockFetcherFactory; + factory['fetcherFactory'][overrideSymbol] = overrideSymbol; + + // eslint-disable-next-line prefer-destructuring + const fetcher = factory.fetcher; + expect(typeof fetcher).toBe('function'); + + fetcher('/api/test', { method: 'GET' }); + + expect(mockFetcherFactory).toHaveBeenCalledWith([]); + expect(mockFactoryReturn).toHaveBeenCalledWith(mockContext); + }); + }); + + describe('метод notifyServerOfInterruption', () => { + let factory: FetcherFactory; + let sendBeaconMock: jest.Mock; + let fetchMock: jest.Mock; + + beforeEach(() => { + factory = new FetcherFactory({ signal: mockSignal }); + factory['interruptionsOnServer'] = { + hasInterruptRequests: true, + endpointName: '/api/cancel', + basePath: 'https://example.com' + }; + factory['meta'].headers = { 'x-request-id': 'test-uuid', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; + + sendBeaconMock = jest.fn().mockReturnValue(true); + fetchMock = jest.fn().mockResolvedValue({} as Response); + global.fetch = fetchMock; + global.navigator = { sendBeacon: sendBeaconMock } as any; + }); + + afterEach(() => { + delete (global as any).fetch; + delete (global as any).navigator; + }); + + it('не должен отправлять запрос, если hasInterruptRequests = false', () => { + if (factory['interruptionsOnServer']) { + factory['interruptionsOnServer'].hasInterruptRequests = false; + } + factory.notifyServerOfInterruption(); + expect(sendBeaconMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('должен использовать navigator.sendBeacon, если доступен', () => { + factory.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + const blobArg = sendBeaconMock.mock.calls[0][1]; + expect(blobArg.type).toBe('text/plain'); + expect(blobArg).toBeInstanceOf(Blob); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('должен использовать fetch, если sendBeacon недоступен', async () => { + delete (global.navigator as any).sendBeacon; + factory.notifyServerOfInterruption(); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/cancel', { + method: 'POST', + body: expect.any(Blob) + }); + const blobArg = fetchMock.mock.calls[0][1].body; + expect(blobArg).toBeInstanceOf(Blob); + }); + + it('должен использовать пустую строку, если x-request-id отсутствует', () => { + Reflect.deleteProperty(factory['meta'].headers ?? {}, 'x-request-id'); + + factory.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + const blobArg = sendBeaconMock.mock.calls[0][1]; + expect(blobArg).toBeInstanceOf(Blob); + }); + }); +}); diff --git a/src/features/fetcher-factory/fetcher-factory.ts b/src/features/fetcher-factory/fetcher-factory.ts index c68b9a9..be2e54f 100644 --- a/src/features/fetcher-factory/fetcher-factory.ts +++ b/src/features/fetcher-factory/fetcher-factory.ts @@ -1,34 +1,161 @@ import * as Types from './fetcher-factory.types'; -import { defaultFetcher, overrideSymbol, override } from './fetcher-factory.lib'; +import { defaultFetcher } from './fetcher-factory.lib'; +import { overrideSymbol } from './fetcher-factory.constants'; +import * as Utils from './fetcher-factory.utils'; +/** + * A factory that creates abortable fetchers with support for request cancellation, + * notification of request interruptions on the server, and automatic request ID generation. + * + * The `FetcherFactory` wraps a fetcher function (or a factory that returns a fetcher) + * and provides an abort signal, headers with a unique `x-request-id`, and the ability + * to notify the server when a request is interrupted (using `sendBeacon` or `fetch`). + * + * @template Factory - The type of the fetcher factory. Defaults to `Types.FetcherFactory`. + * + * @example + * // Create a factory with a custom fetcher + * const factory = new FetcherFactory({ + * fetcher: (url, options) => { + * return async (ctx) => { + * const response = await fetch(url, { ...options, signal: ctx.signal, headers:ctx.headers }); + * return await response.json(); + * } + * }, + * signal: controller.signal, + * interruptionsOnServer: { hasInterruptRequests: true } + * }); + * + * // Get the abortable fetcher + * const fetcher = factory.fetcher; + * + * // Use it like a normal fetch, but with automatic abort and headers + * fetcher('/api/data') + * .then(response => response.json()) + * .catch(err => console.error('Request failed or aborted')); + * + * // Notify the server if the request was interrupted + * factory.notifyServerOfInterruption(); + */ export class FetcherFactory< Factory extends Types.FetcherFactory<[any?, ...any[]]> = Types.FetcherFactory > { + /** + * The underlying fetcher factory. + * + * @protected + */ protected fetcherFactory: Factory; - protected meta: Types.AbortableMeta = {}; + /** + * Configuration for server interruption notification. + * + * @protected + */ + protected interruptionsOnServer?: Types.InterruptionsOnServer; - protected signal: AbortSignal; + /** + * Metadata object that holds headers and response. + * + * @protected + */ + protected meta: Types.RequestMeta = {}; - constructor(props: Types.FetcherFactoryProps) { - this.fetcherFactory = (props.fetcher ?? defaultFetcher) as Factory; - this.signal = props.signal; + /** + * Request map containing metadata. + * + * @private + */ + private requestsMetaMap: Map = new Map(); + + /** + * Cleanup callback called when the request completes. + * + * @private + */ + private pendingRequestMetadataCleanup?: VoidFunction; + + constructor(options: Types.FetcherFactoryOptions) { + this.fetcherFactory = (options.fetcher ?? defaultFetcher) as Factory; + this.meta.signal = options.signal; + + if (typeof window !== 'undefined') { + const { + endpointName = '/api/cancel', + basePath = window.location.origin, + ...restInterruptionsOnServer + } = options.interruptionsOnServer ?? {}; + this.interruptionsOnServer = { ...restInterruptionsOnServer, endpointName, basePath }; + + if (this.interruptionsOnServer.hasInterruptRequests) { + options.listeners?.state.subscribe((state) => { + if (state === 'aborted' || state === 'fulfilled' || state === 'rejected') { + this.pendingRequestMetadataCleanup?.(); + } + }); + } + } } - protected createContext = (): Types.AbortableFetcherContext => { + /** + * Creates the context object that is passed to the fetcher. + * The context contains headers, the abort signal, and a `save` method to store the response. + * + * @returns {Types.FetcherFactoryContext} The context object. + * + * @protected + */ + protected createContext = (): Types.FetcherFactoryContext => { + this.meta.headers = Utils.createHeaders(); + + if (!this.meta.signal) { + throw new ReferenceError('No instance of AbortSignal found!'); + } + return { - save: (data: any) => { - this.meta.response = data; + save: (data: Types.FetcherFactoryContextSaveData) => { + this.meta.url = data.url; + this.meta.method = data.method; + this.requestsMetaMap.set(Utils.getRequestUrlByMeta(this.meta), this.meta); + + this.pendingRequestMetadataCleanup = () => { + if (!this.meta.url) return; + this.requestsMetaMap.delete(Utils.getRequestUrlByMeta(this.meta)); + }; }, headers: this.meta.headers, - signal: this.signal + signal: this.meta.signal }; }; + /** + * Updates the abort signal used for subsequent requests. + * + * @param {AbortSignal} signal - The new abort signal. + */ public setAbortSignal = (signal: AbortSignal): void => { - this.signal = signal; + this.meta.signal = signal; }; + /** + * Returns the abortable fetcher function. + * + * If the internal `fetcherFactory` has an `overrideSymbol` property, the factory is called + * with no arguments and the resulting fetcher is invoked with the context. + * Otherwise, the factory is called with the provided arguments, and then the resulting + * function is invoked with the context. + * + * @returns {Factory extends typeof overrideSymbol + * ? ReturnType> + * : (...args: Parameters) => Result} + * A function that can be called like a normal fetcher, but automatically receives + * the abort signal and headers. + * + * @example + * const fetcher = factory.fetcher; + * // Use with standard fetch-like arguments + * fetcher('/api/users').then(handleResponse); + */ public get fetcher(): Factory extends typeof overrideSymbol ? ReturnType> : (...args: Parameters) => Result { @@ -43,4 +170,29 @@ export class FetcherFactory< return this.fetcherFactory([])(context); } + + /** + * Notifies the server that a request was interrupted (e.g., because the user navigated away). + * This method sends the request ID (from the last request) to a server endpoint. + */ + public notifyServerOfInterruption = (): void => { + if (this.interruptionsOnServer?.hasInterruptRequests) { + const url = `${this.interruptionsOnServer.basePath}${this.interruptionsOnServer.endpointName}`; + + this.requestsMetaMap.forEach((meta) => { + const blob = new Blob([meta.headers?.['x-request-id'] ?? ''], { type: 'text/plain' }); + + if (navigator && 'sendBeacon' in navigator && typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon(url, blob); + + return; + } + + fetch(url, { + method: 'POST', + body: blob + }); + }); + } + }; } diff --git a/src/features/fetcher-factory/fetcher-factory.types.ts b/src/features/fetcher-factory/fetcher-factory.types.ts index 8dfee3f..9f1d3a7 100644 --- a/src/features/fetcher-factory/fetcher-factory.types.ts +++ b/src/features/fetcher-factory/fetcher-factory.types.ts @@ -1,24 +1,149 @@ -import { overrideSymbol } from './fetcher-factory.lib'; +import { overrideSymbol } from './fetcher-factory.constants'; +import { EventListener } from '../event-listener'; -export interface AbortableFetcherContext { - save: (data: any) => void; +/** + * Headers sent with every abortable fetch request. + * Includes a unique request ID and cache-control headers. + */ +export interface FetchableRequestHeaders { + /** + * 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'; +} + +/** + * Data saved by the `save` method of the fetcher context. + * Contains the final URL (after possible redirects) and the HTTP method used. + */ +export interface FetcherFactoryContextSaveData { + /** + * Request URL. + */ + url: string; + /** + * The HTTP method used (e.g., 'GET', 'POST'). + */ + method: string; +} + +/** + * Context object passed to the fetcher function. + * Provides access to headers, an abort signal, and a callback to save request metadata. + */ +export interface FetcherFactoryContext { + /** + * Callback to store metadata about the request after it completes. + * @param data - The metadata to save. + */ + save: (data: FetcherFactoryContextSaveData) => void; + /** + * Abort signal that can be used to cancel the request. + */ signal: AbortSignal; - headers?: HeadersInit; + /** + * Headers that must be included in the request. + */ + headers: FetchableRequestHeaders; } -export interface AbortableMeta { - headers?: HeadersInit; - response?: Response; +/** + * Metadata describing a request. + */ +export interface RequestMeta { + /** + * Request URL. + */ + url?: string; + /** + * HTTP method. + */ + method?: string; + /** + * Headers used in the request. + */ + headers?: FetchableRequestHeaders; + /** + * Abort signal associated with the request. + */ + signal?: AbortSignal; } +/** + * A factory that creates a fetcher function (a function that accepts a context + * and returns a promise). The factory can be decorated with the `overrideSymbol` + * to indicate that it should be treated specially by the `FetcherFactory` class. + * + * @template Args - The arguments expected by the factory. + */ export interface FetcherFactory { - (...args: Args): (context: AbortableFetcherContext) => any extends infer P ? P : never; + /** + * Creates a fetcher function that will be invoked with a context. + * @param args - Arguments passed to the factory. + * @returns A function that, when given a context, returns a promise. + */ + (...args: Args): (context: FetcherFactoryContext) => any extends infer P ? P : never; + /** + * Optional marker property used to identify factories that should be overridden. + * If present, the factory is called with an empty array and the resulting + * function is invoked with the context. + */ [overrideSymbol]?: typeof overrideSymbol; } +/** + * Argument types for the default fetcher factory. + * The default fetcher expects a URL and optional fetch options. + */ export type DefaultFetcherFactoryArgs = [url: string, init?: RequestInit]; -export interface FetcherFactoryProps> { +/** + * Configuration for notifying the server when a request is interrupted. + */ +export interface InterruptionsOnServer { + /** + * Whether to notify the server on interruption. + */ + hasInterruptRequests?: boolean; + /** + * Base path of the server (e.g., `window.location.origin`). + */ + basePath?: string; + /** + * Endpoint path where the interruption notification is sent. + */ + endpointName?: string; +} + +/** + * Options for creating a `FetcherFactory` instance. + * + * @template Factory - The type of fetcher factory to use. + */ +export interface FetcherFactoryOptions> { + /** + * Abort signal that will be passed to all requests created by this factory. + */ signal: AbortSignal; + /** + * Optional event listener for state changes. + * @instance EventListener + */ + listeners?: EventListener; + /** + * The fetcher factory to use. Defaults to the built-in `defaultFetcher`. + */ fetcher?: Factory; + /** + * Configuration for server interruption notifications. + */ + interruptionsOnServer?: InterruptionsOnServer; } diff --git a/src/features/fetcher-factory/fetcher-factory.utils.ts b/src/features/fetcher-factory/fetcher-factory.utils.ts new file mode 100644 index 0000000..54407d7 --- /dev/null +++ b/src/features/fetcher-factory/fetcher-factory.utils.ts @@ -0,0 +1,28 @@ +import { RequestMeta, FetchableRequestHeaders } from './fetcher-factory.types'; +import { generateUuid } from '../../shared/utils'; + +/** + * Generates a unique request identifier by combining the URL and HTTP method. + * + * @param {RequestMeta} meta - The request metadata object. + * @param {string} meta.url - The request URL. + * @param {string} meta.method - The HTTP method (e.g., 'GET', 'POST'). + * @returns {string} A string in the format `{method}@{url}`. + * + * @example + * const meta = { url: '/api/users', method: 'GET' }; + * const key = getRequestUrlByMeta(meta); // 'GET@/api/users' + */ +export const getRequestUrlByMeta = (meta: RequestMeta): string => { + return `${meta.method}@${meta.url}`; +}; + +/** + * Creates a set of headers to be sent with each request. + * Includes a unique request ID and cache-control headers. + * + * @returns {Types.FetchableRequestHeaders} The headers object. + */ +export const createHeaders = (): FetchableRequestHeaders => { + return { 'x-request-id': generateUuid(), 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; +}; From 7acab09003a230e7a9941c5becef1c38b1e8c65e Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:18:28 +0300 Subject: [PATCH 08/28] chore: adds a dot-notation rule (#59) --- .eslintrc | 1 + 1 file changed, 1 insertion(+) 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", { From 81b90eda9c58a8736d3fcff4e3a722b916634fc9 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:19:00 +0300 Subject: [PATCH 09/28] adds a new word to the exception (#59) --- cspell.json | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/cspell.json b/cspell.json index 418a613..8548834 100644 --- a/cspell.json +++ b/cspell.json @@ -1,15 +1,7 @@ { "version": "0.2", "language": "en,ru", - "words": [ - "Сalls", - "Laptev", - "saborter", - "Saborter", - "tgz", - "Vladislav", - "yxxx" - ], + "words": ["Сalls", "Laptev", "saborter", "Saborter", "tgz", "Vladislav", "yxxx", "TENSIILE"], "flagWords": [], "ignorePaths": [ "node_modules/**", @@ -23,13 +15,7 @@ "./cspell.json", "./integrations" ], - "dictionaries": [ - "typescript", - "node", - "softwareTerms", - "en_US", - "ru_RU" - ], + "dictionaries": ["typescript", "node", "softwareTerms", "en_US", "ru_RU"], "useGitignore": true, "patterns": [ { @@ -43,12 +29,7 @@ "description": "Ignore HTML entities" } ], - "ignoreRegExpList": [ - "/\\[.*\\]\\(.*\\)/g", - "/&[a-z]+;/g", - "/0x[a-fA-F0-9]+/g", - "/\\$[^{][\\w.]+/g" - ], + "ignoreRegExpList": ["/\\[.*\\]\\(.*\\)/g", "/&[a-z]+;/g", "/0x[a-fA-F0-9]+/g", "/\\$[^{][\\w.]+/g"], "caseSensitive": false, "allowCompoundWords": true, "minWordLength": 3 From 78f2d3c3f36c66d3f11138d59bfe4038410fa3bd Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:23:58 +0300 Subject: [PATCH 10/28] feat: integrates the fetcher factory into Aborter (#59) --- src/modules/aborter/aborter.ts | 21 +++++++++++++++++---- src/modules/aborter/aborter.types.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/modules/aborter/aborter.ts b/src/modules/aborter/aborter.ts index 3d1d6f4..8768669 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -56,12 +56,20 @@ export class Aborter = IFetche */ public listeners: EventListener; - protected fetcherFactory: FetcherFactory; + /** + * A factory that creates abortable fetchers with support for request cancellation, + * notification of request interruptions on the server, and automatic request ID generation. + */ + protected masterFetcher: FetcherFactory; constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); - this.fetcherFactory = new FetcherFactory({ fetcher: options?.fetcher, signal: this.signal }); + this.masterFetcher = new FetcherFactory({ + fetcher: options?.fetcher, + signal: this.signal, + listeners: this.listeners + }); this.try = this.try.bind(this); } @@ -81,8 +89,11 @@ export class Aborter = IFetche return this.abortController?.signal; } + /** + * Returns the abortable fetcher function. + */ public get fetcher() { - return this.fetcherFactory.fetcher; + return this.masterFetcher.fetcher; } private setRequestState = (state: RequestState): void => { @@ -119,7 +130,7 @@ export class Aborter = IFetche } this.abortController = new AbortController(); - this.fetcherFactory.setAbortSignal(this.abortController.signal); + this.masterFetcher.setAbortSignal(this.abortController.signal); const promise = new Promise((resolve, reject) => { this.isRequestInProgress = true; @@ -185,6 +196,8 @@ export class Aborter = IFetche this.abortController.abort(error); + this.masterFetcher.notifyServerOfInterruption(); + this.setRequestState(error.type!); }; diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index 0ff2755..f379043 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -1,6 +1,6 @@ import { EventListenerConstructorOptions } from '../../features/event-listener/event-listener.types'; import { TimeoutErrorOptions } from '../../features/timeout'; -import { FetcherFactory } from '../../features/fetcher-factory/fetcher-factory.types'; +import { FetcherFactory, InterruptionsOnServer } from '../../features/fetcher-factory/fetcher-factory.types'; export type AbortableRequest = (signal: AbortSignal) => Promise; @@ -25,5 +25,12 @@ export interface AborterOptions EventListenerConstructorOptions, 'onAbort' | 'onStateChange' > { + /** + * The fetcher factory to use. Defaults to the built-in `defaultFetcher`. + */ fetcher?: Fetcher; + /** + * Configuration for server interruption notifications. + */ + interruptionsOnServer?: InterruptionsOnServer; } From 7a954b6993872995dc0103192559650aefc3cd60 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 19:43:10 +0300 Subject: [PATCH 11/28] fix: fixes fetcher factory tests (#59) --- .../fetcher-factory/fetcher-factory.test.ts | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/features/fetcher-factory/fetcher-factory.test.ts b/src/features/fetcher-factory/fetcher-factory.test.ts index 4f831cd..b9cd70c 100644 --- a/src/features/fetcher-factory/fetcher-factory.test.ts +++ b/src/features/fetcher-factory/fetcher-factory.test.ts @@ -1,13 +1,7 @@ import { FetcherFactory } from './fetcher-factory'; import { defaultFetcher } from './fetcher-factory.lib'; import { overrideSymbol } from './fetcher-factory.constants'; -import { Utils } from '../../shared'; - -jest.mock('../../shared', () => ({ - Utils: { - generateUuid: jest.fn().mockReturnValue('mock-uuid') - } -})); +import { createHeaders } from './fetcher-factory.utils'; jest.mock('./fetcher-factory.lib', () => ({ defaultFetcher: jest.fn() @@ -45,37 +39,34 @@ describe('FetcherFactory', () => { describe('конструктор', () => { it('должен использовать defaultFetcher, если fetcher не передан', () => { const factory = new FetcherFactory({ signal: mockSignal }); + expect(factory['fetcherFactory']).toBe(defaultFetcher); }); it('должен сохранять переданный fetcher', () => { const customFetcher = jest.fn(); const factory = new FetcherFactory({ fetcher: customFetcher, signal: mockSignal }); + expect(factory['fetcherFactory']).toBe(customFetcher); }); it('должен сохранять переданный сигнал', () => { const factory = new FetcherFactory({ signal: mockSignal }); - expect(factory['signal']).toBe(mockSignal); + + expect(factory['meta']['signal']).toBe(mockSignal); }); it('должен устанавливать interruptionsOnServer с дефолтными значениями, если options.interruptionsOnServer не передан', () => { const factory = new FetcherFactory({ signal: mockSignal }); + expect(factory['interruptionsOnServer']).not.toHaveProperty('hasInterruptRequests'); }); }); - describe('метод createHeaders', () => { - it('должен генерировать уникальный x-request-id', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - const headers = factory['createHeaders'](); - expect(Utils.generateUuid).toHaveBeenCalled(); - expect(headers['x-request-id']).toBe('mock-uuid'); - }); - + describe('функция createHeaders', () => { it('должен устанавливать Cache-Control и Pragma', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - const headers = factory['createHeaders'](); + const headers = createHeaders(); + expect(headers['Cache-Control']).toBe('no-cache'); expect(headers.Pragma).toBe('no-cache'); }); @@ -84,10 +75,8 @@ describe('FetcherFactory', () => { describe('метод createContext', () => { it('должен вызывать createHeaders и сохранять их в meta.headers', () => { const factory = new FetcherFactory({ signal: mockSignal }); - const createHeadersSpy = jest.spyOn(factory as any, 'createHeaders'); const context = factory['createContext'](); - expect(createHeadersSpy).toHaveBeenCalled(); expect(factory['meta'].headers).toBeDefined(); expect(context.headers).toBe(factory['meta'].headers); }); @@ -106,7 +95,9 @@ describe('FetcherFactory', () => { const factory = new FetcherFactory({ signal: mockSignal }); const context = factory['createContext'](); const testUrl = 'https://'; + context.save({ url: testUrl, method: 'post' }); + expect(factory['meta'].url).toBe(testUrl); }); }); @@ -114,10 +105,14 @@ describe('FetcherFactory', () => { describe('метод setAbortSignal', () => { it('должен обновлять сигнал', () => { const factory = new FetcherFactory({ signal: mockSignal }); + const newController = new AbortController(); + const newSignal = newController.signal; + factory.setAbortSignal(newSignal); - expect(factory['signal']).toBe(newSignal); + + expect(factory['meta']['signal']).toBe(newSignal); }); }); @@ -180,6 +175,9 @@ describe('FetcherFactory', () => { basePath: 'https://example.com' }; factory['meta'].headers = { 'x-request-id': 'test-uuid', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; + factory['requestsMetaMap'] = new Map([ + ['http://', { headers: factory['meta'].headers, signal: mockSignal, url: 'http://', method: 'get' }] + ]); sendBeaconMock = jest.fn().mockReturnValue(true); fetchMock = jest.fn().mockResolvedValue({} as Response); @@ -197,14 +195,18 @@ describe('FetcherFactory', () => { factory['interruptionsOnServer'].hasInterruptRequests = false; } factory.notifyServerOfInterruption(); + expect(sendBeaconMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); }); it('должен использовать navigator.sendBeacon, если доступен', () => { factory.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + const blobArg = sendBeaconMock.mock.calls[0][1]; + expect(blobArg.type).toBe('text/plain'); expect(blobArg).toBeInstanceOf(Blob); expect(fetchMock).not.toHaveBeenCalled(); @@ -213,11 +215,14 @@ describe('FetcherFactory', () => { it('должен использовать fetch, если sendBeacon недоступен', async () => { delete (global.navigator as any).sendBeacon; factory.notifyServerOfInterruption(); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/cancel', { method: 'POST', body: expect.any(Blob) }); + const blobArg = fetchMock.mock.calls[0][1].body; + expect(blobArg).toBeInstanceOf(Blob); }); @@ -225,8 +230,11 @@ describe('FetcherFactory', () => { Reflect.deleteProperty(factory['meta'].headers ?? {}, 'x-request-id'); factory.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + const blobArg = sendBeaconMock.mock.calls[0][1]; + expect(blobArg).toBeInstanceOf(Blob); }); }); From 3473f9bdb35a709ea0aa2556b32c741ecf214efc Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Wed, 25 Mar 2026 21:18:25 +0300 Subject: [PATCH 12/28] test: renames the test (#59) --- src/features/lib/abort-signal-any/abort-signal-any.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d5fe2c3..aa4a091 100644 --- a/src/features/lib/abort-signal-any/abort-signal-any.test.ts +++ b/src/features/lib/abort-signal-any/abort-signal-any.test.ts @@ -13,7 +13,7 @@ describe('abortSignalAny', () => { }); describe('basic behavior', () => { - it('должен создавать новый AbortSignal', () => { + it('must create a new AbortSignal', () => { const signal = abortSignalAny(controller1.signal); expect(signal).toBeInstanceOf(AbortSignal); From e156035ce55ea013704670c7ed35c0b50388e952 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Thu, 26 Mar 2026 03:03:38 +0300 Subject: [PATCH 13/28] chore: add badges for test coverage (#59) --- .github/workflows/tests.yml | 23 +++++++++++++++++++++++ readme.md | 8 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests.yml 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/readme.md b/readme.md index 16a8da2..73e7fd4 100644 --- a/readme.md +++ b/readme.md @@ -7,8 +7,12 @@ - - + + + + + + From 9e76cd65d86e37d75f83b7e3f22d50fe0d46452b Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Thu, 26 Mar 2026 17:46:57 +0300 Subject: [PATCH 14/28] docs(readme): fixes badge in testing coverage --- readme.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/readme.md b/readme.md index da4cfb3..8914ddc 100644 --- a/readme.md +++ b/readme.md @@ -13,8 +13,7 @@ - - +Coverage Status From 0cc158a733e45fad947dff9af73cc0031592005e Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 27 Mar 2026 02:33:44 +0300 Subject: [PATCH 15/28] refactor: removes the fetcher factory implementation (#59) --- .../fetcher-factory.constants.ts | 1 - .../fetcher-factory.lib.test.ts | 160 ------------ .../fetcher-factory/fetcher-factory.lib.ts | 76 ------ .../fetcher-factory/fetcher-factory.test.ts | 241 ------------------ .../fetcher-factory/fetcher-factory.ts | 198 -------------- .../fetcher-factory/fetcher-factory.types.ts | 149 ----------- .../fetcher-factory/fetcher-factory.utils.ts | 28 -- src/features/fetcher-factory/index.ts | 1 - 8 files changed, 854 deletions(-) delete mode 100644 src/features/fetcher-factory/fetcher-factory.constants.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.lib.test.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.lib.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.test.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.types.ts delete mode 100644 src/features/fetcher-factory/fetcher-factory.utils.ts delete mode 100644 src/features/fetcher-factory/index.ts diff --git a/src/features/fetcher-factory/fetcher-factory.constants.ts b/src/features/fetcher-factory/fetcher-factory.constants.ts deleted file mode 100644 index 242bbb0..0000000 --- a/src/features/fetcher-factory/fetcher-factory.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const overrideSymbol = Symbol('Fetcher.override'); diff --git a/src/features/fetcher-factory/fetcher-factory.lib.test.ts b/src/features/fetcher-factory/fetcher-factory.lib.test.ts deleted file mode 100644 index 598fdac..0000000 --- a/src/features/fetcher-factory/fetcher-factory.lib.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { defaultFetcher, makeFetchGetter } from './fetcher-factory.lib'; -import { abortSignalAny } from '../lib/abort-signal-any'; -import { overrideSymbol } from './fetcher-factory.constants'; - -jest.mock('../lib/abort-signal-any', () => ({ - abortSignalAny: jest.fn() -})); - -jest.mock('./fetcher-factory.constants', () => ({ - overrideSymbol: Symbol('override') -})); - -describe('defaultFetcher', () => { - let mockAbortSignalAny; - let mockContext; - let mockFetch; - let url: string; - let init: RequestInit; - - beforeEach(() => { - mockAbortSignalAny = jest.fn().mockReturnValue({ aborted: false }); - (abortSignalAny as jest.Mock).mockImplementation(mockAbortSignalAny); - - mockFetch = jest.fn(); - global.fetch = mockFetch; - - url = '/api/test'; - init = { method: 'POST', headers: { 'X-Custom': 'value' }, signal: { aborted: false } as AbortSignal }; - mockContext = { - headers: { 'X-Context': 'ctx' }, - signal: { aborted: false }, - save: jest.fn() - }; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return a function that takes the context', () => { - const fetcher = defaultFetcher(url, init); - expect(typeof fetcher).toBe('function'); - }); - - it('should call abortSignalAny with signals from init and context', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'ok' }), - url: 'https://example.com/api/test' - }); - const fetcher = defaultFetcher(url, init); - await fetcher(mockContext); - expect(abortSignalAny).toHaveBeenCalledWith(init.signal, mockContext.signal); - }); - - it('should fetch with the combined signal and headers', async () => { - const combinedSignal = { aborted: false }; - mockAbortSignalAny.mockReturnValue(combinedSignal); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'ok' }), - url: 'https://example.com/api/test' - }); - - const fetcher = defaultFetcher(url, init); - await fetcher(mockContext); - - expect(mockFetch).toHaveBeenCalledWith(url, { - method: init.method, - signal: combinedSignal, - headers: { ...init.headers, ...mockContext.headers } - }); - }); - - it('should parse JSON and return data', async () => { - const responseData = { id: 1 }; - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(responseData), - url: 'https://example.com/api/test' - }); - - const fetcher = defaultFetcher(url, init); - const result = await fetcher(mockContext); - expect(result).toEqual(responseData); - }); - - it('must call context.save with request metadata', async () => { - const responseUrl = 'https://example.com/api/test'; - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - url: responseUrl - }); - - const fetcher = defaultFetcher(url, init); - await fetcher(mockContext); - - expect(mockContext.save).toHaveBeenCalledWith({ - url: responseUrl, - method: init.method - }); - }); - - it('should use the default "get" method if init.method is not specified', async () => { - const fetcher = defaultFetcher(url, undefined); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({}), - url: 'https://example.com/api/test' - }); - - await fetcher(mockContext); - - expect(mockContext.save).toHaveBeenCalledWith({ - url: expect.any(String), - method: 'get' - }); - }); - - it('should throw an error with the response property if response.ok === false', async () => { - const errorResponse = { ok: false, status: 404, statusText: 'Not Found' }; - mockFetch.mockResolvedValue(errorResponse); - - const fetcher = defaultFetcher(url, init); - await expect(fetcher(mockContext)).rejects.toThrow('The request failed'); - - try { - await fetcher(mockContext); - } catch (err) { - expect(err.response).toBe(errorResponse); - } - }); -}); - -describe('makeFetchGetter', () => { - it('must return the same function it received', () => { - const callback = () => {}; - const result = makeFetchGetter(callback); - expect(result).toBe(callback); - }); - - it('must add the overrideSymbol property to the function', () => { - const callback = () => {}; - const result = makeFetchGetter(callback); - expect(result[overrideSymbol]).toBe(overrideSymbol); - }); - - it('must preserve the return type', () => { - const callback = (a: number) => a + 1; - const result = makeFetchGetter(callback); - expect(result(5)).toBe(6); - }); - - it('should work correctly with asynchronous functions', async () => { - const asyncCallback = async (x: number) => x * 2; - const result = makeFetchGetter(asyncCallback); - await expect(result(3)).resolves.toBe(6); - }); -}); diff --git a/src/features/fetcher-factory/fetcher-factory.lib.ts b/src/features/fetcher-factory/fetcher-factory.lib.ts deleted file mode 100644 index de3ab67..0000000 --- a/src/features/fetcher-factory/fetcher-factory.lib.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { FetcherFactory, DefaultFetcherFactoryArgs, FetcherFactoryContext } from './fetcher-factory.types'; -import { abortSignalAny } from '../lib'; -import { overrideSymbol } from './fetcher-factory.constants'; - -/** - * Default fetcher factory that creates an abortable fetch function. - * - * This factory produces a function that: - * - Merges the provided signal with the context signal using `abortSignalAny`. - * - Performs a `fetch` request with the combined signal. - * - If the response is not ok, throws an error with the `response` property attached. - * - Parses the response as JSON. - * - Saves the request metadata (url and method) via `context.save`. - * - * @type {FetcherFactory} - * - * @param {string} url - The request URL. - * @param {RequestInit} [init] - Optional fetch options. - * @returns {(context: FetcherFactoryContext) => Promise} - * A function that accepts a context and returns a promise resolving to the parsed JSON. - * - * @example - * const fetcher = defaultFetcher('/api/users', { method: 'GET' }); - * const context = { headers: {}, signal: controller.signal, save: console.log }; - * fetcher(context).then(data => console.log(data)); - */ -export const defaultFetcher: FetcherFactory = (url: string, init?: RequestInit) => { - return async (context: FetcherFactoryContext) => { - const signal = abortSignalAny(init?.signal, context.signal); - - const response = await fetch(url, { - ...init, - signal, - headers: { ...init?.headers, ...context.headers } - }); - - if (!response.ok) { - const error = new Error('The request failed'); - (error as any).response = response; - - throw error; - } - - const data = await response.json(); - - context.save({ url: response.url, method: init?.method ?? 'get' }); - - return data; - }; -}; - -/** - * Marks a callback as overridable by attaching a unique symbol property. - * - * This function is used internally to indicate that a fetcher factory - * should be treated specially (e.g., when it is intended to be overridden - * by a custom implementation). The resulting function receives the - * `overrideSymbol` property, which is used by the `FetcherFactory` class - * to differentiate between standard and overridden factories. - * - * @template C - The type of the callback function. - * @param {C} callback - The callback function to mark. - * @returns {C & typeof overrideSymbol} The same function with the `overrideSymbol` property attached. - * - * @example - * const myFetcher = makeFetchGetter((url, init) => { - * return (context) => fetch(url, { ...init, signal: context.signal }); - * }); - * // `myFetcher[overrideSymbol]` is now truthy. - */ -export const makeFetchGetter = any>(callback: C): C & typeof overrideSymbol => { - // eslint-disable-next-line no-param-reassign - (callback as any)[overrideSymbol] = overrideSymbol; - - return callback as C & typeof overrideSymbol; -}; diff --git a/src/features/fetcher-factory/fetcher-factory.test.ts b/src/features/fetcher-factory/fetcher-factory.test.ts deleted file mode 100644 index b9cd70c..0000000 --- a/src/features/fetcher-factory/fetcher-factory.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { FetcherFactory } from './fetcher-factory'; -import { defaultFetcher } from './fetcher-factory.lib'; -import { overrideSymbol } from './fetcher-factory.constants'; -import { createHeaders } from './fetcher-factory.utils'; - -jest.mock('./fetcher-factory.lib', () => ({ - defaultFetcher: jest.fn() -})); - -jest.mock('./fetcher-factory.constants', () => ({ - overrideSymbol: Symbol('override') -})); - -describe('FetcherFactory', () => { - let mockSignal: AbortSignal; - let mockController: AbortController; - let originalWindow: any; - let originalNavigator: any; - - beforeAll(() => { - originalWindow = global.window; - originalNavigator = global.navigator; - }); - - afterAll(() => { - global.window = originalWindow; - global.navigator = originalNavigator; - }); - - beforeEach(() => { - jest.clearAllMocks(); - mockController = new AbortController(); - mockSignal = mockController.signal; - - delete (global as any).window; - delete (global as any).navigator; - }); - - describe('конструктор', () => { - it('должен использовать defaultFetcher, если fetcher не передан', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - - expect(factory['fetcherFactory']).toBe(defaultFetcher); - }); - - it('должен сохранять переданный fetcher', () => { - const customFetcher = jest.fn(); - const factory = new FetcherFactory({ fetcher: customFetcher, signal: mockSignal }); - - expect(factory['fetcherFactory']).toBe(customFetcher); - }); - - it('должен сохранять переданный сигнал', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - - expect(factory['meta']['signal']).toBe(mockSignal); - }); - - it('должен устанавливать interruptionsOnServer с дефолтными значениями, если options.interruptionsOnServer не передан', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - - expect(factory['interruptionsOnServer']).not.toHaveProperty('hasInterruptRequests'); - }); - }); - - describe('функция createHeaders', () => { - it('должен устанавливать Cache-Control и Pragma', () => { - const headers = createHeaders(); - - expect(headers['Cache-Control']).toBe('no-cache'); - expect(headers.Pragma).toBe('no-cache'); - }); - }); - - describe('метод createContext', () => { - it('должен вызывать createHeaders и сохранять их в meta.headers', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - const context = factory['createContext'](); - - expect(factory['meta'].headers).toBeDefined(); - expect(context.headers).toBe(factory['meta'].headers); - }); - - it('должен возвращать объект с save, headers и signal', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - const context = factory['createContext'](); - - expect(context).toHaveProperty('save'); - expect(typeof context.save).toBe('function'); - expect(context.headers).toBeDefined(); - expect(context.signal).toBe(mockSignal); - }); - - it('метод save должен сохранять данные в meta.url', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - const context = factory['createContext'](); - const testUrl = 'https://'; - - context.save({ url: testUrl, method: 'post' }); - - expect(factory['meta'].url).toBe(testUrl); - }); - }); - - describe('метод setAbortSignal', () => { - it('должен обновлять сигнал', () => { - const factory = new FetcherFactory({ signal: mockSignal }); - - const newController = new AbortController(); - - const newSignal = newController.signal; - - factory.setAbortSignal(newSignal); - - expect(factory['meta']['signal']).toBe(newSignal); - }); - }); - - describe('геттер fetcher', () => { - let factory: FetcherFactory; - let mockContext: any; - - beforeEach(() => { - factory = new FetcherFactory({ signal: mockSignal }); - mockContext = { - headers: { 'x-request-id': 'uuid' }, - signal: mockSignal, - save: jest.fn() - }; - jest.spyOn(factory as any, 'createContext').mockReturnValue(mockContext); - }); - - it('должен возвращать функцию, которая вызывает fetcherFactory с переданными аргументами, если нет overrideSymbol', () => { - const mockFetcher = jest.fn().mockReturnValue(jest.fn()); - factory['fetcherFactory'] = mockFetcher; - delete factory['fetcherFactory'][overrideSymbol]; - - // eslint-disable-next-line prefer-destructuring - const fetcher = factory.fetcher; - expect(typeof fetcher).toBe('function'); - - fetcher('/api/test', { method: 'GET' }); - expect(mockFetcher).toHaveBeenCalledWith('/api/test', { method: 'GET' }); - const returnedFn = mockFetcher.mock.results[0].value; - expect(returnedFn).toHaveBeenCalledWith(mockContext); - }); - - it('должен возвращать результат вызова fetcherFactory([]) с контекстом, если есть overrideSymbol', () => { - const mockFactoryReturn = jest.fn().mockReturnValue(jest.fn()); - const mockFetcherFactory = jest.fn().mockReturnValue(mockFactoryReturn); - factory['fetcherFactory'] = mockFetcherFactory; - factory['fetcherFactory'][overrideSymbol] = overrideSymbol; - - // eslint-disable-next-line prefer-destructuring - const fetcher = factory.fetcher; - expect(typeof fetcher).toBe('function'); - - fetcher('/api/test', { method: 'GET' }); - - expect(mockFetcherFactory).toHaveBeenCalledWith([]); - expect(mockFactoryReturn).toHaveBeenCalledWith(mockContext); - }); - }); - - describe('метод notifyServerOfInterruption', () => { - let factory: FetcherFactory; - let sendBeaconMock: jest.Mock; - let fetchMock: jest.Mock; - - beforeEach(() => { - factory = new FetcherFactory({ signal: mockSignal }); - factory['interruptionsOnServer'] = { - hasInterruptRequests: true, - endpointName: '/api/cancel', - basePath: 'https://example.com' - }; - factory['meta'].headers = { 'x-request-id': 'test-uuid', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; - factory['requestsMetaMap'] = new Map([ - ['http://', { headers: factory['meta'].headers, signal: mockSignal, url: 'http://', method: 'get' }] - ]); - - sendBeaconMock = jest.fn().mockReturnValue(true); - fetchMock = jest.fn().mockResolvedValue({} as Response); - global.fetch = fetchMock; - global.navigator = { sendBeacon: sendBeaconMock } as any; - }); - - afterEach(() => { - delete (global as any).fetch; - delete (global as any).navigator; - }); - - it('не должен отправлять запрос, если hasInterruptRequests = false', () => { - if (factory['interruptionsOnServer']) { - factory['interruptionsOnServer'].hasInterruptRequests = false; - } - factory.notifyServerOfInterruption(); - - expect(sendBeaconMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('должен использовать navigator.sendBeacon, если доступен', () => { - factory.notifyServerOfInterruption(); - - expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); - - const blobArg = sendBeaconMock.mock.calls[0][1]; - - expect(blobArg.type).toBe('text/plain'); - expect(blobArg).toBeInstanceOf(Blob); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('должен использовать fetch, если sendBeacon недоступен', async () => { - delete (global.navigator as any).sendBeacon; - factory.notifyServerOfInterruption(); - - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/cancel', { - method: 'POST', - body: expect.any(Blob) - }); - - const blobArg = fetchMock.mock.calls[0][1].body; - - expect(blobArg).toBeInstanceOf(Blob); - }); - - it('должен использовать пустую строку, если x-request-id отсутствует', () => { - Reflect.deleteProperty(factory['meta'].headers ?? {}, 'x-request-id'); - - factory.notifyServerOfInterruption(); - - expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); - - const blobArg = sendBeaconMock.mock.calls[0][1]; - - expect(blobArg).toBeInstanceOf(Blob); - }); - }); -}); diff --git a/src/features/fetcher-factory/fetcher-factory.ts b/src/features/fetcher-factory/fetcher-factory.ts deleted file mode 100644 index be2e54f..0000000 --- a/src/features/fetcher-factory/fetcher-factory.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as Types from './fetcher-factory.types'; -import { defaultFetcher } from './fetcher-factory.lib'; -import { overrideSymbol } from './fetcher-factory.constants'; -import * as Utils from './fetcher-factory.utils'; - -/** - * A factory that creates abortable fetchers with support for request cancellation, - * notification of request interruptions on the server, and automatic request ID generation. - * - * The `FetcherFactory` wraps a fetcher function (or a factory that returns a fetcher) - * and provides an abort signal, headers with a unique `x-request-id`, and the ability - * to notify the server when a request is interrupted (using `sendBeacon` or `fetch`). - * - * @template Factory - The type of the fetcher factory. Defaults to `Types.FetcherFactory`. - * - * @example - * // Create a factory with a custom fetcher - * const factory = new FetcherFactory({ - * fetcher: (url, options) => { - * return async (ctx) => { - * const response = await fetch(url, { ...options, signal: ctx.signal, headers:ctx.headers }); - * return await response.json(); - * } - * }, - * signal: controller.signal, - * interruptionsOnServer: { hasInterruptRequests: true } - * }); - * - * // Get the abortable fetcher - * const fetcher = factory.fetcher; - * - * // Use it like a normal fetch, but with automatic abort and headers - * fetcher('/api/data') - * .then(response => response.json()) - * .catch(err => console.error('Request failed or aborted')); - * - * // Notify the server if the request was interrupted - * factory.notifyServerOfInterruption(); - */ -export class FetcherFactory< - Factory extends Types.FetcherFactory<[any?, ...any[]]> = Types.FetcherFactory -> { - /** - * The underlying fetcher factory. - * - * @protected - */ - protected fetcherFactory: Factory; - - /** - * Configuration for server interruption notification. - * - * @protected - */ - protected interruptionsOnServer?: Types.InterruptionsOnServer; - - /** - * Metadata object that holds headers and response. - * - * @protected - */ - protected meta: Types.RequestMeta = {}; - - /** - * Request map containing metadata. - * - * @private - */ - private requestsMetaMap: Map = new Map(); - - /** - * Cleanup callback called when the request completes. - * - * @private - */ - private pendingRequestMetadataCleanup?: VoidFunction; - - constructor(options: Types.FetcherFactoryOptions) { - this.fetcherFactory = (options.fetcher ?? defaultFetcher) as Factory; - this.meta.signal = options.signal; - - if (typeof window !== 'undefined') { - const { - endpointName = '/api/cancel', - basePath = window.location.origin, - ...restInterruptionsOnServer - } = options.interruptionsOnServer ?? {}; - this.interruptionsOnServer = { ...restInterruptionsOnServer, endpointName, basePath }; - - if (this.interruptionsOnServer.hasInterruptRequests) { - options.listeners?.state.subscribe((state) => { - if (state === 'aborted' || state === 'fulfilled' || state === 'rejected') { - this.pendingRequestMetadataCleanup?.(); - } - }); - } - } - } - - /** - * Creates the context object that is passed to the fetcher. - * The context contains headers, the abort signal, and a `save` method to store the response. - * - * @returns {Types.FetcherFactoryContext} The context object. - * - * @protected - */ - protected createContext = (): Types.FetcherFactoryContext => { - this.meta.headers = Utils.createHeaders(); - - if (!this.meta.signal) { - throw new ReferenceError('No instance of AbortSignal found!'); - } - - return { - save: (data: Types.FetcherFactoryContextSaveData) => { - this.meta.url = data.url; - this.meta.method = data.method; - this.requestsMetaMap.set(Utils.getRequestUrlByMeta(this.meta), this.meta); - - this.pendingRequestMetadataCleanup = () => { - if (!this.meta.url) return; - this.requestsMetaMap.delete(Utils.getRequestUrlByMeta(this.meta)); - }; - }, - headers: this.meta.headers, - signal: this.meta.signal - }; - }; - - /** - * Updates the abort signal used for subsequent requests. - * - * @param {AbortSignal} signal - The new abort signal. - */ - public setAbortSignal = (signal: AbortSignal): void => { - this.meta.signal = signal; - }; - - /** - * Returns the abortable fetcher function. - * - * If the internal `fetcherFactory` has an `overrideSymbol` property, the factory is called - * with no arguments and the resulting fetcher is invoked with the context. - * Otherwise, the factory is called with the provided arguments, and then the resulting - * function is invoked with the context. - * - * @returns {Factory extends typeof overrideSymbol - * ? ReturnType> - * : (...args: Parameters) => Result} - * A function that can be called like a normal fetcher, but automatically receives - * the abort signal and headers. - * - * @example - * const fetcher = factory.fetcher; - * // Use with standard fetch-like arguments - * fetcher('/api/users').then(handleResponse); - */ - public get fetcher(): Factory extends typeof overrideSymbol - ? ReturnType> - : (...args: Parameters) => Result { - const context = this.createContext(); - - if (!this.fetcherFactory[overrideSymbol]) { - // @ts-expect-error - return (...args: Parameters) => { - return this.fetcherFactory(...args)(context); - }; - } - - return this.fetcherFactory([])(context); - } - - /** - * Notifies the server that a request was interrupted (e.g., because the user navigated away). - * This method sends the request ID (from the last request) to a server endpoint. - */ - public notifyServerOfInterruption = (): void => { - if (this.interruptionsOnServer?.hasInterruptRequests) { - const url = `${this.interruptionsOnServer.basePath}${this.interruptionsOnServer.endpointName}`; - - this.requestsMetaMap.forEach((meta) => { - const blob = new Blob([meta.headers?.['x-request-id'] ?? ''], { type: 'text/plain' }); - - if (navigator && 'sendBeacon' in navigator && typeof navigator.sendBeacon === 'function') { - navigator.sendBeacon(url, blob); - - return; - } - - fetch(url, { - method: 'POST', - body: blob - }); - }); - } - }; -} diff --git a/src/features/fetcher-factory/fetcher-factory.types.ts b/src/features/fetcher-factory/fetcher-factory.types.ts deleted file mode 100644 index 9f1d3a7..0000000 --- a/src/features/fetcher-factory/fetcher-factory.types.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { overrideSymbol } from './fetcher-factory.constants'; -import { EventListener } from '../event-listener'; - -/** - * Headers sent with every abortable fetch request. - * Includes a unique request ID and cache-control headers. - */ -export interface FetchableRequestHeaders { - /** - * 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'; -} - -/** - * Data saved by the `save` method of the fetcher context. - * Contains the final URL (after possible redirects) and the HTTP method used. - */ -export interface FetcherFactoryContextSaveData { - /** - * Request URL. - */ - url: string; - /** - * The HTTP method used (e.g., 'GET', 'POST'). - */ - method: string; -} - -/** - * Context object passed to the fetcher function. - * Provides access to headers, an abort signal, and a callback to save request metadata. - */ -export interface FetcherFactoryContext { - /** - * Callback to store metadata about the request after it completes. - * @param data - The metadata to save. - */ - save: (data: FetcherFactoryContextSaveData) => void; - /** - * Abort signal that can be used to cancel the request. - */ - signal: AbortSignal; - /** - * Headers that must be included in the request. - */ - headers: FetchableRequestHeaders; -} - -/** - * Metadata describing a request. - */ -export interface RequestMeta { - /** - * Request URL. - */ - url?: string; - /** - * HTTP method. - */ - method?: string; - /** - * Headers used in the request. - */ - headers?: FetchableRequestHeaders; - /** - * Abort signal associated with the request. - */ - signal?: AbortSignal; -} - -/** - * A factory that creates a fetcher function (a function that accepts a context - * and returns a promise). The factory can be decorated with the `overrideSymbol` - * to indicate that it should be treated specially by the `FetcherFactory` class. - * - * @template Args - The arguments expected by the factory. - */ -export interface FetcherFactory { - /** - * Creates a fetcher function that will be invoked with a context. - * @param args - Arguments passed to the factory. - * @returns A function that, when given a context, returns a promise. - */ - (...args: Args): (context: FetcherFactoryContext) => any extends infer P ? P : never; - /** - * Optional marker property used to identify factories that should be overridden. - * If present, the factory is called with an empty array and the resulting - * function is invoked with the context. - */ - [overrideSymbol]?: typeof overrideSymbol; -} - -/** - * Argument types for the default fetcher factory. - * The default fetcher expects a URL and optional fetch options. - */ -export type DefaultFetcherFactoryArgs = [url: string, init?: RequestInit]; - -/** - * Configuration for notifying the server when a request is interrupted. - */ -export interface InterruptionsOnServer { - /** - * Whether to notify the server on interruption. - */ - hasInterruptRequests?: boolean; - /** - * Base path of the server (e.g., `window.location.origin`). - */ - basePath?: string; - /** - * Endpoint path where the interruption notification is sent. - */ - endpointName?: string; -} - -/** - * Options for creating a `FetcherFactory` instance. - * - * @template Factory - The type of fetcher factory to use. - */ -export interface FetcherFactoryOptions> { - /** - * Abort signal that will be passed to all requests created by this factory. - */ - signal: AbortSignal; - /** - * Optional event listener for state changes. - * @instance EventListener - */ - listeners?: EventListener; - /** - * The fetcher factory to use. Defaults to the built-in `defaultFetcher`. - */ - fetcher?: Factory; - /** - * Configuration for server interruption notifications. - */ - interruptionsOnServer?: InterruptionsOnServer; -} diff --git a/src/features/fetcher-factory/fetcher-factory.utils.ts b/src/features/fetcher-factory/fetcher-factory.utils.ts deleted file mode 100644 index 54407d7..0000000 --- a/src/features/fetcher-factory/fetcher-factory.utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RequestMeta, FetchableRequestHeaders } from './fetcher-factory.types'; -import { generateUuid } from '../../shared/utils'; - -/** - * Generates a unique request identifier by combining the URL and HTTP method. - * - * @param {RequestMeta} meta - The request metadata object. - * @param {string} meta.url - The request URL. - * @param {string} meta.method - The HTTP method (e.g., 'GET', 'POST'). - * @returns {string} A string in the format `{method}@{url}`. - * - * @example - * const meta = { url: '/api/users', method: 'GET' }; - * const key = getRequestUrlByMeta(meta); // 'GET@/api/users' - */ -export const getRequestUrlByMeta = (meta: RequestMeta): string => { - return `${meta.method}@${meta.url}`; -}; - -/** - * Creates a set of headers to be sent with each request. - * Includes a unique request ID and cache-control headers. - * - * @returns {Types.FetchableRequestHeaders} The headers object. - */ -export const createHeaders = (): FetchableRequestHeaders => { - return { 'x-request-id': generateUuid(), 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; -}; diff --git a/src/features/fetcher-factory/index.ts b/src/features/fetcher-factory/index.ts deleted file mode 100644 index f0b7640..0000000 --- a/src/features/fetcher-factory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './fetcher-factory'; From 5dcd0db31e22aa797c905384a41791cc8f731119 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 27 Mar 2026 06:13:34 +0300 Subject: [PATCH 16/28] feat: adds server breaker functionality (#59) --- readme.md | 2 + src/features/index.ts | 2 +- src/features/server-breaker/index.ts | 1 + .../server-breaker.constants.ts | 3 + .../server-breaker/server-breaker.test.ts | 129 ++++++++++++++++++ src/features/server-breaker/server-breaker.ts | 75 ++++++++++ .../server-breaker/server-breaker.types.ts | 50 +++++++ .../server-breaker/server-breaker.utils.ts | 12 ++ src/modules/aborter/aborter.ts | 48 ++++--- src/modules/aborter/aborter.types.ts | 67 ++++++--- src/types.ts | 2 + 11 files changed, 350 insertions(+), 41 deletions(-) create mode 100644 src/features/server-breaker/index.ts create mode 100644 src/features/server-breaker/server-breaker.constants.ts create mode 100644 src/features/server-breaker/server-breaker.test.ts create mode 100644 src/features/server-breaker/server-breaker.ts create mode 100644 src/features/server-breaker/server-breaker.types.ts create mode 100644 src/features/server-breaker/server-breaker.utils.ts diff --git a/readme.md b/readme.md index 8914ddc..7a5a0c7 100644 --- a/readme.md +++ b/readme.md @@ -27,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: diff --git a/src/features/index.ts b/src/features/index.ts index 8c0cb85..82c81e9 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,3 +1,3 @@ export * from './abort-error'; export * from './timeout'; -export * from './fetcher-factory'; +export * from './server-breaker'; 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.constants.ts b/src/features/server-breaker/server-breaker.constants.ts new file mode 100644 index 0000000..8c127f6 --- /dev/null +++ b/src/features/server-breaker/server-breaker.constants.ts @@ -0,0 +1,3 @@ +export const ENDPOINT_NAME = '/api/@cancel'; +export const X_REQUEST_ID_HEADER = 'x-request-id'; +export const REQUEST_ID_SEARCH_PARAM = 'requestId'; 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..a81c193 --- /dev/null +++ b/src/features/server-breaker/server-breaker.test.ts @@ -0,0 +1,129 @@ +import { ServerBreaker } from './server-breaker'; +import { createHeaders } from './server-breaker.utils'; + +jest.mock('./server-breaker.utils', () => ({ + createHeaders: jest.fn().mockReturnValue({ 'x-request-id': 'test-uuid' }) +})); + +describe('ServerBreaker', () => { + let originalWindow; + let originalNavigator; + + beforeAll(() => { + originalWindow = global.window; + originalNavigator = global.navigator; + }); + + afterAll(() => { + global.window = originalWindow; + global.navigator = originalNavigator; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + Reflect.deleteProperty(global, 'window'); + Reflect.deleteProperty(global, 'navigator'); + }); + + describe('setInterruptionsOnServer', () => { + it('should set interruptionsOnServer with default values ​​if true is passed', () => { + const breaker = new ServerBreaker({ interruptionsOnServer: true }); + expect(breaker['interruptionsOnServer']).toEqual( + expect.objectContaining({ + endpointName: '/api/cancel' + }) + ); + expect(createHeaders).toHaveBeenCalled(); + }); + + it('must use the passed interruptionsOnServer object', () => { + const customConfig = { + baseURL: 'https://custom.com', + endpointName: '/custom/cancel' + }; + const breaker = new ServerBreaker({ interruptionsOnServer: customConfig }); + expect(breaker['interruptionsOnServer']).toEqual(customConfig); + expect(createHeaders).toHaveBeenCalled(); + }); + + it('should ignore interruptionsOnServer if false is passed', () => { + const breaker = new ServerBreaker({ interruptionsOnServer: false }); + expect(breaker['interruptionsOnServer']).toBeUndefined(); + expect(createHeaders).not.toHaveBeenCalled(); + }); + + it('should ignore interruptionsOnServer if undefined is passed', () => { + const breaker = new ServerBreaker({ interruptionsOnServer: undefined }); + expect(breaker['interruptionsOnServer']).toBeUndefined(); + expect(createHeaders).not.toHaveBeenCalled(); + }); + }); + + describe('getter headers', () => { + it('should return headers from meta.headers if interruptionsOnServer is set', () => { + const breaker = new ServerBreaker({ interruptionsOnServer: true }); + expect(breaker.headers).toEqual({ 'x-request-id': 'test-uuid' }); + }); + + it('should return undefined if interruptionsOnServer is not set', () => { + const breaker = new ServerBreaker(); + expect(breaker.headers).toBeUndefined(); + }); + }); + + describe('notifyServerOfInterruption', () => { + let breaker; + let sendBeaconMock; + let fetchMock; + + beforeEach(() => { + breaker = new ServerBreaker({ interruptionsOnServer: true }); + sendBeaconMock = jest.fn().mockReturnValue(true); + fetchMock = jest.fn().mockResolvedValue({}); + global.fetch = fetchMock; + }); + + afterEach(() => { + Reflect.deleteProperty(global, 'fetch'); + }); + + it('should not send a request if interruptionsOnServer is not present', () => { + const breakerWithout = new ServerBreaker(); + breakerWithout.notifyServerOfInterruption(); + expect(sendBeaconMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should use navigator.sendBeacon if available', () => { + global.navigator = { sendBeacon: sendBeaconMock } as any; + breaker = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); + breaker.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + const blobArg = sendBeaconMock.mock.calls[0][1]; + expect(blobArg.type).toBe('text/plain'); + expect(blobArg).toBeInstanceOf(Blob); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should use fetch if sendBeacon is not available', () => { + global.navigator = {} as any; + breaker = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); + breaker.notifyServerOfInterruption(); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/cancel', { + method: 'POST', + body: expect.any(Blob) + }); + const blobArg = fetchMock.mock.calls[0][1].body; + expect(blobArg).toBeInstanceOf(Blob); + }); + + it('should use an empty string if x-request-id is missing', () => { + (createHeaders as any).mockReturnValueOnce({}); + const breaker2 = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); + global.navigator = { sendBeacon: sendBeaconMock } as any; + breaker2.notifyServerOfInterruption(); + expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + }); + }); +}); diff --git a/src/features/server-breaker/server-breaker.ts b/src/features/server-breaker/server-breaker.ts new file mode 100644 index 0000000..19260db --- /dev/null +++ b/src/features/server-breaker/server-breaker.ts @@ -0,0 +1,75 @@ +import * as Types from './server-breaker.types'; +import { createHeaders } from './server-breaker.utils'; +import * as Constants from './server-breaker.constants'; + +/** + * Manages server‑side interruption notification for abortable requests. + */ +export class ServerBreaker { + /** + * Configuration for server interruption notification. + * + * @protected + */ + protected interruptionsOnServer?: Types.InterruptionsOnServer; + + /** + * Metadata storage for request‑related data (e.g., headers). + * @protected + */ + protected meta: Types.RequestMeta = {}; + + constructor(options?: Types.ServerBreakerOptions) { + this.setInterruptionsOnServer(options?.interruptionsOnServer); + } + + /** + * Configures the server interruption settings based on the provided value. + * Only runs in a browser environment (`window` exists). + * + * @private + * @param {Types.ServerBreakerOptions['interruptionsOnServer']} interruptionsOnServer - + * The configuration value from the constructor options. + */ + private setInterruptionsOnServer = (interruptionsOnServer: Types.ServerBreakerOptions['interruptionsOnServer']) => { + if (typeof window === 'undefined' || !interruptionsOnServer) { + return; + } + + const { baseURL = window.location.origin, endpointName = Constants.ENDPOINT_NAME } = + typeof interruptionsOnServer === 'object' ? interruptionsOnServer : {}; + + this.interruptionsOnServer = { baseURL, endpointName }; + 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; + } + + /** + * Notifies the server that a request was interrupted (e.g., because the user navigated away). + * This method sends the request ID (from the last request) to a server endpoint. + */ + public notifyServerOfInterruption = (): void => { + if (this.interruptionsOnServer) { + const url = `${this.interruptionsOnServer.baseURL}${this.interruptionsOnServer.endpointName}`; + + const blob = new Blob([this.headers?.[Constants.X_REQUEST_ID_HEADER] ?? ''], { type: 'text/plain' }); + + if (navigator && 'sendBeacon' in navigator && typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon(url, blob); + + return; + } + + fetch(url); + } + }; +} 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..fa5d7bb --- /dev/null +++ b/src/features/server-breaker/server-breaker.types.ts @@ -0,0 +1,50 @@ +/** + * 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; +} + +/** + * Configuration for notifying the server when a request is interrupted. + */ +export interface InterruptionsOnServer { + /** + * Base path of the server (e.g., `window.location.origin`). + */ + baseURL?: string; + /** + * Endpoint path where the interruption notification is sent. + * @default "api/@cancel" + */ + endpointName?: string; +} + +export interface ServerBreakerOptions { + /** + * Configuration for server interruption notifications. + */ + interruptionsOnServer?: boolean | InterruptionsOnServer; +} 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.ts b/src/modules/aborter/aborter.ts index 2204627..4070fbd 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -2,11 +2,7 @@ import { RequestState, emitRequestState } from '../../features/state-observer'; import { AbortError, isAbortError } from '../../features/abort-error'; import { EventListener, clearEventListeners } from '../../features/event-listener'; -import { FetcherFactory } from '../../features/fetcher-factory'; -import { - FetcherFactory as IFetcherFactory, - DefaultFetcherFactoryArgs -} from '../../features/fetcher-factory/fetcher-factory.types'; +import { ServerBreaker } from '../../features/server-breaker'; import { Timeout, TimeoutError } from '../../features/timeout'; import { ErrorMessage, disposeSymbol } from './aborter.constants'; import * as Utils from './aborter.utils'; @@ -29,7 +25,7 @@ import { logger } from '../../shared'; * { timeout: 5000 } * ); */ -export class Aborter = IFetcherFactory> { +export class Aborter { /** * Internal abort controller for the current request. * @@ -57,19 +53,14 @@ export class Aborter = IFetche public listeners: EventListener; /** - * A factory that creates abortable fetchers with support for request cancellation, - * notification of request interruptions on the server, and automatic request ID generation. + * Manages server‑side interruption notification for abortable requests. */ - protected masterFetcher: FetcherFactory; + protected serverBreaker: ServerBreaker; - constructor(options?: Types.AborterOptions) { + constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); - this.masterFetcher = new FetcherFactory({ - fetcher: options?.fetcher, - signal: this.signal, - listeners: this.listeners - }); + this.serverBreaker = new ServerBreaker({ interruptionsOnServer: options?.interruptionsOnServer }); this.try = this.try.bind(this); } @@ -90,10 +81,22 @@ export class Aborter = IFetche } /** - * Returns the abortable fetcher function. + * 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': '...' } } */ - public get fetcher() { - return this.masterFetcher.fetcher; + protected get requestOptions(): Types.AbortableRequestOptions { + return { headers: this.serverBreaker.headers }; } private setRequestState = (state: RequestState): void => { @@ -130,7 +133,6 @@ export class Aborter = IFetche } this.abortController = new AbortController(); - this.masterFetcher.setAbortSignal(this.abortController.signal); const promise = new Promise((resolve, reject) => { this.isRequestInProgress = true; @@ -150,7 +152,7 @@ export class Aborter = IFetche queueMicrotask(() => this.setRequestState('pending')); Promise.race([ - request(this.abortController.signal), + request(this.abortController.signal, this.requestOptions), Utils.createAbortablePromise(this.abortController.signal, { isErrorNativeBehavior }) ]) .then((response) => { @@ -205,9 +207,11 @@ export class Aborter = IFetche this.listeners.dispatchEvent(error.type!, error); - this.abortController.abort(error); + if (error.type === 'aborted') { + this.serverBreaker.notifyServerOfInterruption(); + } - this.masterFetcher.notifyServerOfInterruption(); + this.abortController.abort(error); this.setRequestState(error.type!); }; diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index f379043..3612568 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -1,36 +1,67 @@ import { EventListenerConstructorOptions } from '../../features/event-listener/event-listener.types'; import { TimeoutErrorOptions } from '../../features/timeout'; -import { FetcherFactory, InterruptionsOnServer } from '../../features/fetcher-factory/fetcher-factory.types'; +import { RequestHeaders, ServerBreakerOptions } 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; } -export interface AborterOptions> extends Pick< - EventListenerConstructorOptions, - 'onAbort' | 'onStateChange' -> { - /** - * The fetcher factory to use. Defaults to the built-in `defaultFetcher`. - */ - fetcher?: Fetcher; - /** - * Configuration for server interruption notifications. - */ - interruptionsOnServer?: InterruptionsOnServer; -} +/** + * Configuration options for creating an `Aborter` instance. + * Combines options from `EventListenerConstructorOptions` and `ServerBreakerOptions`. + */ +export interface AborterOptions + extends + Pick, + Pick {} 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'; From aee9f62f554730d538ed78c07ed8d17e9d3c01e3 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 27 Mar 2026 22:17:35 +0300 Subject: [PATCH 17/28] refactor: simplifies API interaction with the @saborter/server package (#59) --- .../server-breaker.constants.ts | 3 - .../server-breaker/server-breaker.test.ts | 129 ++++-------------- src/features/server-breaker/server-breaker.ts | 52 +------ .../server-breaker/server-breaker.types.ts | 22 --- 4 files changed, 29 insertions(+), 177 deletions(-) delete mode 100644 src/features/server-breaker/server-breaker.constants.ts diff --git a/src/features/server-breaker/server-breaker.constants.ts b/src/features/server-breaker/server-breaker.constants.ts deleted file mode 100644 index 8c127f6..0000000 --- a/src/features/server-breaker/server-breaker.constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const ENDPOINT_NAME = '/api/@cancel'; -export const X_REQUEST_ID_HEADER = 'x-request-id'; -export const REQUEST_ID_SEARCH_PARAM = 'requestId'; diff --git a/src/features/server-breaker/server-breaker.test.ts b/src/features/server-breaker/server-breaker.test.ts index a81c193..dcbecb7 100644 --- a/src/features/server-breaker/server-breaker.test.ts +++ b/src/features/server-breaker/server-breaker.test.ts @@ -2,128 +2,53 @@ import { ServerBreaker } from './server-breaker'; import { createHeaders } from './server-breaker.utils'; jest.mock('./server-breaker.utils', () => ({ - createHeaders: jest.fn().mockReturnValue({ 'x-request-id': 'test-uuid' }) + createHeaders: jest.fn() })); describe('ServerBreaker', () => { - let originalWindow; - let originalNavigator; - - beforeAll(() => { - originalWindow = global.window; - originalNavigator = global.navigator; - }); - - afterAll(() => { - global.window = originalWindow; - global.navigator = originalNavigator; - }); + const mockHeaders = { + 'x-request-id': 'test-uuid-123', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + }; beforeEach(() => { jest.clearAllMocks(); - - Reflect.deleteProperty(global, 'window'); - Reflect.deleteProperty(global, 'navigator'); + (createHeaders as jest.Mock).mockReturnValue(mockHeaders); }); - describe('setInterruptionsOnServer', () => { - it('should set interruptionsOnServer with default values ​​if true is passed', () => { - const breaker = new ServerBreaker({ interruptionsOnServer: true }); - expect(breaker['interruptionsOnServer']).toEqual( - expect.objectContaining({ - endpointName: '/api/cancel' - }) - ); - expect(createHeaders).toHaveBeenCalled(); - }); - - it('must use the passed interruptionsOnServer object', () => { - const customConfig = { - baseURL: 'https://custom.com', - endpointName: '/custom/cancel' - }; - const breaker = new ServerBreaker({ interruptionsOnServer: customConfig }); - expect(breaker['interruptionsOnServer']).toEqual(customConfig); - expect(createHeaders).toHaveBeenCalled(); - }); - - it('should ignore interruptionsOnServer if false is passed', () => { - const breaker = new ServerBreaker({ interruptionsOnServer: false }); - expect(breaker['interruptionsOnServer']).toBeUndefined(); - expect(createHeaders).not.toHaveBeenCalled(); + describe('constructor', () => { + it('should call createHeaders once', () => { + // eslint-disable-next-line no-new + new ServerBreaker(); + expect(createHeaders).toHaveBeenCalledTimes(1); }); - it('should ignore interruptionsOnServer if undefined is passed', () => { - const breaker = new ServerBreaker({ interruptionsOnServer: undefined }); - expect(breaker['interruptionsOnServer']).toBeUndefined(); - expect(createHeaders).not.toHaveBeenCalled(); + it('should store headers in meta.headers', () => { + const breaker = new ServerBreaker(); + expect(breaker['meta'].headers).toBe(mockHeaders); }); }); - describe('getter headers', () => { - it('should return headers from meta.headers if interruptionsOnServer is set', () => { - const breaker = new ServerBreaker({ interruptionsOnServer: true }); - expect(breaker.headers).toEqual({ 'x-request-id': 'test-uuid' }); + 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 interruptionsOnServer is not set', () => { + it('should return undefined if createHeaders returns undefined', () => { + (createHeaders as jest.Mock).mockReturnValue(undefined); const breaker = new ServerBreaker(); expect(breaker.headers).toBeUndefined(); }); }); - describe('notifyServerOfInterruption', () => { - let breaker; - let sendBeaconMock; - let fetchMock; - - beforeEach(() => { - breaker = new ServerBreaker({ interruptionsOnServer: true }); - sendBeaconMock = jest.fn().mockReturnValue(true); - fetchMock = jest.fn().mockResolvedValue({}); - global.fetch = fetchMock; - }); - - afterEach(() => { - Reflect.deleteProperty(global, 'fetch'); - }); - - it('should not send a request if interruptionsOnServer is not present', () => { - const breakerWithout = new ServerBreaker(); - breakerWithout.notifyServerOfInterruption(); - expect(sendBeaconMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('should use navigator.sendBeacon if available', () => { - global.navigator = { sendBeacon: sendBeaconMock } as any; - breaker = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); - breaker.notifyServerOfInterruption(); - expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); - const blobArg = sendBeaconMock.mock.calls[0][1]; - expect(blobArg.type).toBe('text/plain'); - expect(blobArg).toBeInstanceOf(Blob); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('should use fetch if sendBeacon is not available', () => { - global.navigator = {} as any; - breaker = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); - breaker.notifyServerOfInterruption(); - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/cancel', { - method: 'POST', - body: expect.any(Blob) - }); - const blobArg = fetchMock.mock.calls[0][1].body; - expect(blobArg).toBeInstanceOf(Blob); - }); - - it('should use an empty string if x-request-id is missing', () => { - (createHeaders as any).mockReturnValueOnce({}); - const breaker2 = new ServerBreaker({ interruptionsOnServer: { baseURL: 'https://example.com' } }); - global.navigator = { sendBeacon: sendBeaconMock } as any; - breaker2.notifyServerOfInterruption(); - expect(sendBeaconMock).toHaveBeenCalledWith('https://example.com/api/cancel', expect.any(Blob)); + 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 index 19260db..598ae5d 100644 --- a/src/features/server-breaker/server-breaker.ts +++ b/src/features/server-breaker/server-breaker.ts @@ -1,47 +1,19 @@ import * as Types from './server-breaker.types'; import { createHeaders } from './server-breaker.utils'; -import * as Constants from './server-breaker.constants'; /** * Manages server‑side interruption notification for abortable requests. */ export class ServerBreaker { - /** - * Configuration for server interruption notification. - * - * @protected - */ - protected interruptionsOnServer?: Types.InterruptionsOnServer; - /** * Metadata storage for request‑related data (e.g., headers). * @protected */ protected meta: Types.RequestMeta = {}; - constructor(options?: Types.ServerBreakerOptions) { - this.setInterruptionsOnServer(options?.interruptionsOnServer); - } - - /** - * Configures the server interruption settings based on the provided value. - * Only runs in a browser environment (`window` exists). - * - * @private - * @param {Types.ServerBreakerOptions['interruptionsOnServer']} interruptionsOnServer - - * The configuration value from the constructor options. - */ - private setInterruptionsOnServer = (interruptionsOnServer: Types.ServerBreakerOptions['interruptionsOnServer']) => { - if (typeof window === 'undefined' || !interruptionsOnServer) { - return; - } - - const { baseURL = window.location.origin, endpointName = Constants.ENDPOINT_NAME } = - typeof interruptionsOnServer === 'object' ? interruptionsOnServer : {}; - - this.interruptionsOnServer = { baseURL, endpointName }; + constructor() { this.meta.headers = createHeaders(); - }; + } /** * Returns the request headers that should be sent with the abortable request. @@ -52,24 +24,4 @@ export class ServerBreaker { public get headers(): Types.RequestHeaders | undefined { return this.meta.headers; } - - /** - * Notifies the server that a request was interrupted (e.g., because the user navigated away). - * This method sends the request ID (from the last request) to a server endpoint. - */ - public notifyServerOfInterruption = (): void => { - if (this.interruptionsOnServer) { - const url = `${this.interruptionsOnServer.baseURL}${this.interruptionsOnServer.endpointName}`; - - const blob = new Blob([this.headers?.[Constants.X_REQUEST_ID_HEADER] ?? ''], { type: 'text/plain' }); - - if (navigator && 'sendBeacon' in navigator && typeof navigator.sendBeacon === 'function') { - navigator.sendBeacon(url, blob); - - return; - } - - fetch(url); - } - }; } diff --git a/src/features/server-breaker/server-breaker.types.ts b/src/features/server-breaker/server-breaker.types.ts index fa5d7bb..b75fc4d 100644 --- a/src/features/server-breaker/server-breaker.types.ts +++ b/src/features/server-breaker/server-breaker.types.ts @@ -26,25 +26,3 @@ export interface RequestMeta { */ headers?: RequestHeaders; } - -/** - * Configuration for notifying the server when a request is interrupted. - */ -export interface InterruptionsOnServer { - /** - * Base path of the server (e.g., `window.location.origin`). - */ - baseURL?: string; - /** - * Endpoint path where the interruption notification is sent. - * @default "api/@cancel" - */ - endpointName?: string; -} - -export interface ServerBreakerOptions { - /** - * Configuration for server interruption notifications. - */ - interruptionsOnServer?: boolean | InterruptionsOnServer; -} From d30468f474dc42a70f7b87d3202d35c96274c23a Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 27 Mar 2026 22:25:29 +0300 Subject: [PATCH 18/28] refactor: simplifies the server breaker in Aborter (#59) --- src/modules/aborter/aborter.ts | 8 +------- src/modules/aborter/aborter.types.ts | 8 ++------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/modules/aborter/aborter.ts b/src/modules/aborter/aborter.ts index 4070fbd..d42a11d 100644 --- a/src/modules/aborter/aborter.ts +++ b/src/modules/aborter/aborter.ts @@ -55,13 +55,11 @@ export class Aborter { /** * Manages server‑side interruption notification for abortable requests. */ - protected serverBreaker: ServerBreaker; + protected serverBreaker: ServerBreaker = new ServerBreaker(); constructor(options?: Types.AborterOptions) { this.listeners = new EventListener(options); - this.serverBreaker = new ServerBreaker({ interruptionsOnServer: options?.interruptionsOnServer }); - this.try = this.try.bind(this); } @@ -207,10 +205,6 @@ export class Aborter { this.listeners.dispatchEvent(error.type!, error); - if (error.type === 'aborted') { - this.serverBreaker.notifyServerOfInterruption(); - } - this.abortController.abort(error); this.setRequestState(error.type!); diff --git a/src/modules/aborter/aborter.types.ts b/src/modules/aborter/aborter.types.ts index 3612568..e075225 100644 --- a/src/modules/aborter/aborter.types.ts +++ b/src/modules/aborter/aborter.types.ts @@ -1,6 +1,6 @@ import { EventListenerConstructorOptions } from '../../features/event-listener/event-listener.types'; import { TimeoutErrorOptions } from '../../features/timeout'; -import { RequestHeaders, ServerBreakerOptions } from '../../features/server-breaker/server-breaker.types'; +import { RequestHeaders } from '../../features/server-breaker/server-breaker.types'; /** * Options that can be passed to an abortable request. @@ -59,9 +59,5 @@ export interface FnTryOptions { /** * Configuration options for creating an `Aborter` instance. - * Combines options from `EventListenerConstructorOptions` and `ServerBreakerOptions`. */ -export interface AborterOptions - extends - Pick, - Pick {} +export interface AborterOptions extends Pick {} From 15fb63c7672dc5b4d6bb1e327a0ae1bb46dace8e Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Fri, 27 Mar 2026 22:33:02 +0300 Subject: [PATCH 19/28] fix: fixes the test with Aborter's headings (#59) --- src/modules/aborter/aborter.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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); }); From 5534e7b26db96946c9a24df87442acf93396b018 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Sun, 29 Mar 2026 23:08:52 +0300 Subject: [PATCH 20/28] docs(lib): adds documentation to the abortSignalAny lib function (#60) --- docs/libs.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) 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. From e5264b99cce0e666749281bc458b2d644d9cce8a Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Sun, 29 Mar 2026 23:15:04 +0300 Subject: [PATCH 21/28] fix(AbortError): fixes a bug with inheritance of the AbortError error cause (#60) --- src/features/abort-error/abort-error.lib.ts | 33 +++++++++++++++++++ src/features/lib/debounce/debounce.lib.ts | 6 ++-- .../set-timeout-async.lib.ts | 6 ++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/features/abort-error/abort-error.lib.ts b/src/features/abort-error/abort-error.lib.ts index 6390e4c..c0835bc 100644 --- a/src/features/abort-error/abort-error.lib.ts +++ b/src/features/abort-error/abort-error.lib.ts @@ -53,3 +53,36 @@ export const isAbortError = (error: any): error is Error => { return !!checkErrorCause(error); }; + +/** + * 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] }, 'cause' | 'timestamp' | 'stack' | 'name'> +) => { + return new AbortError(override?.message ?? abortError.message, { ...abortError, ...override, cause: abortError }); +}; 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/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`, { From 7487700b27b101b34539e348196d9a4b21753e52 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 21:38:19 +0300 Subject: [PATCH 22/28] test(AbortError): adds a test for copyAbortError (#60) --- src/features/abort-error/abort-error.test.ts | 109 ++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/src/features/abort-error/abort-error.test.ts b/src/features/abort-error/abort-error.test.ts index c7db1a5..602e61c 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'; @@ -125,4 +125,111 @@ 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(AbortError).toHaveBeenCalledWith( + 'Original message', + expect.objectContaining({ + ...originalError, + cause: originalError + }) + ); + + expect(copy.cause).toBe(originalError); + }); + + it('should override the message when provided', () => { + const copy = copyAbortError(originalError, { message: 'New message' }); + + expect(AbortError).toHaveBeenCalledWith( + 'New message', + expect.objectContaining({ + ...originalError, + cause: originalError, + message: 'New message' + }) + ); + }); + + 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(AbortError).toHaveBeenCalledWith( + 'Original message', + expect.objectContaining({ + ...originalError, + ...override, + cause: originalError + }) + ); + }); + + 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(AbortError).toHaveBeenCalledWith( + 'Minimal', + expect.objectContaining({ + cause: minimalError + }) + ); + }); + + it('should handle undefined override', () => { + const copy = copyAbortError(originalError, undefined); + + expect(AbortError).toHaveBeenCalledWith( + 'Original message', + expect.objectContaining({ + ...originalError, + cause: originalError + }) + ); + }); + }); }); From 3ed41dcfe1e7e945b33b28a3dbd66416688433c1 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 21:39:34 +0300 Subject: [PATCH 23/28] feat: adds a runtime check for what cannot be overridden in AbortError (#60) --- src/features/abort-error/abort-error.lib.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/abort-error/abort-error.lib.ts b/src/features/abort-error/abort-error.lib.ts index c0835bc..d7300f1 100644 --- a/src/features/abort-error/abort-error.lib.ts +++ b/src/features/abort-error/abort-error.lib.ts @@ -54,6 +54,8 @@ 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. @@ -82,7 +84,13 @@ export const isAbortError = (error: any): error is Error => { */ export const copyAbortError = ( abortError: AbortError, - override?: Omit<{ [key in keyof AbortError]?: AbortError[key] }, 'cause' | 'timestamp' | 'stack' | 'name'> + 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 }); }; From 23bc30b9be0f301846f556c828962fca722574ef Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 21:45:46 +0300 Subject: [PATCH 24/28] test(AbortError): renames tests (#60) --- src/features/abort-error/abort-error.test.ts | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/features/abort-error/abort-error.test.ts b/src/features/abort-error/abort-error.test.ts index 602e61c..41df641 100644 --- a/src/features/abort-error/abort-error.test.ts +++ b/src/features/abort-error/abort-error.test.ts @@ -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); From 3c6a9fda5be41027bcfc2ecf72fbf2b83c40b0f2 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 22:06:00 +0300 Subject: [PATCH 25/28] docs(readme): adds an example to the documentation about interrupting a server-side process (#60) --- readme.md | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index 7a5a0c7..b86d87f 100644 --- a/readme.md +++ b/readme.md @@ -128,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 }); }, @@ -147,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: @@ -169,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 }); @@ -177,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: @@ -198,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: @@ -407,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 @@ -546,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. From cf658ad847066850e584c0fd95b8df76dad28725 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 22:08:24 +0300 Subject: [PATCH 26/28] docs(changelog): updates the changelog (#60) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 7988be663b9a89403956b1fddcfbe1a69948737c Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 22:08:52 +0300 Subject: [PATCH 27/28] raises the package version (#60) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 934020cf3caf2bbbcd3383a5e6a79adb1a1ddb81 Mon Sep 17 00:00:00 2001 From: TENSIILE Date: Tue, 31 Mar 2026 22:47:08 +0300 Subject: [PATCH 28/28] test(abortError|debounce): fixes tests related to fixing the error overriding bug (#60) --- src/features/abort-error/abort-error.test.ts | 53 +++++---------- src/features/lib/debounce/debounce.test.ts | 69 ++++++++++++++------ 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/src/features/abort-error/abort-error.test.ts b/src/features/abort-error/abort-error.test.ts index 41df641..4c27682 100644 --- a/src/features/abort-error/abort-error.test.ts +++ b/src/features/abort-error/abort-error.test.ts @@ -142,28 +142,15 @@ describe('isAbortError', () => { it('should create a new AbortError with the original message when no override provided', () => { const copy = copyAbortError(originalError); - expect(AbortError).toHaveBeenCalledWith( - 'Original message', - expect.objectContaining({ - ...originalError, - cause: originalError - }) - ); - - expect(copy.cause).toBe(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(AbortError).toHaveBeenCalledWith( - 'New message', - expect.objectContaining({ - ...originalError, - cause: originalError, - message: 'New message' - }) - ); + expect(copy.message).toEqual('New message'); + expect(copy.cause).toEqual(originalError); }); it('should override other properties when provided', () => { @@ -176,14 +163,12 @@ describe('isAbortError', () => { const copy = copyAbortError(originalError, override); - expect(AbortError).toHaveBeenCalledWith( - 'Original message', - expect.objectContaining({ - ...originalError, - ...override, - cause: originalError - }) - ); + 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', () => { @@ -212,24 +197,16 @@ describe('isAbortError', () => { const copy = copyAbortError(minimalError); - expect(AbortError).toHaveBeenCalledWith( - 'Minimal', - expect.objectContaining({ - cause: minimalError - }) - ); + expect(copy.message).toEqual(minimalError.message); + expect(copy.cause).toEqual(minimalError); }); it('should handle undefined override', () => { const copy = copyAbortError(originalError, undefined); - expect(AbortError).toHaveBeenCalledWith( - 'Original message', - expect.objectContaining({ - ...originalError, - cause: originalError - }) - ); + expect(copy.cause).toEqual(originalError); + expect(copy.message).toEqual(originalError.message); + expect(copy.type).toEqual(originalError.type); }); }); }); 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', () => {