@naturalcycles/js-lib 14.117.1 → 14.118.0

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