@naturalcycles/js-lib 14.252.1 → 14.254.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.
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
  /**
@@ -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;
package/dist/index.d.ts CHANGED
@@ -74,6 +74,7 @@ export * from './string/pupa';
74
74
  export * from './string/readingTime';
75
75
  export * from './string/regex';
76
76
  export * from './string/safeJsonStringify';
77
+ export * from './string/slugify';
77
78
  export * from './string/string.util';
78
79
  export * from './string/stringify';
79
80
  export * from './string/url.util';
package/dist/index.js CHANGED
@@ -78,6 +78,7 @@ tslib_1.__exportStar(require("./string/pupa"), exports);
78
78
  tslib_1.__exportStar(require("./string/readingTime"), exports);
79
79
  tslib_1.__exportStar(require("./string/regex"), exports);
80
80
  tslib_1.__exportStar(require("./string/safeJsonStringify"), exports);
81
+ tslib_1.__exportStar(require("./string/slugify"), exports);
81
82
  tslib_1.__exportStar(require("./string/string.util"), exports);
82
83
  tslib_1.__exportStar(require("./string/stringify"), exports);
83
84
  tslib_1.__exportStar(require("./string/url.util"), exports);
@@ -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?
@@ -0,0 +1,19 @@
1
+ export interface SlugifyOptions {
2
+ /**
3
+ * Default: `-`
4
+ */
5
+ separator?: string;
6
+ /**
7
+ * Default: true
8
+ */
9
+ lowercase?: boolean;
10
+ /**
11
+ * Default: true
12
+ */
13
+ decamelize?: boolean;
14
+ /**
15
+ * Default: []
16
+ */
17
+ preserveCharacters?: string[];
18
+ }
19
+ export declare function _slugify(s: string, opt?: SlugifyOptions): string;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ // Credit to (adopted from): https://github.com/sindresorhus/slugify/
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports._slugify = _slugify;
5
+ function _slugify(s, opt = {}) {
6
+ opt = {
7
+ separator: '-',
8
+ lowercase: true,
9
+ decamelize: true,
10
+ preserveCharacters: [],
11
+ ...opt,
12
+ };
13
+ if (opt.decamelize) {
14
+ s = decamelize(s);
15
+ }
16
+ const patternSlug = buildPatternSlug(opt);
17
+ if (opt.lowercase) {
18
+ s = s.toLowerCase();
19
+ }
20
+ // based on https://stackoverflow.com/a/23633850/4919972
21
+ // Combining Diacritical Marks
22
+ // https://www.unicode.org/charts/PDF/U0300.pdf
23
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: ok
24
+ s = s.normalize('NFKD').replaceAll(/[\u0300-\u036F]/g, '');
25
+ // Detect contractions/possessives by looking for any word followed by a `'t`
26
+ // or `'s` in isolation and then remove it.
27
+ s = s.replaceAll(/([a-zA-Z\d]+)'([ts])(\s|$)/g, '$1$2$3');
28
+ s = s.replace(patternSlug, opt.separator);
29
+ s = s.replaceAll('\\', '');
30
+ if (opt.separator) {
31
+ s = removeMootSeparators(s, opt.separator);
32
+ }
33
+ return s;
34
+ }
35
+ function buildPatternSlug(options) {
36
+ let negationSetPattern = String.raw `a-z\d`;
37
+ negationSetPattern += options.lowercase ? '' : 'A-Z';
38
+ if (options.preserveCharacters.length > 0) {
39
+ for (const character of options.preserveCharacters) {
40
+ if (character === options.separator) {
41
+ throw new Error(`The separator character \`${options.separator}\` cannot be included in preserved characters: ${options.preserveCharacters}`);
42
+ }
43
+ negationSetPattern += escapeStringRegexp(character);
44
+ }
45
+ }
46
+ return new RegExp(`[^${negationSetPattern}]+`, 'g');
47
+ }
48
+ function removeMootSeparators(s, separator) {
49
+ const escapedSeparator = escapeStringRegexp(separator);
50
+ return s
51
+ .replaceAll(new RegExp(`${escapedSeparator}{2,}`, 'g'), separator)
52
+ .replaceAll(new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), '');
53
+ }
54
+ function decamelize(s) {
55
+ return (s
56
+ // Separate capitalized words.
57
+ .replaceAll(/([A-Z]{2,})(\d+)/g, '$1 $2')
58
+ .replaceAll(/([a-z\d]+)([A-Z]{2,})/g, '$1 $2')
59
+ .replaceAll(/([a-z\d])([A-Z])/g, '$1 $2')
60
+ // `[a-rt-z]` matches all lowercase characters except `s`.
61
+ // This avoids matching plural acronyms like `APIs`.
62
+ .replaceAll(/([A-Z]+)([A-Z][a-rt-z\d]+)/g, '$1 $2'));
63
+ }
64
+ // based on: https://github.com/sindresorhus/escape-string-regexp/
65
+ function escapeStringRegexp(s) {
66
+ // Escape characters with special meaning either inside or outside character sets.
67
+ // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
68
+ return s.replaceAll(/[|\\{}()[\]^$+*?.]/g, String.raw `\$&`).replaceAll('-', String.raw `\x2d`);
69
+ }
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
  /**
@@ -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']),
package/dist-esm/index.js CHANGED
@@ -74,6 +74,7 @@ export * from './string/pupa';
74
74
  export * from './string/readingTime';
75
75
  export * from './string/regex';
76
76
  export * from './string/safeJsonStringify';
77
+ export * from './string/slugify';
77
78
  export * from './string/string.util';
78
79
  export * from './string/stringify';
79
80
  export * from './string/url.util';
@@ -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?
@@ -0,0 +1,66 @@
1
+ // Credit to (adopted from): https://github.com/sindresorhus/slugify/
2
+ export function _slugify(s, opt = {}) {
3
+ opt = {
4
+ separator: '-',
5
+ lowercase: true,
6
+ decamelize: true,
7
+ preserveCharacters: [],
8
+ ...opt,
9
+ };
10
+ if (opt.decamelize) {
11
+ s = decamelize(s);
12
+ }
13
+ const patternSlug = buildPatternSlug(opt);
14
+ if (opt.lowercase) {
15
+ s = s.toLowerCase();
16
+ }
17
+ // based on https://stackoverflow.com/a/23633850/4919972
18
+ // Combining Diacritical Marks
19
+ // https://www.unicode.org/charts/PDF/U0300.pdf
20
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: ok
21
+ s = s.normalize('NFKD').replaceAll(/[\u0300-\u036F]/g, '');
22
+ // Detect contractions/possessives by looking for any word followed by a `'t`
23
+ // or `'s` in isolation and then remove it.
24
+ s = s.replaceAll(/([a-zA-Z\d]+)'([ts])(\s|$)/g, '$1$2$3');
25
+ s = s.replace(patternSlug, opt.separator);
26
+ s = s.replaceAll('\\', '');
27
+ if (opt.separator) {
28
+ s = removeMootSeparators(s, opt.separator);
29
+ }
30
+ return s;
31
+ }
32
+ function buildPatternSlug(options) {
33
+ let negationSetPattern = String.raw `a-z\d`;
34
+ negationSetPattern += options.lowercase ? '' : 'A-Z';
35
+ if (options.preserveCharacters.length > 0) {
36
+ for (const character of options.preserveCharacters) {
37
+ if (character === options.separator) {
38
+ throw new Error(`The separator character \`${options.separator}\` cannot be included in preserved characters: ${options.preserveCharacters}`);
39
+ }
40
+ negationSetPattern += escapeStringRegexp(character);
41
+ }
42
+ }
43
+ return new RegExp(`[^${negationSetPattern}]+`, 'g');
44
+ }
45
+ function removeMootSeparators(s, separator) {
46
+ const escapedSeparator = escapeStringRegexp(separator);
47
+ return s
48
+ .replaceAll(new RegExp(`${escapedSeparator}{2,}`, 'g'), separator)
49
+ .replaceAll(new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), '');
50
+ }
51
+ function decamelize(s) {
52
+ return (s
53
+ // Separate capitalized words.
54
+ .replaceAll(/([A-Z]{2,})(\d+)/g, '$1 $2')
55
+ .replaceAll(/([a-z\d]+)([A-Z]{2,})/g, '$1 $2')
56
+ .replaceAll(/([a-z\d])([A-Z])/g, '$1 $2')
57
+ // `[a-rt-z]` matches all lowercase characters except `s`.
58
+ // This avoids matching plural acronyms like `APIs`.
59
+ .replaceAll(/([A-Z]+)([A-Z][a-rt-z\d]+)/g, '$1 $2'));
60
+ }
61
+ // based on: https://github.com/sindresorhus/escape-string-regexp/
62
+ function escapeStringRegexp(s) {
63
+ // Escape characters with special meaning either inside or outside character sets.
64
+ // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
65
+ return s.replaceAll(/[|\\{}()[\]^$+*?.]/g, String.raw `\$&`).replaceAll('-', String.raw `\x2d`);
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.252.1",
3
+ "version": "14.254.0",
4
4
  "scripts": {
5
5
  "prepare": "husky",
6
6
  "build": "dev-lib build-esm-cjs",
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
 
@@ -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']),
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ export * from './string/pupa'
74
74
  export * from './string/readingTime'
75
75
  export * from './string/regex'
76
76
  export * from './string/safeJsonStringify'
77
+ export * from './string/slugify'
77
78
  export * from './string/string.util'
78
79
  export * from './string/stringify'
79
80
  export * from './string/url.util'
@@ -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]
@@ -0,0 +1,107 @@
1
+ // Credit to (adopted from): https://github.com/sindresorhus/slugify/
2
+
3
+ export interface SlugifyOptions {
4
+ /**
5
+ * Default: `-`
6
+ */
7
+ separator?: string
8
+ /**
9
+ * Default: true
10
+ */
11
+ lowercase?: boolean
12
+ /**
13
+ * Default: true
14
+ */
15
+ decamelize?: boolean
16
+ /**
17
+ * Default: []
18
+ */
19
+ preserveCharacters?: string[]
20
+ }
21
+
22
+ export function _slugify(s: string, opt: SlugifyOptions = {}): string {
23
+ opt = {
24
+ separator: '-',
25
+ lowercase: true,
26
+ decamelize: true,
27
+ preserveCharacters: [],
28
+ ...opt,
29
+ }
30
+
31
+ if (opt.decamelize) {
32
+ s = decamelize(s)
33
+ }
34
+
35
+ const patternSlug = buildPatternSlug(opt)
36
+
37
+ if (opt.lowercase) {
38
+ s = s.toLowerCase()
39
+ }
40
+
41
+ // based on https://stackoverflow.com/a/23633850/4919972
42
+ // Combining Diacritical Marks
43
+ // https://www.unicode.org/charts/PDF/U0300.pdf
44
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: ok
45
+ s = s.normalize('NFKD').replaceAll(/[\u0300-\u036F]/g, '')
46
+
47
+ // Detect contractions/possessives by looking for any word followed by a `'t`
48
+ // or `'s` in isolation and then remove it.
49
+ s = s.replaceAll(/([a-zA-Z\d]+)'([ts])(\s|$)/g, '$1$2$3')
50
+
51
+ s = s.replace(patternSlug, opt.separator!)
52
+ s = s.replaceAll('\\', '')
53
+
54
+ if (opt.separator) {
55
+ s = removeMootSeparators(s, opt.separator)
56
+ }
57
+
58
+ return s
59
+ }
60
+
61
+ function buildPatternSlug(options: any): RegExp {
62
+ let negationSetPattern = String.raw`a-z\d`
63
+ negationSetPattern += options.lowercase ? '' : 'A-Z'
64
+
65
+ if (options.preserveCharacters.length > 0) {
66
+ for (const character of options.preserveCharacters) {
67
+ if (character === options.separator) {
68
+ throw new Error(
69
+ `The separator character \`${options.separator}\` cannot be included in preserved characters: ${options.preserveCharacters}`,
70
+ )
71
+ }
72
+
73
+ negationSetPattern += escapeStringRegexp(character)
74
+ }
75
+ }
76
+
77
+ return new RegExp(`[^${negationSetPattern}]+`, 'g')
78
+ }
79
+
80
+ function removeMootSeparators(s: string, separator: string): string {
81
+ const escapedSeparator = escapeStringRegexp(separator)
82
+
83
+ return s
84
+ .replaceAll(new RegExp(`${escapedSeparator}{2,}`, 'g'), separator)
85
+ .replaceAll(new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), '')
86
+ }
87
+
88
+ function decamelize(s: string): string {
89
+ return (
90
+ s
91
+ // Separate capitalized words.
92
+ .replaceAll(/([A-Z]{2,})(\d+)/g, '$1 $2')
93
+ .replaceAll(/([a-z\d]+)([A-Z]{2,})/g, '$1 $2')
94
+
95
+ .replaceAll(/([a-z\d])([A-Z])/g, '$1 $2')
96
+ // `[a-rt-z]` matches all lowercase characters except `s`.
97
+ // This avoids matching plural acronyms like `APIs`.
98
+ .replaceAll(/([A-Z]+)([A-Z][a-rt-z\d]+)/g, '$1 $2')
99
+ )
100
+ }
101
+
102
+ // based on: https://github.com/sindresorhus/escape-string-regexp/
103
+ function escapeStringRegexp(s: string): string {
104
+ // Escape characters with special meaning either inside or outside character sets.
105
+ // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
106
+ return s.replaceAll(/[|\\{}()[\]^$+*?.]/g, String.raw`\$&`).replaceAll('-', String.raw`\x2d`)
107
+ }