@naturalcycles/js-lib 14.118.0 → 14.119.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.
@@ -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;
@@ -125,8 +124,10 @@ export declare class Fetcher {
125
124
  static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
126
125
  getJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
127
126
  postJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
127
+ putJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
128
+ patchJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
129
+ deleteJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
128
130
  getText(url: string, opt?: FetcherOptions): Promise<string>;
129
- postText(url: string, opt?: FetcherOptions): Promise<string>;
130
131
  fetch<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
131
132
  rawFetch<T = unknown>(url: string, rawOpt?: FetcherOptions): Promise<FetcherResponse<T>>;
132
133
  private processRetry;
@@ -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,
@@ -30,68 +28,59 @@ class Fetcher {
30
28
  static create(cfg = {}) {
31
29
  return new Fetcher(cfg);
32
30
  }
33
- async getJson(url, opt = {}) {
31
+ async getJson(url, opt) {
34
32
  return await this.fetch(url, {
35
33
  ...opt,
36
34
  mode: 'json',
37
35
  });
38
36
  }
39
- async postJson(url, opt = {}) {
37
+ async postJson(url, opt) {
40
38
  return await this.fetch(url, {
41
39
  ...opt,
42
40
  method: 'post',
43
41
  mode: 'json',
44
42
  });
45
43
  }
46
- async getText(url, opt = {}) {
44
+ async putJson(url, opt) {
47
45
  return await this.fetch(url, {
48
46
  ...opt,
49
- mode: 'text',
47
+ method: 'put',
48
+ mode: 'json',
50
49
  });
51
50
  }
52
- async postText(url, opt = {}) {
51
+ async patchJson(url, opt) {
52
+ return await this.fetch(url, {
53
+ ...opt,
54
+ method: 'patch',
55
+ mode: 'json',
56
+ });
57
+ }
58
+ async deleteJson(url, opt) {
59
+ return await this.fetch(url, {
60
+ ...opt,
61
+ method: 'delete',
62
+ mode: 'json',
63
+ });
64
+ }
65
+ async getText(url, opt) {
53
66
  return await this.fetch(url, {
54
67
  ...opt,
55
- method: 'post',
56
68
  mode: 'text',
57
69
  });
58
70
  }
59
- async fetch(url, opt = {}) {
71
+ async fetch(url, opt) {
60
72
  const res = await this.rawFetch(url, opt);
61
73
  if (res.err) {
62
- if (res.req.opt.throwHttpErrors)
74
+ if (res.req.throwHttpErrors)
63
75
  throw res.err;
64
76
  return res;
65
77
  }
66
78
  return res.body;
67
79
  }
68
80
  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
- }
81
+ const { logger } = this.cfg;
82
+ const req = this.normalizeOptions(url, rawOpt);
83
+ const { timeoutSeconds, mode, init: { method }, } = req;
95
84
  // setup timeout
96
85
  let timeout;
97
86
  if (timeoutSeconds) {
@@ -101,16 +90,13 @@ class Fetcher {
101
90
  abortController.abort(`timeout of ${timeoutSeconds} sec`);
102
91
  }, timeoutSeconds * 1000);
103
92
  }
104
- if (opt.requestInit) {
105
- (0, types_1._objectAssign)(req.init, opt.requestInit);
106
- }
107
93
  await this.cfg.hooks?.beforeRequest?.(req);
108
94
  const res = {
109
95
  req,
110
96
  retryStatus: {
111
97
  retryAttempt: 0,
112
98
  retryStopped: false,
113
- retryTimeout: opt.retry.timeout,
99
+ retryTimeout: req.retry.timeout,
114
100
  },
115
101
  };
116
102
  const shortUrl = this.getShortUrl(req.url);
@@ -120,7 +106,7 @@ class Fetcher {
120
106
  const started = Date.now();
121
107
  if (this.cfg.logRequest) {
122
108
  const { retryAttempt } = res.retryStatus;
123
- logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
109
+ logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
124
110
  .filter(Boolean)
125
111
  .join(' '));
126
112
  if (this.cfg.logRequestBody && req.init.body) {
@@ -146,7 +132,7 @@ class Fetcher {
146
132
  ' <<',
147
133
  res.fetchResponse.status,
148
134
  signature,
149
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
135
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
150
136
  (0, time_util_1._since)(started),
151
137
  ]
152
138
  .filter(Boolean)
@@ -173,21 +159,25 @@ class Fetcher {
173
159
  url: req.url,
174
160
  // tryCount: req.tryCount,
175
161
  }));
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
- }
162
+ // We don't log errors when they are also thrown,
163
+ // otherwise it gets logged twice: here, and upstream
164
+ // if (this.cfg.logResponse) {
165
+ // const { retryAttempt } = res.retryStatus
166
+ // logger.error(
167
+ // [
168
+ // [
169
+ // ' <<',
170
+ // res.fetchResponse.status,
171
+ // signature,
172
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
173
+ // _since(started),
174
+ // ]
175
+ // .filter(Boolean)
176
+ // .join(' '),
177
+ // _stringifyAny(body),
178
+ // ].join('\n'),
179
+ // )
180
+ // }
191
181
  await this.processRetry(res);
192
182
  }
193
183
  }
@@ -200,12 +190,13 @@ class Fetcher {
200
190
  retryStatus.retryStopped = true;
201
191
  }
202
192
  await this.cfg.hooks?.beforeRetry?.(res);
203
- const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry;
193
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry;
204
194
  if (retryStatus.retryAttempt >= count) {
205
195
  retryStatus.retryStopped = true;
206
196
  }
207
197
  if (retryStatus.retryStopped)
208
198
  return;
199
+ retryStatus.retryAttempt++;
209
200
  retryStatus.retryTimeout = (0, number_util_1._clamp)(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
210
201
  await (0, pDelay_1.pDelay)(retryStatus.retryTimeout);
211
202
  }
@@ -214,7 +205,7 @@ class Fetcher {
214
205
  * unless there's reason not to (e.g method is POST).
215
206
  */
216
207
  shouldRetry(res) {
217
- const { retryPost, retry4xx, retry5xx } = res.req.opt;
208
+ const { retryPost, retry4xx, retry5xx } = res.req;
218
209
  const { method } = res.req.init;
219
210
  if (method === 'post' && !retryPost)
220
211
  return false;
@@ -255,9 +246,10 @@ class Fetcher {
255
246
  cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
256
247
  }
257
248
  const { debug } = cfg;
258
- return {
249
+ const norm = (0, object_util_1._merge)({
250
+ url: '',
251
+ searchParams: {},
259
252
  timeoutSeconds: 30,
260
- method: 'get',
261
253
  throwHttpErrors: true,
262
254
  retryPost: false,
263
255
  retry4xx: false,
@@ -267,28 +259,60 @@ class Fetcher {
267
259
  logRequestBody: debug,
268
260
  logResponse: debug,
269
261
  logResponseBody: debug,
270
- ...cfg,
271
- retry: {
272
- ...defRetryOptions,
273
- ...cfg.retry,
262
+ retry: { ...defRetryOptions },
263
+ init: {
264
+ method: 'get',
265
+ headers: {},
274
266
  },
275
- };
267
+ }, cfg);
268
+ norm.init.headers = (0, object_util_1._mapKeys)(norm.init.headers, k => k.toLowerCase());
269
+ return norm;
276
270
  }
277
- normalizeOptions(opt) {
278
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
279
- return {
271
+ normalizeOptions(url, opt) {
272
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } = this.cfg;
273
+ const req = {
274
+ url,
280
275
  timeoutSeconds,
281
276
  throwHttpErrors,
282
- method,
283
277
  retryPost,
284
278
  retry4xx,
285
279
  retry5xx,
286
- ...opt,
280
+ ...(0, object_util_1._omit)(opt, ['method', 'headers']),
287
281
  retry: {
288
282
  ...retry,
289
283
  ...(0, object_util_1._filterUndefinedValues)(opt.retry || {}),
290
284
  },
285
+ init: (0, object_util_1._merge)({ ...this.cfg.init }, opt.init, (0, object_util_1._filterUndefinedValues)({
286
+ method: opt.method,
287
+ headers: (0, object_util_1._mapKeys)(opt.headers || {}, k => k.toLowerCase()),
288
+ })),
289
+ };
290
+ // setup url
291
+ if (baseUrl) {
292
+ if (url.startsWith('/')) {
293
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
294
+ url = url.slice(1);
295
+ }
296
+ req.url = `${baseUrl}/${url}`;
297
+ }
298
+ const searchParams = {
299
+ ...this.cfg.searchParams,
300
+ ...opt.searchParams,
291
301
  };
302
+ if (Object.keys(searchParams).length) {
303
+ const qs = new URLSearchParams(searchParams).toString();
304
+ req.url += req.url.includes('?') ? '&' : '?' + qs;
305
+ }
306
+ // setup request body
307
+ if (opt.json !== undefined) {
308
+ req.init.body = JSON.stringify(opt.json);
309
+ req.init.headers['content-type'] = 'application/json';
310
+ }
311
+ else if (opt.text !== undefined) {
312
+ req.init.body = opt.text;
313
+ req.init.headers['content-type'] = 'text/plain';
314
+ }
315
+ return req;
292
316
  }
293
317
  }
294
318
  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,
@@ -27,22 +25,28 @@ export class Fetcher {
27
25
  static create(cfg = {}) {
28
26
  return new Fetcher(cfg);
29
27
  }
30
- async getJson(url, opt = {}) {
28
+ async getJson(url, opt) {
31
29
  return await this.fetch(url, Object.assign(Object.assign({}, opt), { mode: 'json' }));
32
30
  }
33
- async postJson(url, opt = {}) {
31
+ async postJson(url, opt) {
34
32
  return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'post', mode: 'json' }));
35
33
  }
36
- async getText(url, opt = {}) {
37
- return await this.fetch(url, Object.assign(Object.assign({}, opt), { mode: 'text' }));
34
+ async putJson(url, opt) {
35
+ return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'put', mode: 'json' }));
36
+ }
37
+ async patchJson(url, opt) {
38
+ return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'patch', mode: 'json' }));
39
+ }
40
+ async deleteJson(url, opt) {
41
+ return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'delete', mode: 'json' }));
38
42
  }
39
- async postText(url, opt = {}) {
40
- return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'post', mode: 'text' }));
43
+ async getText(url, opt) {
44
+ return await this.fetch(url, Object.assign(Object.assign({}, opt), { mode: 'text' }));
41
45
  }
42
- async fetch(url, opt = {}) {
46
+ async fetch(url, opt) {
43
47
  const res = await this.rawFetch(url, opt);
44
48
  if (res.err) {
45
- if (res.req.opt.throwHttpErrors)
49
+ if (res.req.throwHttpErrors)
46
50
  throw res.err;
47
51
  return res;
48
52
  }
@@ -50,29 +54,9 @@ export class Fetcher {
50
54
  }
51
55
  async rawFetch(url, rawOpt = {}) {
52
56
  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
- }
57
+ const { logger } = this.cfg;
58
+ const req = this.normalizeOptions(url, rawOpt);
59
+ const { timeoutSeconds, mode, init: { method }, } = req;
76
60
  // setup timeout
77
61
  let timeout;
78
62
  if (timeoutSeconds) {
@@ -82,16 +66,13 @@ export class Fetcher {
82
66
  abortController.abort(`timeout of ${timeoutSeconds} sec`);
83
67
  }, timeoutSeconds * 1000);
84
68
  }
85
- if (opt.requestInit) {
86
- _objectAssign(req.init, opt.requestInit);
87
- }
88
69
  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
70
  const res = {
90
71
  req,
91
72
  retryStatus: {
92
73
  retryAttempt: 0,
93
74
  retryStopped: false,
94
- retryTimeout: opt.retry.timeout,
75
+ retryTimeout: req.retry.timeout,
95
76
  },
96
77
  };
97
78
  const shortUrl = this.getShortUrl(req.url);
@@ -101,7 +82,7 @@ export class Fetcher {
101
82
  const started = Date.now();
102
83
  if (this.cfg.logRequest) {
103
84
  const { retryAttempt } = res.retryStatus;
104
- logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
85
+ logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
105
86
  .filter(Boolean)
106
87
  .join(' '));
107
88
  if (this.cfg.logRequestBody && req.init.body) {
@@ -127,7 +108,7 @@ export class Fetcher {
127
108
  ' <<',
128
109
  res.fetchResponse.status,
129
110
  signature,
130
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
111
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
131
112
  _since(started),
132
113
  ]
133
114
  .filter(Boolean)
@@ -149,21 +130,25 @@ export class Fetcher {
149
130
  // Enabled, cause `data` is not printed by default when error is HttpError
150
131
  // method: req.method,
151
132
  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
- }
133
+ // We don't log errors when they are also thrown,
134
+ // otherwise it gets logged twice: here, and upstream
135
+ // if (this.cfg.logResponse) {
136
+ // const { retryAttempt } = res.retryStatus
137
+ // logger.error(
138
+ // [
139
+ // [
140
+ // ' <<',
141
+ // res.fetchResponse.status,
142
+ // signature,
143
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
144
+ // _since(started),
145
+ // ]
146
+ // .filter(Boolean)
147
+ // .join(' '),
148
+ // _stringifyAny(body),
149
+ // ].join('\n'),
150
+ // )
151
+ // }
167
152
  await this.processRetry(res);
168
153
  }
169
154
  }
@@ -177,12 +162,13 @@ export class Fetcher {
177
162
  retryStatus.retryStopped = true;
178
163
  }
179
164
  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;
165
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry;
181
166
  if (retryStatus.retryAttempt >= count) {
182
167
  retryStatus.retryStopped = true;
183
168
  }
184
169
  if (retryStatus.retryStopped)
185
170
  return;
171
+ retryStatus.retryAttempt++;
186
172
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
187
173
  await pDelay(retryStatus.retryTimeout);
188
174
  }
@@ -191,7 +177,7 @@ export class Fetcher {
191
177
  * unless there's reason not to (e.g method is POST).
192
178
  */
193
179
  shouldRetry(res) {
194
- const { retryPost, retry4xx, retry5xx } = res.req.opt;
180
+ const { retryPost, retry4xx, retry5xx } = res.req;
195
181
  const { method } = res.req.init;
196
182
  if (method === 'post' && !retryPost)
197
183
  return false;
@@ -234,16 +220,62 @@ export class Fetcher {
234
220
  cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
235
221
  }
236
222
  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) });
223
+ const norm = _merge({
224
+ url: '',
225
+ searchParams: {},
226
+ timeoutSeconds: 30,
227
+ throwHttpErrors: true,
228
+ retryPost: false,
229
+ retry4xx: false,
230
+ retry5xx: true,
231
+ logger: console,
232
+ logRequest: debug,
233
+ logRequestBody: debug,
234
+ logResponse: debug,
235
+ logResponseBody: debug,
236
+ retry: Object.assign({}, defRetryOptions),
237
+ init: {
238
+ method: 'get',
239
+ headers: {},
240
+ },
241
+ }, cfg);
242
+ norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase());
243
+ return norm;
238
244
  }
239
- normalizeOptions(opt) {
240
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
241
- return Object.assign(Object.assign({ timeoutSeconds,
245
+ normalizeOptions(url, opt) {
246
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } = this.cfg;
247
+ const req = Object.assign(Object.assign({ url,
248
+ timeoutSeconds,
242
249
  throwHttpErrors,
243
- method,
244
250
  retryPost,
245
251
  retry4xx,
246
- retry5xx }, opt), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})) });
252
+ retry5xx }, _omit(opt, ['method', 'headers'])), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})), init: _merge(Object.assign({}, this.cfg.init), opt.init, _filterUndefinedValues({
253
+ method: opt.method,
254
+ headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
255
+ })) });
256
+ // setup url
257
+ if (baseUrl) {
258
+ if (url.startsWith('/')) {
259
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
260
+ url = url.slice(1);
261
+ }
262
+ req.url = `${baseUrl}/${url}`;
263
+ }
264
+ const searchParams = Object.assign(Object.assign({}, this.cfg.searchParams), opt.searchParams);
265
+ if (Object.keys(searchParams).length) {
266
+ const qs = new URLSearchParams(searchParams).toString();
267
+ req.url += req.url.includes('?') ? '&' : '?' + qs;
268
+ }
269
+ // setup request body
270
+ if (opt.json !== undefined) {
271
+ req.init.body = JSON.stringify(opt.json);
272
+ req.init.headers['content-type'] = 'application/json';
273
+ }
274
+ else if (opt.text !== undefined) {
275
+ req.init.body = opt.text;
276
+ req.init.headers['content-type'] = 'text/plain';
277
+ }
278
+ return req;
247
279
  }
248
280
  }
249
281
  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.1",
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> {
@@ -159,40 +167,52 @@ export class Fetcher {
159
167
  return new Fetcher(cfg)
160
168
  }
161
169
 
162
- async getJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
170
+ async getJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
163
171
  return await this.fetch<T>(url, {
164
172
  ...opt,
165
173
  mode: 'json',
166
174
  })
167
175
  }
168
-
169
- async postJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
176
+ async postJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
170
177
  return await this.fetch<T>(url, {
171
178
  ...opt,
172
179
  method: 'post',
173
180
  mode: 'json',
174
181
  })
175
182
  }
176
-
177
- async getText(url: string, opt: FetcherOptions = {}): Promise<string> {
178
- return await this.fetch<string>(url, {
183
+ async putJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
184
+ return await this.fetch<T>(url, {
179
185
  ...opt,
180
- mode: 'text',
186
+ method: 'put',
187
+ mode: 'json',
188
+ })
189
+ }
190
+ async patchJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
191
+ return await this.fetch<T>(url, {
192
+ ...opt,
193
+ method: 'patch',
194
+ mode: 'json',
195
+ })
196
+ }
197
+ async deleteJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
198
+ return await this.fetch<T>(url, {
199
+ ...opt,
200
+ method: 'delete',
201
+ mode: 'json',
181
202
  })
182
203
  }
183
204
 
184
- async postText(url: string, opt: FetcherOptions = {}): Promise<string> {
205
+ async getText(url: string, opt?: FetcherOptions): Promise<string> {
185
206
  return await this.fetch<string>(url, {
186
207
  ...opt,
187
- method: 'post',
188
208
  mode: 'text',
189
209
  })
190
210
  }
191
211
 
192
- async fetch<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
212
+ async fetch<T = unknown>(url: string, opt?: FetcherOptions): Promise<T> {
193
213
  const res = await this.rawFetch<T>(url, opt)
194
214
  if (res.err) {
195
- if (res.req.opt.throwHttpErrors) throw res.err
215
+ if (res.req.throwHttpErrors) throw res.err
196
216
  return res as any
197
217
  }
198
218
  return res.body!
@@ -202,35 +222,14 @@ export class Fetcher {
202
222
  url: string,
203
223
  rawOpt: FetcherOptions = {},
204
224
  ): 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
- }
225
+ const { logger } = this.cfg
227
226
 
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
- }
227
+ const req = this.normalizeOptions(url, rawOpt)
228
+ const {
229
+ timeoutSeconds,
230
+ mode,
231
+ init: { method },
232
+ } = req
234
233
 
235
234
  // setup timeout
236
235
  let timeout: number | undefined
@@ -242,10 +241,6 @@ export class Fetcher {
242
241
  }, timeoutSeconds * 1000) as any as number
243
242
  }
244
243
 
245
- if (opt.requestInit) {
246
- _objectAssign(req.init, opt.requestInit)
247
- }
248
-
249
244
  await this.cfg.hooks?.beforeRequest?.(req)
250
245
 
251
246
  const res: FetcherResponse<any> = {
@@ -253,7 +248,7 @@ export class Fetcher {
253
248
  retryStatus: {
254
249
  retryAttempt: 0,
255
250
  retryStopped: false,
256
- retryTimeout: opt.retry.timeout,
251
+ retryTimeout: req.retry.timeout,
257
252
  },
258
253
  }
259
254
 
@@ -267,7 +262,7 @@ export class Fetcher {
267
262
  if (this.cfg.logRequest) {
268
263
  const { retryAttempt } = res.retryStatus
269
264
  logger.log(
270
- [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
265
+ [' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`]
271
266
  .filter(Boolean)
272
267
  .join(' '),
273
268
  )
@@ -298,7 +293,7 @@ export class Fetcher {
298
293
  ' <<',
299
294
  res.fetchResponse.status,
300
295
  signature,
301
- retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
296
+ retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
302
297
  _since(started),
303
298
  ]
304
299
  .filter(Boolean)
@@ -335,23 +330,25 @@ export class Fetcher {
335
330
  }),
336
331
  )
337
332
 
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
- }
333
+ // We don't log errors when they are also thrown,
334
+ // otherwise it gets logged twice: here, and upstream
335
+ // if (this.cfg.logResponse) {
336
+ // const { retryAttempt } = res.retryStatus
337
+ // logger.error(
338
+ // [
339
+ // [
340
+ // ' <<',
341
+ // res.fetchResponse.status,
342
+ // signature,
343
+ // retryAttempt && `try#${retryAttempt + 1}/${req.retry.count}`,
344
+ // _since(started),
345
+ // ]
346
+ // .filter(Boolean)
347
+ // .join(' '),
348
+ // _stringifyAny(body),
349
+ // ].join('\n'),
350
+ // )
351
+ // }
355
352
 
356
353
  await this.processRetry(res)
357
354
  }
@@ -371,7 +368,7 @@ export class Fetcher {
371
368
 
372
369
  await this.cfg.hooks?.beforeRetry?.(res)
373
370
 
374
- const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry
371
+ const { count, timeoutMultiplier, timeoutMax } = res.req.retry
375
372
 
376
373
  if (retryStatus.retryAttempt >= count) {
377
374
  retryStatus.retryStopped = true
@@ -379,6 +376,7 @@ export class Fetcher {
379
376
 
380
377
  if (retryStatus.retryStopped) return
381
378
 
379
+ retryStatus.retryAttempt++
382
380
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
383
381
 
384
382
  await pDelay(retryStatus.retryTimeout)
@@ -389,7 +387,7 @@ export class Fetcher {
389
387
  * unless there's reason not to (e.g method is POST).
390
388
  */
391
389
  private shouldRetry(res: FetcherResponse): boolean {
392
- const { retryPost, retry4xx, retry5xx } = res.req.opt
390
+ const { retryPost, retry4xx, retry5xx } = res.req
393
391
  const { method } = res.req.init
394
392
  if (method === 'post' && !retryPost) return false
395
393
  const { statusFamily } = res
@@ -425,42 +423,89 @@ export class Fetcher {
425
423
  }
426
424
  const { debug } = cfg
427
425
 
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,
426
+ const norm: FetcherNormalizedCfg = _merge(
427
+ {
428
+ url: '',
429
+ searchParams: {},
430
+ timeoutSeconds: 30,
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
+ retry: { ...defRetryOptions },
441
+ init: {
442
+ method: 'get',
443
+ headers: {},
444
+ },
444
445
  },
445
- }
446
+ cfg,
447
+ )
448
+
449
+ norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase())
450
+
451
+ return norm
446
452
  }
447
453
 
448
- private normalizeOptions(opt: FetcherOptions): FetcherNormalizedOptions {
449
- const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } =
454
+ private normalizeOptions(url: string, opt: FetcherOptions): FetcherRequest {
455
+ const { baseUrl, timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry } =
450
456
  this.cfg
451
- return {
457
+
458
+ const req: FetcherRequest = {
459
+ url,
452
460
  timeoutSeconds,
453
461
  throwHttpErrors,
454
- method,
455
462
  retryPost,
456
463
  retry4xx,
457
464
  retry5xx,
458
- ...opt,
465
+ ..._omit(opt, ['method', 'headers']),
459
466
  retry: {
460
467
  ...retry,
461
468
  ..._filterUndefinedValues(opt.retry || {}),
462
469
  },
470
+ init: _merge(
471
+ { ...this.cfg.init },
472
+ opt.init,
473
+ _filterUndefinedValues({
474
+ method: opt.method,
475
+ headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
476
+ }),
477
+ ),
478
+ }
479
+
480
+ // setup url
481
+ if (baseUrl) {
482
+ if (url.startsWith('/')) {
483
+ console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
484
+ url = url.slice(1)
485
+ }
486
+ req.url = `${baseUrl}/${url}`
487
+ }
488
+
489
+ const searchParams = {
490
+ ...this.cfg.searchParams,
491
+ ...opt.searchParams,
463
492
  }
493
+
494
+ if (Object.keys(searchParams).length) {
495
+ const qs = new URLSearchParams(searchParams).toString()
496
+ req.url += req.url.includes('?') ? '&' : '?' + qs
497
+ }
498
+
499
+ // setup request body
500
+ if (opt.json !== undefined) {
501
+ req.init.body = JSON.stringify(opt.json)
502
+ req.init.headers['content-type'] = 'application/json'
503
+ } else if (opt.text !== undefined) {
504
+ req.init.body = opt.text
505
+ req.init.headers['content-type'] = 'text/plain'
506
+ }
507
+
508
+ return req
464
509
  }
465
510
  }
466
511
 
@@ -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