@naturalcycles/js-lib 15.82.0 → 15.84.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.
@@ -1,6 +1,7 @@
1
1
  import type { CommonLogger } from '../log/commonLogger.js';
2
2
  import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js';
3
- import type { AsyncMemoCache, MethodDecorator } from './memo.util.js';
3
+ import type { MethodDecorator } from './decorator.util.js';
4
+ import type { AsyncMemoCache } from './memo.util.js';
4
5
  export interface AsyncMemoOptions<FN> {
5
6
  /**
6
7
  * Provide a custom implementation of AsyncMemoCache.
@@ -1,31 +1,101 @@
1
- import type { AnyFunction } from '../types.js';
1
+ import type { AnyAsyncFunction, AnyFunction } from '../types.js';
2
+ export declare function _debounce<T extends AnyFunction>(func: T, wait: number, opt?: DebounceOptions): T & Cancelable;
3
+ export declare function _throttle<T extends AnyFunction>(func: T, wait: number, opt?: ThrottleOptions): T & Cancelable;
4
+ /**
5
+ * Like `_debounce`, but for async functions.
6
+ *
7
+ * Unlike `_debounce` (which returns a stale `result`/`undefined` for suppressed calls),
8
+ * `_asyncDebounce` always returns a real Promise, resolving with the coalesced invocation's result.
9
+ *
10
+ * @experimental
11
+ */
12
+ export declare function _asyncDebounce<T extends AnyAsyncFunction>(func: T, wait: number, opt?: AsyncDebounceOptions): AsyncDebounced<T>;
13
+ /**
14
+ * Like `_throttle`, but for async functions. See `_asyncDebounce` for the Promise semantics.
15
+ *
16
+ * @experimental
17
+ */
18
+ export declare function _asyncThrottle<T extends AnyAsyncFunction>(func: T, wait: number, opt?: AsyncThrottleOptions): AsyncDebounced<T>;
2
19
  export interface Cancelable {
3
20
  cancel: () => void;
4
21
  flush: () => void;
5
22
  }
23
+ /**
24
+ * Like `Cancelable`, but for the async debounced functions: `flush()` resolves with the flushed
25
+ * invocation's result (or `undefined` if nothing was pending), and `pending()` is exposed.
26
+ */
27
+ export interface AsyncCancelable<T> {
28
+ cancel: () => void;
29
+ flush: () => Promise<T | undefined>;
30
+ pending: () => boolean;
31
+ }
6
32
  export interface ThrottleOptions {
7
33
  /**
34
+ * Invoke on the leading edge of the window (immediately, on the first call).
35
+ *
8
36
  * @default true
9
37
  */
10
38
  leading?: boolean;
11
39
  /**
40
+ * Invoke on the trailing edge of the window (after `wait` has elapsed).
41
+ *
12
42
  * @default true
13
43
  */
14
44
  trailing?: boolean;
15
45
  }
16
46
  export interface DebounceOptions {
17
47
  /**
48
+ * Invoke on the leading edge of the window (immediately, on the first call).
49
+ *
18
50
  * @default false
19
51
  */
20
52
  leading?: boolean;
21
53
  /**
54
+ * Invoke on the trailing edge of the window (after `wait` has elapsed).
55
+ *
56
+ * @default true
57
+ */
58
+ trailing?: boolean;
59
+ /**
60
+ * Maximum time `func` is allowed to be delayed before it's forcibly invoked.
61
+ */
62
+ maxWait?: number;
63
+ }
64
+ export interface AsyncThrottleOptions {
65
+ /**
66
+ * Invoke on the leading edge of the window (immediately, on the first call).
67
+ *
68
+ * @default true
69
+ */
70
+ leading?: boolean;
71
+ /**
72
+ * Invoke on the trailing edge of the window (after `wait` has elapsed).
73
+ *
74
+ * When `false`, calls dropped within the window (not served by a leading invocation) resolve
75
+ * with `undefined`. There's no invocation for them to await. This is why the returned function
76
+ * can resolve with `undefined` even though the original function's return type doesn't express that.
77
+ *
22
78
  * @default true
23
79
  */
24
80
  trailing?: boolean;
81
+ }
82
+ export interface AsyncDebounceOptions extends AsyncThrottleOptions {
25
83
  /**
84
+ * Invoke on the leading edge of the window (immediately, on the first call).
26
85
  *
86
+ * @default false
87
+ */
88
+ leading?: boolean;
89
+ /**
90
+ * Maximum time `func` is allowed to be delayed before it's forcibly invoked.
27
91
  */
28
92
  maxWait?: number;
29
93
  }
30
- export declare function _debounce<T extends AnyFunction>(func: T, wait: number, opt?: DebounceOptions): T & Cancelable;
31
- export declare function _throttle<T extends AnyFunction>(func: T, wait: number, opt?: ThrottleOptions): T & Cancelable;
94
+ /**
95
+ * The function returned by `_asyncDebounce`/`_asyncThrottle`. Same signature as `T`, but resolves
96
+ * `Awaited<ReturnType<T>> | undefined`
97
+ *
98
+ * This is because some configurations (e.g. `trailing: false`) may drop calls without invoking
99
+ * the original function, so there's no result to await.
100
+ */
101
+ export type AsyncDebounced<T extends AnyAsyncFunction> = ((this: ThisParameterType<T>, ...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>) & AsyncCancelable<Awaited<ReturnType<T>>>;
@@ -1,3 +1,23 @@
1
- import type { DebounceOptions, ThrottleOptions } from './debounce.js';
2
- export declare function _Debounce(wait: number, opt?: DebounceOptions): MethodDecorator;
3
- export declare function _Throttle(wait: number, opt?: ThrottleOptions): MethodDecorator;
1
+ import type { AnyAsyncFunction, AnyFunction } from '../types.js';
2
+ import type { AsyncDebounceOptions, AsyncThrottleOptions, DebounceOptions, ThrottleOptions } from './debounce.js';
3
+ import type { MethodDecorator } from './decorator.util.js';
4
+ export declare function _Debounce<T extends AnyFunction>(wait: number, opt?: DebounceOptions): MethodDecorator<T>;
5
+ export declare function _Throttle<T extends AnyFunction>(wait: number, opt?: ThrottleOptions): MethodDecorator<T>;
6
+ /**
7
+ * Like `@_Debounce`, but for async methods: every call returns a real Promise resolving with the
8
+ * coalesced invocation's result, so `await`-ing never yields a stale value or a non-promise
9
+ *
10
+ * Be aware that the decorated method may resolve `undefined` if the config yields no invocation,
11
+ * which the method's `T` type can't express.
12
+ *
13
+ * @experimental
14
+ */
15
+ export declare function _AsyncDebounce<T extends AnyAsyncFunction>(wait: number, opt?: AsyncDebounceOptions): MethodDecorator<T>;
16
+ /**
17
+ * Like `@_Throttle`, but for async methods.
18
+ *
19
+ * @see {@link _AsyncDebounce}
20
+ *
21
+ * @experimental
22
+ */
23
+ export declare function _AsyncThrottle<T extends AnyAsyncFunction>(wait: number, opt?: AsyncThrottleOptions): MethodDecorator<T>;
@@ -1,4 +1,4 @@
1
- import { _debounce, _throttle } from './debounce.js';
1
+ import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js';
2
2
  export function _Debounce(wait, opt = {}) {
3
3
  return (_target, _key, descriptor) => {
4
4
  const originalFn = descriptor.value;
@@ -13,3 +13,33 @@ export function _Throttle(wait, opt = {}) {
13
13
  return descriptor;
14
14
  };
15
15
  }
16
+ /**
17
+ * Like `@_Debounce`, but for async methods: every call returns a real Promise resolving with the
18
+ * coalesced invocation's result, so `await`-ing never yields a stale value or a non-promise
19
+ *
20
+ * Be aware that the decorated method may resolve `undefined` if the config yields no invocation,
21
+ * which the method's `T` type can't express.
22
+ *
23
+ * @experimental
24
+ */
25
+ export function _AsyncDebounce(wait, opt = {}) {
26
+ return (_target, _key, descriptor) => {
27
+ const originalFn = descriptor.value;
28
+ descriptor.value = _asyncDebounce(originalFn, wait, opt);
29
+ return descriptor;
30
+ };
31
+ }
32
+ /**
33
+ * Like `@_Throttle`, but for async methods.
34
+ *
35
+ * @see {@link _AsyncDebounce}
36
+ *
37
+ * @experimental
38
+ */
39
+ export function _AsyncThrottle(wait, opt = {}) {
40
+ return (_target, _key, descriptor) => {
41
+ const originalFn = descriptor.value;
42
+ descriptor.value = _asyncThrottle(originalFn, wait, opt);
43
+ return descriptor;
44
+ };
45
+ }
@@ -1,59 +1,43 @@
1
+ import { _anyToError } from '../error/error.util.js';
2
+ import { pDefer } from '../promise/pDefer.js';
1
3
  export function _debounce(func, wait, opt = {}) {
2
4
  let lastArgs;
3
5
  let lastThis;
4
6
  let result;
5
7
  let timerId;
6
- let lastCallTime;
7
- let lastInvokeTime = 0;
8
8
  const maxing = 'maxWait' in opt;
9
9
  const { leading = false, trailing = true } = opt;
10
- const maxWait = maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait;
10
+ const state = {
11
+ lastCallTime: undefined,
12
+ lastInvokeTime: 0,
13
+ wait,
14
+ maxing,
15
+ maxWait: maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait,
16
+ };
11
17
  function invokeFunc(time) {
12
18
  const args = lastArgs;
13
19
  const thisArg = lastThis;
14
20
  lastArgs = lastThis = undefined;
15
- lastInvokeTime = time;
21
+ state.lastInvokeTime = time;
16
22
  result = func.apply(thisArg, args);
17
23
  return result;
18
24
  }
19
- function startTimer(pendingFunc, wait) {
20
- return setTimeout(pendingFunc, wait);
21
- }
22
- function cancelTimer(id) {
23
- clearTimeout(id);
24
- }
25
25
  function leadingEdge(time) {
26
26
  // Reset any `maxWait` timer.
27
- lastInvokeTime = time;
27
+ state.lastInvokeTime = time;
28
28
  // Start the timer for the trailing edge.
29
- timerId = startTimer(timerExpired, wait);
29
+ timerId = startTimer(timerExpired, state.wait);
30
30
  // Invoke the leading edge.
31
31
  return leading ? invokeFunc(time) : result;
32
32
  }
33
- function remainingWait(time) {
34
- const timeSinceLastCall = time - lastCallTime;
35
- const timeSinceLastInvoke = time - lastInvokeTime;
36
- const timeWaiting = wait - timeSinceLastCall;
37
- return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting;
38
- }
39
- function shouldInvoke(time) {
40
- const timeSinceLastCall = time - lastCallTime;
41
- const timeSinceLastInvoke = time - lastInvokeTime;
42
- // Either this is the first call, activity has stopped and we're at the
43
- // trailing edge, the system time has gone backwards and we're treating
44
- // it as the trailing edge, or we've hit the `maxWait` limit.
45
- return (lastCallTime === undefined ||
46
- timeSinceLastCall >= wait ||
47
- timeSinceLastCall < 0 ||
48
- (maxing && timeSinceLastInvoke >= maxWait));
49
- }
50
33
  function timerExpired() {
51
34
  const time = Date.now();
52
- if (shouldInvoke(time)) {
53
- return trailingEdge(time);
35
+ if (shouldInvoke(time, state)) {
36
+ trailingEdge(time);
37
+ return;
54
38
  }
55
39
  // Restart the timer.
56
- timerId = startTimer(timerExpired, remainingWait(time));
40
+ timerId = startTimer(timerExpired, remainingWait(time, state));
57
41
  }
58
42
  function trailingEdge(time) {
59
43
  timerId = undefined;
@@ -69,8 +53,9 @@ export function _debounce(func, wait, opt = {}) {
69
53
  if (timerId !== undefined) {
70
54
  cancelTimer(timerId);
71
55
  }
72
- lastInvokeTime = 0;
73
- lastArgs = lastCallTime = lastThis = timerId = undefined;
56
+ state.lastInvokeTime = 0;
57
+ state.lastCallTime = undefined;
58
+ lastArgs = lastThis = timerId = undefined;
74
59
  }
75
60
  function flush() {
76
61
  return timerId === undefined ? result : trailingEdge(Date.now());
@@ -80,22 +65,22 @@ export function _debounce(func, wait, opt = {}) {
80
65
  }
81
66
  function debounced(...args) {
82
67
  const time = Date.now();
83
- const isInvoking = shouldInvoke(time);
68
+ const isInvoking = shouldInvoke(time, state);
84
69
  lastArgs = args;
85
70
  lastThis = this;
86
- lastCallTime = time;
71
+ state.lastCallTime = time;
87
72
  if (isInvoking) {
88
73
  if (timerId === undefined) {
89
- return leadingEdge(lastCallTime);
74
+ return leadingEdge(state.lastCallTime);
90
75
  }
91
- if (maxing) {
76
+ if (state.maxing) {
92
77
  // Handle invocations in a tight loop.
93
- timerId = startTimer(timerExpired, wait);
94
- return invokeFunc(lastCallTime);
78
+ timerId = startTimer(timerExpired, state.wait);
79
+ return invokeFunc(state.lastCallTime);
95
80
  }
96
81
  }
97
82
  if (timerId === undefined) {
98
- timerId = startTimer(timerExpired, wait);
83
+ timerId = startTimer(timerExpired, state.wait);
99
84
  }
100
85
  return result;
101
86
  }
@@ -112,3 +97,174 @@ export function _throttle(func, wait, opt = {}) {
112
97
  maxWait: wait,
113
98
  });
114
99
  }
100
+ /**
101
+ * Like `_debounce`, but for async functions.
102
+ *
103
+ * Unlike `_debounce` (which returns a stale `result`/`undefined` for suppressed calls),
104
+ * `_asyncDebounce` always returns a real Promise, resolving with the coalesced invocation's result.
105
+ *
106
+ * @experimental
107
+ */
108
+ export function _asyncDebounce(func, wait, opt = {}) {
109
+ let lastArgs;
110
+ let lastThis;
111
+ let timerId;
112
+ // Promise shared by all calls coalesced into the next invocation (undefined when none pending).
113
+ let deferred;
114
+ const { leading = false, trailing = true } = opt;
115
+ const maxing = 'maxWait' in opt;
116
+ const maxWait = maxing ? Math.max(Number(opt.maxWait) || 0, wait) : opt.maxWait;
117
+ const state = {
118
+ lastCallTime: undefined,
119
+ lastInvokeTime: 0,
120
+ wait,
121
+ maxing,
122
+ maxWait,
123
+ };
124
+ async function invokeFunc(time) {
125
+ const args = lastArgs;
126
+ const thisArg = lastThis;
127
+ lastArgs = lastThis = undefined;
128
+ state.lastInvokeTime = time;
129
+ // Detach and settle the current batch.
130
+ const d = deferred;
131
+ deferred = undefined;
132
+ try {
133
+ const result = await func.apply(thisArg, args);
134
+ d.resolve(result);
135
+ }
136
+ catch (err) {
137
+ d.reject(_anyToError(err));
138
+ }
139
+ }
140
+ async function leadingEdge(time) {
141
+ // Reset any `maxWait` timer.
142
+ state.lastInvokeTime = time;
143
+ // Start the timer for the trailing edge.
144
+ timerId = startTimer(timerExpired, state.wait);
145
+ // Capture the batch before `invokeFunc` detaches it.
146
+ const d = deferred;
147
+ // Invoke the leading edge.
148
+ if (leading) {
149
+ await invokeFunc(time);
150
+ }
151
+ return d;
152
+ }
153
+ function timerExpired() {
154
+ const time = Date.now();
155
+ if (shouldInvoke(time, state)) {
156
+ void trailingEdge(time);
157
+ return;
158
+ }
159
+ // Restart the timer.
160
+ timerId = startTimer(timerExpired, remainingWait(time, state));
161
+ }
162
+ async function trailingEdge(time) {
163
+ timerId = undefined;
164
+ // Only invoke if we have `lastArgs` which means `func` has been
165
+ // debounced at least once.
166
+ if (trailing && lastArgs && deferred) {
167
+ await invokeFunc(time);
168
+ return;
169
+ }
170
+ lastArgs = lastThis = undefined;
171
+ // Dropped batch (only reachable with `trailing: false`): no invocation, so resolve `undefined`.
172
+ if (deferred) {
173
+ const d = deferred;
174
+ deferred = undefined;
175
+ d.resolve(undefined);
176
+ }
177
+ }
178
+ function cancel() {
179
+ if (timerId !== undefined) {
180
+ cancelTimer(timerId);
181
+ }
182
+ state.lastInvokeTime = 0;
183
+ state.lastCallTime = undefined;
184
+ lastArgs = lastThis = timerId = undefined;
185
+ // Reject pending awaiters.
186
+ if (deferred) {
187
+ const d = deferred;
188
+ deferred = undefined;
189
+ d.reject(new Error('asyncDebounce cancelled'));
190
+ }
191
+ }
192
+ async function flush() {
193
+ if (timerId === undefined || !deferred) {
194
+ return undefined;
195
+ }
196
+ const d = deferred;
197
+ cancelTimer(timerId);
198
+ await trailingEdge(Date.now());
199
+ return d;
200
+ }
201
+ function pending() {
202
+ return timerId !== undefined;
203
+ }
204
+ async function debounced(...args) {
205
+ const time = Date.now();
206
+ const isInvoking = shouldInvoke(time, state);
207
+ lastArgs = args;
208
+ lastThis = this;
209
+ state.lastCallTime = time;
210
+ deferred ||= pDefer();
211
+ const currentDeferred = deferred;
212
+ if (isInvoking) {
213
+ if (timerId === undefined) {
214
+ return leadingEdge(state.lastCallTime);
215
+ }
216
+ if (state.maxing) {
217
+ // Handle invocations in a tight loop.
218
+ timerId = startTimer(timerExpired, state.wait);
219
+ await invokeFunc(state.lastCallTime);
220
+ return currentDeferred;
221
+ }
222
+ }
223
+ if (timerId === undefined) {
224
+ timerId = startTimer(timerExpired, state.wait);
225
+ }
226
+ return currentDeferred;
227
+ }
228
+ debounced.cancel = cancel;
229
+ debounced.flush = flush;
230
+ debounced.pending = pending;
231
+ return debounced;
232
+ }
233
+ /**
234
+ * Like `_throttle`, but for async functions. See `_asyncDebounce` for the Promise semantics.
235
+ *
236
+ * @experimental
237
+ */
238
+ export function _asyncThrottle(func, wait, opt = {}) {
239
+ return _asyncDebounce(func, wait, {
240
+ leading: true,
241
+ trailing: true,
242
+ ...opt,
243
+ maxWait: wait,
244
+ });
245
+ }
246
+ function remainingWait(time, state) {
247
+ const { lastCallTime, lastInvokeTime, wait, maxing, maxWait } = state;
248
+ const timeSinceLastCall = time - lastCallTime;
249
+ const timeSinceLastInvoke = time - lastInvokeTime;
250
+ const timeWaiting = wait - timeSinceLastCall;
251
+ return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting;
252
+ }
253
+ function shouldInvoke(time, state) {
254
+ const { lastCallTime, lastInvokeTime, wait, maxing, maxWait } = state;
255
+ const timeSinceLastCall = time - lastCallTime;
256
+ const timeSinceLastInvoke = time - lastInvokeTime;
257
+ // Either this is the first call, activity has stopped and we're at the
258
+ // trailing edge, the system time has gone backwards and we're treating
259
+ // it as the trailing edge, or we've hit the `maxWait` limit.
260
+ return (lastCallTime === undefined ||
261
+ timeSinceLastCall >= wait ||
262
+ timeSinceLastCall < 0 ||
263
+ (maxing && timeSinceLastInvoke >= maxWait));
264
+ }
265
+ function startTimer(pendingFunc, wait) {
266
+ return setTimeout(pendingFunc, wait);
267
+ }
268
+ function cancelTimer(id) {
269
+ clearTimeout(id);
270
+ }
@@ -1,4 +1,10 @@
1
1
  import type { AnyObject } from '../types.js';
2
+ /**
3
+ * Generic override of TypeScript's built-in legacy MethodDecorator, that
4
+ * allows us to infer the parameters of the decorated method from the parameters
5
+ * of a decorator.
6
+ */
7
+ export type MethodDecorator<T> = (target: AnyObject, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | undefined;
2
8
  /**
3
9
  * @returns
4
10
  * e.g `NameOfYourClass.methodName`
@@ -1,6 +1,7 @@
1
1
  import type { CommonLogger } from '../log/commonLogger.js';
2
2
  import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js';
3
- import type { MemoCache, MethodDecorator } from './memo.util.js';
3
+ import type { MethodDecorator } from './decorator.util.js';
4
+ import type { MemoCache } from './memo.util.js';
4
5
  export interface MemoOptions<FN> {
5
6
  /**
6
7
  * Provide a custom implementation of MemoCache.
@@ -1,4 +1,4 @@
1
- import type { AnyObject, UnixTimestamp } from '../types.js';
1
+ import type { UnixTimestamp } from '../types.js';
2
2
  import { MISS } from '../types.js';
3
3
  export type MemoSerializer = (args: any[]) => any;
4
4
  export declare const jsonMemoSerializer: MemoSerializer;
@@ -59,9 +59,3 @@ export declare class MapAsyncMemoCache<KEY = any, VALUE = any> implements AsyncM
59
59
  set(k: KEY, v: VALUE): Promise<void>;
60
60
  clear(): Promise<void>;
61
61
  }
62
- /**
63
- * Generic override of Typescript's built in legacy MethodDecorator, that
64
- * allows us to infer the parameters of the decorated method from the parameters
65
- * of a decorator.
66
- */
67
- export type MethodDecorator<T> = (target: AnyObject, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | undefined;
@@ -35,8 +35,15 @@ export declare class Fetcher {
35
35
  * See FetcherInitHook docs.
36
36
  */
37
37
  onInit(hook: FetcherInitHook): this;
38
+ /**
39
+ * Clears the cached result of the init hooks, so they re-run before the next request.
40
+ * Useful when the state acquired during init (e.g an auth token) becomes stale.
41
+ * See also `cfg.hooks.shouldReinit`, which calls this automatically.
42
+ */
43
+ resetInit(): void;
38
44
  cfg: FetcherNormalizedCfg;
39
45
  private initPromise?;
46
+ private initGeneration;
40
47
  static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
41
48
  get: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
42
49
  post: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
@@ -110,6 +117,12 @@ export declare class Fetcher {
110
117
  * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
111
118
  */
112
119
  doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>>;
120
+ /**
121
+ * Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
122
+ * If detected - resets the init (so it re-runs) and returns true,
123
+ * telling the caller to retry the request. At most once per request.
124
+ */
125
+ private detectStaleInit;
113
126
  private runInitHooks;
114
127
  private onOkResponse;
115
128
  /**
@@ -101,8 +101,18 @@ export class Fetcher {
101
101
  (this.cfg.hooks.init ||= []).push(hook);
102
102
  return this;
103
103
  }
104
+ /**
105
+ * Clears the cached result of the init hooks, so they re-run before the next request.
106
+ * Useful when the state acquired during init (e.g an auth token) becomes stale.
107
+ * See also `cfg.hooks.shouldReinit`, which calls this automatically.
108
+ */
109
+ resetInit() {
110
+ this.initPromise = undefined;
111
+ this.initGeneration++;
112
+ }
104
113
  cfg;
105
114
  initPromise;
115
+ initGeneration = 0;
106
116
  static create(cfg = {}) {
107
117
  return new Fetcher(cfg);
108
118
  }
@@ -271,15 +281,17 @@ export class Fetcher {
271
281
  * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
272
282
  */
273
283
  async doFetch(opt) {
284
+ let initGeneration = 0;
274
285
  if (this.cfg.hooks.init) {
275
286
  try {
276
287
  await (this.initPromise ??= this.runInitHooks());
277
288
  }
278
289
  catch (err) {
279
290
  // Reset, so init hooks are re-attempted on the next request
280
- this.initPromise = undefined;
291
+ this.resetInit();
281
292
  throw err;
282
293
  }
294
+ initGeneration = this.initGeneration;
283
295
  }
284
296
  const req = this.normalizeOptions(opt);
285
297
  const { logger } = this.cfg;
@@ -368,8 +380,31 @@ export class Fetcher {
368
380
  for (const hook of this.cfg.hooks.afterResponse || []) {
369
381
  await hook(res);
370
382
  }
383
+ if (res.err && (await this.detectStaleInit(opt, res, initGeneration))) {
384
+ // Stale init (e.g expired auth token) was detected and reset - retry the request once
385
+ return await this.doFetch({ ...opt, [reinitAttempted]: true });
386
+ }
371
387
  return res;
372
388
  }
389
+ /**
390
+ * Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
391
+ * If detected - resets the init (so it re-runs) and returns true,
392
+ * telling the caller to retry the request. At most once per request.
393
+ */
394
+ async detectStaleInit(opt, res, initGeneration) {
395
+ const { shouldReinit, init } = this.cfg.hooks;
396
+ if (!shouldReinit || !init)
397
+ return false;
398
+ if (opt[reinitAttempted])
399
+ return false;
400
+ if (!(await shouldReinit(res)))
401
+ return false;
402
+ // Generation check prevents concurrent stale requests from resetting the already-fresh init
403
+ if (initGeneration === this.initGeneration) {
404
+ this.resetInit();
405
+ }
406
+ return true;
407
+ }
373
408
  async runInitHooks() {
374
409
  for (const hook of this.cfg.hooks.init || []) {
375
410
  await hook(this.cfg);
@@ -816,6 +851,9 @@ export class Fetcher {
816
851
  export function getFetcher(cfg = {}) {
817
852
  return Fetcher.create(cfg);
818
853
  }
854
+ // Marks FetcherOptions of a request that was already retried due to a reinit,
855
+ // to guarantee at most one reinit per request
856
+ const reinitAttempted = Symbol('reinitAttempted');
819
857
  const acceptByResponseType = {
820
858
  text: 'text/plain',
821
859
  json: 'application/json',
@@ -30,6 +30,15 @@ export type FetcherOnErrorHook = (err: Error) => Promisable<void>;
30
30
  * and set `cfg.init.headers` based on it.
31
31
  */
32
32
  export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable<void>;
33
+ /**
34
+ * Decides, based on the error response, if the Fetcher init state is "stale",
35
+ * e.g the auth token acquired in the init hook has expired.
36
+ * Returning true triggers `resetInit()`, so the init hooks re-run,
37
+ * and the request is then retried (at most once per request).
38
+ *
39
+ * Only consulted when init hooks are defined and the request resulted in an error.
40
+ */
41
+ export type FetcherShouldReinitFn = (res: FetcherErrorResponse) => Promisable<boolean>;
33
42
  /**
34
43
  * FetcherCfg: configuration of the Fetcher instance. One per instance.
35
44
  * FetcherOptions: options for a single request. One per request.
@@ -56,6 +65,13 @@ export interface FetcherCfg {
56
65
  * See FetcherInitHook docs.
57
66
  */
58
67
  init?: FetcherInitHook[];
68
+ /**
69
+ * Allows to detect "stale init" (e.g the auth token acquired in the init hook
70
+ * has expired) based on the error response, and trigger reinit+retry.
71
+ * Unlike other hooks, it's a single decision predicate, not an array.
72
+ * See FetcherShouldReinitFn docs.
73
+ */
74
+ shouldReinit?: FetcherShouldReinitFn;
59
75
  /**
60
76
  * Allows to mutate req.
61
77
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
3
  "type": "module",
4
- "version": "15.82.0",
4
+ "version": "15.84.0",
5
5
  "dependencies": {
6
6
  "tslib": "^2"
7
7
  },
@@ -2,8 +2,9 @@ import { _assert, _assertTypeOf } from '../error/assert.js'
2
2
  import type { CommonLogger } from '../log/commonLogger.js'
3
3
  import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js'
4
4
  import { _objectAssign, MISS } from '../types.js'
5
+ import type { MethodDecorator } from './decorator.util.js'
5
6
  import { _getTargetMethodSignature } from './decorator.util.js'
6
- import type { AsyncMemoCache, MethodDecorator } from './memo.util.js'
7
+ import type { AsyncMemoCache } from './memo.util.js'
7
8
  import { jsonMemoSerializer } from './memo.util.js'
8
9
 
9
10
  export interface AsyncMemoOptions<FN> {