@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.
Files changed (74) hide show
  1. package/dist/cjs/async-debouncer.cjs +32 -17
  2. package/dist/cjs/async-debouncer.cjs.map +1 -1
  3. package/dist/cjs/async-debouncer.d.cts +51 -9
  4. package/dist/cjs/async-queuer.cjs +189 -191
  5. package/dist/cjs/async-queuer.cjs.map +1 -1
  6. package/dist/cjs/async-queuer.d.cts +151 -94
  7. package/dist/cjs/async-rate-limiter.cjs +23 -13
  8. package/dist/cjs/async-rate-limiter.cjs.map +1 -1
  9. package/dist/cjs/async-rate-limiter.d.cts +54 -5
  10. package/dist/cjs/async-throttler.cjs +38 -17
  11. package/dist/cjs/async-throttler.cjs.map +1 -1
  12. package/dist/cjs/async-throttler.d.cts +51 -8
  13. package/dist/cjs/batcher.cjs +138 -0
  14. package/dist/cjs/batcher.cjs.map +1 -0
  15. package/dist/cjs/batcher.d.cts +149 -0
  16. package/dist/cjs/debouncer.cjs +3 -4
  17. package/dist/cjs/debouncer.cjs.map +1 -1
  18. package/dist/cjs/debouncer.d.cts +1 -2
  19. package/dist/cjs/index.cjs +3 -0
  20. package/dist/cjs/index.cjs.map +1 -1
  21. package/dist/cjs/index.d.cts +1 -0
  22. package/dist/cjs/queuer.cjs +68 -41
  23. package/dist/cjs/queuer.cjs.map +1 -1
  24. package/dist/cjs/queuer.d.cts +123 -92
  25. package/dist/cjs/rate-limiter.cjs +3 -4
  26. package/dist/cjs/rate-limiter.cjs.map +1 -1
  27. package/dist/cjs/rate-limiter.d.cts +1 -2
  28. package/dist/cjs/throttler.cjs +3 -4
  29. package/dist/cjs/throttler.cjs.map +1 -1
  30. package/dist/cjs/throttler.d.cts +1 -2
  31. package/dist/cjs/types.d.cts +1 -0
  32. package/dist/esm/async-debouncer.d.ts +51 -9
  33. package/dist/esm/async-debouncer.js +32 -17
  34. package/dist/esm/async-debouncer.js.map +1 -1
  35. package/dist/esm/async-queuer.d.ts +151 -94
  36. package/dist/esm/async-queuer.js +189 -191
  37. package/dist/esm/async-queuer.js.map +1 -1
  38. package/dist/esm/async-rate-limiter.d.ts +54 -5
  39. package/dist/esm/async-rate-limiter.js +23 -13
  40. package/dist/esm/async-rate-limiter.js.map +1 -1
  41. package/dist/esm/async-throttler.d.ts +51 -8
  42. package/dist/esm/async-throttler.js +38 -17
  43. package/dist/esm/async-throttler.js.map +1 -1
  44. package/dist/esm/batcher.d.ts +149 -0
  45. package/dist/esm/batcher.js +138 -0
  46. package/dist/esm/batcher.js.map +1 -0
  47. package/dist/esm/debouncer.d.ts +1 -2
  48. package/dist/esm/debouncer.js +3 -4
  49. package/dist/esm/debouncer.js.map +1 -1
  50. package/dist/esm/index.d.ts +1 -0
  51. package/dist/esm/index.js +3 -0
  52. package/dist/esm/index.js.map +1 -1
  53. package/dist/esm/queuer.d.ts +123 -92
  54. package/dist/esm/queuer.js +68 -41
  55. package/dist/esm/queuer.js.map +1 -1
  56. package/dist/esm/rate-limiter.d.ts +1 -2
  57. package/dist/esm/rate-limiter.js +3 -4
  58. package/dist/esm/rate-limiter.js.map +1 -1
  59. package/dist/esm/throttler.d.ts +1 -2
  60. package/dist/esm/throttler.js +3 -4
  61. package/dist/esm/throttler.js.map +1 -1
  62. package/dist/esm/types.d.ts +1 -0
  63. package/package.json +11 -1
  64. package/src/async-debouncer.ts +76 -20
  65. package/src/async-queuer.ts +309 -275
  66. package/src/async-rate-limiter.ts +73 -16
  67. package/src/async-throttler.ts +84 -20
  68. package/src/batcher.ts +253 -0
  69. package/src/debouncer.ts +3 -4
  70. package/src/index.ts +1 -0
  71. package/src/queuer.ts +142 -98
  72. package/src/rate-limiter.ts +3 -4
  73. package/src/throttler.ts +3 -4
  74. 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
- const defaultOptions: Required<
53
- Omit<AsyncRateLimiterOptions<any>, 'limit' | 'window'>
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
- * { limit: 5, window: 1000, windowType: 'sliding' } // 5 calls per second with sliding window
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: AsyncRateLimiterOptions<TFn>
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(): Required<AsyncRateLimiterOptions<TFn>> {
131
- return this._options as Required<AsyncRateLimiterOptions<TFn>>
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.executeFunction(...args)
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.executeFunction(...args)
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 executeFunction(
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
  * }
@@ -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
- const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
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
- * }, { wait: 1000 });
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: Required<AsyncThrottlerOptions<TFn>>
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(): Required<AsyncThrottlerOptions<TFn>> {
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.executeFunction(...args)
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.executeFunction(...this._lastArgs)
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 executeFunction(
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
- * }, { wait: 1000 });
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
+ }