@tanstack/pacer 0.15.4 → 0.16.1

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 (99) hide show
  1. package/dist/cjs/async-batcher.cjs +66 -5
  2. package/dist/cjs/async-batcher.cjs.map +1 -1
  3. package/dist/cjs/async-batcher.d.cts +94 -17
  4. package/dist/cjs/async-debouncer.cjs +53 -19
  5. package/dist/cjs/async-debouncer.cjs.map +1 -1
  6. package/dist/cjs/async-debouncer.d.cts +76 -10
  7. package/dist/cjs/async-queuer.cjs +58 -2
  8. package/dist/cjs/async-queuer.cjs.map +1 -1
  9. package/dist/cjs/async-queuer.d.cts +100 -10
  10. package/dist/cjs/async-rate-limiter.cjs +51 -2
  11. package/dist/cjs/async-rate-limiter.cjs.map +1 -1
  12. package/dist/cjs/async-rate-limiter.d.cts +91 -28
  13. package/dist/cjs/async-retryer.cjs +285 -0
  14. package/dist/cjs/async-retryer.cjs.map +1 -0
  15. package/dist/cjs/async-retryer.d.cts +308 -0
  16. package/dist/cjs/async-throttler.cjs +101 -53
  17. package/dist/cjs/async-throttler.cjs.map +1 -1
  18. package/dist/cjs/async-throttler.d.cts +82 -10
  19. package/dist/cjs/batcher.cjs +5 -1
  20. package/dist/cjs/batcher.cjs.map +1 -1
  21. package/dist/cjs/batcher.d.cts +10 -2
  22. package/dist/cjs/debouncer.cjs +5 -1
  23. package/dist/cjs/debouncer.cjs.map +1 -1
  24. package/dist/cjs/debouncer.d.cts +7 -3
  25. package/dist/cjs/event-client.cjs +3 -1
  26. package/dist/cjs/event-client.cjs.map +1 -1
  27. package/dist/cjs/event-client.d.cts +3 -0
  28. package/dist/cjs/index.cjs +13 -1
  29. package/dist/cjs/index.cjs.map +1 -1
  30. package/dist/cjs/index.d.cts +1 -0
  31. package/dist/cjs/queuer.cjs +5 -1
  32. package/dist/cjs/queuer.cjs.map +1 -1
  33. package/dist/cjs/queuer.d.cts +8 -4
  34. package/dist/cjs/rate-limiter.cjs +5 -1
  35. package/dist/cjs/rate-limiter.cjs.map +1 -1
  36. package/dist/cjs/rate-limiter.d.cts +8 -1
  37. package/dist/cjs/throttler.cjs +5 -1
  38. package/dist/cjs/throttler.cjs.map +1 -1
  39. package/dist/cjs/throttler.d.cts +7 -0
  40. package/dist/cjs/utils.cjs +0 -10
  41. package/dist/cjs/utils.cjs.map +1 -1
  42. package/dist/cjs/utils.d.cts +0 -1
  43. package/dist/esm/async-batcher.d.ts +94 -17
  44. package/dist/esm/async-batcher.js +68 -7
  45. package/dist/esm/async-batcher.js.map +1 -1
  46. package/dist/esm/async-debouncer.d.ts +76 -10
  47. package/dist/esm/async-debouncer.js +55 -21
  48. package/dist/esm/async-debouncer.js.map +1 -1
  49. package/dist/esm/async-queuer.d.ts +100 -10
  50. package/dist/esm/async-queuer.js +60 -4
  51. package/dist/esm/async-queuer.js.map +1 -1
  52. package/dist/esm/async-rate-limiter.d.ts +91 -28
  53. package/dist/esm/async-rate-limiter.js +53 -4
  54. package/dist/esm/async-rate-limiter.js.map +1 -1
  55. package/dist/esm/async-retryer.d.ts +308 -0
  56. package/dist/esm/async-retryer.js +285 -0
  57. package/dist/esm/async-retryer.js.map +1 -0
  58. package/dist/esm/async-throttler.d.ts +82 -10
  59. package/dist/esm/async-throttler.js +103 -55
  60. package/dist/esm/async-throttler.js.map +1 -1
  61. package/dist/esm/batcher.d.ts +10 -2
  62. package/dist/esm/batcher.js +6 -2
  63. package/dist/esm/batcher.js.map +1 -1
  64. package/dist/esm/debouncer.d.ts +7 -3
  65. package/dist/esm/debouncer.js +7 -3
  66. package/dist/esm/debouncer.js.map +1 -1
  67. package/dist/esm/event-client.d.ts +3 -0
  68. package/dist/esm/event-client.js +3 -1
  69. package/dist/esm/event-client.js.map +1 -1
  70. package/dist/esm/index.d.ts +1 -0
  71. package/dist/esm/index.js +24 -12
  72. package/dist/esm/index.js.map +1 -1
  73. package/dist/esm/queuer.d.ts +8 -4
  74. package/dist/esm/queuer.js +7 -3
  75. package/dist/esm/queuer.js.map +1 -1
  76. package/dist/esm/rate-limiter.d.ts +8 -1
  77. package/dist/esm/rate-limiter.js +7 -3
  78. package/dist/esm/rate-limiter.js.map +1 -1
  79. package/dist/esm/throttler.d.ts +7 -0
  80. package/dist/esm/throttler.js +7 -3
  81. package/dist/esm/throttler.js.map +1 -1
  82. package/dist/esm/utils.d.ts +0 -1
  83. package/dist/esm/utils.js +0 -10
  84. package/dist/esm/utils.js.map +1 -1
  85. package/package.json +13 -3
  86. package/src/async-batcher.ts +144 -22
  87. package/src/async-debouncer.ts +118 -33
  88. package/src/async-queuer.ts +147 -14
  89. package/src/async-rate-limiter.ts +129 -33
  90. package/src/async-retryer.ts +664 -0
  91. package/src/async-throttler.ts +196 -75
  92. package/src/batcher.ts +16 -4
  93. package/src/debouncer.ts +17 -5
  94. package/src/event-client.ts +6 -1
  95. package/src/index.ts +2 -0
  96. package/src/queuer.ts +19 -6
  97. package/src/rate-limiter.ts +18 -3
  98. package/src/throttler.ts +17 -2
  99. package/src/utils.ts +0 -15
@@ -1,6 +1,8 @@
1
1
  import { Store } from '@tanstack/store'
2
- import { createKey, parseFunctionOrValue } from './utils'
2
+ import { AsyncRetryer } from './async-retryer'
3
+ import { parseFunctionOrValue } from './utils'
3
4
  import { emitChange, pacerEventClient } from './event-client'
5
+ import type { AsyncRetryerOptions } from './async-retryer'
4
6
  import type { AnyAsyncFunction } from './types'
5
7
 
6
8
  export interface AsyncRateLimiterState<TFn extends AnyAsyncFunction> {
@@ -67,6 +69,10 @@ function getDefaultAsyncRateLimiterState<
67
69
  * Options for configuring an async rate-limited function
68
70
  */
69
71
  export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
72
+ /**
73
+ * Options for configuring the underlying async retryer
74
+ */
75
+ asyncRetryerOptions?: AsyncRetryerOptions<TFn>
70
76
  /**
71
77
  * Whether the rate limiter is enabled. When disabled, maybeExecute will not trigger any executions.
72
78
  * Can be a boolean or a function that returns a boolean.
@@ -93,7 +99,7 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
93
99
  * This can be used alongside throwOnError - the handler will be called before any error is thrown.
94
100
  */
95
101
  onError?: (
96
- error: unknown,
102
+ error: Error,
97
103
  args: Parameters<TFn>,
98
104
  rateLimiter: AsyncRateLimiter<TFn>,
99
105
  ) => void
@@ -136,10 +142,25 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
136
142
  windowType?: 'fixed' | 'sliding'
137
143
  }
138
144
 
145
+ /**
146
+ * Utility function for sharing common `AsyncRateLimiterOptions` options between different `AsyncRateLimiter` instances.
147
+ */
148
+ export function asyncRateLimiterOptions<
149
+ TFn extends AnyAsyncFunction = AnyAsyncFunction,
150
+ TOptions extends Partial<AsyncRateLimiterOptions<TFn>> = Partial<
151
+ AsyncRateLimiterOptions<TFn>
152
+ >,
153
+ >(options: TOptions): TOptions {
154
+ return options
155
+ }
156
+
139
157
  const defaultOptions: Omit<
140
158
  Required<AsyncRateLimiterOptions<any>>,
141
159
  'initialState' | 'onError' | 'onReject' | 'onSettled' | 'onSuccess' | 'key'
142
160
  > = {
161
+ asyncRetryerOptions: {
162
+ maxAttempts: 1,
163
+ },
143
164
  enabled: true,
144
165
  limit: 1,
145
166
  window: 0,
@@ -150,26 +171,34 @@ const defaultOptions: Omit<
150
171
  /**
151
172
  * A class that creates an async rate-limited function.
152
173
  *
153
- * Rate limiting is a simple approach that allows a function to execute up to a limit within a time window,
174
+ * Async vs Sync Versions:
175
+ * The async version provides advanced features over the sync RateLimiter:
176
+ * - Returns promises that can be awaited for rate-limited function results
177
+ * - Built-in retry support via AsyncRetryer integration
178
+ * - Abort support to cancel in-flight executions
179
+ * - Comprehensive error handling with onError callbacks and throwOnError control
180
+ * - Detailed execution tracking (success/error/settle counts, rejection counts)
181
+ * - More sophisticated window management with automatic cleanup
182
+ *
183
+ * The sync RateLimiter is lighter weight and simpler when you don't need async features,
184
+ * return values, or execution control.
185
+ *
186
+ * What is Rate Limiting?
187
+ * Rate limiting allows a function to execute up to a limit within a time window,
154
188
  * then blocks all subsequent calls until the window passes. This can lead to "bursty" behavior where
155
189
  * all executions happen immediately, followed by a complete block.
156
190
  *
157
- * The rate limiter supports two types of windows:
191
+ * Window Types:
158
192
  * - 'fixed': A strict window that resets after the window period. All executions within the window count
159
193
  * towards the limit, and the window resets completely after the period.
160
194
  * - 'sliding': A rolling window that allows executions as old ones expire. This provides a more
161
195
  * consistent rate of execution over time.
162
196
  *
163
- * Unlike the non-async RateLimiter, this async version supports returning values from the rate-limited function,
164
- * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
165
- * instead of setting the result on a state variable from within the rate-limited function.
166
- *
167
- * For smoother execution patterns, consider using:
168
- * - Throttling: Ensures consistent spacing between executions (e.g. max once per 200ms)
169
- * - Debouncing: Waits for a pause in calls before executing (e.g. after 500ms of no calls)
170
- *
197
+ * When to Use Rate Limiting:
171
198
  * Rate limiting is best used for hard API limits or resource constraints. For UI updates or
172
199
  * smoothing out frequent events, throttling or debouncing usually provide better user experience.
200
+ * - Throttling: Ensures consistent spacing between executions (e.g. max once per 200ms)
201
+ * - Debouncing: Waits for a pause in calls before executing (e.g. after 500ms of no calls)
173
202
  *
174
203
  * State Management:
175
204
  * - Uses TanStack Store for reactive state management
@@ -217,15 +246,16 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
217
246
  readonly store: Store<Readonly<AsyncRateLimiterState<TFn>>> = new Store<
218
247
  AsyncRateLimiterState<TFn>
219
248
  >(getDefaultAsyncRateLimiterState<TFn>())
220
- key: string
249
+ key: string | undefined
221
250
  options: AsyncRateLimiterOptions<TFn>
251
+ asyncRetryers = new Map<number, AsyncRetryer<TFn>>()
222
252
  #timeoutIds: Set<NodeJS.Timeout> = new Set()
223
253
 
224
254
  constructor(
225
255
  public fn: TFn,
226
256
  initialOptions: AsyncRateLimiterOptions<TFn>,
227
257
  ) {
228
- this.key = createKey(initialOptions.key)
258
+ this.key = initialOptions.key
229
259
  this.options = {
230
260
  ...defaultOptions,
231
261
  ...initialOptions,
@@ -347,6 +377,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
347
377
  ): Promise<ReturnType<TFn> | undefined> => {
348
378
  if (!this.#getEnabled()) return
349
379
 
380
+ const currentMaybeExecute = this.store.state.maybeExecuteCount
350
381
  const now = Date.now()
351
382
  const executionTimes = [...this.store.state.executionTimes, now]
352
383
  this.#setState({
@@ -355,22 +386,29 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
355
386
  })
356
387
 
357
388
  try {
358
- const result = await this.fn(...args) // EXECUTE!
389
+ // Create a new AsyncRetryer for this execution to avoid cancelling concurrent executions
390
+ const currentAsyncRetryer = new AsyncRetryer(this.fn, {
391
+ ...this.options.asyncRetryerOptions,
392
+ key: `${this.key}-retryer-${currentMaybeExecute}`,
393
+ })
394
+ this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer)
395
+ const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
359
396
  this.#setCleanupTimeout(now)
360
397
  this.#setState({
361
398
  successCount: this.store.state.successCount + 1,
362
399
  lastResult: result,
363
400
  })
364
- this.options.onSuccess?.(result, args, this)
401
+ this.options.onSuccess?.(result as ReturnType<TFn>, args, this)
365
402
  } catch (error) {
366
403
  this.#setState({
367
404
  errorCount: this.store.state.errorCount + 1,
368
405
  })
369
- this.options.onError?.(error, args, this)
406
+ this.options.onError?.(error as Error, args, this)
370
407
  if (this.options.throwOnError) {
371
408
  throw error
372
409
  }
373
410
  } finally {
411
+ this.asyncRetryers.delete(currentMaybeExecute) // dispose retryer
374
412
  this.#setState({
375
413
  isExecuting: false,
376
414
  settleCount: this.store.state.settleCount + 1,
@@ -462,33 +500,102 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
462
500
  return oldestExecution + this.#getWindow() - Date.now()
463
501
  }
464
502
 
503
+ /**
504
+ * Returns the AbortSignal for a specific execution.
505
+ * If no maybeExecuteCount is provided, returns the signal for the most recent execution.
506
+ * Returns null if no execution is found or not currently executing.
507
+ *
508
+ * @param maybeExecuteCount - Optional specific execution to get signal for
509
+ * @example
510
+ * ```typescript
511
+ * const rateLimiter = new AsyncRateLimiter(
512
+ * async (userId: string) => {
513
+ * const signal = rateLimiter.getAbortSignal()
514
+ * if (signal) {
515
+ * const response = await fetch(`/api/users/${userId}`, { signal })
516
+ * return response.json()
517
+ * }
518
+ * },
519
+ * { limit: 5, window: 1000 }
520
+ * )
521
+ * ```
522
+ */
523
+ getAbortSignal(maybeExecuteCount?: number): AbortSignal | null {
524
+ const count = maybeExecuteCount ?? this.store.state.maybeExecuteCount
525
+ const retryer = this.asyncRetryers.get(count)
526
+ return retryer?.getAbortSignal() ?? null
527
+ }
528
+
529
+ /**
530
+ * Aborts all ongoing executions with the internal abort controllers.
531
+ * Does NOT clear out the execution times or reset the rate limiter.
532
+ */
533
+ abort = (): void => {
534
+ this.asyncRetryers.forEach((retryer) => retryer.abort())
535
+ this.asyncRetryers.clear()
536
+ this.#setState({
537
+ isExecuting: false,
538
+ })
539
+ }
540
+
465
541
  /**
466
542
  * Resets the rate limiter state
467
543
  */
468
544
  reset = (): void => {
469
545
  this.#setState(getDefaultAsyncRateLimiterState())
470
546
  this.#clearTimeouts()
547
+ this.asyncRetryers.forEach((retryer) => retryer.reset())
471
548
  }
472
549
  }
473
550
 
474
551
  /**
475
552
  * Creates an async rate-limited function that will execute the provided function up to a maximum number of times within a time window.
476
553
  *
477
- * Unlike the non-async rate limiter, this async version supports returning values from the rate-limited function,
478
- * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
479
- * instead of setting the result on a state variable from within the rate-limited function.
554
+ * Async vs Sync Versions:
555
+ * The async version provides advanced features over the sync rate limit function:
556
+ * - Returns promises that can be awaited for rate-limited function results
557
+ * - Built-in retry support via AsyncRetryer integration
558
+ * - Abort support to cancel in-flight executions
559
+ * - Comprehensive error handling with onError callbacks and throwOnError control
560
+ * - Detailed execution tracking (success/error/settle counts, rejection counts)
561
+ * - More sophisticated window management with automatic cleanup
480
562
  *
481
- * The rate limiter supports two types of windows:
563
+ * The sync rate limit function is lighter weight and simpler when you don't need async features,
564
+ * return values, or execution control.
565
+ *
566
+ * What is Rate Limiting?
567
+ * Rate limiting allows a function to execute up to a limit within a time window,
568
+ * then blocks all subsequent calls until the window passes. This can lead to "bursty" behavior where
569
+ * all executions happen immediately, followed by a complete block.
570
+ *
571
+ * Window Types:
482
572
  * - 'fixed': A strict window that resets after the window period. All executions within the window count
483
573
  * towards the limit, and the window resets completely after the period.
484
574
  * - 'sliding': A rolling window that allows executions as old ones expire. This provides a more
485
575
  * consistent rate of execution over time.
486
576
  *
487
- * Note that rate limiting is a simpler form of execution control compared to throttling or debouncing:
577
+ * Configuration Options:
578
+ * - `limit`: Maximum number of executions allowed within the window (required)
579
+ * - `window`: Time window in milliseconds (required)
580
+ * - `windowType`: 'fixed' or 'sliding' (default: 'fixed')
581
+ * - `enabled`: Whether the rate limiter is enabled (default: true)
582
+ * - `asyncRetryerOptions`: Configure retry behavior for executions
583
+ *
584
+ * When to Use Rate Limiting:
585
+ * Rate limiting is best used for hard API limits or resource constraints. For UI updates or
586
+ * smoothing out frequent events, throttling or debouncing usually provide better user experience.
488
587
  * - A rate limiter will allow all executions until the limit is reached, then block all subsequent calls until the window resets
489
588
  * - A throttler ensures even spacing between executions, which can be better for consistent performance
490
589
  * - A debouncer collapses multiple calls into one, which is better for handling bursts of events
491
590
  *
591
+ * Error Handling:
592
+ * - If an `onError` handler is provided, it will be called with the error and rate limiter instance
593
+ * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
594
+ * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
595
+ * - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
596
+ * - The error state can be checked using the underlying AsyncRateLimiter instance
597
+ * - Rate limit rejections (when limit is exceeded) are handled separately from execution errors via the `onReject` handler
598
+ *
492
599
  * State Management:
493
600
  * - Uses TanStack Store for reactive state management
494
601
  * - Use `initialState` to provide initial state values when creating the rate limiter
@@ -501,17 +608,6 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
501
608
  * - State can be accessed via the underlying AsyncRateLimiter instance's `store.state` property
502
609
  * - When using framework adapters (React/Solid), state is accessed from the hook's state property
503
610
  *
504
- * Consider using throttle() or debounce() if you need more intelligent execution control. Use rate limiting when you specifically
505
- * need to enforce a hard limit on the number of executions within a time period.
506
- *
507
- * Error Handling:
508
- * - If an `onError` handler is provided, it will be called with the error and rate limiter instance
509
- * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
510
- * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
511
- * - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
512
- * - The error state can be checked using the underlying AsyncRateLimiter instance
513
- * - Rate limit rejections (when limit is exceeded) are handled separately from execution errors via the `onReject` handler
514
- *
515
611
  * @example
516
612
  * ```ts
517
613
  * // Rate limit to 5 calls per minute with a sliding window