@tanstack/pacer 0.5.0 → 0.7.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.
- package/dist/cjs/async-debouncer.cjs +32 -17
- package/dist/cjs/async-debouncer.cjs.map +1 -1
- package/dist/cjs/async-debouncer.d.cts +51 -9
- package/dist/cjs/async-queuer.cjs +189 -191
- package/dist/cjs/async-queuer.cjs.map +1 -1
- package/dist/cjs/async-queuer.d.cts +151 -94
- package/dist/cjs/async-rate-limiter.cjs +23 -13
- package/dist/cjs/async-rate-limiter.cjs.map +1 -1
- package/dist/cjs/async-rate-limiter.d.cts +54 -5
- package/dist/cjs/async-throttler.cjs +38 -17
- package/dist/cjs/async-throttler.cjs.map +1 -1
- package/dist/cjs/async-throttler.d.cts +51 -8
- package/dist/cjs/batcher.cjs +138 -0
- package/dist/cjs/batcher.cjs.map +1 -0
- package/dist/cjs/batcher.d.cts +149 -0
- package/dist/cjs/debouncer.cjs +3 -4
- package/dist/cjs/debouncer.cjs.map +1 -1
- package/dist/cjs/debouncer.d.cts +1 -2
- package/dist/cjs/index.cjs +3 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/queuer.cjs +68 -41
- package/dist/cjs/queuer.cjs.map +1 -1
- package/dist/cjs/queuer.d.cts +123 -92
- package/dist/cjs/rate-limiter.cjs +3 -4
- package/dist/cjs/rate-limiter.cjs.map +1 -1
- package/dist/cjs/rate-limiter.d.cts +1 -2
- package/dist/cjs/throttler.cjs +3 -4
- package/dist/cjs/throttler.cjs.map +1 -1
- package/dist/cjs/throttler.d.cts +1 -2
- package/dist/cjs/types.d.cts +1 -0
- package/dist/esm/async-debouncer.d.ts +51 -9
- package/dist/esm/async-debouncer.js +32 -17
- package/dist/esm/async-debouncer.js.map +1 -1
- package/dist/esm/async-queuer.d.ts +151 -94
- package/dist/esm/async-queuer.js +189 -191
- package/dist/esm/async-queuer.js.map +1 -1
- package/dist/esm/async-rate-limiter.d.ts +54 -5
- package/dist/esm/async-rate-limiter.js +23 -13
- package/dist/esm/async-rate-limiter.js.map +1 -1
- package/dist/esm/async-throttler.d.ts +51 -8
- package/dist/esm/async-throttler.js +38 -17
- package/dist/esm/async-throttler.js.map +1 -1
- package/dist/esm/batcher.d.ts +149 -0
- package/dist/esm/batcher.js +138 -0
- package/dist/esm/batcher.js.map +1 -0
- package/dist/esm/debouncer.d.ts +1 -2
- package/dist/esm/debouncer.js +3 -4
- package/dist/esm/debouncer.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/queuer.d.ts +123 -92
- package/dist/esm/queuer.js +68 -41
- package/dist/esm/queuer.js.map +1 -1
- package/dist/esm/rate-limiter.d.ts +1 -2
- package/dist/esm/rate-limiter.js +3 -4
- package/dist/esm/rate-limiter.js.map +1 -1
- package/dist/esm/throttler.d.ts +1 -2
- package/dist/esm/throttler.js +3 -4
- package/dist/esm/throttler.js.map +1 -1
- package/dist/esm/types.d.ts +1 -0
- package/package.json +11 -1
- package/src/async-debouncer.ts +76 -20
- package/src/async-queuer.ts +309 -275
- package/src/async-rate-limiter.ts +73 -16
- package/src/async-throttler.ts +84 -20
- package/src/batcher.ts +253 -0
- package/src/debouncer.ts +3 -4
- package/src/index.ts +1 -0
- package/src/queuer.ts +142 -98
- package/src/rate-limiter.ts +3 -4
- package/src/throttler.ts +3 -4
- package/src/types.ts +3 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseFunctionOrValue } from './utils'
|
|
2
|
-
import type { AnyAsyncFunction } from './types'
|
|
2
|
+
import type { AnyAsyncFunction, OptionalKeys } from './types'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Options for configuring an async rate-limited function
|
|
@@ -17,7 +17,9 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
|
|
|
17
17
|
*/
|
|
18
18
|
limit: number | ((rateLimiter: AsyncRateLimiter<TFn>) => number)
|
|
19
19
|
/**
|
|
20
|
-
* Optional error handler for when the rate-limited function throws
|
|
20
|
+
* Optional error handler for when the rate-limited function throws.
|
|
21
|
+
* If provided, the handler will be called with the error and rate limiter instance.
|
|
22
|
+
* This can be used alongside throwOnError - the handler will be called before any error is thrown.
|
|
21
23
|
*/
|
|
22
24
|
onError?: (error: unknown, rateLimiter: AsyncRateLimiter<TFn>) => void
|
|
23
25
|
/**
|
|
@@ -35,6 +37,12 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
|
|
|
35
37
|
result: ReturnType<TFn>,
|
|
36
38
|
rateLimiter: AsyncRateLimiter<TFn>,
|
|
37
39
|
) => void
|
|
40
|
+
/**
|
|
41
|
+
* Whether to throw errors when they occur.
|
|
42
|
+
* Defaults to true if no onError handler is provided, false if an onError handler is provided.
|
|
43
|
+
* Can be explicitly set to override these defaults.
|
|
44
|
+
*/
|
|
45
|
+
throwOnError?: boolean
|
|
38
46
|
/**
|
|
39
47
|
* Time window in milliseconds within which the limit applies.
|
|
40
48
|
* Can be a number or a function that returns a number.
|
|
@@ -49,14 +57,16 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
|
|
|
49
57
|
windowType?: 'fixed' | 'sliding'
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
type AsyncRateLimiterOptionsWithOptionalCallbacks = OptionalKeys<
|
|
61
|
+
AsyncRateLimiterOptions<any>,
|
|
62
|
+
'onError' | 'onReject' | 'onSettled' | 'onSuccess'
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
const defaultOptions: Omit<
|
|
66
|
+
AsyncRateLimiterOptionsWithOptionalCallbacks,
|
|
67
|
+
'limit' | 'window'
|
|
54
68
|
> = {
|
|
55
69
|
enabled: true,
|
|
56
|
-
onError: () => {},
|
|
57
|
-
onReject: () => {},
|
|
58
|
-
onSettled: () => {},
|
|
59
|
-
onSuccess: () => {},
|
|
60
70
|
windowType: 'fixed',
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -84,11 +94,29 @@ const defaultOptions: Required<
|
|
|
84
94
|
* Rate limiting is best used for hard API limits or resource constraints. For UI updates or
|
|
85
95
|
* smoothing out frequent events, throttling or debouncing usually provide better user experience.
|
|
86
96
|
*
|
|
97
|
+
* Error Handling:
|
|
98
|
+
* - If an `onError` handler is provided, it will be called with the error and rate limiter instance
|
|
99
|
+
* - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
|
|
100
|
+
* - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
|
|
101
|
+
* - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
|
|
102
|
+
* - The error state can be checked using the underlying AsyncRateLimiter instance
|
|
103
|
+
* - Rate limit rejections (when limit is exceeded) are handled separately from execution errors via the `onReject` handler
|
|
104
|
+
*
|
|
87
105
|
* @example
|
|
88
106
|
* ```ts
|
|
89
107
|
* const rateLimiter = new AsyncRateLimiter(
|
|
90
108
|
* async (id: string) => await api.getData(id),
|
|
91
|
-
* {
|
|
109
|
+
* {
|
|
110
|
+
* limit: 5,
|
|
111
|
+
* window: 1000,
|
|
112
|
+
* windowType: 'sliding',
|
|
113
|
+
* onError: (error) => {
|
|
114
|
+
* console.error('API call failed:', error);
|
|
115
|
+
* },
|
|
116
|
+
* onReject: (limiter) => {
|
|
117
|
+
* console.log(`Rate limit exceeded. Try again in ${limiter.getMsUntilNextWindow()}ms`);
|
|
118
|
+
* }
|
|
119
|
+
* }
|
|
92
120
|
* );
|
|
93
121
|
*
|
|
94
122
|
* // Will execute immediately until limit reached, then block
|
|
@@ -97,7 +125,7 @@ const defaultOptions: Required<
|
|
|
97
125
|
* ```
|
|
98
126
|
*/
|
|
99
127
|
export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
100
|
-
private _options:
|
|
128
|
+
private _options: AsyncRateLimiterOptionsWithOptionalCallbacks
|
|
101
129
|
private _errorCount = 0
|
|
102
130
|
private _executionTimes: Array<number> = []
|
|
103
131
|
private _lastResult: ReturnType<TFn> | undefined
|
|
@@ -113,12 +141,12 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
113
141
|
this._options = {
|
|
114
142
|
...defaultOptions,
|
|
115
143
|
...initialOptions,
|
|
144
|
+
throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
|
|
116
145
|
}
|
|
117
146
|
}
|
|
118
147
|
|
|
119
148
|
/**
|
|
120
149
|
* Updates the rate limiter options
|
|
121
|
-
* Returns the new options state
|
|
122
150
|
*/
|
|
123
151
|
setOptions(newOptions: Partial<AsyncRateLimiterOptions<TFn>>): void {
|
|
124
152
|
this._options = { ...this._options, ...newOptions }
|
|
@@ -127,8 +155,8 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
127
155
|
/**
|
|
128
156
|
* Returns the current rate limiter options
|
|
129
157
|
*/
|
|
130
|
-
getOptions():
|
|
131
|
-
return this._options
|
|
158
|
+
getOptions(): AsyncRateLimiterOptions<TFn> {
|
|
159
|
+
return this._options
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/**
|
|
@@ -157,6 +185,19 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
157
185
|
* Will reject execution if the number of calls in the current window exceeds the limit.
|
|
158
186
|
* If execution is allowed, waits for any previous execution to complete before proceeding.
|
|
159
187
|
*
|
|
188
|
+
* Error Handling:
|
|
189
|
+
* - If the rate-limited function throws and no `onError` handler is configured,
|
|
190
|
+
* the error will be thrown from this method.
|
|
191
|
+
* - If an `onError` handler is configured, errors will be caught and passed to the handler,
|
|
192
|
+
* and this method will return undefined.
|
|
193
|
+
* - If the rate limit is exceeded, the execution will be rejected and the `onReject` handler
|
|
194
|
+
* will be called if configured.
|
|
195
|
+
* - The error state can be checked using `getErrorCount()` and `getIsExecuting()`.
|
|
196
|
+
* - Rate limit rejections can be tracked using `getRejectionCount()`.
|
|
197
|
+
*
|
|
198
|
+
* @returns A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError
|
|
199
|
+
* @throws The error from the rate-limited function if no onError handler is configured
|
|
200
|
+
*
|
|
160
201
|
* @example
|
|
161
202
|
* ```ts
|
|
162
203
|
* const rateLimiter = new AsyncRateLimiter(fn, { limit: 5, window: 1000 });
|
|
@@ -179,7 +220,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
179
220
|
if (this._options.windowType === 'sliding') {
|
|
180
221
|
// For sliding window, we can execute if we have capacity in the current window
|
|
181
222
|
if (this._executionTimes.length < limit) {
|
|
182
|
-
await this.
|
|
223
|
+
await this.execute(...args)
|
|
183
224
|
return this._lastResult
|
|
184
225
|
}
|
|
185
226
|
} else {
|
|
@@ -189,7 +230,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
189
230
|
const isNewWindow = oldestExecution + window <= now
|
|
190
231
|
|
|
191
232
|
if (isNewWindow || this._executionTimes.length < limit) {
|
|
192
|
-
await this.
|
|
233
|
+
await this.execute(...args)
|
|
193
234
|
return this._lastResult
|
|
194
235
|
}
|
|
195
236
|
}
|
|
@@ -198,7 +239,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
198
239
|
return undefined
|
|
199
240
|
}
|
|
200
241
|
|
|
201
|
-
private async
|
|
242
|
+
private async execute(
|
|
202
243
|
...args: Parameters<TFn>
|
|
203
244
|
): Promise<ReturnType<TFn> | undefined> {
|
|
204
245
|
if (!this.getEnabled()) return
|
|
@@ -213,6 +254,11 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
213
254
|
} catch (error) {
|
|
214
255
|
this._errorCount++
|
|
215
256
|
this._options.onError?.(error, this)
|
|
257
|
+
if (this._options.throwOnError) {
|
|
258
|
+
throw error
|
|
259
|
+
} else {
|
|
260
|
+
console.error(error)
|
|
261
|
+
}
|
|
216
262
|
} finally {
|
|
217
263
|
this._isExecuting = false
|
|
218
264
|
this._settleCount++
|
|
@@ -326,6 +372,14 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
326
372
|
* Consider using throttle() or debounce() if you need more intelligent execution control. Use rate limiting when you specifically
|
|
327
373
|
* need to enforce a hard limit on the number of executions within a time period.
|
|
328
374
|
*
|
|
375
|
+
* Error Handling:
|
|
376
|
+
* - If an `onError` handler is provided, it will be called with the error and rate limiter instance
|
|
377
|
+
* - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
|
|
378
|
+
* - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
|
|
379
|
+
* - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
|
|
380
|
+
* - The error state can be checked using the underlying AsyncRateLimiter instance
|
|
381
|
+
* - Rate limit rejections (when limit is exceeded) are handled separately from execution errors via the `onReject` handler
|
|
382
|
+
*
|
|
329
383
|
* @example
|
|
330
384
|
* ```ts
|
|
331
385
|
* // Rate limit to 5 calls per minute with a sliding window
|
|
@@ -333,6 +387,9 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
333
387
|
* limit: 5,
|
|
334
388
|
* window: 60000,
|
|
335
389
|
* windowType: 'sliding',
|
|
390
|
+
* onError: (error) => {
|
|
391
|
+
* console.error('API call failed:', error);
|
|
392
|
+
* },
|
|
336
393
|
* onReject: (rateLimiter) => {
|
|
337
394
|
* console.log(`Rate limit exceeded. Try again in ${rateLimiter.getMsUntilNextWindow()}ms`);
|
|
338
395
|
* }
|
package/src/async-throttler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseFunctionOrValue } from './utils'
|
|
2
|
-
import type { AnyAsyncFunction } from './types'
|
|
2
|
+
import type { AnyAsyncFunction, OptionalKeys } from './types'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Options for configuring an async throttled function
|
|
@@ -17,7 +17,9 @@ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
|
|
|
17
17
|
*/
|
|
18
18
|
leading?: boolean
|
|
19
19
|
/**
|
|
20
|
-
* Optional error handler for when the throttled function throws
|
|
20
|
+
* Optional error handler for when the throttled function throws.
|
|
21
|
+
* If provided, the handler will be called with the error and throttler instance.
|
|
22
|
+
* This can be used alongside throwOnError - the handler will be called before any error is thrown.
|
|
21
23
|
*/
|
|
22
24
|
onError?: (error: unknown, asyncThrottler: AsyncThrottler<TFn>) => void
|
|
23
25
|
/**
|
|
@@ -31,6 +33,12 @@ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
|
|
|
31
33
|
result: ReturnType<TFn>,
|
|
32
34
|
asyncThrottler: AsyncThrottler<TFn>,
|
|
33
35
|
) => void
|
|
36
|
+
/**
|
|
37
|
+
* Whether to throw errors when they occur.
|
|
38
|
+
* Defaults to true if no onError handler is provided, false if an onError handler is provided.
|
|
39
|
+
* Can be explicitly set to override these defaults.
|
|
40
|
+
*/
|
|
41
|
+
throwOnError?: boolean
|
|
34
42
|
/**
|
|
35
43
|
* Whether to execute the function on the trailing edge of the wait period
|
|
36
44
|
* Defaults to true
|
|
@@ -44,12 +52,14 @@ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
|
|
|
44
52
|
wait: number | ((throttler: AsyncThrottler<TFn>) => number)
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
type AsyncThrottlerOptionsWithOptionalCallbacks = OptionalKeys<
|
|
56
|
+
AsyncThrottlerOptions<any>,
|
|
57
|
+
'onError' | 'onSettled' | 'onSuccess'
|
|
58
|
+
>
|
|
59
|
+
|
|
60
|
+
const defaultOptions: AsyncThrottlerOptionsWithOptionalCallbacks = {
|
|
48
61
|
enabled: true,
|
|
49
62
|
leading: true,
|
|
50
|
-
onError: () => {},
|
|
51
|
-
onSettled: () => {},
|
|
52
|
-
onSuccess: () => {},
|
|
53
63
|
trailing: true,
|
|
54
64
|
wait: 0,
|
|
55
65
|
}
|
|
@@ -68,12 +78,24 @@ const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
|
|
|
68
78
|
* This is useful for rate-limiting API calls, handling scroll/resize events, or any scenario where you want to
|
|
69
79
|
* ensure a maximum execution frequency.
|
|
70
80
|
*
|
|
81
|
+
* Error Handling:
|
|
82
|
+
* - If an `onError` handler is provided, it will be called with the error and throttler instance
|
|
83
|
+
* - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
|
|
84
|
+
* - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
|
|
85
|
+
* - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
|
|
86
|
+
* - The error state can be checked using the underlying AsyncThrottler instance
|
|
87
|
+
*
|
|
71
88
|
* @example
|
|
72
89
|
* ```ts
|
|
73
90
|
* const throttler = new AsyncThrottler(async (value: string) => {
|
|
74
91
|
* const result = await saveToAPI(value);
|
|
75
92
|
* return result; // Return value is preserved
|
|
76
|
-
* }, {
|
|
93
|
+
* }, {
|
|
94
|
+
* wait: 1000,
|
|
95
|
+
* onError: (error) => {
|
|
96
|
+
* console.error('API call failed:', error);
|
|
97
|
+
* }
|
|
98
|
+
* });
|
|
77
99
|
*
|
|
78
100
|
* // Will only execute once per second no matter how often called
|
|
79
101
|
* // Returns the API response directly
|
|
@@ -81,7 +103,7 @@ const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
|
|
|
81
103
|
* ```
|
|
82
104
|
*/
|
|
83
105
|
export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
84
|
-
private _options:
|
|
106
|
+
private _options: AsyncThrottlerOptionsWithOptionalCallbacks
|
|
85
107
|
private _abortController: AbortController | null = null
|
|
86
108
|
private _errorCount = 0
|
|
87
109
|
private _isExecuting = false
|
|
@@ -92,6 +114,9 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
92
114
|
private _settleCount = 0
|
|
93
115
|
private _successCount = 0
|
|
94
116
|
private _timeoutId: NodeJS.Timeout | null = null
|
|
117
|
+
private _resolvePreviousPromise:
|
|
118
|
+
| ((value?: ReturnType<TFn> | undefined) => void)
|
|
119
|
+
| null = null
|
|
95
120
|
|
|
96
121
|
constructor(
|
|
97
122
|
private fn: TFn,
|
|
@@ -100,12 +125,12 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
100
125
|
this._options = {
|
|
101
126
|
...defaultOptions,
|
|
102
127
|
...initialOptions,
|
|
128
|
+
throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
/**
|
|
107
133
|
* Updates the throttler options
|
|
108
|
-
* Returns the new options state
|
|
109
134
|
*/
|
|
110
135
|
setOptions(newOptions: Partial<AsyncThrottlerOptions<TFn>>): void {
|
|
111
136
|
this._options = { ...this._options, ...newOptions }
|
|
@@ -119,7 +144,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
119
144
|
/**
|
|
120
145
|
* Returns the current options
|
|
121
146
|
*/
|
|
122
|
-
getOptions():
|
|
147
|
+
getOptions(): AsyncThrottlerOptions<TFn> {
|
|
123
148
|
return this._options
|
|
124
149
|
}
|
|
125
150
|
|
|
@@ -127,7 +152,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
127
152
|
* Returns the current enabled state of the throttler
|
|
128
153
|
*/
|
|
129
154
|
getEnabled(): boolean {
|
|
130
|
-
return parseFunctionOrValue(this._options.enabled, this)
|
|
155
|
+
return !!parseFunctionOrValue(this._options.enabled, this)
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
/**
|
|
@@ -138,8 +163,18 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
/**
|
|
141
|
-
* Attempts to execute the throttled function
|
|
142
|
-
* If a call is already in progress, it may be blocked or queued depending on the `wait` option
|
|
166
|
+
* Attempts to execute the throttled function.
|
|
167
|
+
* If a call is already in progress, it may be blocked or queued depending on the `wait` option.
|
|
168
|
+
*
|
|
169
|
+
* Error Handling:
|
|
170
|
+
* - If the throttled function throws and no `onError` handler is configured,
|
|
171
|
+
* the error will be thrown from this method.
|
|
172
|
+
* - If an `onError` handler is configured, errors will be caught and passed to the handler,
|
|
173
|
+
* and this method will return undefined.
|
|
174
|
+
* - The error state can be checked using `getErrorCount()` and `getIsExecuting()`.
|
|
175
|
+
*
|
|
176
|
+
* @returns A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError
|
|
177
|
+
* @throws The error from the throttled function if no onError handler is configured
|
|
143
178
|
*/
|
|
144
179
|
async maybeExecute(
|
|
145
180
|
...args: Parameters<TFn>
|
|
@@ -148,15 +183,18 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
148
183
|
const timeSinceLastExecution = now - this._lastExecutionTime
|
|
149
184
|
const wait = this.getWait()
|
|
150
185
|
|
|
186
|
+
this.resolvePreviousPromise()
|
|
187
|
+
|
|
151
188
|
// Handle leading execution
|
|
152
189
|
if (this._options.leading && timeSinceLastExecution >= wait) {
|
|
153
|
-
await this.
|
|
190
|
+
await this.execute(...args)
|
|
154
191
|
return this._lastResult
|
|
155
192
|
} else {
|
|
156
193
|
// Store the most recent arguments for potential trailing execution
|
|
157
194
|
this._lastArgs = args
|
|
158
195
|
|
|
159
196
|
return new Promise((resolve) => {
|
|
197
|
+
this._resolvePreviousPromise = resolve
|
|
160
198
|
// Clear any existing timeout to ensure we use the latest arguments
|
|
161
199
|
if (this._timeoutId) {
|
|
162
200
|
clearTimeout(this._timeoutId)
|
|
@@ -170,8 +208,9 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
170
208
|
const timeoutDuration = wait - _timeSinceLastExecution
|
|
171
209
|
this._timeoutId = setTimeout(async () => {
|
|
172
210
|
if (this._lastArgs !== undefined) {
|
|
173
|
-
await this.
|
|
211
|
+
await this.execute(...this._lastArgs)
|
|
174
212
|
}
|
|
213
|
+
this._resolvePreviousPromise = null
|
|
175
214
|
resolve(this._lastResult)
|
|
176
215
|
}, timeoutDuration)
|
|
177
216
|
}
|
|
@@ -179,7 +218,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
179
218
|
}
|
|
180
219
|
}
|
|
181
220
|
|
|
182
|
-
private async
|
|
221
|
+
private async execute(
|
|
183
222
|
...args: Parameters<TFn>
|
|
184
223
|
): Promise<ReturnType<TFn> | undefined> {
|
|
185
224
|
if (!this.getEnabled() || this._isExecuting) return undefined
|
|
@@ -188,21 +227,33 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
188
227
|
this._isExecuting = true
|
|
189
228
|
this._lastResult = await this.fn(...args) // EXECUTE!
|
|
190
229
|
this._successCount++
|
|
191
|
-
this._options.onSuccess(this._lastResult!, this)
|
|
230
|
+
this._options.onSuccess?.(this._lastResult!, this)
|
|
192
231
|
} catch (error) {
|
|
193
232
|
this._errorCount++
|
|
194
|
-
this._options.onError(error, this)
|
|
233
|
+
this._options.onError?.(error, this)
|
|
234
|
+
if (this._options.throwOnError) {
|
|
235
|
+
throw error
|
|
236
|
+
} else {
|
|
237
|
+
console.error(error)
|
|
238
|
+
}
|
|
195
239
|
} finally {
|
|
196
240
|
this._isExecuting = false
|
|
197
241
|
this._settleCount++
|
|
198
242
|
this._abortController = null
|
|
199
243
|
this._lastExecutionTime = Date.now()
|
|
200
244
|
this._nextExecutionTime = this._lastExecutionTime + this.getWait()
|
|
201
|
-
this._options.onSettled(this)
|
|
245
|
+
this._options.onSettled?.(this)
|
|
202
246
|
}
|
|
203
247
|
return this._lastResult
|
|
204
248
|
}
|
|
205
249
|
|
|
250
|
+
private resolvePreviousPromise(): void {
|
|
251
|
+
if (this._resolvePreviousPromise) {
|
|
252
|
+
this._resolvePreviousPromise(this._lastResult)
|
|
253
|
+
this._resolvePreviousPromise = null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
206
257
|
/**
|
|
207
258
|
* Cancels any pending execution or aborts any execution in progress
|
|
208
259
|
*/
|
|
@@ -215,6 +266,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
215
266
|
this._abortController.abort()
|
|
216
267
|
this._abortController = null
|
|
217
268
|
}
|
|
269
|
+
this.resolvePreviousPromise()
|
|
218
270
|
this._lastArgs = undefined
|
|
219
271
|
}
|
|
220
272
|
|
|
@@ -284,12 +336,24 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
284
336
|
* making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
|
|
285
337
|
* instead of setting the result on a state variable from within the throttled function.
|
|
286
338
|
*
|
|
339
|
+
* Error Handling:
|
|
340
|
+
* - If an `onError` handler is provided, it will be called with the error and throttler instance
|
|
341
|
+
* - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
|
|
342
|
+
* - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
|
|
343
|
+
* - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
|
|
344
|
+
* - The error state can be checked using the underlying AsyncThrottler instance
|
|
345
|
+
*
|
|
287
346
|
* @example
|
|
288
347
|
* ```ts
|
|
289
348
|
* const throttled = asyncThrottle(async (value: string) => {
|
|
290
349
|
* const result = await saveToAPI(value);
|
|
291
350
|
* return result; // Return value is preserved
|
|
292
|
-
* }, {
|
|
351
|
+
* }, {
|
|
352
|
+
* wait: 1000,
|
|
353
|
+
* onError: (error) => {
|
|
354
|
+
* console.error('API call failed:', error);
|
|
355
|
+
* }
|
|
356
|
+
* });
|
|
293
357
|
*
|
|
294
358
|
* // This will execute at most once per second
|
|
295
359
|
* // Returns the API response directly
|
package/src/batcher.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { OptionalKeys } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for configuring a Batcher instance
|
|
5
|
+
*/
|
|
6
|
+
export interface BatcherOptions<TValue> {
|
|
7
|
+
/**
|
|
8
|
+
* Custom function to determine if a batch should be processed
|
|
9
|
+
* Return true to process the batch immediately
|
|
10
|
+
*/
|
|
11
|
+
getShouldExecute?: (items: Array<TValue>, batcher: Batcher<TValue>) => boolean
|
|
12
|
+
/**
|
|
13
|
+
* Maximum number of items in a batch
|
|
14
|
+
* @default Infinity
|
|
15
|
+
*/
|
|
16
|
+
maxSize?: number
|
|
17
|
+
/**
|
|
18
|
+
* Callback fired after a batch is processed
|
|
19
|
+
*/
|
|
20
|
+
onExecute?: (batcher: Batcher<TValue>) => void
|
|
21
|
+
/**
|
|
22
|
+
* Callback fired when the batcher's running state changes
|
|
23
|
+
*/
|
|
24
|
+
onIsRunningChange?: (batcher: Batcher<TValue>) => void
|
|
25
|
+
/**
|
|
26
|
+
* Callback fired after items are added to the batcher
|
|
27
|
+
*/
|
|
28
|
+
onItemsChange?: (batcher: Batcher<TValue>) => void
|
|
29
|
+
/**
|
|
30
|
+
* Whether the batcher should start processing immediately
|
|
31
|
+
* @default true
|
|
32
|
+
*/
|
|
33
|
+
started?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Maximum time in milliseconds to wait before processing a batch.
|
|
36
|
+
* If the wait duration has elapsed, the batch will be processed.
|
|
37
|
+
* If not provided, the batch will not be triggered by a timeout.
|
|
38
|
+
* @default Infinity
|
|
39
|
+
*/
|
|
40
|
+
wait?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type BatcherOptionsWithOptionalCallbacks<TValue> = OptionalKeys<
|
|
44
|
+
Required<BatcherOptions<TValue>>,
|
|
45
|
+
'onExecute' | 'onItemsChange' | 'onIsRunningChange'
|
|
46
|
+
>
|
|
47
|
+
|
|
48
|
+
const defaultOptions: BatcherOptionsWithOptionalCallbacks<any> = {
|
|
49
|
+
getShouldExecute: () => false,
|
|
50
|
+
maxSize: Infinity,
|
|
51
|
+
started: true,
|
|
52
|
+
wait: Infinity,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A class that collects items and processes them in batches.
|
|
57
|
+
*
|
|
58
|
+
* Batching is a technique for grouping multiple operations together to be processed as a single unit.
|
|
59
|
+
*
|
|
60
|
+
* The Batcher provides a flexible way to implement batching with configurable:
|
|
61
|
+
* - Maximum batch size (number of items per batch)
|
|
62
|
+
* - Time-based batching (process after X milliseconds)
|
|
63
|
+
* - Custom batch processing logic via getShouldExecute
|
|
64
|
+
* - Event callbacks for monitoring batch operations
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const batcher = new Batcher<number>(
|
|
69
|
+
* (items) => console.log('Processing batch:', items),
|
|
70
|
+
* {
|
|
71
|
+
* maxSize: 5,
|
|
72
|
+
* wait: 2000,
|
|
73
|
+
* onExecuteBatch: (items) => console.log('Batch executed:', items)
|
|
74
|
+
* }
|
|
75
|
+
* );
|
|
76
|
+
*
|
|
77
|
+
* batcher.addItem(1);
|
|
78
|
+
* batcher.addItem(2);
|
|
79
|
+
* // After 2 seconds or when 5 items are added, whichever comes first,
|
|
80
|
+
* // the batch will be processed
|
|
81
|
+
* // batcher.execute() // manually trigger a batch
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export class Batcher<TValue> {
|
|
85
|
+
private _options: BatcherOptionsWithOptionalCallbacks<TValue>
|
|
86
|
+
private _batchExecutionCount = 0
|
|
87
|
+
private _itemExecutionCount = 0
|
|
88
|
+
private _items: Array<TValue> = []
|
|
89
|
+
private _running: boolean
|
|
90
|
+
private _timeoutId: NodeJS.Timeout | null = null
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private fn: (items: Array<TValue>) => void,
|
|
94
|
+
initialOptions: BatcherOptions<TValue>,
|
|
95
|
+
) {
|
|
96
|
+
this._options = { ...defaultOptions, ...initialOptions }
|
|
97
|
+
this._running = this._options.started
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Updates the batcher options
|
|
102
|
+
*/
|
|
103
|
+
setOptions(newOptions: Partial<BatcherOptions<TValue>>): void {
|
|
104
|
+
this._options = { ...this._options, ...newOptions }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns the current batcher options
|
|
109
|
+
*/
|
|
110
|
+
getOptions(): BatcherOptions<TValue> {
|
|
111
|
+
return this._options
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Adds an item to the batcher
|
|
116
|
+
* If the batch size is reached, timeout occurs, or shouldProcess returns true, the batch will be processed
|
|
117
|
+
*/
|
|
118
|
+
addItem(item: TValue): void {
|
|
119
|
+
this._items.push(item)
|
|
120
|
+
this._options.onItemsChange?.(this)
|
|
121
|
+
|
|
122
|
+
const shouldProcess =
|
|
123
|
+
this._items.length >= this._options.maxSize ||
|
|
124
|
+
this._options.getShouldExecute(this._items, this)
|
|
125
|
+
|
|
126
|
+
if (shouldProcess) {
|
|
127
|
+
this.execute()
|
|
128
|
+
} else if (
|
|
129
|
+
this._running &&
|
|
130
|
+
!this._timeoutId &&
|
|
131
|
+
this._options.wait !== Infinity
|
|
132
|
+
) {
|
|
133
|
+
this._timeoutId = setTimeout(() => this.execute(), this._options.wait)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Processes the current batch of items.
|
|
139
|
+
* This method will automatically be triggered if the batcher is running and any of these conditions are met:
|
|
140
|
+
* - The number of items reaches batchSize
|
|
141
|
+
* - The wait duration has elapsed
|
|
142
|
+
* - The getShouldExecute function returns true upon adding an item
|
|
143
|
+
*
|
|
144
|
+
* You can also call this method manually to process the current batch at any time.
|
|
145
|
+
*/
|
|
146
|
+
execute(): void {
|
|
147
|
+
if (this._timeoutId) {
|
|
148
|
+
clearTimeout(this._timeoutId)
|
|
149
|
+
this._timeoutId = null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (this._items.length === 0) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const batch = this.getAllItems() // copy of the items to be processed (to prevent race conditions)
|
|
157
|
+
this._items = [] // Clear items before processing to prevent race conditions
|
|
158
|
+
this._options.onItemsChange?.(this) // Call onItemsChange to notify listeners that the items have changed
|
|
159
|
+
|
|
160
|
+
this.fn(batch)
|
|
161
|
+
this._batchExecutionCount++
|
|
162
|
+
this._itemExecutionCount += batch.length
|
|
163
|
+
this._options.onExecute?.(this)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Stops the batcher from processing batches
|
|
168
|
+
*/
|
|
169
|
+
stop(): void {
|
|
170
|
+
this._running = false
|
|
171
|
+
this._options.onIsRunningChange?.(this)
|
|
172
|
+
if (this._timeoutId) {
|
|
173
|
+
clearTimeout(this._timeoutId)
|
|
174
|
+
this._timeoutId = null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Starts the batcher and processes any pending items
|
|
180
|
+
*/
|
|
181
|
+
start(): void {
|
|
182
|
+
this._running = true
|
|
183
|
+
this._options.onIsRunningChange?.(this)
|
|
184
|
+
if (this._items.length > 0 && !this._timeoutId) {
|
|
185
|
+
this._timeoutId = setTimeout(() => this.execute(), this._options.wait)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns the current number of items in the batcher
|
|
191
|
+
*/
|
|
192
|
+
getSize(): number {
|
|
193
|
+
return this._items.length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Returns true if the batcher is empty
|
|
198
|
+
*/
|
|
199
|
+
getIsEmpty(): boolean {
|
|
200
|
+
return this._items.length === 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns true if the batcher is running
|
|
205
|
+
*/
|
|
206
|
+
getIsRunning(): boolean {
|
|
207
|
+
return this._running
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Returns a copy of all items currently in the batcher
|
|
212
|
+
*/
|
|
213
|
+
getAllItems(): Array<TValue> {
|
|
214
|
+
return [...this._items]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Returns the number of times batches have been processed
|
|
219
|
+
*/
|
|
220
|
+
getBatchExecutionCount(): number {
|
|
221
|
+
return this._batchExecutionCount
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Returns the total number of individual items that have been processed
|
|
226
|
+
*/
|
|
227
|
+
getItemExecutionCount(): number {
|
|
228
|
+
return this._itemExecutionCount
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates a batcher that processes items in batches
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```ts
|
|
237
|
+
* const batchItems = batch<number>({
|
|
238
|
+
* batchSize: 3,
|
|
239
|
+
* processBatch: (items) => console.log('Processing:', items)
|
|
240
|
+
* });
|
|
241
|
+
*
|
|
242
|
+
* batchItems(1);
|
|
243
|
+
* batchItems(2);
|
|
244
|
+
* batchItems(3); // Triggers batch processing
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function batch<TValue>(
|
|
248
|
+
fn: (items: Array<TValue>) => void,
|
|
249
|
+
options: BatcherOptions<TValue>,
|
|
250
|
+
) {
|
|
251
|
+
const batcher = new Batcher<TValue>(fn, options)
|
|
252
|
+
return batcher.addItem.bind(batcher)
|
|
253
|
+
}
|