@tanstack/pacer 0.3.0 → 0.5.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 (67) hide show
  1. package/dist/cjs/async-debouncer.cjs +16 -3
  2. package/dist/cjs/async-debouncer.cjs.map +1 -1
  3. package/dist/cjs/async-debouncer.d.cts +30 -12
  4. package/dist/cjs/async-queuer.cjs +20 -6
  5. package/dist/cjs/async-queuer.cjs.map +1 -1
  6. package/dist/cjs/async-queuer.d.cts +21 -8
  7. package/dist/cjs/async-rate-limiter.cjs +55 -8
  8. package/dist/cjs/async-rate-limiter.cjs.map +1 -1
  9. package/dist/cjs/async-rate-limiter.d.cts +64 -12
  10. package/dist/cjs/async-throttler.cjs +19 -5
  11. package/dist/cjs/async-throttler.cjs.map +1 -1
  12. package/dist/cjs/async-throttler.d.cts +31 -12
  13. package/dist/cjs/debouncer.cjs +16 -3
  14. package/dist/cjs/debouncer.cjs.map +1 -1
  15. package/dist/cjs/debouncer.d.cts +14 -4
  16. package/dist/cjs/index.cjs +2 -0
  17. package/dist/cjs/index.cjs.map +1 -1
  18. package/dist/cjs/queuer.cjs +13 -5
  19. package/dist/cjs/queuer.cjs.map +1 -1
  20. package/dist/cjs/queuer.d.cts +9 -3
  21. package/dist/cjs/rate-limiter.cjs +41 -8
  22. package/dist/cjs/rate-limiter.cjs.map +1 -1
  23. package/dist/cjs/rate-limiter.d.cts +42 -8
  24. package/dist/cjs/throttler.cjs +19 -5
  25. package/dist/cjs/throttler.cjs.map +1 -1
  26. package/dist/cjs/throttler.d.cts +15 -4
  27. package/dist/cjs/utils.cjs +18 -7
  28. package/dist/cjs/utils.cjs.map +1 -1
  29. package/dist/cjs/utils.d.cts +3 -0
  30. package/dist/esm/async-debouncer.d.ts +30 -12
  31. package/dist/esm/async-debouncer.js +16 -3
  32. package/dist/esm/async-debouncer.js.map +1 -1
  33. package/dist/esm/async-queuer.d.ts +21 -8
  34. package/dist/esm/async-queuer.js +20 -6
  35. package/dist/esm/async-queuer.js.map +1 -1
  36. package/dist/esm/async-rate-limiter.d.ts +64 -12
  37. package/dist/esm/async-rate-limiter.js +55 -8
  38. package/dist/esm/async-rate-limiter.js.map +1 -1
  39. package/dist/esm/async-throttler.d.ts +31 -12
  40. package/dist/esm/async-throttler.js +19 -5
  41. package/dist/esm/async-throttler.js.map +1 -1
  42. package/dist/esm/debouncer.d.ts +14 -4
  43. package/dist/esm/debouncer.js +16 -3
  44. package/dist/esm/debouncer.js.map +1 -1
  45. package/dist/esm/index.js +3 -1
  46. package/dist/esm/queuer.d.ts +9 -3
  47. package/dist/esm/queuer.js +13 -5
  48. package/dist/esm/queuer.js.map +1 -1
  49. package/dist/esm/rate-limiter.d.ts +42 -8
  50. package/dist/esm/rate-limiter.js +41 -8
  51. package/dist/esm/rate-limiter.js.map +1 -1
  52. package/dist/esm/throttler.d.ts +15 -4
  53. package/dist/esm/throttler.js +19 -5
  54. package/dist/esm/throttler.js.map +1 -1
  55. package/dist/esm/utils.d.ts +3 -0
  56. package/dist/esm/utils.js +19 -8
  57. package/dist/esm/utils.js.map +1 -1
  58. package/package.json +9 -3
  59. package/src/async-debouncer.ts +40 -15
  60. package/src/async-queuer.ts +32 -13
  61. package/src/async-rate-limiter.ts +107 -19
  62. package/src/async-throttler.ts +44 -17
  63. package/src/debouncer.ts +24 -7
  64. package/src/queuer.ts +19 -7
  65. package/src/rate-limiter.ts +76 -16
  66. package/src/throttler.ts +28 -9
  67. package/src/utils.ts +19 -5
@@ -1,3 +1,4 @@
1
+ import { parseFunctionOrValue } from './utils'
1
2
  import type { AnyAsyncFunction } from './types'
2
3
 
3
4
  /**
@@ -6,9 +7,10 @@ import type { AnyAsyncFunction } from './types'
6
7
  export interface AsyncDebouncerOptions<TFn extends AnyAsyncFunction> {
7
8
  /**
8
9
  * Whether the debouncer is enabled. When disabled, maybeExecute will not trigger any executions.
10
+ * Can be a boolean or a function that returns a boolean.
9
11
  * Defaults to true.
10
12
  */
11
- enabled?: boolean
13
+ enabled?: boolean | ((debouncer: AsyncDebouncer<TFn>) => boolean)
12
14
  /**
13
15
  * Whether to execute on the leading edge of the timeout.
14
16
  * Defaults to false.
@@ -32,10 +34,11 @@ export interface AsyncDebouncerOptions<TFn extends AnyAsyncFunction> {
32
34
  */
33
35
  trailing?: boolean
34
36
  /**
35
- * Delay in milliseconds to wait after the last call before executing
37
+ * Delay in milliseconds to wait after the last call before executing.
38
+ * Can be a number or a function that returns a number.
36
39
  * Defaults to 0ms
37
40
  */
38
- wait: number
41
+ wait: number | ((debouncer: AsyncDebouncer<TFn>) => number)
39
42
  }
40
43
 
41
44
  const defaultOptions: Required<AsyncDebouncerOptions<any>> = {
@@ -58,16 +61,20 @@ const defaultOptions: Required<AsyncDebouncerOptions<any>> = {
58
61
  * Unlike throttling which allows execution at regular intervals, debouncing prevents any execution until
59
62
  * the function stops being called for the specified delay period.
60
63
  *
64
+ * Unlike the non-async Debouncer, this async version supports returning values from the debounced function,
65
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
66
+ * instead of setting the result on a state variable from within the debounced function.
67
+ *
61
68
  * @example
62
69
  * ```ts
63
70
  * const asyncDebouncer = new AsyncDebouncer(async (value: string) => {
64
- * await searchAPI(value);
71
+ * const results = await searchAPI(value);
72
+ * return results; // Return value is preserved
65
73
  * }, { wait: 500 });
66
74
  *
67
75
  * // Called on each keystroke but only executes after 500ms of no typing
68
- * inputElement.addEventListener('input', () => {
69
- * asyncDebouncer.maybeExecute(inputElement.value);
70
- * });
76
+ * // Returns the API response directly
77
+ * const results = await asyncDebouncer.maybeExecute(inputElement.value);
71
78
  * ```
72
79
  */
73
80
  export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
@@ -113,6 +120,20 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
113
120
  return this._options
114
121
  }
115
122
 
123
+ /**
124
+ * Returns the current debouncer enabled state
125
+ */
126
+ getEnabled(): boolean {
127
+ return parseFunctionOrValue(this._options.enabled, this)
128
+ }
129
+
130
+ /**
131
+ * Returns the current debouncer wait state
132
+ */
133
+ getWait(): number {
134
+ return parseFunctionOrValue(this._options.wait, this)
135
+ }
136
+
116
137
  /**
117
138
  * Attempts to execute the debounced function
118
139
  * If a call is already in progress, it will be queued
@@ -145,14 +166,14 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
145
166
  // Reset state and resolve
146
167
  this._canLeadingExecute = true
147
168
  resolve(this._lastResult)
148
- }, this._options.wait)
169
+ }, this.getWait())
149
170
  })
150
171
  }
151
172
 
152
173
  private async executeFunction(
153
174
  ...args: Parameters<TFn>
154
175
  ): Promise<ReturnType<TFn> | undefined> {
155
- if (!this._options.enabled) return undefined
176
+ if (!this.getEnabled()) return undefined
156
177
  this._abortController = new AbortController()
157
178
  try {
158
179
  this._isExecuting = true
@@ -229,7 +250,7 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
229
250
  * Returns `true` if there is a pending execution queued up for trailing execution
230
251
  */
231
252
  getIsPending(): boolean {
232
- return this._options.enabled && this._isPending
253
+ return this.getEnabled() && this._isPending
233
254
  }
234
255
 
235
256
  /**
@@ -245,21 +266,25 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
245
266
  * The debounced function will only execute once the wait period has elapsed without any new calls.
246
267
  * If called again during the wait period, the timer resets and a new wait period begins.
247
268
  *
269
+ * Unlike the non-async Debouncer, this async version supports returning values from the debounced function,
270
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
271
+ * instead of setting the result on a state variable from within the debounced function.
272
+ *
248
273
  * @example
249
274
  * ```ts
250
275
  * const debounced = asyncDebounce(async (value: string) => {
251
- * await saveToAPI(value);
276
+ * const result = await saveToAPI(value);
277
+ * return result; // Return value is preserved
252
278
  * }, { wait: 1000 });
253
279
  *
254
280
  * // Will only execute once, 1 second after the last call
255
- * await debounced("first"); // Cancelled
256
- * await debounced("second"); // Cancelled
257
- * await debounced("third"); // Executes after 1s
281
+ * // Returns the API response directly
282
+ * const result = await debounced("third");
258
283
  * ```
259
284
  */
260
285
  export function asyncDebounce<TFn extends AnyAsyncFunction>(
261
286
  fn: TFn,
262
- initialOptions: Omit<AsyncDebouncerOptions<TFn>, 'enabled'>,
287
+ initialOptions: AsyncDebouncerOptions<TFn>,
263
288
  ) {
264
289
  const asyncDebouncer = new AsyncDebouncer(fn, initialOptions)
265
290
  return asyncDebouncer.maybeExecute.bind(asyncDebouncer)
@@ -1,3 +1,5 @@
1
+ import { parseFunctionOrValue } from './utils'
2
+ import type { AnyAsyncFunction } from './types'
1
3
  import type { QueuePosition } from './queuer'
2
4
 
3
5
  export interface AsyncQueuerOptions<TValue> {
@@ -7,9 +9,11 @@ export interface AsyncQueuerOptions<TValue> {
7
9
  */
8
10
  addItemsTo?: QueuePosition
9
11
  /**
10
- * Maximum number of concurrent tasks to process
12
+ * Maximum number of concurrent tasks to process.
13
+ * Can be a number or a function that returns a number.
14
+ * @default 1
11
15
  */
12
- concurrency?: number
16
+ concurrency?: number | ((queuer: AsyncQueuer<TValue>) => number)
13
17
  /**
14
18
  * Maximum time in milliseconds that an item can stay in the queue
15
19
  * If not provided, items will never expire
@@ -67,9 +71,11 @@ export interface AsyncQueuerOptions<TValue> {
67
71
  */
68
72
  started?: boolean
69
73
  /**
70
- * Time in milliseconds to wait between processing items
74
+ * Time in milliseconds to wait between processing items.
75
+ * Can be a number or a function that returns a number.
76
+ * @default 0
71
77
  */
72
- wait?: number
78
+ wait?: number | ((queuer: AsyncQueuer<TValue>) => number)
73
79
  }
74
80
 
75
81
  const defaultOptions: Required<AsyncQueuerOptions<any>> = {
@@ -160,6 +166,20 @@ export class AsyncQueuer<TValue> {
160
166
  return this._options
161
167
  }
162
168
 
169
+ /**
170
+ * Returns the current wait time between processing items
171
+ */
172
+ getWait(): number {
173
+ return parseFunctionOrValue(this._options.wait, this)
174
+ }
175
+
176
+ /**
177
+ * Returns the current concurrency limit
178
+ */
179
+ getConcurrency(): number {
180
+ return parseFunctionOrValue(this._options.concurrency, this)
181
+ }
182
+
163
183
  /**
164
184
  * Processes items in the queuer
165
185
  */
@@ -173,7 +193,7 @@ export class AsyncQueuer<TValue> {
173
193
  this.checkExpiredItems()
174
194
 
175
195
  while (
176
- this._activeItems.size < this._options.concurrency &&
196
+ this._activeItems.size < this.getConcurrency() &&
177
197
  !this.getIsEmpty()
178
198
  ) {
179
199
  const nextFn = this.getNextItem()
@@ -204,8 +224,9 @@ export class AsyncQueuer<TValue> {
204
224
  }
205
225
  this._onSettledCallbacks.forEach((cb) => cb(success ? res : error!))
206
226
 
207
- if (this._options.wait > 0) {
208
- setTimeout(() => this.tick(), this._options.wait)
227
+ const wait = this.getWait()
228
+ if (wait > 0) {
229
+ setTimeout(() => this.tick(), wait)
209
230
  return
210
231
  }
211
232
 
@@ -322,9 +343,9 @@ export class AsyncQueuer<TValue> {
322
343
  * Adds a task to the queuer
323
344
  */
324
345
  addItem(
325
- fn: (() => Promise<TValue>) & { priority?: number },
346
+ fn: AnyAsyncFunction & { priority?: number },
326
347
  position: QueuePosition = this._options.addItemsTo,
327
- runOnUpdate: boolean = true,
348
+ runOnItemsChange: boolean = true,
328
349
  ): Promise<TValue> {
329
350
  if (this.getIsFull()) {
330
351
  this._rejectionCount++
@@ -381,7 +402,7 @@ export class AsyncQueuer<TValue> {
381
402
  }
382
403
  }
383
404
 
384
- if (runOnUpdate) {
405
+ if (runOnItemsChange) {
385
406
  this._options.onItemsChange(this)
386
407
  }
387
408
 
@@ -557,9 +578,7 @@ export class AsyncQueuer<TValue> {
557
578
  * @param options - Configuration options for the AsyncQueuer
558
579
  * @returns A bound addItem function that can be used to add tasks to the queuer
559
580
  */
560
- export function asyncQueue<TValue>(
561
- options: Omit<AsyncQueuerOptions<TValue>, 'started'> = {},
562
- ) {
581
+ export function asyncQueue<TValue>(options: AsyncQueuerOptions<TValue>) {
563
582
  const queuer = new AsyncQueuer<TValue>(options)
564
583
  return queuer.addItem.bind(queuer)
565
584
  }
@@ -1,3 +1,4 @@
1
+ import { parseFunctionOrValue } from './utils'
1
2
  import type { AnyAsyncFunction } from './types'
2
3
 
3
4
  /**
@@ -6,17 +7,23 @@ import type { AnyAsyncFunction } from './types'
6
7
  export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
7
8
  /**
8
9
  * Whether the rate limiter is enabled. When disabled, maybeExecute will not trigger any executions.
10
+ * Can be a boolean or a function that returns a boolean.
9
11
  * Defaults to true.
10
12
  */
11
- enabled?: boolean
13
+ enabled?: boolean | ((rateLimiter: AsyncRateLimiter<TFn>) => boolean)
12
14
  /**
13
- * Maximum number of executions allowed within the time window
15
+ * Maximum number of executions allowed within the time window.
16
+ * Can be a number or a function that returns a number.
14
17
  */
15
- limit: number
18
+ limit: number | ((rateLimiter: AsyncRateLimiter<TFn>) => number)
16
19
  /**
17
20
  * Optional error handler for when the rate-limited function throws
18
21
  */
19
22
  onError?: (error: unknown, rateLimiter: AsyncRateLimiter<TFn>) => void
23
+ /**
24
+ * Optional callback function that is called when an execution is rejected due to rate limiting
25
+ */
26
+ onReject?: (rateLimiter: AsyncRateLimiter<TFn>) => void
20
27
  /**
21
28
  * Optional function to call when the rate-limited function is executed
22
29
  */
@@ -29,13 +36,17 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
29
36
  rateLimiter: AsyncRateLimiter<TFn>,
30
37
  ) => void
31
38
  /**
32
- * Optional callback function that is called when an execution is rejected due to rate limiting
39
+ * Time window in milliseconds within which the limit applies.
40
+ * Can be a number or a function that returns a number.
33
41
  */
34
- onReject?: (rateLimiter: AsyncRateLimiter<TFn>) => void
42
+ window: number | ((rateLimiter: AsyncRateLimiter<TFn>) => number)
35
43
  /**
36
- * Time window in milliseconds within which the limit applies
44
+ * Type of window to use for rate limiting
45
+ * - 'fixed': Uses a fixed window that resets after the window period
46
+ * - 'sliding': Uses a sliding window that allows executions as old ones expire
47
+ * Defaults to 'fixed'
37
48
  */
38
- window: number
49
+ windowType?: 'fixed' | 'sliding'
39
50
  }
40
51
 
41
52
  const defaultOptions: Required<
@@ -46,6 +57,7 @@ const defaultOptions: Required<
46
57
  onReject: () => {},
47
58
  onSettled: () => {},
48
59
  onSuccess: () => {},
60
+ windowType: 'fixed',
49
61
  }
50
62
 
51
63
  /**
@@ -55,6 +67,16 @@ const defaultOptions: Required<
55
67
  * then blocks all subsequent calls until the window passes. This can lead to "bursty" behavior where
56
68
  * all executions happen immediately, followed by a complete block.
57
69
  *
70
+ * The rate limiter supports two types of windows:
71
+ * - 'fixed': A strict window that resets after the window period. All executions within the window count
72
+ * towards the limit, and the window resets completely after the period.
73
+ * - 'sliding': A rolling window that allows executions as old ones expire. This provides a more
74
+ * consistent rate of execution over time.
75
+ *
76
+ * Unlike the non-async RateLimiter, this async version supports returning values from the rate-limited function,
77
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
78
+ * instead of setting the result on a state variable from within the rate-limited function.
79
+ *
58
80
  * For smoother execution patterns, consider using:
59
81
  * - Throttling: Ensures consistent spacing between executions (e.g. max once per 200ms)
60
82
  * - Debouncing: Waits for a pause in calls before executing (e.g. after 500ms of no calls)
@@ -66,11 +88,12 @@ const defaultOptions: Required<
66
88
  * ```ts
67
89
  * const rateLimiter = new AsyncRateLimiter(
68
90
  * async (id: string) => await api.getData(id),
69
- * { limit: 5, window: 1000 } // 5 calls per second
91
+ * { limit: 5, window: 1000, windowType: 'sliding' } // 5 calls per second with sliding window
70
92
  * );
71
93
  *
72
94
  * // Will execute immediately until limit reached, then block
73
- * await rateLimiter.maybeExecute('123');
95
+ * // Returns the API response directly
96
+ * const data = await rateLimiter.maybeExecute('123');
74
97
  * ```
75
98
  */
76
99
  export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
@@ -81,6 +104,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
81
104
  private _rejectionCount = 0
82
105
  private _settleCount = 0
83
106
  private _successCount = 0
107
+ private _isExecuting = false
84
108
 
85
109
  constructor(
86
110
  private fn: TFn,
@@ -107,6 +131,27 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
107
131
  return this._options as Required<AsyncRateLimiterOptions<TFn>>
108
132
  }
109
133
 
134
+ /**
135
+ * Returns the current enabled state of the rate limiter
136
+ */
137
+ getEnabled(): boolean {
138
+ return !!parseFunctionOrValue(this._options.enabled, this)
139
+ }
140
+
141
+ /**
142
+ * Returns the current limit of executions allowed within the time window
143
+ */
144
+ getLimit(): number {
145
+ return parseFunctionOrValue(this._options.limit, this)
146
+ }
147
+
148
+ /**
149
+ * Returns the current time window in milliseconds
150
+ */
151
+ getWindow(): number {
152
+ return parseFunctionOrValue(this._options.window, this)
153
+ }
154
+
110
155
  /**
111
156
  * Attempts to execute the rate-limited function if within the configured limits.
112
157
  * Will reject execution if the number of calls in the current window exceeds the limit.
@@ -128,9 +173,25 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
128
173
  ): Promise<ReturnType<TFn> | undefined> {
129
174
  this.cleanupOldExecutions()
130
175
 
131
- if (this._executionTimes.length < this._options.limit) {
132
- await this.executeFunction(...args)
133
- return this._lastResult
176
+ const limit = this.getLimit()
177
+ const window = this.getWindow()
178
+
179
+ if (this._options.windowType === 'sliding') {
180
+ // For sliding window, we can execute if we have capacity in the current window
181
+ if (this._executionTimes.length < limit) {
182
+ await this.executeFunction(...args)
183
+ return this._lastResult
184
+ }
185
+ } else {
186
+ // For fixed window, we need to check if we're in a new window
187
+ const now = Date.now()
188
+ const oldestExecution = Math.min(...this._executionTimes)
189
+ const isNewWindow = oldestExecution + window <= now
190
+
191
+ if (isNewWindow || this._executionTimes.length < limit) {
192
+ await this.executeFunction(...args)
193
+ return this._lastResult
194
+ }
134
195
  }
135
196
 
136
197
  this.rejectFunction()
@@ -140,7 +201,8 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
140
201
  private async executeFunction(
141
202
  ...args: Parameters<TFn>
142
203
  ): Promise<ReturnType<TFn> | undefined> {
143
- if (!this._options.enabled) return
204
+ if (!this.getEnabled()) return
205
+ this._isExecuting = true
144
206
  const now = Date.now()
145
207
  this._executionTimes.push(now)
146
208
 
@@ -152,6 +214,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
152
214
  this._errorCount++
153
215
  this._options.onError?.(error, this)
154
216
  } finally {
217
+ this._isExecuting = false
155
218
  this._settleCount++
156
219
  this._options.onSettled?.(this)
157
220
  }
@@ -168,7 +231,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
168
231
 
169
232
  private cleanupOldExecutions(): void {
170
233
  const now = Date.now()
171
- const windowStart = now - this._options.window
234
+ const windowStart = now - this.getWindow()
172
235
  this._executionTimes = this._executionTimes.filter(
173
236
  (time) => time > windowStart,
174
237
  )
@@ -179,14 +242,20 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
179
242
  */
180
243
  getRemainingInWindow(): number {
181
244
  this.cleanupOldExecutions()
182
- return Math.max(0, this._options.limit - this._executionTimes.length)
245
+ return Math.max(0, this.getLimit() - this._executionTimes.length)
183
246
  }
184
247
 
185
248
  /**
186
249
  * Returns the number of milliseconds until the next execution will be possible
250
+ * For fixed windows, this is the time until the current window resets
251
+ * For sliding windows, this is the time until the oldest execution expires
187
252
  */
188
253
  getMsUntilNextWindow(): number {
189
- return this.getRemainingInWindow() * this._options.window
254
+ if (this.getRemainingInWindow() > 0) {
255
+ return 0
256
+ }
257
+ const oldestExecution = Math.min(...this._executionTimes)
258
+ return oldestExecution + this.getWindow() - Date.now()
190
259
  }
191
260
 
192
261
  /**
@@ -217,6 +286,13 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
217
286
  return this._rejectionCount
218
287
  }
219
288
 
289
+ /**
290
+ * Returns whether the function is currently executing
291
+ */
292
+ getIsExecuting(): boolean {
293
+ return this._isExecuting
294
+ }
295
+
220
296
  /**
221
297
  * Resets the rate limiter state
222
298
  */
@@ -232,6 +308,16 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
232
308
  /**
233
309
  * Creates an async rate-limited function that will execute the provided function up to a maximum number of times within a time window.
234
310
  *
311
+ * Unlike the non-async rate limiter, this async version supports returning values from the rate-limited function,
312
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
313
+ * instead of setting the result on a state variable from within the rate-limited function.
314
+ *
315
+ * The rate limiter supports two types of windows:
316
+ * - 'fixed': A strict window that resets after the window period. All executions within the window count
317
+ * towards the limit, and the window resets completely after the period.
318
+ * - 'sliding': A rolling window that allows executions as old ones expire. This provides a more
319
+ * consistent rate of execution over time.
320
+ *
235
321
  * Note that rate limiting is a simpler form of execution control compared to throttling or debouncing:
236
322
  * - A rate limiter will allow all executions until the limit is reached, then block all subsequent calls until the window resets
237
323
  * - A throttler ensures even spacing between executions, which can be better for consistent performance
@@ -242,10 +328,11 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
242
328
  *
243
329
  * @example
244
330
  * ```ts
245
- * // Rate limit to 5 calls per minute
331
+ * // Rate limit to 5 calls per minute with a sliding window
246
332
  * const rateLimited = asyncRateLimit(makeApiCall, {
247
333
  * limit: 5,
248
334
  * window: 60000,
335
+ * windowType: 'sliding',
249
336
  * onReject: (rateLimiter) => {
250
337
  * console.log(`Rate limit exceeded. Try again in ${rateLimiter.getMsUntilNextWindow()}ms`);
251
338
  * }
@@ -253,7 +340,8 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
253
340
  *
254
341
  * // First 5 calls will execute immediately
255
342
  * // Additional calls will be rejected until the minute window resets
256
- * await rateLimited();
343
+ * // Returns the API response directly
344
+ * const result = await rateLimited();
257
345
  *
258
346
  * // For more even execution, consider using throttle instead:
259
347
  * const throttled = throttle(makeApiCall, { wait: 12000 }); // One call every 12 seconds
@@ -261,7 +349,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
261
349
  */
262
350
  export function asyncRateLimit<TFn extends AnyAsyncFunction>(
263
351
  fn: TFn,
264
- initialOptions: Omit<AsyncRateLimiterOptions<TFn>, 'enabled'>,
352
+ initialOptions: AsyncRateLimiterOptions<TFn>,
265
353
  ) {
266
354
  const rateLimiter = new AsyncRateLimiter(fn, initialOptions)
267
355
  return rateLimiter.maybeExecute.bind(rateLimiter)
@@ -1,3 +1,4 @@
1
+ import { parseFunctionOrValue } from './utils'
1
2
  import type { AnyAsyncFunction } from './types'
2
3
 
3
4
  /**
@@ -6,9 +7,10 @@ import type { AnyAsyncFunction } from './types'
6
7
  export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
7
8
  /**
8
9
  * Whether the throttler is enabled. When disabled, maybeExecute will not trigger any executions.
10
+ * Can be a boolean or a function that returns a boolean.
9
11
  * Defaults to true.
10
12
  */
11
- enabled?: boolean
13
+ enabled?: boolean | ((throttler: AsyncThrottler<TFn>) => boolean)
12
14
  /**
13
15
  * Whether to execute the function immediately when called
14
16
  * Defaults to true
@@ -35,10 +37,11 @@ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
35
37
  */
36
38
  trailing?: boolean
37
39
  /**
38
- * Time window in milliseconds during which the function can only be executed once
40
+ * Time window in milliseconds during which the function can only be executed once.
41
+ * Can be a number or a function that returns a number.
39
42
  * Defaults to 0ms
40
43
  */
41
- wait: number
44
+ wait: number | ((throttler: AsyncThrottler<TFn>) => number)
42
45
  }
43
46
 
44
47
  const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
@@ -58,19 +61,23 @@ const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
58
61
  * Unlike debouncing which resets the delay timer on each call, throttling ensures the function executes at a
59
62
  * regular interval regardless of how often it's called.
60
63
  *
64
+ * Unlike the non-async Throttler, this async version supports returning values from the throttled function,
65
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
66
+ * instead of setting the result on a state variable from within the throttled function.
67
+ *
61
68
  * This is useful for rate-limiting API calls, handling scroll/resize events, or any scenario where you want to
62
69
  * ensure a maximum execution frequency.
63
70
  *
64
71
  * @example
65
72
  * ```ts
66
73
  * const throttler = new AsyncThrottler(async (value: string) => {
67
- * await saveToAPI(value);
74
+ * const result = await saveToAPI(value);
75
+ * return result; // Return value is preserved
68
76
  * }, { wait: 1000 });
69
77
  *
70
78
  * // Will only execute once per second no matter how often called
71
- * inputElement.addEventListener('input', () => {
72
- * throttler.maybeExecute(inputElement.value);
73
- * });
79
+ * // Returns the API response directly
80
+ * const result = await throttler.maybeExecute(inputElement.value);
74
81
  * ```
75
82
  */
76
83
  export class AsyncThrottler<TFn extends AnyAsyncFunction> {
@@ -116,6 +123,20 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
116
123
  return this._options
117
124
  }
118
125
 
126
+ /**
127
+ * Returns the current enabled state of the throttler
128
+ */
129
+ getEnabled(): boolean {
130
+ return parseFunctionOrValue(this._options.enabled, this)
131
+ }
132
+
133
+ /**
134
+ * Returns the current wait time in milliseconds
135
+ */
136
+ getWait(): number {
137
+ return parseFunctionOrValue(this._options.wait, this)
138
+ }
139
+
119
140
  /**
120
141
  * Attempts to execute the throttled function
121
142
  * If a call is already in progress, it may be blocked or queued depending on the `wait` option
@@ -125,9 +146,10 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
125
146
  ): Promise<ReturnType<TFn> | undefined> {
126
147
  const now = Date.now()
127
148
  const timeSinceLastExecution = now - this._lastExecutionTime
149
+ const wait = this.getWait()
128
150
 
129
151
  // Handle leading execution
130
- if (this._options.leading && timeSinceLastExecution >= this._options.wait) {
152
+ if (this._options.leading && timeSinceLastExecution >= wait) {
131
153
  await this.executeFunction(...args)
132
154
  return this._lastResult
133
155
  } else {
@@ -145,7 +167,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
145
167
  const _timeSinceLastExecution = this._lastExecutionTime
146
168
  ? now - this._lastExecutionTime
147
169
  : 0
148
- const timeoutDuration = this._options.wait - _timeSinceLastExecution
170
+ const timeoutDuration = wait - _timeSinceLastExecution
149
171
  this._timeoutId = setTimeout(async () => {
150
172
  if (this._lastArgs !== undefined) {
151
173
  await this.executeFunction(...this._lastArgs)
@@ -160,7 +182,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
160
182
  private async executeFunction(
161
183
  ...args: Parameters<TFn>
162
184
  ): Promise<ReturnType<TFn> | undefined> {
163
- if (!this._options.enabled || this._isExecuting) return undefined
185
+ if (!this.getEnabled() || this._isExecuting) return undefined
164
186
  this._abortController = new AbortController()
165
187
  try {
166
188
  this._isExecuting = true
@@ -175,7 +197,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
175
197
  this._settleCount++
176
198
  this._abortController = null
177
199
  this._lastExecutionTime = Date.now()
178
- this._nextExecutionTime = this._lastExecutionTime + this._options.wait
200
+ this._nextExecutionTime = this._lastExecutionTime + this.getWait()
179
201
  this._options.onSettled(this)
180
202
  }
181
203
  return this._lastResult
@@ -242,7 +264,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
242
264
  * Returns the current pending state
243
265
  */
244
266
  getIsPending(): boolean {
245
- return this._options.enabled && !!this._timeoutId
267
+ return this.getEnabled() && !!this._timeoutId
246
268
  }
247
269
 
248
270
  /**
@@ -258,20 +280,25 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
258
280
  * The throttled function will execute at most once per wait period, even if called multiple times.
259
281
  * If called while executing, it will wait until execution completes before scheduling the next call.
260
282
  *
283
+ * Unlike the non-async Throttler, this async version supports returning values from the throttled function,
284
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
285
+ * instead of setting the result on a state variable from within the throttled function.
286
+ *
261
287
  * @example
262
288
  * ```ts
263
- * const throttled = asyncThrottle(async () => {
264
- * await someAsyncOperation();
289
+ * const throttled = asyncThrottle(async (value: string) => {
290
+ * const result = await saveToAPI(value);
291
+ * return result; // Return value is preserved
265
292
  * }, { wait: 1000 });
266
293
  *
267
294
  * // This will execute at most once per second
268
- * await throttled();
269
- * await throttled(); // Waits 1 second before executing
295
+ * // Returns the API response directly
296
+ * const result = await throttled(inputElement.value);
270
297
  * ```
271
298
  */
272
299
  export function asyncThrottle<TFn extends AnyAsyncFunction>(
273
300
  fn: TFn,
274
- initialOptions: Omit<AsyncThrottlerOptions<TFn>, 'enabled'>,
301
+ initialOptions: AsyncThrottlerOptions<TFn>,
275
302
  ) {
276
303
  const asyncThrottler = new AsyncThrottler(fn, initialOptions)
277
304
  return asyncThrottler.maybeExecute.bind(asyncThrottler)