@naturalcycles/js-lib 14.253.0 → 14.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -161,11 +161,15 @@ export declare function _intersection<T>(a1: T[], a2: T[] | Set<T>): T[];
161
161
  */
162
162
  export declare function _intersectsWith<T>(a1: T[], a2: T[] | Set<T>): boolean;
163
163
  /**
164
+ * Returns array1 minus array2.
165
+ *
164
166
  * @example
165
167
  * _difference([2, 1], [2, 3])
166
168
  * // [1]
169
+ *
170
+ * Passing second array as Set is more performant (it'll skip turning the array into Set in-place).
167
171
  */
168
- export declare function _difference<T>(source: T[], ...diffs: T[][]): T[];
172
+ export declare function _difference<T>(a1: T[], a2: T[] | Set<T>): T[];
169
173
  /**
170
174
  * Returns the sum of items, or 0 for empty array.
171
175
  */
@@ -318,16 +318,17 @@ function _intersectsWith(a1, a2) {
318
318
  return a1.some(v => a2set.has(v));
319
319
  }
320
320
  /**
321
+ * Returns array1 minus array2.
322
+ *
321
323
  * @example
322
324
  * _difference([2, 1], [2, 3])
323
325
  * // [1]
326
+ *
327
+ * Passing second array as Set is more performant (it'll skip turning the array into Set in-place).
324
328
  */
325
- function _difference(source, ...diffs) {
326
- let a = source;
327
- for (const b of diffs) {
328
- a = a.filter(c => !b.includes(c));
329
- }
330
- return a;
329
+ function _difference(a1, a2) {
330
+ const a2set = a2 instanceof Set ? a2 : new Set(a2);
331
+ return a1.filter(v => !a2set.has(v));
331
332
  }
332
333
  /**
333
334
  * Returns the sum of items, or 0 for empty array.
package/dist/env.js CHANGED
@@ -9,6 +9,7 @@ exports.isClientSide = isClientSide;
9
9
  * Will return `false` in the Browser.
10
10
  */
11
11
  function isServerSide() {
12
+ // eslint-disable-next-line unicorn/prefer-global-this
12
13
  return typeof window === 'undefined';
13
14
  }
14
15
  /**
@@ -18,5 +19,6 @@ function isServerSide() {
18
19
  * Will return `false` in Node.js.
19
20
  */
20
21
  function isClientSide() {
22
+ // eslint-disable-next-line unicorn/prefer-global-this
21
23
  return typeof window !== 'undefined';
22
24
  }
@@ -219,10 +219,9 @@ function _isErrorLike(o) {
219
219
  function _errorDataAppend(err, data) {
220
220
  if (!data)
221
221
  return err;
222
- err.data = {
223
- ...err.data,
224
- ...data,
225
- };
222
+ err.data ||= {}; // create err.data if it doesn't exist
223
+ // Using Object.assign instead of ...data to not override err.data's non-enumerable properties
224
+ Object.assign(err.data, data);
226
225
  return err;
227
226
  }
228
227
  /**
@@ -29,7 +29,7 @@ function _tryCatch(fn, opt = {}) {
29
29
  }
30
30
  if (onError) {
31
31
  try {
32
- return await onError((0, index_1._anyToError)(err)); // eslint-disable-line @typescript-eslint/return-await
32
+ return await onError((0, index_1._anyToError)(err));
33
33
  }
34
34
  catch { }
35
35
  }
@@ -3,7 +3,7 @@
3
3
  /// <reference lib="dom.iterable" preserve="true" />
4
4
  import { HttpRequestError } from '../error/error.util';
5
5
  import { ErrorDataTuple } from '../types';
6
- import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherNormalizedCfg, FetcherOptions, FetcherResponse, RequestInitNormalized } from './fetcher.model';
6
+ import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherNormalizedCfg, FetcherOnErrorHook, FetcherOptions, FetcherResponse, RequestInitNormalized } from './fetcher.model';
7
7
  /**
8
8
  * Experimental wrapper around Fetch.
9
9
  * Works in both Browser and Node, using `globalThis.fetch`.
@@ -24,6 +24,7 @@ export declare class Fetcher {
24
24
  onBeforeRequest(hook: FetcherBeforeRequestHook): this;
25
25
  onAfterResponse(hook: FetcherAfterResponseHook): this;
26
26
  onBeforeRetry(hook: FetcherBeforeRetryHook): this;
27
+ onError(hook: FetcherOnErrorHook): this;
27
28
  cfg: FetcherNormalizedCfg;
28
29
  static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
29
30
  get: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
@@ -98,6 +98,11 @@ class Fetcher {
98
98
  (this.cfg.hooks.beforeRetry ||= []).push(hook);
99
99
  return this;
100
100
  }
101
+ onError(hook) {
102
+ ;
103
+ (this.cfg.hooks.onError ||= []).push(hook);
104
+ return this;
105
+ }
101
106
  static create(cfg = {}) {
102
107
  return new Fetcher(cfg);
103
108
  }
@@ -195,12 +200,12 @@ class Fetcher {
195
200
  abortController.abort(new error_util_1.TimeoutError(`request timed out after ${timeoutSeconds} sec`));
196
201
  }, timeoutSeconds * 1000);
197
202
  }
198
- if (this.cfg.logRequest) {
203
+ if (req.logRequest) {
199
204
  const { retryAttempt } = res.retryStatus;
200
205
  logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`]
201
206
  .filter(Boolean)
202
207
  .join(' '));
203
- if (this.cfg.logRequestBody && req.init.body) {
208
+ if (req.logRequestBody && req.init.body) {
204
209
  logger.log(req.init.body); // todo: check if we can _inspect it
205
210
  }
206
211
  }
@@ -248,6 +253,13 @@ class Fetcher {
248
253
  await this.onNotOkResponse(res);
249
254
  }
250
255
  }
256
+ if (res.err) {
257
+ (0, error_util_1._errorDataAppend)(res.err, req.errorData);
258
+ req.onError?.(res.err);
259
+ for (const hook of this.cfg.hooks.onError || []) {
260
+ await hook(res.err);
261
+ }
262
+ }
251
263
  for (const hook of this.cfg.hooks.afterResponse || []) {
252
264
  await hook(res);
253
265
  }
@@ -294,7 +306,7 @@ class Fetcher {
294
306
  }
295
307
  res.retryStatus.retryStopped = true;
296
308
  // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
297
- if ((!res.err || !req.throwHttpErrors) && this.cfg.logResponse) {
309
+ if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
298
310
  const { retryAttempt } = res.retryStatus;
299
311
  const { logger } = this.cfg;
300
312
  logger.log([
@@ -306,7 +318,7 @@ class Fetcher {
306
318
  ]
307
319
  .filter(Boolean)
308
320
  .join(' '));
309
- if (this.cfg.logResponseBody && res.body !== undefined) {
321
+ if (req.logResponseBody && res.body !== undefined) {
310
322
  logger.log(res.body);
311
323
  }
312
324
  }
@@ -542,6 +554,7 @@ class Fetcher {
542
554
  },
543
555
  hooks: {},
544
556
  throwHttpErrors: true,
557
+ errorData: {},
545
558
  }, (0, object_util_1._omit)(cfg, ['method', 'credentials', 'headers', 'redirect', 'logger']));
546
559
  norm.init.headers = (0, object_util_1._mapKeys)(norm.init.headers, k => k.toLowerCase());
547
560
  return norm;
@@ -561,6 +574,7 @@ class Fetcher {
561
574
  'logResponseBody',
562
575
  'debug',
563
576
  'throwHttpErrors',
577
+ 'errorData',
564
578
  ]),
565
579
  started: Date.now(),
566
580
  ...(0, object_util_1._omit)(opt, ['method', 'headers', 'credentials']),
@@ -1,16 +1,22 @@
1
1
  /// <reference lib="es2022" preserve="true" />
2
2
  /// <reference lib="dom" preserve="true" />
3
+ import type { ErrorData } from '../error/error.model';
3
4
  import type { CommonLogger } from '../log/commonLogger';
4
5
  import type { Promisable } from '../typeFest';
5
6
  import type { AnyObject, NumberOfMilliseconds, Reviver, UnixTimestampMillisNumber } from '../types';
6
7
  import type { HttpMethod, HttpStatusFamily } from './http.model';
7
- export interface FetcherNormalizedCfg extends Required<FetcherCfg>, Omit<FetcherRequest, 'started' | 'fullUrl' | 'logRequest' | 'logRequestBody' | 'logResponse' | 'logResponseBody' | 'debug' | 'redirect' | 'credentials' | 'throwHttpErrors'> {
8
+ export interface FetcherNormalizedCfg extends Required<FetcherCfg>, Omit<FetcherRequest, 'started' | 'fullUrl' | 'logRequest' | 'logRequestBody' | 'logResponse' | 'logResponseBody' | 'debug' | 'redirect' | 'credentials' | 'throwHttpErrors' | 'errorData'> {
8
9
  logger: CommonLogger;
9
10
  searchParams: Record<string, any>;
10
11
  }
11
12
  export type FetcherBeforeRequestHook = (req: FetcherRequest) => Promisable<void>;
12
13
  export type FetcherAfterResponseHook = <BODY = unknown>(res: FetcherResponse<BODY>) => Promisable<void>;
13
14
  export type FetcherBeforeRetryHook = <BODY = unknown>(res: FetcherResponse<BODY>) => Promisable<void>;
15
+ /**
16
+ * Allows to mutate the error.
17
+ * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
18
+ */
19
+ export type FetcherOnErrorHook = (err: Error) => Promisable<void>;
14
20
  export interface FetcherCfg {
15
21
  /**
16
22
  * Should **not** contain trailing slash.
@@ -35,7 +41,17 @@ export interface FetcherCfg {
35
41
  * Allows to mutate res.retryStatus to override retry behavior.
36
42
  */
37
43
  beforeRetry?: FetcherBeforeRetryHook[];
44
+ onError?: FetcherOnErrorHook[];
38
45
  };
46
+ /**
47
+ * If Fetcher has an error - `errorData` object will be appended to the error data.
48
+ * Like this:
49
+ *
50
+ * _errorDataAppend(err, cfg.errorData)
51
+ *
52
+ * So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
53
+ */
54
+ errorData?: ErrorData | undefined;
39
55
  /**
40
56
  * If true - enables all possible logging.
41
57
  */
@@ -188,6 +204,20 @@ export interface FetcherOptions {
188
204
  * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
189
205
  */
190
206
  throwHttpErrors?: boolean;
207
+ /**
208
+ * If Fetcher has an error - `errorData` object will be appended to the error data.
209
+ * Like this:
210
+ *
211
+ * _errorDataAppend(err, cfg.errorData)
212
+ *
213
+ * So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
214
+ */
215
+ errorData?: ErrorData;
216
+ /**
217
+ * Allows to mutate the error.
218
+ * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
219
+ */
220
+ onError?: FetcherOnErrorHook;
191
221
  }
192
222
  export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
193
223
  method: HttpMethod;
@@ -56,6 +56,7 @@ function _isBetween(x, min, max, incl = '[)') {
56
56
  return x > min && x < max;
57
57
  }
58
58
  function _clamp(x, minIncl, maxIncl) {
59
+ // eslint-disable-next-line unicorn/prefer-math-min-max
59
60
  return x <= minIncl ? minIncl : x >= maxIncl ? maxIncl : x;
60
61
  }
61
62
  /**
@@ -366,6 +366,7 @@ function _get(obj = {}, path = '') {
366
366
  * Based on: https://stackoverflow.com/a/54733755/4919972
367
367
  */
368
368
  function _set(obj, path, value) {
369
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
369
370
  if (!obj || Object(obj) !== obj || !path)
370
371
  return obj; // When obj is not an object
371
372
  // If not yet an array, get the keys from the string-path
@@ -377,7 +378,9 @@ function _set(obj, path, value) {
377
378
  }
378
379
  // eslint-disable-next-line unicorn/no-array-reduce
379
380
  ;
380
- path.slice(0, -1).reduce((a, c, i) => Object(a[c]) === a[c] // Does the key exist and is its value an object?
381
+ path.slice(0, -1).reduce((a, c, i) =>
382
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
383
+ Object(a[c]) === a[c] // Does the key exist and is its value an object?
381
384
  ? // Yes: then follow that path
382
385
  a[c]
383
386
  : // No: create the key. Is the next key a potential array-index?
package/dist/types.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import type { Promisable } from './typeFest';
2
+ declare const __brand: unique symbol;
3
+ interface Brand<B> {
4
+ [__brand]: B;
5
+ }
6
+ /**
7
+ * Helper to create "Branded" types.
8
+ *
9
+ * Example:
10
+ * export type MyId = Branded<string, 'MyId'>
11
+ *
12
+ * MyId can be assigned to a string,
13
+ * but string cannot be assigned to MyId without casting it (`as MyId`).
14
+ */
15
+ export type Branded<T, B> = T & Brand<B>;
2
16
  /**
3
17
  * Map from String to String (or <T>).
4
18
  *
@@ -292,3 +306,4 @@ export interface CommonClient extends AsyncDisposable {
292
306
  disconnect: () => Promise<void>;
293
307
  ping: () => Promise<void>;
294
308
  }
309
+ export {};
@@ -277,16 +277,17 @@ export function _intersectsWith(a1, a2) {
277
277
  return a1.some(v => a2set.has(v));
278
278
  }
279
279
  /**
280
+ * Returns array1 minus array2.
281
+ *
280
282
  * @example
281
283
  * _difference([2, 1], [2, 3])
282
284
  * // [1]
285
+ *
286
+ * Passing second array as Set is more performant (it'll skip turning the array into Set in-place).
283
287
  */
284
- export function _difference(source, ...diffs) {
285
- let a = source;
286
- for (const b of diffs) {
287
- a = a.filter(c => !b.includes(c));
288
- }
289
- return a;
288
+ export function _difference(a1, a2) {
289
+ const a2set = a2 instanceof Set ? a2 : new Set(a2);
290
+ return a1.filter(v => !a2set.has(v));
290
291
  }
291
292
  /**
292
293
  * Returns the sum of items, or 0 for empty array.
package/dist-esm/env.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Will return `false` in the Browser.
6
6
  */
7
7
  export function isServerSide() {
8
+ // eslint-disable-next-line unicorn/prefer-global-this
8
9
  return typeof window === 'undefined';
9
10
  }
10
11
  /**
@@ -14,5 +15,6 @@ export function isServerSide() {
14
15
  * Will return `false` in Node.js.
15
16
  */
16
17
  export function isClientSide() {
18
+ // eslint-disable-next-line unicorn/prefer-global-this
17
19
  return typeof window !== 'undefined';
18
20
  }
@@ -204,12 +204,12 @@ export function _isErrorLike(o) {
204
204
  * }
205
205
  */
206
206
  export function _errorDataAppend(err, data) {
207
+ var _a;
207
208
  if (!data)
208
209
  return err;
209
- err.data = {
210
- ...err.data,
211
- ...data,
212
- };
210
+ (_a = err).data || (_a.data = {}); // create err.data if it doesn't exist
211
+ // Using Object.assign instead of ...data to not override err.data's non-enumerable properties
212
+ Object.assign(err.data, data);
213
213
  return err;
214
214
  }
215
215
  /**
@@ -25,7 +25,7 @@ export function _tryCatch(fn, opt = {}) {
25
25
  }
26
26
  if (onError) {
27
27
  try {
28
- return await onError(_anyToError(err)); // eslint-disable-line @typescript-eslint/return-await
28
+ return await onError(_anyToError(err));
29
29
  }
30
30
  catch { }
31
31
  }
@@ -4,7 +4,7 @@
4
4
  var _a;
5
5
  import { isServerSide } from '../env';
6
6
  import { _assertErrorClassOrRethrow, _assertIsError } from '../error/assert';
7
- import { _anyToError, _anyToErrorObject, _errorLikeToErrorObject, HttpRequestError, TimeoutError, UnexpectedPassError, } from '../error/error.util';
7
+ import { _anyToError, _anyToErrorObject, _errorDataAppend, _errorLikeToErrorObject, HttpRequestError, TimeoutError, UnexpectedPassError, } from '../error/error.util';
8
8
  import { _clamp } from '../number/number.util';
9
9
  import { _filterNullishValues, _filterUndefinedValues, _mapKeys, _merge, _omit, _pick, } from '../object/object.util';
10
10
  import { pDelay } from '../promise/pDelay';
@@ -90,6 +90,12 @@ export class Fetcher {
90
90
  ((_b = this.cfg.hooks).beforeRetry || (_b.beforeRetry = [])).push(hook);
91
91
  return this;
92
92
  }
93
+ onError(hook) {
94
+ var _b;
95
+ ;
96
+ ((_b = this.cfg.hooks).onError || (_b.onError = [])).push(hook);
97
+ return this;
98
+ }
93
99
  static create(cfg = {}) {
94
100
  return new _a(cfg);
95
101
  }
@@ -187,12 +193,12 @@ export class Fetcher {
187
193
  abortController.abort(new TimeoutError(`request timed out after ${timeoutSeconds} sec`));
188
194
  }, timeoutSeconds * 1000);
189
195
  }
190
- if (this.cfg.logRequest) {
196
+ if (req.logRequest) {
191
197
  const { retryAttempt } = res.retryStatus;
192
198
  logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`]
193
199
  .filter(Boolean)
194
200
  .join(' '));
195
- if (this.cfg.logRequestBody && req.init.body) {
201
+ if (req.logRequestBody && req.init.body) {
196
202
  logger.log(req.init.body); // todo: check if we can _inspect it
197
203
  }
198
204
  }
@@ -240,6 +246,13 @@ export class Fetcher {
240
246
  await this.onNotOkResponse(res);
241
247
  }
242
248
  }
249
+ if (res.err) {
250
+ _errorDataAppend(res.err, req.errorData);
251
+ req.onError?.(res.err);
252
+ for (const hook of this.cfg.hooks.onError || []) {
253
+ await hook(res.err);
254
+ }
255
+ }
243
256
  for (const hook of this.cfg.hooks.afterResponse || []) {
244
257
  await hook(res);
245
258
  }
@@ -286,7 +299,7 @@ export class Fetcher {
286
299
  }
287
300
  res.retryStatus.retryStopped = true;
288
301
  // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
289
- if ((!res.err || !req.throwHttpErrors) && this.cfg.logResponse) {
302
+ if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
290
303
  const { retryAttempt } = res.retryStatus;
291
304
  const { logger } = this.cfg;
292
305
  logger.log([
@@ -298,7 +311,7 @@ export class Fetcher {
298
311
  ]
299
312
  .filter(Boolean)
300
313
  .join(' '));
301
- if (this.cfg.logResponseBody && res.body !== undefined) {
314
+ if (req.logResponseBody && res.body !== undefined) {
302
315
  logger.log(res.body);
303
316
  }
304
317
  }
@@ -534,6 +547,7 @@ export class Fetcher {
534
547
  },
535
548
  hooks: {},
536
549
  throwHttpErrors: true,
550
+ errorData: {},
537
551
  }, _omit(cfg, ['method', 'credentials', 'headers', 'redirect', 'logger']));
538
552
  norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase());
539
553
  return norm;
@@ -554,6 +568,7 @@ export class Fetcher {
554
568
  'logResponseBody',
555
569
  'debug',
556
570
  'throwHttpErrors',
571
+ 'errorData',
557
572
  ]),
558
573
  started: Date.now(),
559
574
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -45,6 +45,7 @@ export function _isBetween(x, min, max, incl = '[)') {
45
45
  return x > min && x < max;
46
46
  }
47
47
  export function _clamp(x, minIncl, maxIncl) {
48
+ // eslint-disable-next-line unicorn/prefer-math-min-max
48
49
  return x <= minIncl ? minIncl : x >= maxIncl ? maxIncl : x;
49
50
  }
50
51
  /**
@@ -337,6 +337,7 @@ export function _get(obj = {}, path = '') {
337
337
  * Based on: https://stackoverflow.com/a/54733755/4919972
338
338
  */
339
339
  export function _set(obj, path, value) {
340
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
340
341
  if (!obj || Object(obj) !== obj || !path)
341
342
  return obj; // When obj is not an object
342
343
  // If not yet an array, get the keys from the string-path
@@ -348,7 +349,9 @@ export function _set(obj, path, value) {
348
349
  }
349
350
  // eslint-disable-next-line unicorn/no-array-reduce
350
351
  ;
351
- path.slice(0, -1).reduce((a, c, i) => Object(a[c]) === a[c] // Does the key exist and is its value an object?
352
+ path.slice(0, -1).reduce((a, c, i) =>
353
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
354
+ Object(a[c]) === a[c] // Does the key exist and is its value an object?
352
355
  ? // Yes: then follow that path
353
356
  a[c]
354
357
  : // No: create the key. Is the next key a potential array-index?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.253.0",
3
+ "version": "14.255.0",
4
4
  "scripts": {
5
5
  "prepare": "husky",
6
6
  "build": "dev-lib build-esm-cjs",
@@ -331,16 +331,17 @@ export function _intersectsWith<T>(a1: T[], a2: T[] | Set<T>): boolean {
331
331
  }
332
332
 
333
333
  /**
334
+ * Returns array1 minus array2.
335
+ *
334
336
  * @example
335
337
  * _difference([2, 1], [2, 3])
336
338
  * // [1]
339
+ *
340
+ * Passing second array as Set is more performant (it'll skip turning the array into Set in-place).
337
341
  */
338
- export function _difference<T>(source: T[], ...diffs: T[][]): T[] {
339
- let a = source
340
- for (const b of diffs) {
341
- a = a.filter(c => !b.includes(c))
342
- }
343
- return a
342
+ export function _difference<T>(a1: T[], a2: T[] | Set<T>): T[] {
343
+ const a2set = a2 instanceof Set ? a2 : new Set(a2)
344
+ return a1.filter(v => !a2set.has(v))
344
345
  }
345
346
 
346
347
  /**
package/src/env.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Will return `false` in the Browser.
6
6
  */
7
7
  export function isServerSide(): boolean {
8
+ // eslint-disable-next-line unicorn/prefer-global-this
8
9
  return typeof window === 'undefined'
9
10
  }
10
11
 
@@ -15,5 +16,6 @@ export function isServerSide(): boolean {
15
16
  * Will return `false` in Node.js.
16
17
  */
17
18
  export function isClientSide(): boolean {
19
+ // eslint-disable-next-line unicorn/prefer-global-this
18
20
  return typeof window !== 'undefined'
19
21
  }
@@ -265,11 +265,9 @@ export function _isErrorLike(o: any): o is ErrorLike {
265
265
  */
266
266
  export function _errorDataAppend<ERR>(err: ERR, data?: ErrorData): ERR {
267
267
  if (!data) return err
268
- ;(err as any).data = {
269
- ...(err as any).data,
270
- ...data,
271
- }
272
-
268
+ ;(err as any).data ||= {} // create err.data if it doesn't exist
269
+ // Using Object.assign instead of ...data to not override err.data's non-enumerable properties
270
+ Object.assign((err as any).data, data)
273
271
  return err
274
272
  }
275
273
 
@@ -56,7 +56,7 @@ export function _tryCatch<T extends AnyFunction>(fn: T, opt: TryCatchOptions = {
56
56
 
57
57
  if (onError) {
58
58
  try {
59
- return await onError(_anyToError(err)) // eslint-disable-line @typescript-eslint/return-await
59
+ return await onError(_anyToError(err))
60
60
  } catch {}
61
61
  }
62
62
  // returns undefined, but doesn't rethrow
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="es2022" preserve="true" />
2
2
  /// <reference lib="dom" preserve="true" />
3
3
 
4
+ import type { ErrorData } from '../error/error.model'
4
5
  import type { CommonLogger } from '../log/commonLogger'
5
6
  import type { Promisable } from '../typeFest'
6
7
  import type { AnyObject, NumberOfMilliseconds, Reviver, UnixTimestampMillisNumber } from '../types'
@@ -20,6 +21,7 @@ export interface FetcherNormalizedCfg
20
21
  | 'redirect'
21
22
  | 'credentials'
22
23
  | 'throwHttpErrors'
24
+ | 'errorData'
23
25
  > {
24
26
  logger: CommonLogger
25
27
  searchParams: Record<string, any>
@@ -32,6 +34,11 @@ export type FetcherAfterResponseHook = <BODY = unknown>(
32
34
  export type FetcherBeforeRetryHook = <BODY = unknown>(
33
35
  res: FetcherResponse<BODY>,
34
36
  ) => Promisable<void>
37
+ /**
38
+ * Allows to mutate the error.
39
+ * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
40
+ */
41
+ export type FetcherOnErrorHook = (err: Error) => Promisable<void>
35
42
 
36
43
  export interface FetcherCfg {
37
44
  /**
@@ -58,8 +65,20 @@ export interface FetcherCfg {
58
65
  * Allows to mutate res.retryStatus to override retry behavior.
59
66
  */
60
67
  beforeRetry?: FetcherBeforeRetryHook[]
68
+
69
+ onError?: FetcherOnErrorHook[]
61
70
  }
62
71
 
72
+ /**
73
+ * If Fetcher has an error - `errorData` object will be appended to the error data.
74
+ * Like this:
75
+ *
76
+ * _errorDataAppend(err, cfg.errorData)
77
+ *
78
+ * So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
79
+ */
80
+ errorData?: ErrorData | undefined
81
+
63
82
  /**
64
83
  * If true - enables all possible logging.
65
84
  */
@@ -241,6 +260,22 @@ export interface FetcherOptions {
241
260
  * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
242
261
  */
243
262
  throwHttpErrors?: boolean
263
+
264
+ /**
265
+ * If Fetcher has an error - `errorData` object will be appended to the error data.
266
+ * Like this:
267
+ *
268
+ * _errorDataAppend(err, cfg.errorData)
269
+ *
270
+ * So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
271
+ */
272
+ errorData?: ErrorData
273
+
274
+ /**
275
+ * Allows to mutate the error.
276
+ * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
277
+ */
278
+ onError?: FetcherOnErrorHook
244
279
  }
245
280
 
246
281
  export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
@@ -8,6 +8,7 @@ import { ErrorLike, ErrorObject } from '../error/error.model'
8
8
  import {
9
9
  _anyToError,
10
10
  _anyToErrorObject,
11
+ _errorDataAppend,
11
12
  _errorLikeToErrorObject,
12
13
  HttpRequestError,
13
14
  TimeoutError,
@@ -34,6 +35,7 @@ import type {
34
35
  FetcherBeforeRetryHook,
35
36
  FetcherCfg,
36
37
  FetcherNormalizedCfg,
38
+ FetcherOnErrorHook,
37
39
  FetcherOptions,
38
40
  FetcherRequest,
39
41
  FetcherResponse,
@@ -135,6 +137,11 @@ export class Fetcher {
135
137
  return this
136
138
  }
137
139
 
140
+ onError(hook: FetcherOnErrorHook): this {
141
+ ;(this.cfg.hooks.onError ||= []).push(hook)
142
+ return this
143
+ }
144
+
138
145
  cfg: FetcherNormalizedCfg
139
146
 
140
147
  static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
@@ -273,14 +280,14 @@ export class Fetcher {
273
280
  }, timeoutSeconds * 1000) as any as number
274
281
  }
275
282
 
276
- if (this.cfg.logRequest) {
283
+ if (req.logRequest) {
277
284
  const { retryAttempt } = res.retryStatus
278
285
  logger.log(
279
286
  [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`]
280
287
  .filter(Boolean)
281
288
  .join(' '),
282
289
  )
283
- if (this.cfg.logRequestBody && req.init.body) {
290
+ if (req.logRequestBody && req.init.body) {
284
291
  logger.log(req.init.body) // todo: check if we can _inspect it
285
292
  }
286
293
  }
@@ -335,6 +342,16 @@ export class Fetcher {
335
342
  }
336
343
  }
337
344
 
345
+ if (res.err) {
346
+ _errorDataAppend(res.err, req.errorData)
347
+
348
+ req.onError?.(res.err)
349
+
350
+ for (const hook of this.cfg.hooks.onError || []) {
351
+ await hook(res.err)
352
+ }
353
+ }
354
+
338
355
  for (const hook of this.cfg.hooks.afterResponse || []) {
339
356
  await hook(res)
340
357
  }
@@ -384,7 +401,7 @@ export class Fetcher {
384
401
  res.retryStatus.retryStopped = true
385
402
 
386
403
  // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
387
- if ((!res.err || !req.throwHttpErrors) && this.cfg.logResponse) {
404
+ if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
388
405
  const { retryAttempt } = res.retryStatus
389
406
  const { logger } = this.cfg
390
407
  logger.log(
@@ -399,7 +416,7 @@ export class Fetcher {
399
416
  .join(' '),
400
417
  )
401
418
 
402
- if (this.cfg.logResponseBody && res.body !== undefined) {
419
+ if (req.logResponseBody && res.body !== undefined) {
403
420
  logger.log(res.body)
404
421
  }
405
422
  }
@@ -664,6 +681,7 @@ export class Fetcher {
664
681
  },
665
682
  hooks: {},
666
683
  throwHttpErrors: true,
684
+ errorData: {},
667
685
  },
668
686
  _omit(cfg, ['method', 'credentials', 'headers', 'redirect', 'logger']),
669
687
  )
@@ -688,6 +706,7 @@ export class Fetcher {
688
706
  'logResponseBody',
689
707
  'debug',
690
708
  'throwHttpErrors',
709
+ 'errorData',
691
710
  ]),
692
711
  started: Date.now(),
693
712
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -57,6 +57,7 @@ export function _isBetween<T extends number | string>(
57
57
  }
58
58
 
59
59
  export function _clamp(x: number, minIncl: number, maxIncl: number): number {
60
+ // eslint-disable-next-line unicorn/prefer-math-min-max
60
61
  return x <= minIncl ? minIncl : x >= maxIncl ? maxIncl : x
61
62
  }
62
63
 
@@ -400,6 +400,7 @@ type PropertyPath = Many<PropertyKey>
400
400
  * Based on: https://stackoverflow.com/a/54733755/4919972
401
401
  */
402
402
  export function _set<T extends AnyObject>(obj: T, path: PropertyPath, value: any): T {
403
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
403
404
  if (!obj || Object(obj) !== obj || !path) return obj as any // When obj is not an object
404
405
 
405
406
  // If not yet an array, get the keys from the string-path
@@ -416,6 +417,7 @@ export function _set<T extends AnyObject>(obj: T, path: PropertyPath, value: any
416
417
  c,
417
418
  i, // Iterate all of them except the last one
418
419
  ) =>
420
+ // biome-ignore lint/style/useConsistentBuiltinInstantiation: ok
419
421
  Object(a[c]) === a[c] // Does the key exist and is its value an object?
420
422
  ? // Yes: then follow that path
421
423
  a[c]
package/src/types.ts CHANGED
@@ -1,5 +1,22 @@
1
1
  import type { Promisable } from './typeFest'
2
2
 
3
+ declare const __brand: unique symbol
4
+
5
+ interface Brand<B> {
6
+ [__brand]: B
7
+ }
8
+
9
+ /**
10
+ * Helper to create "Branded" types.
11
+ *
12
+ * Example:
13
+ * export type MyId = Branded<string, 'MyId'>
14
+ *
15
+ * MyId can be assigned to a string,
16
+ * but string cannot be assigned to MyId without casting it (`as MyId`).
17
+ */
18
+ export type Branded<T, B> = T & Brand<B>
19
+
3
20
  /**
4
21
  * Map from String to String (or <T>).
5
22
  *