@naturalcycles/js-lib 14.118.0 → 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.
@@ -23,16 +23,16 @@ class AppError extends Error {
23
23
  configurable: true,
24
24
  enumerable: false,
25
25
  });
26
- if (Error.captureStackTrace) {
27
- Error.captureStackTrace(this, this.constructor);
28
- }
29
- else {
30
- Object.defineProperty(this, 'stack', {
31
- value: new Error().stack,
32
- writable: true,
33
- configurable: true,
34
- });
35
- }
26
+ // todo: check if it's needed at all!
27
+ // if (Error.captureStackTrace) {
28
+ // Error.captureStackTrace(this, this.constructor)
29
+ // } else {
30
+ // Object.defineProperty(this, 'stack', {
31
+ // value: new Error().stack, // eslint-disable-line unicorn/error-message
32
+ // writable: true,
33
+ // configurable: true,
34
+ // })
35
+ // }
36
36
  }
37
37
  }
38
38
  exports.AppError = AppError;
@@ -2,8 +2,9 @@
2
2
  import { CommonLogger } from '../log/commonLogger';
3
3
  import type { Promisable } from '../typeFest';
4
4
  import type { HttpMethod, HttpStatusFamily } from './http.model';
5
- export interface FetcherNormalizedCfg extends FetcherCfg, FetcherNormalizedOptions {
5
+ export interface FetcherNormalizedCfg extends FetcherCfg, FetcherRequest {
6
6
  logger: CommonLogger;
7
+ searchParams: Record<string, any>;
7
8
  }
8
9
  export interface FetcherCfg {
9
10
  baseUrl?: string;
@@ -45,8 +46,9 @@ export interface FetcherRetryOptions {
45
46
  timeoutMax: number;
46
47
  timeoutMultiplier: number;
47
48
  }
48
- export interface FetcherNormalizedOptions extends FetcherOptions {
49
- method: HttpMethod;
49
+ export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers'> {
50
+ url: string;
51
+ init: RequestInitNormalized;
50
52
  throwHttpErrors: boolean;
51
53
  timeoutSeconds: number;
52
54
  retry: FetcherRetryOptions;
@@ -66,10 +68,10 @@ export interface FetcherOptions {
66
68
  timeoutSeconds?: number;
67
69
  json?: any;
68
70
  text?: string;
69
- requestInit?: RequestInit & {
70
- method?: HttpMethod;
71
- };
71
+ init?: Partial<RequestInitNormalized>;
72
+ headers?: Record<string, any>;
72
73
  mode?: FetcherMode;
74
+ searchParams?: Record<string, any>;
73
75
  /**
74
76
  * Default is 2 retries (3 tries in total).
75
77
  * Pass `retry: { count: 0 }` to disable retries.
@@ -89,13 +91,10 @@ export interface FetcherOptions {
89
91
  */
90
92
  retry5xx?: boolean;
91
93
  }
92
- export interface FetcherRequest {
93
- url: string;
94
- init: RequestInit & {
95
- method: HttpMethod;
96
- };
97
- opt: FetcherNormalizedOptions;
98
- }
94
+ export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
95
+ method: HttpMethod;
96
+ headers: Record<string, any>;
97
+ };
99
98
  export interface FetcherSuccessResponse<BODY = unknown> extends FetcherResponse<BODY> {
100
99
  err?: undefined;
101
100
  fetchResponse: Response;
@@ -8,9 +8,7 @@ const number_util_1 = require("../number/number.util");
8
8
  const object_util_1 = require("../object/object.util");
9
9
  const pDelay_1 = require("../promise/pDelay");
10
10
  const json_util_1 = require("../string/json.util");
11
- const stringifyAny_1 = require("../string/stringifyAny");
12
11
  const time_util_1 = require("../time/time.util");
13
- const types_1 = require("../types");
14
12
  const defRetryOptions = {
15
13
  count: 2,
16
14
  timeout: 500,
@@ -59,39 +57,16 @@ class Fetcher {
59
57
  async fetch(url, opt = {}) {
60
58
  const res = await this.rawFetch(url, opt);
61
59
  if (res.err) {
62
- if (res.req.opt.throwHttpErrors)
60
+ if (res.req.throwHttpErrors)
63
61
  throw res.err;
64
62
  return res;
65
63
  }
66
64
  return res.body;
67
65
  }
68
66
  async rawFetch(url, rawOpt = {}) {
69
- const { baseUrl, logger } = this.cfg;
70
- const opt = this.normalizeOptions(rawOpt);
71
- const { method, timeoutSeconds, mode } = opt;
72
- const req = {
73
- url,
74
- init: {
75
- ...this.cfg.requestInit,
76
- method,
77
- },
78
- opt,
79
- };
80
- // setup url
81
- if (baseUrl) {
82
- if (url.startsWith('/')) {
83
- console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
84
- url = url.slice(1);
85
- }
86
- req.url = `${baseUrl}/${url}`;
87
- }
88
- // setup request body
89
- if (opt.json !== undefined) {
90
- req.init.body = JSON.stringify(opt.json);
91
- }
92
- else if (opt.text !== undefined) {
93
- req.init.body = opt.text;
94
- }
67
+ const { logger } = this.cfg;
68
+ const req = this.normalizeOptions(url, rawOpt);
69
+ const { timeoutSeconds, mode, init: { method }, } = req;
95
70
  // setup timeout
96
71
  let timeout;
97
72
  if (timeoutSeconds) {
@@ -101,16 +76,13 @@ class Fetcher {
101
76
  abortController.abort(`timeout of ${timeoutSeconds} sec`);
102
77
  }, timeoutSeconds * 1000);
103
78
  }
104
- if (opt.requestInit) {
105
- (0, types_1._objectAssign)(req.init, opt.requestInit);
106
- }
107
79
  await this.cfg.hooks?.beforeRequest?.(req);
108
80
  const res = {
109
81
  req,
110
82
  retryStatus: {
111
83
  retryAttempt: 0,
112
84
  retryStopped: false,
113
- retryTimeout: opt.retry.timeout,
85
+ retryTimeout: req.retry.timeout,
114
86
  },
115
87
  };
116
88
  const shortUrl = this.getShortUrl(req.url);
@@ -120,7 +92,7 @@ class Fetcher {
120
92
  const started = Date.now();
121
93
  if (this.cfg.logRequest) {
122
94
  const { retryAttempt } = res.retryStatus;
123
- logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
95
+ logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
124
96
  .filter(Boolean)
125
97
  .join(' '));
126
98
  if (this.cfg.logRequestBody && req.init.body) {
@@ -146,7 +118,7 @@ class Fetcher {
146
118
  ' <<',
147
119
  res.fetchResponse.status,
148
120
  signature,
149
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
121
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
150
122
  (0, time_util_1._since)(started),
151
123
  ]
152
124
  .filter(Boolean)
@@ -173,21 +145,25 @@ class Fetcher {
173
145
  url: req.url,
174
146
  // tryCount: req.tryCount,
175
147
  }));
176
- if (this.cfg.logResponse) {
177
- const { retryAttempt } = res.retryStatus;
178
- logger.error([
179
- [
180
- ' <<',
181
- res.fetchResponse.status,
182
- signature,
183
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
184
- (0, time_util_1._since)(started),
185
- ]
186
- .filter(Boolean)
187
- .join(' '),
188
- (0, stringifyAny_1._stringifyAny)(body),
189
- ].join('\n'));
190
- }
148
+ // We don't log errors when they are also thrown,
149
+ // otherwise it gets logged twice: here, and upstream
150
+ // if (this.cfg.logResponse) {
151
+ // const { retryAttempt } = res.retryStatus
152
+ // logger.error(
153
+ // [
154
+ // [
155
+ // ' <<',
156
+ // res.fetchResponse.status,
157
+ // signature,
158
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
159
+ // _since(started),
160
+ // ]
161
+ // .filter(Boolean)
162
+ // .join(' '),
163
+ // _stringifyAny(body),
164
+ // ].join('\n'),
165
+ // )
166
+ // }
191
167
  await this.processRetry(res);
192
168
  }
193
169
  }
@@ -200,12 +176,13 @@ class Fetcher {
200
176
  retryStatus.retryStopped = true;
201
177
  }
202
178
  await this.cfg.hooks?.beforeRetry?.(res);
203
- const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry;
179
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry;
204
180
  if (retryStatus.retryAttempt >= count) {
205
181
  retryStatus.retryStopped = true;
206
182
  }
207
183
  if (retryStatus.retryStopped)
208
184
  return;
185
+ retryStatus.retryAttempt++;
209
186
  retryStatus.retryTimeout = (0, number_util_1._clamp)(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
210
187
  await (0, pDelay_1.pDelay)(retryStatus.retryTimeout);
211
188
  }
@@ -214,7 +191,7 @@ class Fetcher {
214
191
  * unless there's reason not to (e.g method is POST).
215
192
  */
216
193
  shouldRetry(res) {
217
- const { retryPost, retry4xx, retry5xx } = res.req.opt;
194
+ const { retryPost, retry4xx, retry5xx } = res.req;
218
195
  const { method } = res.req.init;
219
196
  if (method === 'post' && !retryPost)
220
197
  return false;
@@ -255,9 +232,10 @@ class Fetcher {
255
232
  cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
256
233
  }
257
234
  const { debug } = cfg;
258
- return {
235
+ const norm = (0, object_util_1._merge)({
236
+ url: '',
237
+ searchParams: {},
259
238
  timeoutSeconds: 30,
260
- method: 'get',
261
239
  throwHttpErrors: true,
262
240
  retryPost: false,
263
241
  retry4xx: false,
@@ -267,28 +245,60 @@ class Fetcher {
267
245
  logRequestBody: debug,
268
246
  logResponse: debug,
269
247
  logResponseBody: debug,
270
- ...cfg,
271
- retry: {
272
- ...defRetryOptions,
273
- ...cfg.retry,
248
+ retry: { ...defRetryOptions },
249
+ init: {
250
+ method: 'get',
251
+ headers: {},
274
252
  },
275
- };
253
+ }, cfg);
254
+ norm.init.headers = (0, object_util_1._mapKeys)(norm.init.headers, k => k.toLowerCase());
255
+ return norm;
276
256
  }
277
- normalizeOptions(opt) {
278
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
279
- return {
257
+ normalizeOptions(url, opt) {
258
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } = this.cfg;
259
+ const req = {
260
+ url,
280
261
  timeoutSeconds,
281
262
  throwHttpErrors,
282
- method,
283
263
  retryPost,
284
264
  retry4xx,
285
265
  retry5xx,
286
- ...opt,
266
+ ...(0, object_util_1._omit)(opt, ['method', 'headers']),
287
267
  retry: {
288
268
  ...retry,
289
269
  ...(0, object_util_1._filterUndefinedValues)(opt.retry || {}),
290
270
  },
271
+ init: (0, object_util_1._merge)({ ...this.cfg.init }, opt.init, (0, object_util_1._filterUndefinedValues)({
272
+ method: opt.method,
273
+ headers: (0, object_util_1._mapKeys)(opt.headers || {}, k => k.toLowerCase()),
274
+ })),
291
275
  };
276
+ // setup url
277
+ if (baseUrl) {
278
+ if (url.startsWith('/')) {
279
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
280
+ url = url.slice(1);
281
+ }
282
+ req.url = `${baseUrl}/${url}`;
283
+ }
284
+ const searchParams = {
285
+ ...this.cfg.searchParams,
286
+ ...opt.searchParams,
287
+ };
288
+ if (Object.keys(searchParams).length) {
289
+ const qs = new URLSearchParams(searchParams).toString();
290
+ req.url += req.url.includes('?') ? '&' : '?' + qs;
291
+ }
292
+ // setup request body
293
+ if (opt.json !== undefined) {
294
+ req.init.body = JSON.stringify(opt.json);
295
+ req.init.headers['content-type'] = 'application/json';
296
+ }
297
+ else if (opt.text !== undefined) {
298
+ req.init.body = opt.text;
299
+ req.init.headers['content-type'] = 'text/plain';
300
+ }
301
+ return req;
292
302
  }
293
303
  }
294
304
  exports.Fetcher = Fetcher;
@@ -101,6 +101,8 @@ export declare function _filterEmptyValues<T extends AnyObject>(obj: T, mutate?:
101
101
  * are applied from left to right. Subsequent sources overwrite property
102
102
  * assignments of previous sources.
103
103
  *
104
+ * Works as "recursive Object.assign".
105
+ *
104
106
  * **Note:** This method mutates `object`.
105
107
  *
106
108
  * @category Object
@@ -186,6 +186,8 @@ exports._filterEmptyValues = _filterEmptyValues;
186
186
  * are applied from left to right. Subsequent sources overwrite property
187
187
  * assignments of previous sources.
188
188
  *
189
+ * Works as "recursive Object.assign".
190
+ *
189
191
  * **Note:** This method mutates `object`.
190
192
  *
191
193
  * @category Object
@@ -209,18 +211,19 @@ exports._filterEmptyValues = _filterEmptyValues;
209
211
  */
210
212
  function _merge(target, ...sources) {
211
213
  sources.forEach(source => {
212
- if ((0, is_util_1._isObject)(source)) {
213
- Object.keys(source).forEach(key => {
214
- if ((0, is_util_1._isObject)(source[key])) {
215
- if (!target[key])
216
- Object.assign(target, { [key]: {} });
217
- _merge(target[key], source[key]);
218
- }
219
- else {
220
- Object.assign(target, { [key]: source[key] });
221
- }
222
- });
223
- }
214
+ if (!(0, is_util_1._isObject)(source))
215
+ return;
216
+ Object.keys(source).forEach(key => {
217
+ if ((0, is_util_1._isObject)(source[key])) {
218
+ ;
219
+ target[key] ||= {};
220
+ _merge(target[key], source[key]);
221
+ }
222
+ else {
223
+ ;
224
+ target[key] = source[key];
225
+ }
226
+ });
224
227
  });
225
228
  return target;
226
229
  }
@@ -20,15 +20,15 @@ export class AppError extends Error {
20
20
  configurable: true,
21
21
  enumerable: false,
22
22
  });
23
- if (Error.captureStackTrace) {
24
- Error.captureStackTrace(this, this.constructor);
25
- }
26
- else {
27
- Object.defineProperty(this, 'stack', {
28
- value: new Error().stack,
29
- writable: true,
30
- configurable: true,
31
- });
32
- }
23
+ // todo: check if it's needed at all!
24
+ // if (Error.captureStackTrace) {
25
+ // Error.captureStackTrace(this, this.constructor)
26
+ // } else {
27
+ // Object.defineProperty(this, 'stack', {
28
+ // value: new Error().stack, // eslint-disable-line unicorn/error-message
29
+ // writable: true,
30
+ // configurable: true,
31
+ // })
32
+ // }
33
33
  }
34
34
  }
@@ -2,12 +2,10 @@
2
2
  import { _anyToErrorObject } from '../error/error.util';
3
3
  import { HttpError } from '../error/http.error';
4
4
  import { _clamp } from '../number/number.util';
5
- import { _filterNullishValues, _filterUndefinedValues } from '../object/object.util';
5
+ import { _filterNullishValues, _filterUndefinedValues, _mapKeys, _merge, _omit, } from '../object/object.util';
6
6
  import { pDelay } from '../promise/pDelay';
7
7
  import { _jsonParseIfPossible } from '../string/json.util';
8
- import { _stringifyAny } from '../string/stringifyAny';
9
8
  import { _since } from '../time/time.util';
10
- import { _objectAssign } from '../types';
11
9
  const defRetryOptions = {
12
10
  count: 2,
13
11
  timeout: 500,
@@ -42,7 +40,7 @@ export class Fetcher {
42
40
  async fetch(url, opt = {}) {
43
41
  const res = await this.rawFetch(url, opt);
44
42
  if (res.err) {
45
- if (res.req.opt.throwHttpErrors)
43
+ if (res.req.throwHttpErrors)
46
44
  throw res.err;
47
45
  return res;
48
46
  }
@@ -50,29 +48,9 @@ export class Fetcher {
50
48
  }
51
49
  async rawFetch(url, rawOpt = {}) {
52
50
  var _a, _b, _c, _d;
53
- const { baseUrl, logger } = this.cfg;
54
- const opt = this.normalizeOptions(rawOpt);
55
- const { method, timeoutSeconds, mode } = opt;
56
- const req = {
57
- url,
58
- init: Object.assign(Object.assign({}, this.cfg.requestInit), { method }),
59
- opt,
60
- };
61
- // setup url
62
- if (baseUrl) {
63
- if (url.startsWith('/')) {
64
- console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
65
- url = url.slice(1);
66
- }
67
- req.url = `${baseUrl}/${url}`;
68
- }
69
- // setup request body
70
- if (opt.json !== undefined) {
71
- req.init.body = JSON.stringify(opt.json);
72
- }
73
- else if (opt.text !== undefined) {
74
- req.init.body = opt.text;
75
- }
51
+ const { logger } = this.cfg;
52
+ const req = this.normalizeOptions(url, rawOpt);
53
+ const { timeoutSeconds, mode, init: { method }, } = req;
76
54
  // setup timeout
77
55
  let timeout;
78
56
  if (timeoutSeconds) {
@@ -82,16 +60,13 @@ export class Fetcher {
82
60
  abortController.abort(`timeout of ${timeoutSeconds} sec`);
83
61
  }, timeoutSeconds * 1000);
84
62
  }
85
- if (opt.requestInit) {
86
- _objectAssign(req.init, opt.requestInit);
87
- }
88
63
  await ((_b = (_a = this.cfg.hooks) === null || _a === void 0 ? void 0 : _a.beforeRequest) === null || _b === void 0 ? void 0 : _b.call(_a, req));
89
64
  const res = {
90
65
  req,
91
66
  retryStatus: {
92
67
  retryAttempt: 0,
93
68
  retryStopped: false,
94
- retryTimeout: opt.retry.timeout,
69
+ retryTimeout: req.retry.timeout,
95
70
  },
96
71
  };
97
72
  const shortUrl = this.getShortUrl(req.url);
@@ -101,7 +76,7 @@ export class Fetcher {
101
76
  const started = Date.now();
102
77
  if (this.cfg.logRequest) {
103
78
  const { retryAttempt } = res.retryStatus;
104
- logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
79
+ logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
105
80
  .filter(Boolean)
106
81
  .join(' '));
107
82
  if (this.cfg.logRequestBody && req.init.body) {
@@ -127,7 +102,7 @@ export class Fetcher {
127
102
  ' <<',
128
103
  res.fetchResponse.status,
129
104
  signature,
130
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
105
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
131
106
  _since(started),
132
107
  ]
133
108
  .filter(Boolean)
@@ -149,21 +124,25 @@ export class Fetcher {
149
124
  // Enabled, cause `data` is not printed by default when error is HttpError
150
125
  // method: req.method,
151
126
  url: req.url })));
152
- if (this.cfg.logResponse) {
153
- const { retryAttempt } = res.retryStatus;
154
- logger.error([
155
- [
156
- ' <<',
157
- res.fetchResponse.status,
158
- signature,
159
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
160
- _since(started),
161
- ]
162
- .filter(Boolean)
163
- .join(' '),
164
- _stringifyAny(body),
165
- ].join('\n'));
166
- }
127
+ // We don't log errors when they are also thrown,
128
+ // otherwise it gets logged twice: here, and upstream
129
+ // if (this.cfg.logResponse) {
130
+ // const { retryAttempt } = res.retryStatus
131
+ // logger.error(
132
+ // [
133
+ // [
134
+ // ' <<',
135
+ // res.fetchResponse.status,
136
+ // signature,
137
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
138
+ // _since(started),
139
+ // ]
140
+ // .filter(Boolean)
141
+ // .join(' '),
142
+ // _stringifyAny(body),
143
+ // ].join('\n'),
144
+ // )
145
+ // }
167
146
  await this.processRetry(res);
168
147
  }
169
148
  }
@@ -177,12 +156,13 @@ export class Fetcher {
177
156
  retryStatus.retryStopped = true;
178
157
  }
179
158
  await ((_b = (_a = this.cfg.hooks) === null || _a === void 0 ? void 0 : _a.beforeRetry) === null || _b === void 0 ? void 0 : _b.call(_a, res));
180
- const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry;
159
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry;
181
160
  if (retryStatus.retryAttempt >= count) {
182
161
  retryStatus.retryStopped = true;
183
162
  }
184
163
  if (retryStatus.retryStopped)
185
164
  return;
165
+ retryStatus.retryAttempt++;
186
166
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
187
167
  await pDelay(retryStatus.retryTimeout);
188
168
  }
@@ -191,7 +171,7 @@ export class Fetcher {
191
171
  * unless there's reason not to (e.g method is POST).
192
172
  */
193
173
  shouldRetry(res) {
194
- const { retryPost, retry4xx, retry5xx } = res.req.opt;
174
+ const { retryPost, retry4xx, retry5xx } = res.req;
195
175
  const { method } = res.req.init;
196
176
  if (method === 'post' && !retryPost)
197
177
  return false;
@@ -234,16 +214,62 @@ export class Fetcher {
234
214
  cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
235
215
  }
236
216
  const { debug } = cfg;
237
- return Object.assign(Object.assign({ timeoutSeconds: 30, method: 'get', throwHttpErrors: true, retryPost: false, retry4xx: false, retry5xx: true, logger: console, logRequest: debug, logRequestBody: debug, logResponse: debug, logResponseBody: debug }, cfg), { retry: Object.assign(Object.assign({}, defRetryOptions), cfg.retry) });
217
+ const norm = _merge({
218
+ url: '',
219
+ searchParams: {},
220
+ timeoutSeconds: 30,
221
+ throwHttpErrors: true,
222
+ retryPost: false,
223
+ retry4xx: false,
224
+ retry5xx: true,
225
+ logger: console,
226
+ logRequest: debug,
227
+ logRequestBody: debug,
228
+ logResponse: debug,
229
+ logResponseBody: debug,
230
+ retry: Object.assign({}, defRetryOptions),
231
+ init: {
232
+ method: 'get',
233
+ headers: {},
234
+ },
235
+ }, cfg);
236
+ norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase());
237
+ return norm;
238
238
  }
239
- normalizeOptions(opt) {
240
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
241
- return Object.assign(Object.assign({ timeoutSeconds,
239
+ normalizeOptions(url, opt) {
240
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } = this.cfg;
241
+ const req = Object.assign(Object.assign({ url,
242
+ timeoutSeconds,
242
243
  throwHttpErrors,
243
- method,
244
244
  retryPost,
245
245
  retry4xx,
246
- retry5xx }, opt), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})) });
246
+ retry5xx }, _omit(opt, ['method', 'headers'])), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})), init: _merge(Object.assign({}, this.cfg.init), opt.init, _filterUndefinedValues({
247
+ method: opt.method,
248
+ headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
249
+ })) });
250
+ // setup url
251
+ if (baseUrl) {
252
+ if (url.startsWith('/')) {
253
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
254
+ url = url.slice(1);
255
+ }
256
+ req.url = `${baseUrl}/${url}`;
257
+ }
258
+ const searchParams = Object.assign(Object.assign({}, this.cfg.searchParams), opt.searchParams);
259
+ if (Object.keys(searchParams).length) {
260
+ const qs = new URLSearchParams(searchParams).toString();
261
+ req.url += req.url.includes('?') ? '&' : '?' + qs;
262
+ }
263
+ // setup request body
264
+ if (opt.json !== undefined) {
265
+ req.init.body = JSON.stringify(opt.json);
266
+ req.init.headers['content-type'] = 'application/json';
267
+ }
268
+ else if (opt.text !== undefined) {
269
+ req.init.body = opt.text;
270
+ req.init.headers['content-type'] = 'text/plain';
271
+ }
272
+ return req;
247
273
  }
248
274
  }
249
275
  export function getFetcher(cfg = {}) {
@@ -168,6 +168,8 @@ export function _filterEmptyValues(obj, mutate = false) {
168
168
  * are applied from left to right. Subsequent sources overwrite property
169
169
  * assignments of previous sources.
170
170
  *
171
+ * Works as "recursive Object.assign".
172
+ *
171
173
  * **Note:** This method mutates `object`.
172
174
  *
173
175
  * @category Object
@@ -191,18 +193,20 @@ export function _filterEmptyValues(obj, mutate = false) {
191
193
  */
192
194
  export function _merge(target, ...sources) {
193
195
  sources.forEach(source => {
194
- if (_isObject(source)) {
195
- Object.keys(source).forEach(key => {
196
- if (_isObject(source[key])) {
197
- if (!target[key])
198
- Object.assign(target, { [key]: {} });
199
- _merge(target[key], source[key]);
200
- }
201
- else {
202
- Object.assign(target, { [key]: source[key] });
203
- }
204
- });
205
- }
196
+ if (!_isObject(source))
197
+ return;
198
+ Object.keys(source).forEach(key => {
199
+ var _a;
200
+ if (_isObject(source[key])) {
201
+ ;
202
+ (_a = target)[key] || (_a[key] = {});
203
+ _merge(target[key], source[key]);
204
+ }
205
+ else {
206
+ ;
207
+ target[key] = source[key];
208
+ }
209
+ });
206
210
  });
207
211
  return target;
208
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.118.0",
3
+ "version": "14.119.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build-prod": "build-prod-esm-cjs",
@@ -27,14 +27,15 @@ export class AppError<DATA_TYPE extends ErrorData = ErrorData> extends Error {
27
27
  enumerable: false,
28
28
  })
29
29
 
30
- if (Error.captureStackTrace) {
31
- Error.captureStackTrace(this, this.constructor)
32
- } else {
33
- Object.defineProperty(this, 'stack', {
34
- value: new Error().stack, // eslint-disable-line unicorn/error-message
35
- writable: true,
36
- configurable: true,
37
- })
38
- }
30
+ // todo: check if it's needed at all!
31
+ // if (Error.captureStackTrace) {
32
+ // Error.captureStackTrace(this, this.constructor)
33
+ // } else {
34
+ // Object.defineProperty(this, 'stack', {
35
+ // value: new Error().stack, // eslint-disable-line unicorn/error-message
36
+ // writable: true,
37
+ // configurable: true,
38
+ // })
39
+ // }
39
40
  }
40
41
  }
@@ -4,17 +4,22 @@ import { _anyToErrorObject } from '../error/error.util'
4
4
  import { HttpError } from '../error/http.error'
5
5
  import { CommonLogger } from '../log/commonLogger'
6
6
  import { _clamp } from '../number/number.util'
7
- import { _filterNullishValues, _filterUndefinedValues } from '../object/object.util'
7
+ import {
8
+ _filterNullishValues,
9
+ _filterUndefinedValues,
10
+ _mapKeys,
11
+ _merge,
12
+ _omit,
13
+ } from '../object/object.util'
8
14
  import { pDelay } from '../promise/pDelay'
9
15
  import { _jsonParseIfPossible } from '../string/json.util'
10
- import { _stringifyAny } from '../string/stringifyAny'
11
16
  import { _since } from '../time/time.util'
12
17
  import type { Promisable } from '../typeFest'
13
- import { _objectAssign } from '../types'
14
18
  import type { HttpMethod, HttpStatusFamily } from './http.model'
15
19
 
16
- export interface FetcherNormalizedCfg extends FetcherCfg, FetcherNormalizedOptions {
20
+ export interface FetcherNormalizedCfg extends FetcherCfg, FetcherRequest {
17
21
  logger: CommonLogger
22
+ searchParams: Record<string, any>
18
23
  }
19
24
 
20
25
  export interface FetcherCfg {
@@ -62,8 +67,9 @@ export interface FetcherRetryOptions {
62
67
  timeoutMultiplier: number
63
68
  }
64
69
 
65
- export interface FetcherNormalizedOptions extends FetcherOptions {
66
- method: HttpMethod
70
+ export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers'> {
71
+ url: string
72
+ init: RequestInitNormalized
67
73
  throwHttpErrors: boolean
68
74
  timeoutSeconds: number
69
75
  retry: FetcherRetryOptions
@@ -84,9 +90,12 @@ export interface FetcherOptions {
84
90
  timeoutSeconds?: number
85
91
  json?: any
86
92
  text?: string
87
- requestInit?: RequestInit & { method?: HttpMethod }
93
+ init?: Partial<RequestInitNormalized>
94
+ headers?: Record<string, any>
88
95
  mode?: FetcherMode // default to undefined (void response)
89
96
 
97
+ searchParams?: Record<string, any>
98
+
90
99
  /**
91
100
  * Default is 2 retries (3 tries in total).
92
101
  * Pass `retry: { count: 0 }` to disable retries.
@@ -108,10 +117,9 @@ export interface FetcherOptions {
108
117
  retry5xx?: boolean
109
118
  }
110
119
 
111
- export interface FetcherRequest {
112
- url: string
113
- init: RequestInit & { method: HttpMethod }
114
- opt: FetcherNormalizedOptions
120
+ export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
121
+ method: HttpMethod
122
+ headers: Record<string, any>
115
123
  }
116
124
 
117
125
  export interface FetcherSuccessResponse<BODY = unknown> extends FetcherResponse<BODY> {
@@ -192,7 +200,7 @@ export class Fetcher {
192
200
  async fetch<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
193
201
  const res = await this.rawFetch<T>(url, opt)
194
202
  if (res.err) {
195
- if (res.req.opt.throwHttpErrors) throw res.err
203
+ if (res.req.throwHttpErrors) throw res.err
196
204
  return res as any
197
205
  }
198
206
  return res.body!
@@ -202,35 +210,14 @@ export class Fetcher {
202
210
  url: string,
203
211
  rawOpt: FetcherOptions = {},
204
212
  ): Promise<FetcherResponse<T>> {
205
- const { baseUrl, logger } = this.cfg
206
-
207
- const opt = this.normalizeOptions(rawOpt)
208
- const { method, timeoutSeconds, mode } = opt
209
-
210
- const req: FetcherRequest = {
211
- url,
212
- init: {
213
- ...this.cfg.requestInit,
214
- method,
215
- },
216
- opt,
217
- }
218
-
219
- // setup url
220
- if (baseUrl) {
221
- if (url.startsWith('/')) {
222
- console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
223
- url = url.slice(1)
224
- }
225
- req.url = `${baseUrl}/${url}`
226
- }
213
+ const { logger } = this.cfg
227
214
 
228
- // setup request body
229
- if (opt.json !== undefined) {
230
- req.init.body = JSON.stringify(opt.json)
231
- } else if (opt.text !== undefined) {
232
- req.init.body = opt.text
233
- }
215
+ const req = this.normalizeOptions(url, rawOpt)
216
+ const {
217
+ timeoutSeconds,
218
+ mode,
219
+ init: { method },
220
+ } = req
234
221
 
235
222
  // setup timeout
236
223
  let timeout: number | undefined
@@ -242,10 +229,6 @@ export class Fetcher {
242
229
  }, timeoutSeconds * 1000) as any as number
243
230
  }
244
231
 
245
- if (opt.requestInit) {
246
- _objectAssign(req.init, opt.requestInit)
247
- }
248
-
249
232
  await this.cfg.hooks?.beforeRequest?.(req)
250
233
 
251
234
  const res: FetcherResponse<any> = {
@@ -253,7 +236,7 @@ export class Fetcher {
253
236
  retryStatus: {
254
237
  retryAttempt: 0,
255
238
  retryStopped: false,
256
- retryTimeout: opt.retry.timeout,
239
+ retryTimeout: req.retry.timeout,
257
240
  },
258
241
  }
259
242
 
@@ -267,7 +250,7 @@ export class Fetcher {
267
250
  if (this.cfg.logRequest) {
268
251
  const { retryAttempt } = res.retryStatus
269
252
  logger.log(
270
- [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
253
+ [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
271
254
  .filter(Boolean)
272
255
  .join(' '),
273
256
  )
@@ -298,7 +281,7 @@ export class Fetcher {
298
281
  ' <<',
299
282
  res.fetchResponse.status,
300
283
  signature,
301
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
284
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
302
285
  _since(started),
303
286
  ]
304
287
  .filter(Boolean)
@@ -335,23 +318,25 @@ export class Fetcher {
335
318
  }),
336
319
  )
337
320
 
338
- if (this.cfg.logResponse) {
339
- const { retryAttempt } = res.retryStatus
340
- logger.error(
341
- [
342
- [
343
- ' <<',
344
- res.fetchResponse.status,
345
- signature,
346
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
347
- _since(started),
348
- ]
349
- .filter(Boolean)
350
- .join(' '),
351
- _stringifyAny(body),
352
- ].join('\n'),
353
- )
354
- }
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
+ // }
355
340
 
356
341
  await this.processRetry(res)
357
342
  }
@@ -371,7 +356,7 @@ export class Fetcher {
371
356
 
372
357
  await this.cfg.hooks?.beforeRetry?.(res)
373
358
 
374
- const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry
359
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry
375
360
 
376
361
  if (retryStatus.retryAttempt >= count) {
377
362
  retryStatus.retryStopped = true
@@ -379,6 +364,7 @@ export class Fetcher {
379
364
 
380
365
  if (retryStatus.retryStopped) return
381
366
 
367
+ retryStatus.retryAttempt++
382
368
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
383
369
 
384
370
  await pDelay(retryStatus.retryTimeout)
@@ -389,7 +375,7 @@ export class Fetcher {
389
375
  * unless there's reason not to (e.g method is POST).
390
376
  */
391
377
  private shouldRetry(res: FetcherResponse): boolean {
392
- const { retryPost, retry4xx, retry5xx } = res.req.opt
378
+ const { retryPost, retry4xx, retry5xx } = res.req
393
379
  const { method } = res.req.init
394
380
  if (method === 'post' && !retryPost) return false
395
381
  const { statusFamily } = res
@@ -425,42 +411,89 @@ export class Fetcher {
425
411
  }
426
412
  const { debug } = cfg
427
413
 
428
- return {
429
- timeoutSeconds: 30,
430
- method: 'get',
431
- throwHttpErrors: true,
432
- retryPost: false,
433
- retry4xx: false,
434
- retry5xx: true,
435
- logger: console,
436
- logRequest: debug,
437
- logRequestBody: debug,
438
- logResponse: debug,
439
- logResponseBody: debug,
440
- ...cfg,
441
- retry: {
442
- ...defRetryOptions,
443
- ...cfg.retry,
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
+ },
444
433
  },
445
- }
434
+ cfg,
435
+ )
436
+
437
+ norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase())
438
+
439
+ return norm
446
440
  }
447
441
 
448
- private normalizeOptions(opt: FetcherOptions): FetcherNormalizedOptions {
449
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } =
442
+ private normalizeOptions(url: string, opt: FetcherOptions): FetcherRequest {
443
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } =
450
444
  this.cfg
451
- return {
445
+
446
+ const req: FetcherRequest = {
447
+ url,
452
448
  timeoutSeconds,
453
449
  throwHttpErrors,
454
- method,
455
450
  retryPost,
456
451
  retry4xx,
457
452
  retry5xx,
458
- ...opt,
453
+ ..._omit(opt, ['method', 'headers']),
459
454
  retry: {
460
455
  ...retry,
461
456
  ..._filterUndefinedValues(opt.retry || {}),
462
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
+ ),
463
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
464
497
  }
465
498
  }
466
499
 
@@ -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