@naturalcycles/js-lib 15.81.0 → 15.82.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.
@@ -4,7 +4,7 @@
4
4
  import type { ReadableStream as WebReadableStream } from 'node:stream/web';
5
5
  import { HttpRequestError } from '../error/error.util.js';
6
6
  import type { ErrorDataTuple } from '../types.js';
7
- import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherGraphQLOptions, FetcherNormalizedCfg, FetcherOnErrorHook, FetcherOptions, FetcherResponse, FetchFunction, RequestInitNormalized } from './fetcher.model.js';
7
+ import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherGraphQLOptions, FetcherInitHook, FetcherNormalizedCfg, FetcherOnErrorHook, FetcherOptions, FetcherResponse, FetcherSuccessResponse, FetchFunction, RequestInitNormalized } from './fetcher.model.js';
8
8
  /**
9
9
  * Experimental wrapper around Fetch.
10
10
  * Works in both Browser and Node, using `globalThis.fetch`.
@@ -16,7 +16,7 @@ export declare class Fetcher {
16
16
  *
17
17
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
18
18
  */
19
- static readonly VERSION = 4;
19
+ static readonly VERSION = 5;
20
20
  /**
21
21
  * userAgent is statically exposed as Fetcher.userAgent.
22
22
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -30,7 +30,13 @@ export declare class Fetcher {
30
30
  onAfterResponse(hook: FetcherAfterResponseHook): this;
31
31
  onBeforeRetry(hook: FetcherBeforeRetryHook): this;
32
32
  onError(hook: FetcherOnErrorHook): this;
33
+ /**
34
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
35
+ * See FetcherInitHook docs.
36
+ */
37
+ onInit(hook: FetcherInitHook): this;
33
38
  cfg: FetcherNormalizedCfg;
39
+ private initPromise?;
34
40
  static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
35
41
  get: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
36
42
  post: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
@@ -56,10 +62,12 @@ export declare class Fetcher {
56
62
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
57
63
  *
58
64
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
59
- *
60
- * @experimental
61
65
  */
62
66
  queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T>;
67
+ /**
68
+ * Returns response body as Uint8Array.
69
+ */
70
+ getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array>;
63
71
  /**
64
72
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
65
73
  *
@@ -68,6 +76,16 @@ export declare class Fetcher {
68
76
  */
69
77
  getReadableStream(url: string, opt?: FetcherOptions): Promise<WebReadableStream<Uint8Array>>;
70
78
  fetch<T = unknown>(opt: FetcherOptions): Promise<T>;
79
+ /**
80
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
81
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
82
+ * while still throwing on errors (unlike `doFetch`).
83
+ *
84
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
85
+ * otherwise the returned FetcherSuccessResponse type would lie.
86
+ * Use `doFetch` if you don't want throwing.
87
+ */
88
+ fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>>;
71
89
  /**
72
90
  * Execute fetch and expect/assert it to return an Error (which will be wrapped in
73
91
  * HttpRequestError as it normally would).
@@ -86,11 +104,13 @@ export declare class Fetcher {
86
104
  /**
87
105
  * Returns FetcherResponse.
88
106
  * Never throws, returns `err` property in the response instead.
107
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
89
108
  * Use this method instead of `throwHttpErrors: false` or try-catching.
90
109
  *
91
- * Note: responseType defaults to `void`, so, override it if you expect different.
110
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
92
111
  */
93
112
  doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>>;
113
+ private runInitHooks;
94
114
  private onOkResponse;
95
115
  /**
96
116
  * This method exists to be able to easily mock it.
@@ -99,6 +119,11 @@ export declare class Fetcher {
99
119
  static callNativeFetch(url: string, init: RequestInitNormalized, fetchFn?: FetchFunction): Promise<Response>;
100
120
  private onNotOkResponse;
101
121
  private processRetry;
122
+ /**
123
+ * Returns the delay before the next retry attempt.
124
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
125
+ * meaning the retry should not be attempted at all.
126
+ */
102
127
  private getRetryTimeout;
103
128
  /**
104
129
  * Default is yes,
@@ -8,7 +8,7 @@ import { _assertErrorClassOrRethrow, _assertIsError } from '../error/assert.js';
8
8
  import { _anyToError, _anyToErrorObject, _errorDataAppend, _errorLikeToErrorObject, HttpRequestError, UnexpectedPassError, } from '../error/error.util.js';
9
9
  import { _clamp } from '../number/number.util.js';
10
10
  import { _filterFalsyValues, _filterNullishValues, _filterUndefinedValues, _mapKeys, _merge, _omit, _pick, } from '../object/object.util.js';
11
- import { pDelay } from '../promise/pDelay.js';
11
+ import { pDelaySignal } from '../promise/pDelay.js';
12
12
  import { pTimeout } from '../promise/pTimeout.js';
13
13
  import { _toUrlOrNull } from '../string/index.js';
14
14
  import { _jsonParse, _jsonParseIfPossible } from '../string/json.util.js';
@@ -25,7 +25,7 @@ export class Fetcher {
25
25
  *
26
26
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
27
27
  */
28
- static VERSION = 4;
28
+ static VERSION = 5;
29
29
  /**
30
30
  * userAgent is statically exposed as Fetcher.userAgent.
31
31
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -47,9 +47,10 @@ export class Fetcher {
47
47
  ...opt,
48
48
  });
49
49
  };
50
- if (method === 'HEAD')
51
- return // responseType=text
52
- ;
50
+ if (method === 'HEAD') {
51
+ continue; // responseType=text
52
+ }
53
+ ;
53
54
  this[`${m}Text`] = async (url, opt) => {
54
55
  return await this.fetch({
55
56
  url,
@@ -91,7 +92,17 @@ export class Fetcher {
91
92
  (this.cfg.hooks.onError ||= []).push(hook);
92
93
  return this;
93
94
  }
95
+ /**
96
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
97
+ * See FetcherInitHook docs.
98
+ */
99
+ onInit(hook) {
100
+ ;
101
+ (this.cfg.hooks.init ||= []).push(hook);
102
+ return this;
103
+ }
94
104
  cfg;
105
+ initPromise;
95
106
  static create(cfg = {}) {
96
107
  return new Fetcher(cfg);
97
108
  }
@@ -123,10 +134,9 @@ export class Fetcher {
123
134
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
124
135
  *
125
136
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
126
- *
127
- * @experimental
128
137
  */
129
138
  async queryGraphQL(opt) {
139
+ opt = { ...opt }; // avoid mutating the input options
130
140
  opt.method ||= this.cfg.init.method; // defaults to GET
131
141
  const payload = _filterFalsyValues({
132
142
  query: opt.query,
@@ -173,6 +183,17 @@ export class Fetcher {
173
183
  }
174
184
  return data;
175
185
  }
186
+ // responseType=bytes
187
+ /**
188
+ * Returns response body as Uint8Array.
189
+ */
190
+ async getBytes(url, opt) {
191
+ return await this.fetch({
192
+ url,
193
+ responseType: 'bytes',
194
+ ...opt,
195
+ });
196
+ }
176
197
  // responseType=readableStream
177
198
  /**
178
199
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
@@ -189,11 +210,29 @@ export class Fetcher {
189
210
  }
190
211
  async fetch(opt) {
191
212
  const res = await this.doFetch(opt);
192
- if (res.err) {
213
+ if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
214
+ // With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
215
+ // Other errors (network failure, timeout, body parsing) are still thrown.
193
216
  throw res.err;
194
217
  }
195
218
  return res.body;
196
219
  }
220
+ /**
221
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
222
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
223
+ * while still throwing on errors (unlike `doFetch`).
224
+ *
225
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
226
+ * otherwise the returned FetcherSuccessResponse type would lie.
227
+ * Use `doFetch` if you don't want throwing.
228
+ */
229
+ async fetchWithMeta(opt) {
230
+ const res = await this.doFetch(opt);
231
+ if (res.err) {
232
+ throw res.err;
233
+ }
234
+ return res;
235
+ }
197
236
  /**
198
237
  * Execute fetch and expect/assert it to return an Error (which will be wrapped in
199
238
  * HttpRequestError as it normally would).
@@ -226,11 +265,22 @@ export class Fetcher {
226
265
  /**
227
266
  * Returns FetcherResponse.
228
267
  * Never throws, returns `err` property in the response instead.
268
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
229
269
  * Use this method instead of `throwHttpErrors: false` or try-catching.
230
270
  *
231
- * Note: responseType defaults to `void`, so, override it if you expect different.
271
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
232
272
  */
233
273
  async doFetch(opt) {
274
+ if (this.cfg.hooks.init) {
275
+ try {
276
+ await (this.initPromise ??= this.runInitHooks());
277
+ }
278
+ catch (err) {
279
+ // Reset, so init hooks are re-attempted on the next request
280
+ this.initPromise = undefined;
281
+ throw err;
282
+ }
283
+ }
234
284
  const req = this.normalizeOptions(opt);
235
285
  const { logger } = this.cfg;
236
286
  const { init: { method }, } = req;
@@ -253,6 +303,7 @@ export class Fetcher {
253
303
  };
254
304
  while (!res.retryStatus.retryStopped) {
255
305
  req.started = Date.now();
306
+ res.body = undefined;
256
307
  req.init.signal = abortSignalAnyOrUndefined([
257
308
  abortSignalTimeoutOrUndefined(timeoutMillis),
258
309
  opt.signal,
@@ -267,7 +318,7 @@ export class Fetcher {
267
318
  }
268
319
  }
269
320
  try {
270
- res.fetchResponse = await (this.cfg.overrideFetchFn || Fetcher.callNativeFetch)(req.fullUrl, req.init, this.cfg.fetchFn);
321
+ res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(req.fullUrl, req.init, req.fetchFn);
271
322
  res.ok = res.fetchResponse.ok;
272
323
  // important to set it to undefined, otherwise it can keep the previous value (from previous try)
273
324
  res.err = undefined;
@@ -283,11 +334,12 @@ export class Fetcher {
283
334
  }
284
335
  res.statusFamily = this.getStatusFamily(res);
285
336
  res.statusCode = res.fetchResponse?.status;
286
- if (res.fetchResponse?.ok || !req.throwHttpErrors) {
337
+ if (res.fetchResponse?.ok) {
287
338
  try {
288
339
  // We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
289
340
  await pTimeout(async () => await this.onOkResponse(res), {
290
- timeout: timeoutMillis || Number.POSITIVE_INFINITY,
341
+ // 0 means "no timeout" for pTimeout
342
+ timeout: timeoutMillis ?? 0,
291
343
  name: 'Fetcher.downloadBody',
292
344
  });
293
345
  }
@@ -318,6 +370,11 @@ export class Fetcher {
318
370
  }
319
371
  return res;
320
372
  }
373
+ async runInitHooks() {
374
+ for (const hook of this.cfg.hooks.init || []) {
375
+ await hook(this.cfg);
376
+ }
377
+ }
321
378
  async onOkResponse(res) {
322
379
  const { req } = res;
323
380
  const { responseType } = res.req;
@@ -345,10 +402,20 @@ export class Fetcher {
345
402
  res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
346
403
  }
347
404
  else if (responseType === 'arrayBuffer') {
348
- res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : {};
405
+ res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0);
406
+ }
407
+ else if (responseType === 'bytes') {
408
+ // Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
409
+ res.body = res.fetchResponse.body
410
+ ? new Uint8Array(await res.fetchResponse.arrayBuffer())
411
+ : new Uint8Array();
349
412
  }
350
413
  else if (responseType === 'blob') {
351
- res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : {};
414
+ res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob();
415
+ }
416
+ else if (responseType === 'void') {
417
+ // Cancel the body (without downloading it), to free the underlying connection for reuse
418
+ await res.fetchResponse.body?.cancel();
352
419
  }
353
420
  else if (responseType === 'readableStream') {
354
421
  res.body = res.fetchResponse.body;
@@ -358,8 +425,7 @@ export class Fetcher {
358
425
  }
359
426
  }
360
427
  res.retryStatus.retryStopped = true;
361
- // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
362
- if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
428
+ if (req.logResponse) {
363
429
  const { retryAttempt } = res.retryStatus;
364
430
  const { logger } = this.cfg;
365
431
  logger.log([
@@ -474,13 +540,28 @@ export class Fetcher {
474
540
  if (retryStatus.retryStopped)
475
541
  return;
476
542
  retryStatus.retryAttempt++;
477
- retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
478
543
  const timeout = this.getRetryTimeout(res);
544
+ if (timeout === null) {
545
+ this.cfg.logger.warn(`${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`);
546
+ retryStatus.retryStopped = true;
547
+ return;
548
+ }
549
+ // Increase the timeout for the next possible retry
550
+ retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
479
551
  if (res.req.debug) {
480
552
  this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`);
481
553
  }
482
- await pDelay(timeout);
554
+ await pDelaySignal(timeout, res.req.signal);
555
+ if (res.req.signal?.aborted) {
556
+ // Aborted while waiting for the retry - stop, without issuing another request
557
+ retryStatus.retryStopped = true;
558
+ }
483
559
  }
560
+ /**
561
+ * Returns the delay before the next retry attempt.
562
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
563
+ * meaning the retry should not be attempted at all.
564
+ */
484
565
  getRetryTimeout(res) {
485
566
  let timeout = 0;
486
567
  // Handling http 429 with specific retry headers
@@ -489,13 +570,21 @@ export class Fetcher {
489
570
  const retryAfterStr = res.fetchResponse.headers.get('retry-after') ??
490
571
  res.fetchResponse.headers.get('x-ratelimit-reset');
491
572
  if (retryAfterStr) {
492
- if (Number(retryAfterStr)) {
493
- timeout = Number(retryAfterStr) * 1000;
573
+ const retryAfterNumber = Number(retryAfterStr);
574
+ if (retryAfterNumber) {
575
+ if (retryAfterNumber > 10 ** 9) {
576
+ // Value is too large to be a "seconds from now" delay,
577
+ // treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
578
+ timeout = retryAfterNumber * 1000 - Date.now();
579
+ }
580
+ else {
581
+ timeout = retryAfterNumber * 1000;
582
+ }
494
583
  }
495
584
  else {
496
585
  const date = new Date(retryAfterStr);
497
- if (!Number.isNaN(date)) {
498
- timeout = Number(date) - Date.now();
586
+ if (!Number.isNaN(date.getTime())) {
587
+ timeout = date.getTime() - Date.now();
499
588
  }
500
589
  }
501
590
  this.cfg.logger.log(`retry-after: ${retryAfterStr}`);
@@ -504,11 +593,15 @@ export class Fetcher {
504
593
  }
505
594
  }
506
595
  }
507
- if (!timeout) {
508
- const noise = Math.random() * 500;
509
- timeout = res.retryStatus.retryTimeout + noise;
596
+ if (timeout) {
597
+ if (timeout > res.req.retry.maxRetryAfter) {
598
+ return null;
599
+ }
600
+ // Server-indicated delay is honored as-is (a negative value means "retry now")
601
+ return Math.max(0, timeout);
510
602
  }
511
- return timeout;
603
+ const noise = Math.random() * 500;
604
+ return res.retryStatus.retryTimeout + noise;
512
605
  }
513
606
  /**
514
607
  * Default is yes,
@@ -635,6 +728,7 @@ export class Fetcher {
635
728
  ..._pick(this.cfg, [
636
729
  'timeoutSeconds',
637
730
  'retryPost',
731
+ 'retry3xx',
638
732
  'retry4xx',
639
733
  'retry5xx',
640
734
  'responseType',
@@ -646,6 +740,8 @@ export class Fetcher {
646
740
  'debug',
647
741
  'throwHttpErrors',
648
742
  'errorData',
743
+ 'fetchFn',
744
+ 'overrideFetchFn',
649
745
  ]),
650
746
  started: Date.now(),
651
747
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -691,13 +787,14 @@ export class Fetcher {
691
787
  }
692
788
  // setup request body
693
789
  // Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
790
+ // Explicitly passed `content-type` header always wins
694
791
  if (opt.json !== undefined) {
695
792
  req.init.body = JSON.stringify(opt.json);
696
- req.init.headers['content-type'] = 'application/json';
793
+ req.init.headers['content-type'] ||= 'application/json';
697
794
  }
698
795
  else if (opt.text !== undefined) {
699
796
  req.init.body = opt.text;
700
- req.init.headers['content-type'] = 'text/plain';
797
+ req.init.headers['content-type'] ||= 'text/plain';
701
798
  }
702
799
  else if (opt.form) {
703
800
  if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
@@ -705,7 +802,7 @@ export class Fetcher {
705
802
  }
706
803
  else {
707
804
  req.init.body = new URLSearchParams(opt.form);
708
- req.init.headers['content-type'] = 'application/x-www-form-urlencoded';
805
+ req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded';
709
806
  }
710
807
  }
711
808
  else if (opt.body !== undefined) {
@@ -725,6 +822,7 @@ const acceptByResponseType = {
725
822
  void: '*/*',
726
823
  readableStream: 'application/octet-stream',
727
824
  arrayBuffer: 'application/octet-stream',
825
+ bytes: 'application/octet-stream',
728
826
  blob: 'application/octet-stream',
729
827
  };
730
828
  const defaultRetryOptions = {
@@ -732,4 +830,5 @@ const defaultRetryOptions = {
732
830
  timeout: 1000,
733
831
  timeoutMax: 30_000,
734
832
  timeoutMultiplier: 2,
833
+ maxRetryAfter: 600_000, // 10 minutes
735
834
  };
@@ -18,6 +18,18 @@ export type FetcherBeforeRetryHook = <BODY = unknown>(res: FetcherResponse<BODY>
18
18
  * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
19
19
  */
20
20
  export type FetcherOnErrorHook = (err: Error) => Promisable<void>;
21
+ /**
22
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
23
+ * All requests wait for the init hooks to complete, their result is cached,
24
+ * so they run at most once.
25
+ * If a hook throws - the error is re-thrown to the caller of the request,
26
+ * and init hooks are re-attempted on the next request.
27
+ *
28
+ * Receives the Fetcher's normalized cfg, which is allowed (and expected)
29
+ * to be mutated. Allows to do async initialization, e.g fetch an auth token
30
+ * and set `cfg.init.headers` based on it.
31
+ */
32
+ export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable<void>;
21
33
  /**
22
34
  * FetcherCfg: configuration of the Fetcher instance. One per instance.
23
35
  * FetcherOptions: options for a single request. One per request.
@@ -39,6 +51,11 @@ export interface FetcherCfg {
39
51
  * If you throw an error from the hook - it will be re-thrown as-is.
40
52
  */
41
53
  hooks?: {
54
+ /**
55
+ * Runs lazily, once per Fetcher instance, before the first request.
56
+ * See FetcherInitHook docs.
57
+ */
58
+ init?: FetcherInitHook[];
42
59
  /**
43
60
  * Allows to mutate req.
44
61
  */
@@ -113,6 +130,16 @@ export interface FetcherRetryOptions {
113
130
  timeout: NumberOfMilliseconds;
114
131
  timeoutMax: NumberOfMilliseconds;
115
132
  timeoutMultiplier: number;
133
+ /**
134
+ * Upper limit for the server-indicated delay (`retry-after` and similar headers).
135
+ * Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
136
+ * which only limits the exponential backoff).
137
+ * If the server indicates a delay larger than this - the retry is NOT attempted,
138
+ * and the error is returned/thrown right away.
139
+ *
140
+ * Defaults to 10 minutes.
141
+ */
142
+ maxRetryAfter: NumberOfMilliseconds;
116
143
  }
117
144
  export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers' | 'baseUrl' | 'url'> {
118
145
  /**
@@ -265,7 +292,9 @@ export interface FetcherOptions {
265
292
  overrideFetchFn?: FetchFunction;
266
293
  /**
267
294
  * Default to true.
268
- * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
295
+ * Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
296
+ * Retries still apply as usual.
297
+ * Non-http errors (network failure, timeout, body parse error) are still thrown.
269
298
  */
270
299
  throwHttpErrors?: boolean;
271
300
  /**
@@ -318,7 +347,7 @@ export interface FetcherErrorResponse<BODY = unknown> {
318
347
  signature: string;
319
348
  }
320
349
  export type FetcherResponse<BODY = unknown> = FetcherSuccessResponse<BODY> | FetcherErrorResponse<BODY>;
321
- export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'blob' | 'readableStream';
350
+ export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'bytes' | 'blob' | 'readableStream';
322
351
  /**
323
352
  * Signature for the `fetch` function.
324
353
  * Used to be able to override and provide a different implementation,
@@ -7,6 +7,11 @@ import type { DeferredPromise } from './pDefer.js';
7
7
  * If value is instanceof Error - rejects the Promise instead of resolving.
8
8
  */
9
9
  export declare function pDelay<T>(ms?: NumberOfMilliseconds, value?: T): Promise<T>;
10
+ /**
11
+ * Like pDelay, but also resolves early (without an error) as soon as
12
+ * the passed AbortSignal aborts.
13
+ */
14
+ export declare function pDelaySignal(ms: NumberOfMilliseconds, signal?: AbortSignal): Promise<void>;
10
15
  /**
11
16
  * Promisified version of setTimeout.
12
17
  *
@@ -8,6 +8,23 @@ import { pDefer } from './pDefer.js';
8
8
  export async function pDelay(ms = 0, value) {
9
9
  return await new Promise((resolve, reject) => setTimeout(value instanceof Error ? reject : resolve, ms, value));
10
10
  }
11
+ /**
12
+ * Like pDelay, but also resolves early (without an error) as soon as
13
+ * the passed AbortSignal aborts.
14
+ */
15
+ export async function pDelaySignal(ms, signal) {
16
+ if (signal?.aborted)
17
+ return;
18
+ await new Promise(resolve => {
19
+ const timer = setTimeout(done, ms);
20
+ function done() {
21
+ clearTimeout(timer);
22
+ signal?.removeEventListener('abort', done);
23
+ resolve();
24
+ }
25
+ signal?.addEventListener('abort', done);
26
+ });
27
+ }
11
28
  /* oxlint-disable @typescript-eslint/promise-function-async */
12
29
  /**
13
30
  * Promisified version of setTimeout.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
3
  "type": "module",
4
- "version": "15.81.0",
4
+ "version": "15.82.0",
5
5
  "dependencies": {
6
6
  "tslib": "^2"
7
7
  },
@@ -17,7 +17,7 @@
17
17
  "@types/crypto-js": "^4",
18
18
  "@types/node": "^26",
19
19
  "@types/semver": "^7",
20
- "typescript": "rc",
20
+ "typescript": "^7",
21
21
  "crypto-js": "^4",
22
22
  "dayjs": "^1",
23
23
  "@naturalcycles/dev-lib": "0.0.0"
@@ -47,6 +47,18 @@ export type FetcherBeforeRetryHook = <BODY = unknown>(
47
47
  * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
48
48
  */
49
49
  export type FetcherOnErrorHook = (err: Error) => Promisable<void>
50
+ /**
51
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
52
+ * All requests wait for the init hooks to complete, their result is cached,
53
+ * so they run at most once.
54
+ * If a hook throws - the error is re-thrown to the caller of the request,
55
+ * and init hooks are re-attempted on the next request.
56
+ *
57
+ * Receives the Fetcher's normalized cfg, which is allowed (and expected)
58
+ * to be mutated. Allows to do async initialization, e.g fetch an auth token
59
+ * and set `cfg.init.headers` based on it.
60
+ */
61
+ export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable<void>
50
62
 
51
63
  /**
52
64
  * FetcherCfg: configuration of the Fetcher instance. One per instance.
@@ -71,6 +83,11 @@ export interface FetcherCfg {
71
83
  * If you throw an error from the hook - it will be re-thrown as-is.
72
84
  */
73
85
  hooks?: {
86
+ /**
87
+ * Runs lazily, once per Fetcher instance, before the first request.
88
+ * See FetcherInitHook docs.
89
+ */
90
+ init?: FetcherInitHook[]
74
91
  /**
75
92
  * Allows to mutate req.
76
93
  */
@@ -155,6 +172,16 @@ export interface FetcherRetryOptions {
155
172
  timeout: NumberOfMilliseconds
156
173
  timeoutMax: NumberOfMilliseconds
157
174
  timeoutMultiplier: number
175
+ /**
176
+ * Upper limit for the server-indicated delay (`retry-after` and similar headers).
177
+ * Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
178
+ * which only limits the exponential backoff).
179
+ * If the server indicates a delay larger than this - the retry is NOT attempted,
180
+ * and the error is returned/thrown right away.
181
+ *
182
+ * Defaults to 10 minutes.
183
+ */
184
+ maxRetryAfter: NumberOfMilliseconds
158
185
  }
159
186
 
160
187
  export interface FetcherRequest extends Omit<
@@ -336,7 +363,9 @@ export interface FetcherOptions {
336
363
 
337
364
  /**
338
365
  * Default to true.
339
- * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
366
+ * Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
367
+ * Retries still apply as usual.
368
+ * Non-http errors (network failure, timeout, body parse error) are still thrown.
340
369
  */
341
370
  throwHttpErrors?: boolean
342
371
 
@@ -404,6 +433,7 @@ export type FetcherResponseType =
404
433
  | 'text'
405
434
  | 'void'
406
435
  | 'arrayBuffer'
436
+ | 'bytes'
407
437
  | 'blob'
408
438
  | 'readableStream'
409
439
 
@@ -26,7 +26,7 @@ import {
26
26
  _omit,
27
27
  _pick,
28
28
  } from '../object/object.util.js'
29
- import { pDelay } from '../promise/pDelay.js'
29
+ import { pDelaySignal } from '../promise/pDelay.js'
30
30
  import { pTimeout } from '../promise/pTimeout.js'
31
31
  import { _toUrlOrNull } from '../string/index.js'
32
32
  import { _jsonParse, _jsonParseIfPossible } from '../string/json.util.js'
@@ -43,6 +43,7 @@ import type {
43
43
  FetcherBeforeRetryHook,
44
44
  FetcherCfg,
45
45
  FetcherGraphQLOptions,
46
+ FetcherInitHook,
46
47
  FetcherNormalizedCfg,
47
48
  FetcherOnErrorHook,
48
49
  FetcherOptions,
@@ -50,6 +51,7 @@ import type {
50
51
  FetcherResponse,
51
52
  FetcherResponseType,
52
53
  FetcherRetryOptions,
54
+ FetcherSuccessResponse,
53
55
  FetchFunction,
54
56
  GraphQLResponse,
55
57
  RequestInitNormalized,
@@ -68,7 +70,7 @@ export class Fetcher {
68
70
  *
69
71
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
70
72
  */
71
- static readonly VERSION = 4
73
+ static readonly VERSION = 5
72
74
  /**
73
75
  * userAgent is statically exposed as Fetcher.userAgent.
74
76
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -96,7 +98,9 @@ export class Fetcher {
96
98
  })
97
99
  }
98
100
 
99
- if (method === 'HEAD') return // responseType=text
101
+ if (method === 'HEAD') {
102
+ continue // responseType=text
103
+ }
100
104
  ;(this as any)[`${m}Text`] = async (url: string, opt?: FetcherOptions): Promise<string> => {
101
105
  return await this.fetch<string>({
102
106
  url,
@@ -141,8 +145,19 @@ export class Fetcher {
141
145
  return this
142
146
  }
143
147
 
148
+ /**
149
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
150
+ * See FetcherInitHook docs.
151
+ */
152
+ onInit(hook: FetcherInitHook): this {
153
+ ;(this.cfg.hooks.init ||= []).push(hook)
154
+ return this
155
+ }
156
+
144
157
  cfg: FetcherNormalizedCfg
145
158
 
159
+ private initPromise?: Promise<void>
160
+
146
161
  static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
147
162
  return new Fetcher(cfg)
148
163
  }
@@ -178,10 +193,9 @@ export class Fetcher {
178
193
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
179
194
  *
180
195
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
181
- *
182
- * @experimental
183
196
  */
184
197
  async queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T> {
198
+ opt = { ...opt } // avoid mutating the input options
185
199
  opt.method ||= this.cfg.init.method // defaults to GET
186
200
 
187
201
  const payload: AnyObject = _filterFalsyValues({
@@ -236,6 +250,18 @@ export class Fetcher {
236
250
  return data
237
251
  }
238
252
 
253
+ // responseType=bytes
254
+ /**
255
+ * Returns response body as Uint8Array.
256
+ */
257
+ async getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array> {
258
+ return await this.fetch({
259
+ url,
260
+ responseType: 'bytes',
261
+ ...opt,
262
+ })
263
+ }
264
+
239
265
  // responseType=readableStream
240
266
  /**
241
267
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
@@ -255,11 +281,30 @@ export class Fetcher {
255
281
  }
256
282
 
257
283
  async fetch<T = unknown>(opt: FetcherOptions): Promise<T> {
284
+ const res = await this.doFetch<T>(opt)
285
+ if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
286
+ // With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
287
+ // Other errors (network failure, timeout, body parsing) are still thrown.
288
+ throw res.err
289
+ }
290
+ return res.body as T
291
+ }
292
+
293
+ /**
294
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
295
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
296
+ * while still throwing on errors (unlike `doFetch`).
297
+ *
298
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
299
+ * otherwise the returned FetcherSuccessResponse type would lie.
300
+ * Use `doFetch` if you don't want throwing.
301
+ */
302
+ async fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>> {
258
303
  const res = await this.doFetch<T>(opt)
259
304
  if (res.err) {
260
305
  throw res.err
261
306
  }
262
- return res.body
307
+ return res
263
308
  }
264
309
 
265
310
  /**
@@ -298,11 +343,22 @@ export class Fetcher {
298
343
  /**
299
344
  * Returns FetcherResponse.
300
345
  * Never throws, returns `err` property in the response instead.
346
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
301
347
  * Use this method instead of `throwHttpErrors: false` or try-catching.
302
348
  *
303
- * Note: responseType defaults to `void`, so, override it if you expect different.
349
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
304
350
  */
305
351
  async doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>> {
352
+ if (this.cfg.hooks.init) {
353
+ try {
354
+ await (this.initPromise ??= this.runInitHooks())
355
+ } catch (err) {
356
+ // Reset, so init hooks are re-attempted on the next request
357
+ this.initPromise = undefined
358
+ throw err
359
+ }
360
+ }
361
+
306
362
  const req = this.normalizeOptions(opt)
307
363
  const { logger } = this.cfg
308
364
  const {
@@ -331,6 +387,7 @@ export class Fetcher {
331
387
 
332
388
  while (!res.retryStatus.retryStopped) {
333
389
  req.started = Date.now() as UnixTimestampMillis
390
+ res.body = undefined
334
391
 
335
392
  req.init.signal = abortSignalAnyOrUndefined([
336
393
  abortSignalTimeoutOrUndefined(timeoutMillis),
@@ -350,10 +407,10 @@ export class Fetcher {
350
407
  }
351
408
 
352
409
  try {
353
- res.fetchResponse = await (this.cfg.overrideFetchFn || Fetcher.callNativeFetch)(
410
+ res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(
354
411
  req.fullUrl,
355
412
  req.init,
356
- this.cfg.fetchFn,
413
+ req.fetchFn,
357
414
  )
358
415
  res.ok = res.fetchResponse.ok
359
416
  // important to set it to undefined, otherwise it can keep the previous value (from previous try)
@@ -370,14 +427,15 @@ export class Fetcher {
370
427
  res.statusFamily = this.getStatusFamily(res)
371
428
  res.statusCode = res.fetchResponse?.status
372
429
 
373
- if (res.fetchResponse?.ok || !req.throwHttpErrors) {
430
+ if (res.fetchResponse?.ok) {
374
431
  try {
375
432
  // We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
376
433
  await pTimeout(
377
434
  async () =>
378
435
  await this.onOkResponse(res as FetcherResponse<T> & { fetchResponse: Response }),
379
436
  {
380
- timeout: timeoutMillis || Number.POSITIVE_INFINITY,
437
+ // 0 means "no timeout" for pTimeout
438
+ timeout: timeoutMillis ?? 0,
381
439
  name: 'Fetcher.downloadBody',
382
440
  },
383
441
  )
@@ -414,6 +472,12 @@ export class Fetcher {
414
472
  return res
415
473
  }
416
474
 
475
+ private async runInitHooks(): Promise<void> {
476
+ for (const hook of this.cfg.hooks.init || []) {
477
+ await hook(this.cfg)
478
+ }
479
+ }
480
+
417
481
  private async onOkResponse(
418
482
  res: FetcherResponse<any> & { fetchResponse: Response },
419
483
  ): Promise<void> {
@@ -441,9 +505,17 @@ export class Fetcher {
441
505
  } else if (responseType === 'text') {
442
506
  res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
443
507
  } else if (responseType === 'arrayBuffer') {
444
- res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : {}
508
+ res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0)
509
+ } else if (responseType === 'bytes') {
510
+ // Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
511
+ res.body = res.fetchResponse.body
512
+ ? new Uint8Array(await res.fetchResponse.arrayBuffer())
513
+ : new Uint8Array()
445
514
  } else if (responseType === 'blob') {
446
- res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : {}
515
+ res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob()
516
+ } else if (responseType === 'void') {
517
+ // Cancel the body (without downloading it), to free the underlying connection for reuse
518
+ await res.fetchResponse.body?.cancel()
447
519
  } else if (responseType === 'readableStream') {
448
520
  res.body = res.fetchResponse.body
449
521
 
@@ -455,8 +527,7 @@ export class Fetcher {
455
527
 
456
528
  res.retryStatus.retryStopped = true
457
529
 
458
- // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
459
- if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
530
+ if (req.logResponse) {
460
531
  const { retryAttempt } = res.retryStatus
461
532
  const { logger } = this.cfg
462
533
  logger.log(
@@ -596,16 +667,33 @@ export class Fetcher {
596
667
  if (retryStatus.retryStopped) return
597
668
 
598
669
  retryStatus.retryAttempt++
670
+ const timeout = this.getRetryTimeout(res)
671
+ if (timeout === null) {
672
+ this.cfg.logger.warn(
673
+ `${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`,
674
+ )
675
+ retryStatus.retryStopped = true
676
+ return
677
+ }
678
+ // Increase the timeout for the next possible retry
599
679
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
600
680
 
601
- const timeout = this.getRetryTimeout(res)
602
681
  if (res.req.debug) {
603
682
  this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`)
604
683
  }
605
- await pDelay(timeout)
684
+ await pDelaySignal(timeout, res.req.signal)
685
+ if (res.req.signal?.aborted) {
686
+ // Aborted while waiting for the retry - stop, without issuing another request
687
+ retryStatus.retryStopped = true
688
+ }
606
689
  }
607
690
 
608
- private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds {
691
+ /**
692
+ * Returns the delay before the next retry attempt.
693
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
694
+ * meaning the retry should not be attempted at all.
695
+ */
696
+ private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds | null {
609
697
  let timeout: NumberOfMilliseconds = 0
610
698
 
611
699
  // Handling http 429 with specific retry headers
@@ -615,12 +703,19 @@ export class Fetcher {
615
703
  res.fetchResponse.headers.get('retry-after') ??
616
704
  res.fetchResponse.headers.get('x-ratelimit-reset')
617
705
  if (retryAfterStr) {
618
- if (Number(retryAfterStr)) {
619
- timeout = Number(retryAfterStr) * 1000
706
+ const retryAfterNumber = Number(retryAfterStr)
707
+ if (retryAfterNumber) {
708
+ if (retryAfterNumber > 10 ** 9) {
709
+ // Value is too large to be a "seconds from now" delay,
710
+ // treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
711
+ timeout = retryAfterNumber * 1000 - Date.now()
712
+ } else {
713
+ timeout = retryAfterNumber * 1000
714
+ }
620
715
  } else {
621
716
  const date = new Date(retryAfterStr)
622
- if (!Number.isNaN(date)) {
623
- timeout = Number(date) - Date.now()
717
+ if (!Number.isNaN(date.getTime())) {
718
+ timeout = date.getTime() - Date.now()
624
719
  }
625
720
  }
626
721
 
@@ -631,12 +726,16 @@ export class Fetcher {
631
726
  }
632
727
  }
633
728
 
634
- if (!timeout) {
635
- const noise = Math.random() * 500
636
- timeout = res.retryStatus.retryTimeout + noise
729
+ if (timeout) {
730
+ if (timeout > res.req.retry.maxRetryAfter) {
731
+ return null
732
+ }
733
+ // Server-indicated delay is honored as-is (a negative value means "retry now")
734
+ return Math.max(0, timeout)
637
735
  }
638
736
 
639
- return timeout
737
+ const noise = Math.random() * 500
738
+ return res.retryStatus.retryTimeout + noise
640
739
  }
641
740
 
642
741
  /**
@@ -775,6 +874,7 @@ export class Fetcher {
775
874
  ..._pick(this.cfg, [
776
875
  'timeoutSeconds',
777
876
  'retryPost',
877
+ 'retry3xx',
778
878
  'retry4xx',
779
879
  'retry5xx',
780
880
  'responseType',
@@ -786,6 +886,8 @@ export class Fetcher {
786
886
  'debug',
787
887
  'throwHttpErrors',
788
888
  'errorData',
889
+ 'fetchFn',
890
+ 'overrideFetchFn',
789
891
  ]),
790
892
  started: Date.now() as UnixTimestampMillis,
791
893
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -839,18 +941,19 @@ export class Fetcher {
839
941
 
840
942
  // setup request body
841
943
  // Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
944
+ // Explicitly passed `content-type` header always wins
842
945
  if (opt.json !== undefined) {
843
946
  req.init.body = JSON.stringify(opt.json)
844
- req.init.headers['content-type'] = 'application/json'
947
+ req.init.headers['content-type'] ||= 'application/json'
845
948
  } else if (opt.text !== undefined) {
846
949
  req.init.body = opt.text
847
- req.init.headers['content-type'] = 'text/plain'
950
+ req.init.headers['content-type'] ||= 'text/plain'
848
951
  } else if (opt.form) {
849
952
  if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
850
953
  req.init.body = opt.form
851
954
  } else {
852
955
  req.init.body = new URLSearchParams(opt.form)
853
- req.init.headers['content-type'] = 'application/x-www-form-urlencoded'
956
+ req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded'
854
957
  }
855
958
  } else if (opt.body !== undefined) {
856
959
  req.init.body = opt.body
@@ -873,6 +976,7 @@ const acceptByResponseType: Record<FetcherResponseType, string> = {
873
976
  void: '*/*',
874
977
  readableStream: 'application/octet-stream',
875
978
  arrayBuffer: 'application/octet-stream',
979
+ bytes: 'application/octet-stream',
876
980
  blob: 'application/octet-stream',
877
981
  }
878
982
 
@@ -881,4 +985,5 @@ const defaultRetryOptions: FetcherRetryOptions = {
881
985
  timeout: 1000,
882
986
  timeoutMax: 30_000,
883
987
  timeoutMultiplier: 2,
988
+ maxRetryAfter: 600_000, // 10 minutes
884
989
  }
@@ -14,6 +14,26 @@ export async function pDelay<T>(ms: NumberOfMilliseconds = 0, value?: T): Promis
14
14
  )
15
15
  }
16
16
 
17
+ /**
18
+ * Like pDelay, but also resolves early (without an error) as soon as
19
+ * the passed AbortSignal aborts.
20
+ */
21
+ export async function pDelaySignal(ms: NumberOfMilliseconds, signal?: AbortSignal): Promise<void> {
22
+ if (signal?.aborted) return
23
+
24
+ await new Promise<void>(resolve => {
25
+ const timer = setTimeout(done, ms)
26
+
27
+ function done(): void {
28
+ clearTimeout(timer)
29
+ signal?.removeEventListener('abort', done)
30
+ resolve()
31
+ }
32
+
33
+ signal?.addEventListener('abort', done)
34
+ })
35
+ }
36
+
17
37
  /* oxlint-disable @typescript-eslint/promise-function-async */
18
38
 
19
39
  /**