@naturalcycles/js-lib 14.156.0 → 14.157.1

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.
@@ -1,15 +1,24 @@
1
1
  /// <reference lib="dom"/>
2
+ /// <reference lib="dom.iterable"/>
2
3
  import { isServerSide } from '../env';
3
4
  import { _anyToError, _anyToErrorObject, _errorLikeToErrorObject } from '../error/error.util';
4
5
  import { HttpRequestError } from '../error/httpRequestError';
5
6
  import { _clamp } from '../number/number.util';
6
7
  import { _filterNullishValues, _filterUndefinedValues, _mapKeys, _merge, _omit, _pick, } from '../object/object.util';
7
8
  import { pDelay } from '../promise/pDelay';
8
- import { TimeoutError } from '../promise/pTimeout';
9
+ import { pTimeout, TimeoutError } from '../promise/pTimeout';
9
10
  import { _jsonParse, _jsonParseIfPossible } from '../string/json.util';
10
11
  import { _stringifyAny } from '../string/stringifyAny';
11
- import { _since } from '../time/time.util';
12
+ import { _ms, _since } from '../time/time.util';
12
13
  import { HTTP_METHODS } from './http.model';
14
+ const acceptByResponseType = {
15
+ text: 'text/plain',
16
+ json: 'application/json',
17
+ void: '*/*',
18
+ readableStream: 'application/octet-stream',
19
+ arrayBuffer: 'application/octet-stream',
20
+ blob: 'application/octet-stream',
21
+ };
13
22
  const defRetryOptions = {
14
23
  count: 2,
15
24
  timeout: 1000,
@@ -31,18 +40,18 @@ export class Fetcher {
31
40
  const m = method.toLowerCase();
32
41
  this[`${m}Void`] = async (url, opt) => {
33
42
  return await this.fetch(Object.assign({ url,
34
- method, mode: 'void' }, opt));
43
+ method, responseType: 'void' }, opt));
35
44
  };
36
45
  if (method === 'HEAD')
37
- return // mode=text
46
+ return // responseType=text
38
47
  ;
39
48
  this[`${m}Text`] = async (url, opt) => {
40
49
  return await this.fetch(Object.assign({ url,
41
- method, mode: 'text' }, opt));
50
+ method, responseType: 'text' }, opt));
42
51
  };
43
52
  this[m] = async (url, opt) => {
44
53
  return await this.fetch(Object.assign({ url,
45
- method, mode: 'json' }, opt));
54
+ method, responseType: 'json' }, opt));
46
55
  };
47
56
  });
48
57
  }
@@ -70,7 +79,7 @@ export class Fetcher {
70
79
  static create(cfg = {}) {
71
80
  return new Fetcher(cfg);
72
81
  }
73
- // mode=readableStream
82
+ // responseType=readableStream
74
83
  /**
75
84
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
76
85
  *
@@ -78,7 +87,7 @@ export class Fetcher {
78
87
  * https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/
79
88
  */
80
89
  async getReadableStream(url, opt) {
81
- return await this.fetch(Object.assign({ url, mode: 'readableStream' }, opt));
90
+ return await this.fetch(Object.assign({ url, responseType: 'readableStream' }, opt));
82
91
  }
83
92
  async fetch(opt) {
84
93
  const res = await this.doFetch(opt);
@@ -97,19 +106,6 @@ export class Fetcher {
97
106
  const req = this.normalizeOptions(opt);
98
107
  const { logger } = this.cfg;
99
108
  const { timeoutSeconds, init: { method }, } = req;
100
- // setup timeout
101
- let timeout;
102
- if (timeoutSeconds) {
103
- const abortController = new AbortController();
104
- req.init.signal = abortController.signal;
105
- timeout = setTimeout(() => {
106
- // Apparently, providing a `string` reason to abort() causes Undici to throw `invalid_argument` error,
107
- // so, we're wrapping it in a TimeoutError instance
108
- abortController.abort(new TimeoutError(`request timed out after ${timeoutSeconds} sec`));
109
- // abortController.abort(`timeout of ${timeoutSeconds} sec`)
110
- // abortController.abort()
111
- }, timeoutSeconds * 1000);
112
- }
113
109
  for (const hook of this.cfg.hooks.beforeRequest || []) {
114
110
  await hook(req);
115
111
  }
@@ -128,6 +124,18 @@ export class Fetcher {
128
124
  };
129
125
  while (!res.retryStatus.retryStopped) {
130
126
  req.started = Date.now();
127
+ // setup timeout
128
+ let timeoutId;
129
+ if (timeoutSeconds) {
130
+ const abortController = new AbortController();
131
+ req.init.signal = abortController.signal;
132
+ timeoutId = setTimeout(() => {
133
+ // console.log(`actual request timed out in ${_since(req.started)}`)
134
+ // Apparently, providing a `string` reason to abort() causes Undici to throw `invalid_argument` error,
135
+ // so, we're wrapping it in a TimeoutError instance
136
+ abortController.abort(new TimeoutError(`request timed out after ${timeoutSeconds} sec`));
137
+ }, timeoutSeconds * 1000);
138
+ }
131
139
  if (this.cfg.logRequest) {
132
140
  const { retryAttempt } = res.retryStatus;
133
141
  logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`]
@@ -151,14 +159,30 @@ export class Fetcher {
151
159
  // important to set it to undefined, otherwise it can keep the previous value (from previous try)
152
160
  res.fetchResponse = undefined;
153
161
  }
162
+ finally {
163
+ clearTimeout(timeoutId);
164
+ // Separate Timeout will be introduced to "download and parse the body"
165
+ }
154
166
  res.statusFamily = this.getStatusFamily(res);
155
167
  res.statusCode = (_a = res.fetchResponse) === null || _a === void 0 ? void 0 : _a.status;
156
168
  if ((_b = res.fetchResponse) === null || _b === void 0 ? void 0 : _b.ok) {
157
- await this.onOkResponse(res, timeout);
169
+ try {
170
+ // We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
171
+ await pTimeout(async () => await this.onOkResponse(res), {
172
+ timeout: timeoutSeconds * 1000 || Number.POSITIVE_INFINITY,
173
+ name: 'Fetcher.onOkResponse',
174
+ });
175
+ }
176
+ catch (err) {
177
+ // onOkResponse can still fail, e.g when loading/parsing json, text or doing other response manipulation
178
+ res.err = _anyToError(err);
179
+ res.ok = false;
180
+ await this.onNotOkResponse(res);
181
+ }
158
182
  }
159
183
  else {
160
184
  // !res.ok
161
- await this.onNotOkResponse(res, timeout);
185
+ await this.onNotOkResponse(res);
162
186
  }
163
187
  }
164
188
  for (const hook of this.cfg.hooks.afterResponse || []) {
@@ -166,31 +190,17 @@ export class Fetcher {
166
190
  }
167
191
  return res;
168
192
  }
169
- async onOkResponse(res, timeout) {
193
+ async onOkResponse(res) {
170
194
  const { req } = res;
171
- const { mode } = res.req;
172
- if (mode === 'json') {
195
+ const { responseType } = res.req;
196
+ // This function is subject to a separate timeout to "download and parse the data"
197
+ if (responseType === 'json') {
173
198
  if (res.fetchResponse.body) {
174
199
  const text = await res.fetchResponse.text();
175
200
  if (text) {
176
- try {
177
- res.body = text;
178
- res.body = _jsonParse(text, req.jsonReviver);
179
- }
180
- catch (err) {
181
- // Error while parsing json
182
- // res.err = _anyToError(err, HttpRequestError, {
183
- // requestUrl: res.req.url,
184
- // requestBaseUrl: this.cfg.baseUrl,
185
- // requestMethod: res.req.init.method,
186
- // requestSignature: res.signature,
187
- // requestDuration: Date.now() - started,
188
- // responseStatusCode: res.fetchResponse.status,
189
- // } satisfies HttpRequestErrorData)
190
- res.err = _anyToError(err);
191
- res.ok = false;
192
- return await this.onNotOkResponse(res, timeout);
193
- }
201
+ res.body = text;
202
+ res.body = _jsonParse(text, req.jsonReviver);
203
+ // Error while parsing json can happen - it'll be handled upstream
194
204
  }
195
205
  else {
196
206
  // Body had a '' (empty string)
@@ -203,24 +213,22 @@ export class Fetcher {
203
213
  res.body = {};
204
214
  }
205
215
  }
206
- else if (mode === 'text') {
216
+ else if (responseType === 'text') {
207
217
  res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
208
218
  }
209
- else if (mode === 'arrayBuffer') {
219
+ else if (responseType === 'arrayBuffer') {
210
220
  res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : {};
211
221
  }
212
- else if (mode === 'blob') {
222
+ else if (responseType === 'blob') {
213
223
  res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : {};
214
224
  }
215
- else if (mode === 'readableStream') {
225
+ else if (responseType === 'readableStream') {
216
226
  res.body = res.fetchResponse.body;
217
227
  if (res.body === null) {
218
- res.err = new Error(`fetchResponse.body is null`);
219
- res.ok = false;
220
- return await this.onNotOkResponse(res, timeout);
228
+ // Error is to be handled upstream
229
+ throw new Error(`fetchResponse.body is null`);
221
230
  }
222
231
  }
223
- clearTimeout(timeout);
224
232
  res.retryStatus.retryStopped = true;
225
233
  // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
226
234
  if (!res.err && this.cfg.logResponse) {
@@ -246,9 +254,8 @@ export class Fetcher {
246
254
  async callNativeFetch(url, init) {
247
255
  return await globalThis.fetch(url, init);
248
256
  }
249
- async onNotOkResponse(res, timeout) {
257
+ async onNotOkResponse(res) {
250
258
  var _a, _b;
251
- clearTimeout(timeout);
252
259
  let cause;
253
260
  if (res.err) {
254
261
  // This is only possible on JSON.parse error, or CORS error,
@@ -315,7 +322,11 @@ export class Fetcher {
315
322
  return;
316
323
  retryStatus.retryAttempt++;
317
324
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
318
- await pDelay(this.getRetryTimeout(res));
325
+ const timeout = this.getRetryTimeout(res);
326
+ if (res.req.debug) {
327
+ this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`);
328
+ }
329
+ await pDelay(timeout);
319
330
  }
320
331
  getRetryTimeout(res) {
321
332
  var _a;
@@ -371,8 +382,9 @@ export class Fetcher {
371
382
  if (statusFamily === 3 && !retry3xx)
372
383
  return false;
373
384
  // should not retry on `unexpected redirect` in error.cause.cause
374
- if ((_e = (_d = (_c = (_b = res.err) === null || _b === void 0 ? void 0 : _b.cause) === null || _c === void 0 ? void 0 : _c.cause) === null || _d === void 0 ? void 0 : _d.message) === null || _e === void 0 ? void 0 : _e.includes('unexpected redirect'))
385
+ if ((_e = (_d = (_c = (_b = res.err) === null || _b === void 0 ? void 0 : _b.cause) === null || _c === void 0 ? void 0 : _c.cause) === null || _d === void 0 ? void 0 : _d.message) === null || _e === void 0 ? void 0 : _e.includes('unexpected redirect')) {
375
386
  return false;
387
+ }
376
388
  return true; // default is true
377
389
  }
378
390
  getStatusFamily(res) {
@@ -412,14 +424,14 @@ export class Fetcher {
412
424
  normalizeCfg(cfg) {
413
425
  var _a;
414
426
  if ((_a = cfg.baseUrl) === null || _a === void 0 ? void 0 : _a.endsWith('/')) {
415
- console.warn(`Fetcher: baseUrl should not end with /`);
427
+ console.warn(`Fetcher: baseUrl should not end with slash: ${cfg.baseUrl}`);
416
428
  cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
417
429
  }
418
430
  const { debug = false } = cfg;
419
431
  const norm = _merge({
420
432
  baseUrl: '',
421
433
  inputUrl: '',
422
- mode: 'void',
434
+ responseType: 'void',
423
435
  searchParams: {},
424
436
  timeoutSeconds: 30,
425
437
  retryPost: false,
@@ -438,7 +450,7 @@ export class Fetcher {
438
450
  retry: Object.assign({}, defRetryOptions),
439
451
  init: {
440
452
  method: cfg.method || 'GET',
441
- headers: cfg.headers || {},
453
+ headers: Object.assign({ 'user-agent': 'fetcher' }, cfg.headers),
442
454
  credentials: cfg.credentials,
443
455
  redirect: cfg.redirect,
444
456
  },
@@ -448,17 +460,19 @@ export class Fetcher {
448
460
  return norm;
449
461
  }
450
462
  normalizeOptions(opt) {
463
+ var _a;
451
464
  const req = Object.assign(Object.assign(Object.assign(Object.assign({}, _pick(this.cfg, [
452
465
  'timeoutSeconds',
453
466
  'retryPost',
454
467
  'retry4xx',
455
468
  'retry5xx',
456
- 'mode',
469
+ 'responseType',
457
470
  'jsonReviver',
458
471
  'logRequest',
459
472
  'logRequestBody',
460
473
  'logResponse',
461
474
  'logResponseBody',
475
+ 'debug',
462
476
  ])), { started: Date.now() }), _omit(opt, ['method', 'headers', 'credentials'])), { inputUrl: opt.url || '', fullUrl: opt.url || '', retry: Object.assign(Object.assign({}, this.cfg.retry), _filterUndefinedValues(opt.retry || {})), init: _merge(Object.assign(Object.assign({}, this.cfg.init), { method: opt.method || this.cfg.init.method, credentials: opt.credentials || this.cfg.init.credentials, redirect: opt.redirect || this.cfg.init.redirect || 'follow' }), {
463
477
  headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
464
478
  }) });
@@ -485,9 +499,20 @@ export class Fetcher {
485
499
  req.init.body = opt.text;
486
500
  req.init.headers['content-type'] = 'text/plain';
487
501
  }
502
+ else if (opt.form) {
503
+ if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
504
+ req.init.body = opt.form;
505
+ }
506
+ else {
507
+ req.init.body = new URLSearchParams(opt.form);
508
+ }
509
+ req.init.headers['content-type'] = 'application/x-www-form-urlencoded';
510
+ }
488
511
  else if (opt.body !== undefined) {
489
512
  req.init.body = opt.body;
490
513
  }
514
+ // Unless `accept` header was already set - set it based on responseType
515
+ (_a = req.init.headers)['accept'] || (_a['accept'] = acceptByResponseType[req.responseType]);
491
516
  return req;
492
517
  }
493
518
  }
package/dist-esm/index.js CHANGED
@@ -80,6 +80,7 @@ export * from './http/fetcher';
80
80
  export * from './http/fetcher.model';
81
81
  export * from './string/hash.util';
82
82
  export * from './env/buildInfo';
83
+ export * from './form.util';
83
84
  export * from './zod/zod.util';
84
85
  export * from './zod/zod.shared.schemas';
85
86
  import { z, ZodSchema, ZodError } from 'zod';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.156.0",
3
+ "version": "14.157.1",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build-prod": "build-prod-esm-cjs",
@@ -21,7 +21,6 @@
21
21
  "crypto-js": "^4.1.1",
22
22
  "jest": "^29.0.0",
23
23
  "prettier": "^3.0.0",
24
- "rxjs": "^7.0.1",
25
24
  "vuepress": "^1.7.1",
26
25
  "vuepress-plugin-typescript": "^0.3.1"
27
26
  },
@@ -0,0 +1,17 @@
1
+ import type { AnyObject } from './types'
2
+
3
+ /**
4
+ * Convert any object to FormData.
5
+ * Please note that every key and value of FormData is `string`.
6
+ * Even if you pass a number - it'll be converted to string.
7
+ * Think URLSearchParams.
8
+ */
9
+ export function objectToFormData(obj: AnyObject = {}): FormData {
10
+ const fd = new FormData()
11
+ Object.entries(obj).forEach(([k, v]) => fd.append(k, v))
12
+ return fd
13
+ }
14
+
15
+ export function formDataToObject<T extends AnyObject>(formData: FormData): T {
16
+ return Object.fromEntries(formData) as T
17
+ }
@@ -1,6 +1,6 @@
1
1
  import type { CommonLogger } from '../log/commonLogger'
2
2
  import type { Promisable } from '../typeFest'
3
- import type { Reviver, UnixTimestampMillisNumber } from '../types'
3
+ import type { AnyObject, NumberOfMilliseconds, Reviver, UnixTimestampMillisNumber } from '../types'
4
4
  import type { HttpMethod, HttpStatusFamily } from './http.model'
5
5
 
6
6
  export interface FetcherNormalizedCfg
@@ -13,6 +13,7 @@ export interface FetcherNormalizedCfg
13
13
  | 'logRequestBody'
14
14
  | 'logResponse'
15
15
  | 'logResponseBody'
16
+ | 'debug'
16
17
  | 'redirect'
17
18
  | 'credentials'
18
19
  > {
@@ -93,14 +94,14 @@ export interface FetcherCfg {
93
94
 
94
95
  export interface FetcherRetryStatus {
95
96
  retryAttempt: number
96
- retryTimeout: number
97
+ retryTimeout: NumberOfMilliseconds
97
98
  retryStopped: boolean
98
99
  }
99
100
 
100
101
  export interface FetcherRetryOptions {
101
102
  count: number
102
- timeout: number
103
- timeoutMax: number
103
+ timeout: NumberOfMilliseconds
104
+ timeoutMax: NumberOfMilliseconds
104
105
  timeoutMultiplier: number
105
106
  }
106
107
 
@@ -116,7 +117,7 @@ export interface FetcherRequest
116
117
  */
117
118
  fullUrl: string
118
119
  init: RequestInitNormalized
119
- mode: FetcherMode
120
+ responseType: FetcherResponseType
120
121
  timeoutSeconds: number
121
122
  retry: FetcherRetryOptions
122
123
  retryPost: boolean
@@ -145,8 +146,6 @@ export interface FetcherOptions {
145
146
  */
146
147
  timeoutSeconds?: number
147
148
 
148
- json?: any
149
- text?: string
150
149
  /**
151
150
  * Supports all the types that RequestInit.body supports.
152
151
  *
@@ -154,6 +153,26 @@ export interface FetcherOptions {
154
153
  */
155
154
  body?: Blob | BufferSource | FormData | URLSearchParams | string
156
155
 
156
+ /**
157
+ * Same as `body`, but also conveniently sets the
158
+ * Content-Type header to `text/plain`
159
+ */
160
+ text?: string
161
+
162
+ /**
163
+ * Same as `body`, but:
164
+ * 1. JSON.stringifies the passed variable
165
+ * 2. Conveniently sets the Content-Type header to `application/json`
166
+ */
167
+ json?: any
168
+
169
+ /**
170
+ * Same as `body`, but:
171
+ * 1. Transforms the passed plain js object into URLSearchParams and passes it to `body`
172
+ * 2. Conveniently sets the Content-Type header to `application/x-www-form-urlencoded`
173
+ */
174
+ form?: FormData | URLSearchParams | AnyObject
175
+
157
176
  credentials?: RequestCredentials
158
177
  /**
159
178
  * Default to 'follow'.
@@ -167,7 +186,7 @@ export interface FetcherOptions {
167
186
  // init?: Partial<RequestInitNormalized>
168
187
 
169
188
  headers?: Record<string, any>
170
- mode?: FetcherMode // default to 'void'
189
+ responseType?: FetcherResponseType // default to 'void'
171
190
 
172
191
  searchParams?: Record<string, any>
173
192
 
@@ -201,6 +220,10 @@ export interface FetcherOptions {
201
220
  logRequestBody?: boolean
202
221
  logResponse?: boolean
203
222
  logResponseBody?: boolean
223
+ /**
224
+ * If true - enables all possible logging.
225
+ */
226
+ debug?: boolean
204
227
  }
205
228
 
206
229
  export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
@@ -236,4 +259,10 @@ export type FetcherResponse<BODY = unknown> =
236
259
  | FetcherSuccessResponse<BODY>
237
260
  | FetcherErrorResponse<BODY>
238
261
 
239
- export type FetcherMode = 'json' | 'text' | 'void' | 'arrayBuffer' | 'blob' | 'readableStream'
262
+ export type FetcherResponseType =
263
+ | 'json'
264
+ | 'text'
265
+ | 'void'
266
+ | 'arrayBuffer'
267
+ | 'blob'
268
+ | 'readableStream'