@naturalcycles/js-lib 14.117.1 → 14.119.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.
@@ -0,0 +1,502 @@
1
+ /// <reference lib="dom"/>
2
+
3
+ import { _anyToErrorObject } from '../error/error.util'
4
+ import { HttpError } from '../error/http.error'
5
+ import { CommonLogger } from '../log/commonLogger'
6
+ import { _clamp } from '../number/number.util'
7
+ import {
8
+ _filterNullishValues,
9
+ _filterUndefinedValues,
10
+ _mapKeys,
11
+ _merge,
12
+ _omit,
13
+ } from '../object/object.util'
14
+ import { pDelay } from '../promise/pDelay'
15
+ import { _jsonParseIfPossible } from '../string/json.util'
16
+ import { _since } from '../time/time.util'
17
+ import type { Promisable } from '../typeFest'
18
+ import type { HttpMethod, HttpStatusFamily } from './http.model'
19
+
20
+ export interface FetcherNormalizedCfg extends FetcherCfg, FetcherRequest {
21
+ logger: CommonLogger
22
+ searchParams: Record<string, any>
23
+ }
24
+
25
+ export interface FetcherCfg {
26
+ baseUrl?: string
27
+
28
+ /**
29
+ * Default rule is that you **are allowed** to mutate req, res, res.retryStatus
30
+ * properties of hook function arguments.
31
+ * If you throw an error from the hook - it will be re-thrown as-is.
32
+ */
33
+ hooks?: {
34
+ /**
35
+ * Allows to mutate req.
36
+ */
37
+ beforeRequest?(req: FetcherRequest): Promisable<void>
38
+ /**
39
+ * Allows to mutate res.
40
+ * If you set `res.err` - it will be thrown.
41
+ */
42
+ beforeResponse?(res: FetcherResponse): Promisable<void>
43
+ /**
44
+ * Allows to mutate res.retryStatus to override retry behavior.
45
+ */
46
+ beforeRetry?(res: FetcherResponse): Promisable<void>
47
+ }
48
+
49
+ debug?: boolean
50
+ logRequest?: boolean
51
+ logRequestBody?: boolean
52
+ logResponse?: boolean
53
+ logResponseBody?: boolean
54
+ logger?: CommonLogger
55
+ }
56
+
57
+ export interface FetcherRetryStatus {
58
+ retryAttempt: number
59
+ retryTimeout: number
60
+ retryStopped: boolean
61
+ }
62
+
63
+ export interface FetcherRetryOptions {
64
+ count: number
65
+ timeout: number
66
+ timeoutMax: number
67
+ timeoutMultiplier: number
68
+ }
69
+
70
+ export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers'> {
71
+ url: string
72
+ init: RequestInitNormalized
73
+ throwHttpErrors: boolean
74
+ timeoutSeconds: number
75
+ retry: FetcherRetryOptions
76
+ retryPost: boolean
77
+ retry4xx: boolean
78
+ retry5xx: boolean
79
+ }
80
+
81
+ export interface FetcherOptions {
82
+ method?: HttpMethod
83
+ throwHttpErrors?: boolean
84
+ /**
85
+ * Default: 30.
86
+ *
87
+ * Timeout applies to both get the response and retrieve the body (e.g `await res.json()`),
88
+ * so both should finish within this single timeout (not each).
89
+ */
90
+ timeoutSeconds?: number
91
+ json?: any
92
+ text?: string
93
+ init?: Partial<RequestInitNormalized>
94
+ headers?: Record<string, any>
95
+ mode?: FetcherMode // default to undefined (void response)
96
+
97
+ searchParams?: Record<string, any>
98
+
99
+ /**
100
+ * Default is 2 retries (3 tries in total).
101
+ * Pass `retry: { count: 0 }` to disable retries.
102
+ */
103
+ retry?: Partial<FetcherRetryOptions>
104
+
105
+ /**
106
+ * Defaults to false.
107
+ * Set to true to allow retrying `post` requests.
108
+ */
109
+ retryPost?: boolean
110
+ /**
111
+ * Defaults to false.
112
+ */
113
+ retry4xx?: boolean
114
+ /**
115
+ * Defaults to true.
116
+ */
117
+ retry5xx?: boolean
118
+ }
119
+
120
+ export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
121
+ method: HttpMethod
122
+ headers: Record<string, any>
123
+ }
124
+
125
+ export interface FetcherSuccessResponse<BODY = unknown> extends FetcherResponse<BODY> {
126
+ err?: undefined
127
+ fetchResponse: Response
128
+ body: BODY
129
+ }
130
+
131
+ export interface FetcherErrorResponse<BODY = unknown> extends FetcherResponse<BODY> {
132
+ err: Error
133
+ }
134
+
135
+ export interface FetcherResponse<BODY = unknown> {
136
+ err?: Error
137
+ req: FetcherRequest
138
+ fetchResponse?: Response
139
+ statusFamily?: HttpStatusFamily
140
+ body?: BODY
141
+ retryStatus: FetcherRetryStatus
142
+ }
143
+
144
+ export type FetcherMode = 'json' | 'text'
145
+
146
+ const defRetryOptions: FetcherRetryOptions = {
147
+ count: 2,
148
+ timeout: 500,
149
+ timeoutMax: 30_000,
150
+ timeoutMultiplier: 2,
151
+ }
152
+
153
+ /**
154
+ * Experimental wrapper around Fetch.
155
+ * Works in both Browser and Node, using `globalThis.fetch`.
156
+ *
157
+ * @experimental
158
+ */
159
+ export class Fetcher {
160
+ private constructor(cfg: FetcherCfg & FetcherOptions = {}) {
161
+ this.cfg = this.normalizeCfg(cfg)
162
+ }
163
+
164
+ public cfg: FetcherNormalizedCfg
165
+
166
+ static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
167
+ return new Fetcher(cfg)
168
+ }
169
+
170
+ async getJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
171
+ return await this.fetch<T>(url, {
172
+ ...opt,
173
+ mode: 'json',
174
+ })
175
+ }
176
+
177
+ async postJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
178
+ return await this.fetch<T>(url, {
179
+ ...opt,
180
+ method: 'post',
181
+ mode: 'json',
182
+ })
183
+ }
184
+
185
+ async getText(url: string, opt: FetcherOptions = {}): Promise<string> {
186
+ return await this.fetch<string>(url, {
187
+ ...opt,
188
+ mode: 'text',
189
+ })
190
+ }
191
+
192
+ async postText(url: string, opt: FetcherOptions = {}): Promise<string> {
193
+ return await this.fetch<string>(url, {
194
+ ...opt,
195
+ method: 'post',
196
+ mode: 'text',
197
+ })
198
+ }
199
+
200
+ async fetch<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
201
+ const res = await this.rawFetch<T>(url, opt)
202
+ if (res.err) {
203
+ if (res.req.throwHttpErrors) throw res.err
204
+ return res as any
205
+ }
206
+ return res.body!
207
+ }
208
+
209
+ async rawFetch<T = unknown>(
210
+ url: string,
211
+ rawOpt: FetcherOptions = {},
212
+ ): Promise<FetcherResponse<T>> {
213
+ const { logger } = this.cfg
214
+
215
+ const req = this.normalizeOptions(url, rawOpt)
216
+ const {
217
+ timeoutSeconds,
218
+ mode,
219
+ init: { method },
220
+ } = req
221
+
222
+ // setup timeout
223
+ let timeout: number | undefined
224
+ if (timeoutSeconds) {
225
+ const abortController = new AbortController()
226
+ req.init.signal = abortController.signal
227
+ timeout = setTimeout(() => {
228
+ abortController.abort(`timeout of ${timeoutSeconds} sec`)
229
+ }, timeoutSeconds * 1000) as any as number
230
+ }
231
+
232
+ await this.cfg.hooks?.beforeRequest?.(req)
233
+
234
+ const res: FetcherResponse<any> = {
235
+ req,
236
+ retryStatus: {
237
+ retryAttempt: 0,
238
+ retryStopped: false,
239
+ retryTimeout: req.retry.timeout,
240
+ },
241
+ }
242
+
243
+ const shortUrl = this.getShortUrl(req.url)
244
+ const signature = [method.toUpperCase(), shortUrl].join(' ')
245
+
246
+ /* eslint-disable no-await-in-loop */
247
+ while (!res.retryStatus.retryStopped) {
248
+ const started = Date.now()
249
+
250
+ if (this.cfg.logRequest) {
251
+ const { retryAttempt } = res.retryStatus
252
+ logger.log(
253
+ [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
254
+ .filter(Boolean)
255
+ .join(' '),
256
+ )
257
+ if (this.cfg.logRequestBody && req.init.body) {
258
+ logger.log(req.init.body) // todo: check if we can _inspect it
259
+ }
260
+ }
261
+
262
+ res.fetchResponse = await globalThis.fetch(req.url, req.init)
263
+ res.statusFamily = this.getStatusFamily(res)
264
+
265
+ if (res.fetchResponse.ok) {
266
+ if (mode === 'json') {
267
+ // if no body: set responseBody as {}
268
+ // do not throw a "cannot parse null as Json" error
269
+ res.body = res.fetchResponse.body ? await res.fetchResponse.json() : {}
270
+ } else if (mode === 'text') {
271
+ res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
272
+ }
273
+
274
+ clearTimeout(timeout)
275
+ res.retryStatus.retryStopped = true
276
+
277
+ if (this.cfg.logResponse) {
278
+ const { retryAttempt } = res.retryStatus
279
+ logger.log(
280
+ [
281
+ ' <<',
282
+ res.fetchResponse.status,
283
+ signature,
284
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
285
+ _since(started),
286
+ ]
287
+ .filter(Boolean)
288
+ .join(' '),
289
+ )
290
+
291
+ if (this.cfg.logResponseBody) {
292
+ logger.log(res.body)
293
+ }
294
+ }
295
+ } else {
296
+ clearTimeout(timeout)
297
+
298
+ const body = _jsonParseIfPossible(await res.fetchResponse.text())
299
+ const errObj = _anyToErrorObject(body)
300
+ const originalMessage = errObj.message
301
+ errObj.message = [[res.fetchResponse.status, signature].join(' '), originalMessage].join(
302
+ '\n',
303
+ )
304
+
305
+ res.err = new HttpError(
306
+ errObj.message,
307
+
308
+ _filterNullishValues({
309
+ ...errObj.data,
310
+ originalMessage,
311
+ httpStatusCode: res.fetchResponse.status,
312
+ // These properties are provided to be used in e.g custom Sentry error grouping
313
+ // Actually, disabled now, to avoid unnecessary error printing when both msg and data are printed
314
+ // Enabled, cause `data` is not printed by default when error is HttpError
315
+ // method: req.method,
316
+ url: req.url,
317
+ // tryCount: req.tryCount,
318
+ }),
319
+ )
320
+
321
+ // We don't log errors when they are also thrown,
322
+ // otherwise it gets logged twice: here, and upstream
323
+ // if (this.cfg.logResponse) {
324
+ // const { retryAttempt } = res.retryStatus
325
+ // logger.error(
326
+ // [
327
+ // [
328
+ // ' <<',
329
+ // res.fetchResponse.status,
330
+ // signature,
331
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
332
+ // _since(started),
333
+ // ]
334
+ // .filter(Boolean)
335
+ // .join(' '),
336
+ // _stringifyAny(body),
337
+ // ].join('\n'),
338
+ // )
339
+ // }
340
+
341
+ await this.processRetry(res)
342
+ }
343
+ }
344
+
345
+ await this.cfg.hooks?.beforeResponse?.(res)
346
+
347
+ return res
348
+ }
349
+
350
+ private async processRetry(res: FetcherResponse): Promise<void> {
351
+ const { retryStatus } = res
352
+
353
+ if (!this.shouldRetry(res)) {
354
+ retryStatus.retryStopped = true
355
+ }
356
+
357
+ await this.cfg.hooks?.beforeRetry?.(res)
358
+
359
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry
360
+
361
+ if (retryStatus.retryAttempt >= count) {
362
+ retryStatus.retryStopped = true
363
+ }
364
+
365
+ if (retryStatus.retryStopped) return
366
+
367
+ retryStatus.retryAttempt++
368
+ retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
369
+
370
+ await pDelay(retryStatus.retryTimeout)
371
+ }
372
+
373
+ /**
374
+ * Default is yes,
375
+ * unless there's reason not to (e.g method is POST).
376
+ */
377
+ private shouldRetry(res: FetcherResponse): boolean {
378
+ const { retryPost, retry4xx, retry5xx } = res.req
379
+ const { method } = res.req.init
380
+ if (method === 'post' && !retryPost) return false
381
+ const { statusFamily } = res
382
+ if (statusFamily === '5xx' && !retry5xx) return false
383
+ if (statusFamily === '4xx' && !retry4xx) return false
384
+ return true // default is true
385
+ }
386
+
387
+ private getStatusFamily(res: FetcherResponse): HttpStatusFamily | undefined {
388
+ const status = res.fetchResponse?.status
389
+ if (!status) return
390
+ if (status >= 500) return '5xx'
391
+ if (status >= 400) return '4xx'
392
+ if (status >= 300) return '3xx'
393
+ if (status >= 200) return '2xx'
394
+ if (status >= 100) return '1xx'
395
+ }
396
+
397
+ /**
398
+ * Returns url without baseUrl and before ?queryString
399
+ */
400
+ private getShortUrl(url: string): string {
401
+ const { baseUrl } = this.cfg
402
+ if (!baseUrl) return url
403
+
404
+ return url.split('?')[0]!.slice(baseUrl.length)
405
+ }
406
+
407
+ private normalizeCfg(cfg: FetcherCfg & FetcherOptions): FetcherNormalizedCfg {
408
+ if (cfg.baseUrl?.endsWith('/')) {
409
+ console.warn(`Fetcher: baseUrl should not end with /`)
410
+ cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1)
411
+ }
412
+ const { debug } = cfg
413
+
414
+ const norm: FetcherNormalizedCfg = _merge(
415
+ {
416
+ url: '',
417
+ searchParams: {},
418
+ timeoutSeconds: 30,
419
+ throwHttpErrors: true,
420
+ retryPost: false,
421
+ retry4xx: false,
422
+ retry5xx: true,
423
+ logger: console,
424
+ logRequest: debug,
425
+ logRequestBody: debug,
426
+ logResponse: debug,
427
+ logResponseBody: debug,
428
+ retry: { ...defRetryOptions },
429
+ init: {
430
+ method: 'get',
431
+ headers: {},
432
+ },
433
+ },
434
+ cfg,
435
+ )
436
+
437
+ norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase())
438
+
439
+ return norm
440
+ }
441
+
442
+ private normalizeOptions(url: string, opt: FetcherOptions): FetcherRequest {
443
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } =
444
+ this.cfg
445
+
446
+ const req: FetcherRequest = {
447
+ url,
448
+ timeoutSeconds,
449
+ throwHttpErrors,
450
+ retryPost,
451
+ retry4xx,
452
+ retry5xx,
453
+ ..._omit(opt, ['method', 'headers']),
454
+ retry: {
455
+ ...retry,
456
+ ..._filterUndefinedValues(opt.retry || {}),
457
+ },
458
+ init: _merge(
459
+ { ...this.cfg.init },
460
+ opt.init,
461
+ _filterUndefinedValues({
462
+ method: opt.method,
463
+ headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
464
+ }),
465
+ ),
466
+ }
467
+
468
+ // setup url
469
+ if (baseUrl) {
470
+ if (url.startsWith('/')) {
471
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
472
+ url = url.slice(1)
473
+ }
474
+ req.url = `${baseUrl}/${url}`
475
+ }
476
+
477
+ const searchParams = {
478
+ ...this.cfg.searchParams,
479
+ ...opt.searchParams,
480
+ }
481
+
482
+ if (Object.keys(searchParams).length) {
483
+ const qs = new URLSearchParams(searchParams).toString()
484
+ req.url += req.url.includes('?') ? '&' : '?' + qs
485
+ }
486
+
487
+ // setup request body
488
+ if (opt.json !== undefined) {
489
+ req.init.body = JSON.stringify(opt.json)
490
+ req.init.headers['content-type'] = 'application/json'
491
+ } else if (opt.text !== undefined) {
492
+ req.init.body = opt.text
493
+ req.init.headers['content-type'] = 'text/plain'
494
+ }
495
+
496
+ return req
497
+ }
498
+ }
499
+
500
+ export function getFetcher(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
501
+ return Fetcher.create(cfg)
502
+ }
@@ -0,0 +1,3 @@
1
+ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'
2
+
3
+ export type HttpStatusFamily = '5xx' | '4xx' | '3xx' | '2xx' | '1xx'
package/src/index.ts CHANGED
@@ -74,5 +74,7 @@ export * from './datetime/localDate'
74
74
  export * from './datetime/localTime'
75
75
  export * from './datetime/dateInterval'
76
76
  export * from './datetime/timeInterval'
77
+ export * from './http/http.model'
78
+ export * from './http/fetcher'
77
79
 
78
80
  export { is }
@@ -213,6 +213,8 @@ export function _filterEmptyValues<T extends AnyObject>(obj: T, mutate = false):
213
213
  * are applied from left to right. Subsequent sources overwrite property
214
214
  * assignments of previous sources.
215
215
  *
216
+ * Works as "recursive Object.assign".
217
+ *
216
218
  * **Note:** This method mutates `object`.
217
219
  *
218
220
  * @category Object
@@ -236,16 +238,16 @@ export function _filterEmptyValues<T extends AnyObject>(obj: T, mutate = false):
236
238
  */
237
239
  export function _merge<T extends AnyObject>(target: T, ...sources: any[]): T {
238
240
  sources.forEach(source => {
239
- if (_isObject(source)) {
240
- Object.keys(source).forEach(key => {
241
- if (_isObject(source[key])) {
242
- if (!target[key]) Object.assign(target, { [key]: {} })
243
- _merge(target[key], source[key])
244
- } else {
245
- Object.assign(target, { [key]: source[key] })
246
- }
247
- })
248
- }
241
+ if (!_isObject(source)) return
242
+
243
+ Object.keys(source).forEach(key => {
244
+ if (_isObject(source[key])) {
245
+ ;(target as any)[key] ||= {}
246
+ _merge(target[key], source[key])
247
+ } else {
248
+ ;(target as any)[key] = source[key]
249
+ }
250
+ })
249
251
  })
250
252
 
251
253
  return target
@@ -89,7 +89,9 @@ export function _stringifyAny(obj: any, opt: StringifyAnyOptions = {}): string {
89
89
  // This is to fix the rare error (happened with Got) where `err.message` was changed,
90
90
  // but err.stack had "old" err.message
91
91
  // This should "fix" that
92
- s = [s, ...obj.stack.split('\n').slice(1)].join('\n')
92
+ const sLines = s.split('\n').length
93
+
94
+ s = [s, ...obj.stack.split('\n').slice(sLines)].join('\n')
93
95
  }
94
96
 
95
97
  if (_isErrorObject(obj)) {