@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.
- package/dist/cjs/async-debouncer.cjs +16 -3
- package/dist/cjs/async-debouncer.cjs.map +1 -1
- package/dist/cjs/async-debouncer.d.cts +30 -12
- package/dist/cjs/async-queuer.cjs +20 -6
- package/dist/cjs/async-queuer.cjs.map +1 -1
- package/dist/cjs/async-queuer.d.cts +21 -8
- package/dist/cjs/async-rate-limiter.cjs +55 -8
- package/dist/cjs/async-rate-limiter.cjs.map +1 -1
- package/dist/cjs/async-rate-limiter.d.cts +64 -12
- package/dist/cjs/async-throttler.cjs +19 -5
- package/dist/cjs/async-throttler.cjs.map +1 -1
- package/dist/cjs/async-throttler.d.cts +31 -12
- package/dist/cjs/debouncer.cjs +16 -3
- package/dist/cjs/debouncer.cjs.map +1 -1
- package/dist/cjs/debouncer.d.cts +14 -4
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/queuer.cjs +13 -5
- package/dist/cjs/queuer.cjs.map +1 -1
- package/dist/cjs/queuer.d.cts +9 -3
- package/dist/cjs/rate-limiter.cjs +41 -8
- package/dist/cjs/rate-limiter.cjs.map +1 -1
- package/dist/cjs/rate-limiter.d.cts +42 -8
- package/dist/cjs/throttler.cjs +19 -5
- package/dist/cjs/throttler.cjs.map +1 -1
- package/dist/cjs/throttler.d.cts +15 -4
- package/dist/cjs/utils.cjs +18 -7
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/cjs/utils.d.cts +3 -0
- package/dist/esm/async-debouncer.d.ts +30 -12
- package/dist/esm/async-debouncer.js +16 -3
- package/dist/esm/async-debouncer.js.map +1 -1
- package/dist/esm/async-queuer.d.ts +21 -8
- package/dist/esm/async-queuer.js +20 -6
- package/dist/esm/async-queuer.js.map +1 -1
- package/dist/esm/async-rate-limiter.d.ts +64 -12
- package/dist/esm/async-rate-limiter.js +55 -8
- package/dist/esm/async-rate-limiter.js.map +1 -1
- package/dist/esm/async-throttler.d.ts +31 -12
- package/dist/esm/async-throttler.js +19 -5
- package/dist/esm/async-throttler.js.map +1 -1
- package/dist/esm/debouncer.d.ts +14 -4
- package/dist/esm/debouncer.js +16 -3
- package/dist/esm/debouncer.js.map +1 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/queuer.d.ts +9 -3
- package/dist/esm/queuer.js +13 -5
- package/dist/esm/queuer.js.map +1 -1
- package/dist/esm/rate-limiter.d.ts +42 -8
- package/dist/esm/rate-limiter.js +41 -8
- package/dist/esm/rate-limiter.js.map +1 -1
- package/dist/esm/throttler.d.ts +15 -4
- package/dist/esm/throttler.js +19 -5
- package/dist/esm/throttler.js.map +1 -1
- package/dist/esm/utils.d.ts +3 -0
- package/dist/esm/utils.js +19 -8
- package/dist/esm/utils.js.map +1 -1
- package/package.json +9 -3
- package/src/async-debouncer.ts +40 -15
- package/src/async-queuer.ts +32 -13
- package/src/async-rate-limiter.ts +107 -19
- package/src/async-throttler.ts +44 -17
- package/src/debouncer.ts +24 -7
- package/src/queuer.ts +19 -7
- package/src/rate-limiter.ts +76 -16
- package/src/throttler.ts +28 -9
- package/src/utils.ts +19 -5
package/src/async-debouncer.ts
CHANGED
|
@@ -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
|
-
*
|
|
69
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
256
|
-
* await debounced("
|
|
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:
|
|
287
|
+
initialOptions: AsyncDebouncerOptions<TFn>,
|
|
263
288
|
) {
|
|
264
289
|
const asyncDebouncer = new AsyncDebouncer(fn, initialOptions)
|
|
265
290
|
return asyncDebouncer.maybeExecute.bind(asyncDebouncer)
|
package/src/async-queuer.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
208
|
-
|
|
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:
|
|
346
|
+
fn: AnyAsyncFunction & { priority?: number },
|
|
326
347
|
position: QueuePosition = this._options.addItemsTo,
|
|
327
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
|
|
42
|
+
window: number | ((rateLimiter: AsyncRateLimiter<TFn>) => number)
|
|
35
43
|
/**
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
352
|
+
initialOptions: AsyncRateLimiterOptions<TFn>,
|
|
265
353
|
) {
|
|
266
354
|
const rateLimiter = new AsyncRateLimiter(fn, initialOptions)
|
|
267
355
|
return rateLimiter.maybeExecute.bind(rateLimiter)
|
package/src/async-throttler.ts
CHANGED
|
@@ -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
|
-
*
|
|
72
|
-
*
|
|
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 >=
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
269
|
-
* await throttled();
|
|
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:
|
|
301
|
+
initialOptions: AsyncThrottlerOptions<TFn>,
|
|
275
302
|
) {
|
|
276
303
|
const asyncThrottler = new AsyncThrottler(fn, initialOptions)
|
|
277
304
|
return asyncThrottler.maybeExecute.bind(asyncThrottler)
|