@tanstack/pacer 0.8.0 → 0.9.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 (91) hide show
  1. package/dist/cjs/async-batcher.cjs +163 -0
  2. package/dist/cjs/async-batcher.cjs.map +1 -0
  3. package/dist/cjs/async-batcher.d.cts +273 -0
  4. package/dist/cjs/async-debouncer.cjs +149 -162
  5. package/dist/cjs/async-debouncer.cjs.map +1 -1
  6. package/dist/cjs/async-debouncer.d.cts +76 -57
  7. package/dist/cjs/async-queuer.cjs +282 -343
  8. package/dist/cjs/async-queuer.cjs.map +1 -1
  9. package/dist/cjs/async-queuer.d.cts +121 -100
  10. package/dist/cjs/async-rate-limiter.cjs +128 -185
  11. package/dist/cjs/async-rate-limiter.cjs.map +1 -1
  12. package/dist/cjs/async-rate-limiter.d.cts +72 -61
  13. package/dist/cjs/async-throttler.cjs +168 -178
  14. package/dist/cjs/async-throttler.cjs.map +1 -1
  15. package/dist/cjs/async-throttler.d.cts +97 -69
  16. package/dist/cjs/batcher.cjs +110 -119
  17. package/dist/cjs/batcher.cjs.map +1 -1
  18. package/dist/cjs/batcher.d.cts +76 -51
  19. package/dist/cjs/debouncer.cjs +97 -85
  20. package/dist/cjs/debouncer.cjs.map +1 -1
  21. package/dist/cjs/debouncer.d.cts +54 -26
  22. package/dist/cjs/index.cjs +3 -6
  23. package/dist/cjs/index.cjs.map +1 -1
  24. package/dist/cjs/index.d.cts +1 -1
  25. package/dist/cjs/queuer.cjs +246 -294
  26. package/dist/cjs/queuer.cjs.map +1 -1
  27. package/dist/cjs/queuer.d.cts +102 -81
  28. package/dist/cjs/rate-limiter.cjs +97 -130
  29. package/dist/cjs/rate-limiter.cjs.map +1 -1
  30. package/dist/cjs/rate-limiter.d.cts +50 -37
  31. package/dist/cjs/throttler.cjs +107 -123
  32. package/dist/cjs/throttler.cjs.map +1 -1
  33. package/dist/cjs/throttler.d.cts +59 -35
  34. package/dist/cjs/utils.cjs +0 -13
  35. package/dist/cjs/utils.cjs.map +1 -1
  36. package/dist/cjs/utils.d.cts +0 -1
  37. package/dist/esm/async-batcher.d.ts +273 -0
  38. package/dist/esm/async-batcher.js +163 -0
  39. package/dist/esm/async-batcher.js.map +1 -0
  40. package/dist/esm/async-debouncer.d.ts +76 -57
  41. package/dist/esm/async-debouncer.js +149 -162
  42. package/dist/esm/async-debouncer.js.map +1 -1
  43. package/dist/esm/async-queuer.d.ts +121 -100
  44. package/dist/esm/async-queuer.js +282 -343
  45. package/dist/esm/async-queuer.js.map +1 -1
  46. package/dist/esm/async-rate-limiter.d.ts +72 -61
  47. package/dist/esm/async-rate-limiter.js +128 -185
  48. package/dist/esm/async-rate-limiter.js.map +1 -1
  49. package/dist/esm/async-throttler.d.ts +97 -69
  50. package/dist/esm/async-throttler.js +168 -178
  51. package/dist/esm/async-throttler.js.map +1 -1
  52. package/dist/esm/batcher.d.ts +76 -51
  53. package/dist/esm/batcher.js +110 -119
  54. package/dist/esm/batcher.js.map +1 -1
  55. package/dist/esm/debouncer.d.ts +54 -26
  56. package/dist/esm/debouncer.js +97 -85
  57. package/dist/esm/debouncer.js.map +1 -1
  58. package/dist/esm/index.d.ts +1 -1
  59. package/dist/esm/index.js +4 -7
  60. package/dist/esm/queuer.d.ts +102 -81
  61. package/dist/esm/queuer.js +246 -294
  62. package/dist/esm/queuer.js.map +1 -1
  63. package/dist/esm/rate-limiter.d.ts +50 -37
  64. package/dist/esm/rate-limiter.js +97 -130
  65. package/dist/esm/rate-limiter.js.map +1 -1
  66. package/dist/esm/throttler.d.ts +59 -35
  67. package/dist/esm/throttler.js +107 -123
  68. package/dist/esm/throttler.js.map +1 -1
  69. package/dist/esm/utils.d.ts +0 -1
  70. package/dist/esm/utils.js +0 -13
  71. package/dist/esm/utils.js.map +1 -1
  72. package/package.json +14 -11
  73. package/src/async-batcher.ts +475 -0
  74. package/src/async-debouncer.ts +201 -121
  75. package/src/async-queuer.ts +337 -216
  76. package/src/async-rate-limiter.ts +176 -136
  77. package/src/async-throttler.ts +233 -139
  78. package/src/batcher.ts +158 -92
  79. package/src/debouncer.ts +135 -52
  80. package/src/index.ts +1 -1
  81. package/src/queuer.ts +348 -226
  82. package/src/rate-limiter.ts +125 -80
  83. package/src/throttler.ts +152 -78
  84. package/src/utils.ts +0 -15
  85. package/dist/cjs/compare.cjs +0 -72
  86. package/dist/cjs/compare.cjs.map +0 -1
  87. package/dist/cjs/compare.d.cts +0 -12
  88. package/dist/esm/compare.d.ts +0 -12
  89. package/dist/esm/compare.js +0 -72
  90. package/dist/esm/compare.js.map +0 -1
  91. package/src/compare.ts +0 -105
@@ -1,5 +1,51 @@
1
+ import { Store } from '@tanstack/store'
1
2
  import { parseFunctionOrValue } from './utils'
2
- import type { AnyAsyncFunction, OptionalKeys } from './types'
3
+ import type { AnyAsyncFunction } from './types'
4
+
5
+ export interface AsyncRateLimiterState<TFn extends AnyAsyncFunction> {
6
+ /**
7
+ * Number of function executions that have resulted in errors
8
+ */
9
+ errorCount: number
10
+ /**
11
+ * Array of timestamps when executions occurred for rate limiting calculations
12
+ */
13
+ executionTimes: Array<number>
14
+ /**
15
+ * Whether the rate-limited function is currently executing asynchronously
16
+ */
17
+ isExecuting: boolean
18
+ /**
19
+ * The result from the most recent successful function execution
20
+ */
21
+ lastResult: ReturnType<TFn> | undefined
22
+ /**
23
+ * Number of function executions that have been rejected due to rate limiting
24
+ */
25
+ rejectionCount: number
26
+ /**
27
+ * Number of function executions that have completed (either successfully or with errors)
28
+ */
29
+ settleCount: number
30
+ /**
31
+ * Number of function executions that have completed successfully
32
+ */
33
+ successCount: number
34
+ }
35
+
36
+ function getDefaultAsyncRateLimiterState<
37
+ TFn extends AnyAsyncFunction,
38
+ >(): AsyncRateLimiterState<TFn> {
39
+ return {
40
+ errorCount: 0,
41
+ executionTimes: [],
42
+ isExecuting: false,
43
+ lastResult: undefined,
44
+ rejectionCount: 0,
45
+ settleCount: 0,
46
+ successCount: 0,
47
+ }
48
+ }
3
49
 
4
50
  /**
5
51
  * Options for configuring an async rate-limited function
@@ -11,6 +57,10 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
11
57
  * Defaults to true.
12
58
  */
13
59
  enabled?: boolean | ((rateLimiter: AsyncRateLimiter<TFn>) => boolean)
60
+ /**
61
+ * Initial state for the rate limiter
62
+ */
63
+ initialState?: Partial<AsyncRateLimiterState<TFn>>
14
64
  /**
15
65
  * Maximum number of executions allowed within the time window.
16
66
  * Can be a number or a function that returns a number.
@@ -57,17 +107,15 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
57
107
  windowType?: 'fixed' | 'sliding'
58
108
  }
59
109
 
60
- type AsyncRateLimiterOptionsWithOptionalCallbacks = OptionalKeys<
61
- AsyncRateLimiterOptions<any>,
62
- 'onError' | 'onReject' | 'onSettled' | 'onSuccess'
63
- >
64
-
65
110
  const defaultOptions: Omit<
66
- AsyncRateLimiterOptionsWithOptionalCallbacks,
67
- 'limit' | 'window'
111
+ Required<AsyncRateLimiterOptions<any>>,
112
+ 'initialState' | 'onError' | 'onReject' | 'onSettled' | 'onSuccess'
68
113
  > = {
69
114
  enabled: true,
115
+ limit: 1,
116
+ window: 0,
70
117
  windowType: 'fixed',
118
+ throwOnError: true,
71
119
  }
72
120
 
73
121
  /**
@@ -94,6 +142,18 @@ const defaultOptions: Omit<
94
142
  * Rate limiting is best used for hard API limits or resource constraints. For UI updates or
95
143
  * smoothing out frequent events, throttling or debouncing usually provide better user experience.
96
144
  *
145
+ * State Management:
146
+ * - Uses TanStack Store for reactive state management
147
+ * - Use `initialState` to provide initial state values when creating the rate limiter
148
+ * - `initialState` can be a partial state object
149
+ * - Use `onSuccess` callback to react to successful function execution and implement custom logic
150
+ * - Use `onError` callback to react to function execution errors and implement custom error handling
151
+ * - Use `onSettled` callback to react to function execution completion (success or error) and implement custom logic
152
+ * - Use `onReject` callback to react to executions being rejected when rate limit is exceeded
153
+ * - The state includes execution times, success/error counts, and current execution status
154
+ * - State can be accessed via `asyncRateLimiter.store.state` when using the class directly
155
+ * - When using framework adapters (React/Solid), state is accessed from `asyncRateLimiter.state`
156
+ *
97
157
  * Error Handling:
98
158
  * - If an `onError` handler is provided, it will be called with the error and rate limiter instance
99
159
  * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
@@ -125,75 +185,71 @@ const defaultOptions: Omit<
125
185
  * ```
126
186
  */
127
187
  export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
128
- private _options: AsyncRateLimiterOptionsWithOptionalCallbacks
129
- private _errorCount = 0
130
- private _executionTimes: Array<number> = []
131
- private _lastResult: ReturnType<TFn> | undefined
132
- private _rejectionCount = 0
133
- private _settleCount = 0
134
- private _successCount = 0
135
- private _isExecuting = false
188
+ readonly store: Store<Readonly<AsyncRateLimiterState<TFn>>> = new Store<
189
+ AsyncRateLimiterState<TFn>
190
+ >(getDefaultAsyncRateLimiterState<TFn>())
191
+ options: AsyncRateLimiterOptions<TFn>
136
192
 
137
193
  constructor(
138
194
  private fn: TFn,
139
195
  initialOptions: AsyncRateLimiterOptions<TFn>,
140
196
  ) {
141
- this._options = {
197
+ this.options = {
142
198
  ...defaultOptions,
143
199
  ...initialOptions,
144
200
  throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
145
201
  }
202
+ this.#setState(this.options.initialState ?? {})
146
203
  }
147
204
 
148
205
  /**
149
- * Updates the rate limiter options
206
+ * Updates the async rate limiter options
150
207
  */
151
- setOptions(newOptions: Partial<AsyncRateLimiterOptions<TFn>>): void {
152
- this._options = { ...this._options, ...newOptions }
208
+ setOptions = (newOptions: Partial<AsyncRateLimiterOptions<TFn>>): void => {
209
+ this.options = { ...this.options, ...newOptions }
153
210
  }
154
211
 
155
- /**
156
- * Returns the current rate limiter options
157
- */
158
- getOptions(): AsyncRateLimiterOptions<TFn> {
159
- return this._options
212
+ #setState = (newState: Partial<AsyncRateLimiterState<TFn>>): void => {
213
+ this.store.setState((state) => {
214
+ const combinedState = {
215
+ ...state,
216
+ ...newState,
217
+ }
218
+ return combinedState
219
+ })
160
220
  }
161
221
 
162
222
  /**
163
- * Returns the current enabled state of the rate limiter
223
+ * Returns the current enabled state of the async rate limiter
164
224
  */
165
- getEnabled(): boolean {
166
- return !!parseFunctionOrValue(this._options.enabled, this)
225
+ #getEnabled = (): boolean => {
226
+ return !!parseFunctionOrValue(this.options.enabled, this)
167
227
  }
168
228
 
169
229
  /**
170
230
  * Returns the current limit of executions allowed within the time window
171
231
  */
172
- getLimit(): number {
173
- return parseFunctionOrValue(this._options.limit, this)
232
+ #getLimit = (): number => {
233
+ return parseFunctionOrValue(this.options.limit, this)
174
234
  }
175
235
 
176
236
  /**
177
237
  * Returns the current time window in milliseconds
178
238
  */
179
- getWindow(): number {
180
- return parseFunctionOrValue(this._options.window, this)
239
+ #getWindow = (): number => {
240
+ return parseFunctionOrValue(this.options.window, this)
181
241
  }
182
242
 
183
243
  /**
184
244
  * Attempts to execute the rate-limited function if within the configured limits.
185
245
  * Will reject execution if the number of calls in the current window exceeds the limit.
186
- * If execution is allowed, waits for any previous execution to complete before proceeding.
187
246
  *
188
247
  * Error Handling:
189
248
  * - If the rate-limited function throws and no `onError` handler is configured,
190
249
  * the error will be thrown from this method.
191
250
  * - If an `onError` handler is configured, errors will be caught and passed to the handler,
192
251
  * 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
252
  * - The error state can be checked using `getErrorCount()` and `getIsExecuting()`.
196
- * - Rate limit rejections can be tracked using `getRejectionCount()`.
197
253
  *
198
254
  * @returns A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError
199
255
  * @throws The error from the rate-limited function if no onError handler is configured
@@ -202,93 +258,104 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
202
258
  * ```ts
203
259
  * const rateLimiter = new AsyncRateLimiter(fn, { limit: 5, window: 1000 });
204
260
  *
205
- * // First 5 calls will execute
206
- * await rateLimiter.maybeExecute('arg1', 'arg2');
261
+ * // First 5 calls will return a promise that resolves with the result
262
+ * const result = await rateLimiter.maybeExecute('arg1', 'arg2');
207
263
  *
208
- * // Additional calls within the window will be rejected
209
- * await rateLimiter.maybeExecute('arg1', 'arg2'); // Rejected
264
+ * // Additional calls within the window will return undefined
265
+ * const result2 = await rateLimiter.maybeExecute('arg1', 'arg2'); // undefined
210
266
  * ```
211
267
  */
212
- async maybeExecute(
268
+ maybeExecute = async (
213
269
  ...args: Parameters<TFn>
214
- ): Promise<ReturnType<TFn> | undefined> {
215
- this.cleanupOldExecutions()
270
+ ): Promise<ReturnType<TFn> | undefined> => {
271
+ this.#cleanupOldExecutions()
216
272
 
217
- const limit = this.getLimit()
218
- const window = this.getWindow()
273
+ const relevantExecutionTimes = this.#getRelevantExecutionTimes()
219
274
 
220
- if (this._options.windowType === 'sliding') {
221
- // For sliding window, we can execute if we have capacity in the current window
222
- if (this._executionTimes.length < limit) {
223
- await this.execute(...args)
224
- return this._lastResult
225
- }
226
- } else {
227
- // For fixed window, we need to check if we're in a new window
228
- const now = Date.now()
229
- const oldestExecution = Math.min(...this._executionTimes)
230
- const isNewWindow = oldestExecution + window <= now
231
-
232
- if (isNewWindow || this._executionTimes.length < limit) {
233
- await this.execute(...args)
234
- return this._lastResult
235
- }
275
+ if (relevantExecutionTimes.length < this.#getLimit()) {
276
+ await this.#execute(...args)
277
+ return this.store.state.lastResult
236
278
  }
237
279
 
238
- this.rejectFunction()
280
+ this.#setState({
281
+ rejectionCount: this.store.state.rejectionCount + 1,
282
+ })
283
+ this.options.onReject?.(this)
239
284
  return undefined
240
285
  }
241
286
 
242
- private async execute(
287
+ #execute = async (
243
288
  ...args: Parameters<TFn>
244
- ): Promise<ReturnType<TFn> | undefined> {
245
- if (!this.getEnabled()) return
246
- this._isExecuting = true
289
+ ): Promise<ReturnType<TFn> | undefined> => {
290
+ if (!this.#getEnabled()) return
291
+
247
292
  const now = Date.now()
248
- this._executionTimes.push(now)
293
+ const executionTimes = [...this.store.state.executionTimes, now]
294
+ this.#setState({
295
+ isExecuting: true,
296
+ executionTimes,
297
+ })
249
298
 
250
299
  try {
251
- this._lastResult = await this.fn(...args)
252
- this._successCount++
253
- this._options.onSuccess?.(this._lastResult!, this)
300
+ const result = await this.fn(...args)
301
+ this.#setState({
302
+ successCount: this.store.state.successCount + 1,
303
+ lastResult: result,
304
+ })
305
+ this.options.onSuccess?.(result, this)
254
306
  } catch (error) {
255
- this._errorCount++
256
- this._options.onError?.(error, this)
257
- if (this._options.throwOnError) {
307
+ this.#setState({
308
+ errorCount: this.store.state.errorCount + 1,
309
+ })
310
+ this.options.onError?.(error, this)
311
+ if (this.options.throwOnError) {
258
312
  throw error
259
- } else {
260
- console.error(error)
261
313
  }
262
314
  } finally {
263
- this._isExecuting = false
264
- this._settleCount++
265
- this._options.onSettled?.(this)
315
+ this.#setState({
316
+ isExecuting: false,
317
+ settleCount: this.store.state.settleCount + 1,
318
+ })
319
+ this.options.onSettled?.(this)
266
320
  }
267
321
 
268
- return this._lastResult
322
+ return this.store.state.lastResult
269
323
  }
270
324
 
271
- private rejectFunction(): void {
272
- this._rejectionCount++
273
- if (this._options.onReject) {
274
- this._options.onReject(this)
325
+ #getRelevantExecutionTimes = (): Array<number> => {
326
+ if (this.options.windowType === 'sliding') {
327
+ // For sliding window, return all executions within the current window
328
+ return this.store.state.executionTimes.filter(
329
+ (time) => time > Date.now() - this.#getWindow(),
330
+ )
331
+ } else {
332
+ // For fixed window, return all executions in the current window
333
+ // The window starts from the oldest execution time
334
+ const oldestExecution = Math.min(...this.store.state.executionTimes)
335
+ const windowStart = oldestExecution
336
+ return this.store.state.executionTimes.filter(
337
+ (time) =>
338
+ time >= windowStart && time <= windowStart + this.#getWindow(),
339
+ )
275
340
  }
276
341
  }
277
342
 
278
- private cleanupOldExecutions(): void {
343
+ #cleanupOldExecutions = (): void => {
279
344
  const now = Date.now()
280
- const windowStart = now - this.getWindow()
281
- this._executionTimes = this._executionTimes.filter(
282
- (time) => time > windowStart,
283
- )
345
+ const windowStart = now - this.#getWindow()
346
+ this.#setState({
347
+ executionTimes: this.store.state.executionTimes.filter(
348
+ (time) => time > windowStart,
349
+ ),
350
+ })
284
351
  }
285
352
 
286
353
  /**
287
354
  * Returns the number of remaining executions allowed in the current window
288
355
  */
289
- getRemainingInWindow(): number {
290
- this.cleanupOldExecutions()
291
- return Math.max(0, this.getLimit() - this._executionTimes.length)
356
+ getRemainingInWindow = (): number => {
357
+ const relevantExecutionTimes = this.#getRelevantExecutionTimes()
358
+ return Math.max(0, this.#getLimit() - relevantExecutionTimes.length)
292
359
  }
293
360
 
294
361
  /**
@@ -296,58 +363,19 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
296
363
  * For fixed windows, this is the time until the current window resets
297
364
  * For sliding windows, this is the time until the oldest execution expires
298
365
  */
299
- getMsUntilNextWindow(): number {
366
+ getMsUntilNextWindow = (): number => {
300
367
  if (this.getRemainingInWindow() > 0) {
301
368
  return 0
302
369
  }
303
- const oldestExecution = Math.min(...this._executionTimes)
304
- return oldestExecution + this.getWindow() - Date.now()
305
- }
306
-
307
- /**
308
- * Returns the number of times the function has been executed
309
- */
310
- getSuccessCount(): number {
311
- return this._successCount
312
- }
313
-
314
- /**
315
- * Returns the number of times the function has been settled
316
- */
317
- getSettleCount(): number {
318
- return this._settleCount
319
- }
320
-
321
- /**
322
- * Returns the number of times the function has errored
323
- */
324
- getErrorCount(): number {
325
- return this._errorCount
326
- }
327
-
328
- /**
329
- * Returns the number of times the function has been rejected
330
- */
331
- getRejectionCount(): number {
332
- return this._rejectionCount
333
- }
334
-
335
- /**
336
- * Returns whether the function is currently executing
337
- */
338
- getIsExecuting(): boolean {
339
- return this._isExecuting
370
+ const oldestExecution = this.store.state.executionTimes[0] ?? Infinity
371
+ return oldestExecution + this.#getWindow() - Date.now()
340
372
  }
341
373
 
342
374
  /**
343
375
  * Resets the rate limiter state
344
376
  */
345
- reset(): void {
346
- this._executionTimes = []
347
- this._successCount = 0
348
- this._errorCount = 0
349
- this._rejectionCount = 0
350
- this._settleCount = 0
377
+ reset = (): void => {
378
+ this.#setState(getDefaultAsyncRateLimiterState())
351
379
  }
352
380
  }
353
381
 
@@ -369,6 +397,18 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
369
397
  * - A throttler ensures even spacing between executions, which can be better for consistent performance
370
398
  * - A debouncer collapses multiple calls into one, which is better for handling bursts of events
371
399
  *
400
+ * State Management:
401
+ * - Uses TanStack Store for reactive state management
402
+ * - Use `initialState` to provide initial state values when creating the rate limiter
403
+ * - `initialState` can be a partial state object
404
+ * - Use `onSuccess` callback to react to successful function execution and implement custom logic
405
+ * - Use `onError` callback to react to function execution errors and implement custom error handling
406
+ * - Use `onSettled` callback to react to function execution completion (success or error) and implement custom logic
407
+ * - Use `onReject` callback to react to executions being rejected when rate limit is exceeded
408
+ * - The state includes execution times, success/error counts, and current execution status
409
+ * - State can be accessed via the underlying AsyncRateLimiter instance's `store.state` property
410
+ * - When using framework adapters (React/Solid), state is accessed from the hook's state property
411
+ *
372
412
  * Consider using throttle() or debounce() if you need more intelligent execution control. Use rate limiting when you specifically
373
413
  * need to enforce a hard limit on the number of executions within a time period.
374
414
  *
@@ -409,5 +449,5 @@ export function asyncRateLimit<TFn extends AnyAsyncFunction>(
409
449
  initialOptions: AsyncRateLimiterOptions<TFn>,
410
450
  ) {
411
451
  const rateLimiter = new AsyncRateLimiter(fn, initialOptions)
412
- return rateLimiter.maybeExecute.bind(rateLimiter)
452
+ return rateLimiter.maybeExecute
413
453
  }