@limitkit/core 0.1.6 → 1.0.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/README.md +176 -59
- package/dist/index.d.mts +45 -44
- package/dist/index.d.ts +45 -44
- package/dist/index.js +34 -66
- package/dist/index.mjs +32 -64
- package/package.json +43 -29
package/README.md
CHANGED
|
@@ -1,117 +1,234 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 📦 `@limitkit/core`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@limitkit/core)
|
|
4
|
+
[](https://www.npmjs.com/package/@limitkit/core)
|
|
5
|
+
[](https://github.com/alphatrann/limitkit/blob/main/LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**The core rate limiting engine for LimitKit.**
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
`@limitkit/core` evaluates **rules and policies** to decide whether a request should be allowed or rejected.
|
|
10
|
+
It is **store-agnostic** and works with multiple storage backends in any context.
|
|
11
|
+
|
|
12
|
+
Apart from traditional REST APIs, it can also be adopted in any context such as GraphQL, WebSockets, job queues.
|
|
8
13
|
|
|
9
14
|
---
|
|
10
15
|
|
|
11
|
-
##
|
|
16
|
+
## 🔌 Integrations
|
|
17
|
+
|
|
18
|
+
The core engine integrates seamlessly with other LimitKit packages:
|
|
19
|
+
|
|
20
|
+
| Package | Purpose |
|
|
21
|
+
| ------------------- | ------------------------------- |
|
|
22
|
+
| [`@limitkit/memory`](https://www.npmjs.com/package/@limitkit/memory) | In-memory store for development |
|
|
23
|
+
| [`@limitkit/redis`](https://www.npmjs.com/package/@limitkit/redis) | Distributed rate limiting with Redis |
|
|
24
|
+
| [`@limitkit/express`](https://www.npmjs.com/package/@limitkit/express) | Express.js middleware |
|
|
25
|
+
| [`@limitkit/nest`](https://www.npmjs.com/package/@limitkit/nest) | NestJS guards & decorators |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## ⚡ Installation
|
|
12
30
|
|
|
13
31
|
```bash
|
|
14
32
|
npm install @limitkit/core
|
|
15
|
-
|
|
33
|
+
```
|
|
16
34
|
|
|
17
35
|
---
|
|
18
36
|
|
|
19
|
-
##
|
|
37
|
+
## ⚡ Quick Start
|
|
38
|
+
|
|
39
|
+
Simply have a `limiter` instance where you define all the rules, configure store and debug (optional).
|
|
40
|
+
|
|
41
|
+
Then, call `limiter.consume`, which returns an object containing `allowed` that indicates whether the request is allowed or rejected.
|
|
20
42
|
|
|
21
43
|
```ts
|
|
22
|
-
import { RateLimiter } from "@limitkit/core"
|
|
23
|
-
import { InMemoryStore,
|
|
44
|
+
import { RateLimiter } from "@limitkit/core";
|
|
45
|
+
import { InMemoryStore, fixedWindow } from "@limitkit/memory";
|
|
24
46
|
|
|
25
47
|
const limiter = new RateLimiter({
|
|
26
48
|
store: new InMemoryStore(),
|
|
49
|
+
|
|
27
50
|
rules: [
|
|
28
51
|
{
|
|
29
52
|
name: "global",
|
|
30
|
-
key:
|
|
31
|
-
policy:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
key: "global",
|
|
54
|
+
policy: fixedWindow({ window: 60, limit: 100 }),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const result = await limiter.consume(ctx);
|
|
60
|
+
|
|
61
|
+
if (!result.allowed) {
|
|
62
|
+
console.log("Rate limited");
|
|
63
|
+
}
|
|
39
64
|
```
|
|
40
65
|
|
|
66
|
+
|
|
67
|
+
The `rules` array in the `limiter` object are evaluated in order **from first to last**.
|
|
68
|
+
|
|
69
|
+
Once the **first failure** is found, the remaining rules are not evaluated.
|
|
70
|
+
|
|
41
71
|
---
|
|
42
72
|
|
|
43
|
-
##
|
|
73
|
+
## 🏗 Architecture
|
|
44
74
|
|
|
45
|
-
|
|
75
|
+
The engine follows a simple pipeline:
|
|
46
76
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
77
|
+
```
|
|
78
|
+
request
|
|
79
|
+
↓
|
|
80
|
+
rules
|
|
81
|
+
↓
|
|
82
|
+
key resolution
|
|
83
|
+
↓
|
|
84
|
+
policy evaluation
|
|
85
|
+
↓
|
|
86
|
+
store update
|
|
87
|
+
↓
|
|
88
|
+
decision
|
|
89
|
+
```
|
|
53
90
|
|
|
54
|
-
|
|
91
|
+
Each rule:
|
|
55
92
|
|
|
56
|
-
|
|
93
|
+
1. resolves a **key** (who is being limited)
|
|
94
|
+
2. selects a **policy** (how to limit)
|
|
95
|
+
3. consumes quota from the **store**
|
|
96
|
+
4. returns allow / reject
|
|
57
97
|
|
|
58
|
-
|
|
98
|
+
---
|
|
59
99
|
|
|
60
|
-
|
|
100
|
+
## 🧩 Rule Definition
|
|
61
101
|
|
|
62
|
-
|
|
102
|
+
A rule in LimitKit consists of these main properties:
|
|
63
103
|
|
|
64
104
|
```ts
|
|
65
105
|
{
|
|
66
|
-
name: "
|
|
67
|
-
key: (
|
|
68
|
-
policy:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
name: "user",
|
|
107
|
+
key: (ctx) => ctx.user.id,
|
|
108
|
+
policy: tokenBucket(...),
|
|
109
|
+
cost: 1
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Field | Description |
|
|
114
|
+
| -------- | ----------------------------------------------------- |
|
|
115
|
+
| `name` | rule identifier (ensure it is unique in a set of layers) |
|
|
116
|
+
| `key` | groups requests (string, function, or async function) |
|
|
117
|
+
| `policy` | rate limiting algorithm (can be resolved dynamically) |
|
|
118
|
+
| `cost` | weight per request (default `1`) |
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 🧠 Dynamic Policies
|
|
123
|
+
|
|
124
|
+
Policies can be resolved dynamically per request:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
policy: (ctx) => {
|
|
128
|
+
return ctx.user.plan === "pro"
|
|
129
|
+
? proPolicy
|
|
130
|
+
: freePolicy;
|
|
73
131
|
}
|
|
74
132
|
```
|
|
75
133
|
|
|
76
|
-
|
|
134
|
+
This is particularly useful when you want to enforce:
|
|
77
135
|
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
136
|
+
* SaaS plan limits
|
|
137
|
+
* per-endpoint limits
|
|
138
|
+
* feature-based quotas
|
|
81
139
|
|
|
82
140
|
---
|
|
83
141
|
|
|
84
|
-
##
|
|
142
|
+
## 🎯 Examples
|
|
143
|
+
|
|
144
|
+
Here are some common examples in LimitKit:
|
|
145
|
+
|
|
146
|
+
### Layered Limits
|
|
85
147
|
|
|
86
|
-
|
|
148
|
+
As a rule of thumb, rules are evaluated from global scope to user scope.
|
|
149
|
+
|
|
150
|
+
If any rule fails, the evaluation stops and the request is rejected.
|
|
87
151
|
|
|
88
152
|
```ts
|
|
89
|
-
|
|
153
|
+
rules: [
|
|
154
|
+
{ name: "global", key: "global", policy: globalPolicy },
|
|
155
|
+
{ name: "ip", key: (ctx) => ctx.ip, policy: ipPolicy },
|
|
156
|
+
{ name: "user", key: (ctx) => ctx.user.id, policy: userPolicy },
|
|
157
|
+
]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### SaaS Plans
|
|
90
163
|
|
|
91
|
-
|
|
164
|
+
This example introduces dynamic strategies depending on the user's subscription plans.
|
|
92
165
|
|
|
93
|
-
|
|
166
|
+
The `policy` can be an async function in which you can query the database or cache, but it may increase latency.
|
|
94
167
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
168
|
+
```ts
|
|
169
|
+
{
|
|
170
|
+
key: (ctx) => ctx.user.id,
|
|
171
|
+
policy: (ctx) => {
|
|
172
|
+
return ctx.user.plan === "pro"
|
|
173
|
+
? proPolicy
|
|
174
|
+
: freePolicy;
|
|
99
175
|
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
100
178
|
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
### Expensive Operations
|
|
182
|
+
|
|
183
|
+
Sometimes, it's more convenient to add weights to requests instead of restricting the number of requests.
|
|
184
|
+
|
|
185
|
+
In the snippet below, assuming the `/report` endpoint performs expensive computations, `cost` represents the weight of the resources needed to handle a request. Thus, a request to `/report` consumes 10x more tokens than other endpoints, which triggers rate limits faster to mitigate abuse.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
{
|
|
189
|
+
key: (ctx) => ctx.user.id,
|
|
190
|
+
cost: (ctx) => ctx.endpoint === "/report" ? 10 : 1,
|
|
191
|
+
policy: tokenBucketPolicy
|
|
101
192
|
}
|
|
102
193
|
```
|
|
103
194
|
|
|
104
195
|
---
|
|
105
196
|
|
|
106
|
-
##
|
|
197
|
+
## 📊 Result
|
|
107
198
|
|
|
108
|
-
|
|
199
|
+
`consume(context)` returns a normalized result represented as `RateLimitResult` interface:
|
|
109
200
|
|
|
110
|
-
|
|
201
|
+
```ts
|
|
202
|
+
interface RateLimitResult {
|
|
203
|
+
allowed: boolean,
|
|
204
|
+
limit: number,
|
|
205
|
+
remaining: number,
|
|
206
|
+
reset: number,
|
|
207
|
+
retryAt?: number
|
|
208
|
+
failedAt: string | null;
|
|
209
|
+
details: (RateLimitResult & { name: string })[]
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
| Field | Meaning |
|
|
214
|
+
| ------------ | ---------------------------------- |
|
|
215
|
+
| `allowed` | request permitted or blocked |
|
|
216
|
+
| `limit` | maximum requests allowed |
|
|
217
|
+
| `remaining` | remaining quota |
|
|
218
|
+
| `reset` | timestamp (ms) when quota fully resets |
|
|
219
|
+
| `retryAt` | seconds until next allowed request |
|
|
220
|
+
| `failedAt` | the name of the rule failed, `null` if every rule passes |
|
|
221
|
+
| `details` | an array of results for each rule evaluated and the rule's name |
|
|
111
222
|
|
|
112
|
-
|
|
223
|
+
When the request is allowed:
|
|
224
|
+
* The `limit` is the **minimum** of all the rules.
|
|
225
|
+
* The `remaining` is the **minimum** of all the rules.
|
|
226
|
+
* The `reset` is the **maximum** of all the rules.
|
|
113
227
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
interface DebugLimitResult extends RateLimitResult {
|
|
231
|
+
failedAt: string | null;
|
|
232
|
+
details: (RateLimitResult & { name: string })[];
|
|
233
|
+
}
|
|
234
|
+
```
|
package/dist/index.d.mts
CHANGED
|
@@ -158,12 +158,11 @@ interface LimitRule<C = unknown> {
|
|
|
158
158
|
type PolicyResolver<C> = Algorithm<AlgorithmConfig> | ((ctx: C) => Algorithm<AlgorithmConfig> | Promise<Algorithm<AlgorithmConfig>>);
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* Result of a rate limit
|
|
161
|
+
* Result of evaluating a single rate limit rule.
|
|
162
162
|
*
|
|
163
|
-
*
|
|
164
|
-
* window, when the limit resets and how many seconds to wait before retrying.
|
|
163
|
+
* Represents the state of one rule (e.g., per-IP, per-user).
|
|
165
164
|
*/
|
|
166
|
-
interface
|
|
165
|
+
interface RateLimitRuleResult {
|
|
167
166
|
/**
|
|
168
167
|
* Whether the request is allowed (true = within limits, false = limit exceeded).
|
|
169
168
|
*/
|
|
@@ -181,32 +180,40 @@ interface RateLimitResult {
|
|
|
181
180
|
* Unix timestamp (in milliseconds) when the rate limit counter fully resets.
|
|
182
181
|
* Useful for implementing client-side backoff strategies.
|
|
183
182
|
*/
|
|
184
|
-
|
|
183
|
+
resetAt: number;
|
|
185
184
|
/**
|
|
186
|
-
* If the request is rate limited, suggests
|
|
187
|
-
*
|
|
188
|
-
* Only present when `allowed` is false.
|
|
185
|
+
* If the request is rate limited, suggests the timestamp to retry.
|
|
186
|
+
* Defined only present when `allowed` is false.
|
|
189
187
|
*/
|
|
190
|
-
|
|
188
|
+
retryAt?: number;
|
|
189
|
+
}
|
|
190
|
+
interface IdentifiedRateLimitRuleResult extends RateLimitRuleResult {
|
|
191
|
+
/**
|
|
192
|
+
* Unique name of the rule.
|
|
193
|
+
*/
|
|
194
|
+
name: string;
|
|
191
195
|
}
|
|
192
196
|
/**
|
|
193
|
-
*
|
|
197
|
+
* Result of a rate limit check across all rules.
|
|
194
198
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
199
|
+
* This is a composable, lossless representation of all evaluated rules.
|
|
200
|
+
* No aggregation or interpretation is applied at this level.
|
|
197
201
|
*/
|
|
198
|
-
interface
|
|
202
|
+
interface RateLimitResult {
|
|
199
203
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
204
|
+
* Whether the request is allowed across all rules.
|
|
205
|
+
* Equivalent to: all(rule.allowed === true)
|
|
206
|
+
*/
|
|
207
|
+
allowed: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* The name of the rule that caused the rejection.
|
|
210
|
+
* Null if the request was allowed.
|
|
202
211
|
*/
|
|
203
212
|
failedRule: string | null;
|
|
204
213
|
/**
|
|
205
|
-
*
|
|
214
|
+
* Results for each evaluated rule, in order.
|
|
206
215
|
*/
|
|
207
|
-
|
|
208
|
-
name: string;
|
|
209
|
-
})[];
|
|
216
|
+
rules: IdentifiedRateLimitRuleResult[];
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
/**
|
|
@@ -249,9 +256,9 @@ interface Store {
|
|
|
249
256
|
* @param now - Unix timestamp in millisecond
|
|
250
257
|
* @param cost - The cost/weight of this request. Defaults to 1. Higher costs consume
|
|
251
258
|
* more quota (useful for charging different amounts for different operations).
|
|
252
|
-
* @returns A promise that resolves to the rate limit check result.
|
|
259
|
+
* @returns A promise that resolves to the rate limit check result for a particular rule.
|
|
253
260
|
*/
|
|
254
|
-
consume<TConfig extends AlgorithmConfig>(key: string, algorithm: Algorithm<TConfig>, now: number, cost?: number): Promise<
|
|
261
|
+
consume<TConfig extends AlgorithmConfig>(key: string, algorithm: Algorithm<TConfig>, now: number, cost?: number): Promise<RateLimitRuleResult>;
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
/**
|
|
@@ -266,11 +273,6 @@ interface RateLimitConfig<C = unknown> {
|
|
|
266
273
|
* The storage backend for tracking rate limit state.
|
|
267
274
|
*/
|
|
268
275
|
store: Store;
|
|
269
|
-
/**
|
|
270
|
-
* Optional. When true, returns detailed information about
|
|
271
|
-
* each evaluated rule. Useful for troubleshooting. Defaults to false.
|
|
272
|
-
*/
|
|
273
|
-
debug?: boolean;
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
/**
|
|
@@ -296,14 +298,14 @@ interface RateLimitConfig<C = unknown> {
|
|
|
296
298
|
* {
|
|
297
299
|
* name: 'per-user-limit',
|
|
298
300
|
* key: (ctx) => ctx.userId,
|
|
299
|
-
* policy:
|
|
301
|
+
* policy: fixedWindow({ window: 60, limit: 100 })
|
|
300
302
|
* }
|
|
301
303
|
* ]
|
|
302
304
|
* });
|
|
303
305
|
*
|
|
304
306
|
* const result = await limiter.consume({ userId: 'user-123' });
|
|
305
307
|
* if (!result.allowed) {
|
|
306
|
-
* return 429 with headers: Retry-After: result.
|
|
308
|
+
* return 429 with headers: Retry-After: result.retryAt
|
|
307
309
|
* }
|
|
308
310
|
* ```
|
|
309
311
|
* @see Limiter
|
|
@@ -312,7 +314,6 @@ interface RateLimitConfig<C = unknown> {
|
|
|
312
314
|
*/
|
|
313
315
|
declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
314
316
|
private rules;
|
|
315
|
-
private debug;
|
|
316
317
|
private store;
|
|
317
318
|
/**
|
|
318
319
|
* Create a new rate limiter instance.
|
|
@@ -320,7 +321,7 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
320
321
|
* @param config - Configuration for the rate limiter
|
|
321
322
|
* @see RateLimitConfig
|
|
322
323
|
*/
|
|
323
|
-
constructor({ rules,
|
|
324
|
+
constructor({ rules, store }: RateLimitConfig<C>);
|
|
324
325
|
/**
|
|
325
326
|
* Return the configuration object
|
|
326
327
|
* @returns {RateLimitConfig<C>}
|
|
@@ -329,14 +330,15 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
329
330
|
/**
|
|
330
331
|
* Check if a request should be allowed under the configured rate limits.
|
|
331
332
|
*
|
|
332
|
-
* Evaluates each rule in order from left to right. Returns as soon as a rule is exceeded (request denied).
|
|
333
|
-
* If all rules allow the request, returns the result
|
|
333
|
+
* Evaluates each rule in order from left to right. Returns the result of the failed rule as soon as a rule is exceeded (request denied).
|
|
334
|
+
* If all rules allow the request, returns the result aggregated from all the rules.
|
|
334
335
|
*
|
|
335
336
|
* Each rule resolution (key, cost, policy) can be static or dynamic:
|
|
336
337
|
* - Static: evaluated once and reused
|
|
337
338
|
* - Dynamic: evaluated per request based on context
|
|
338
339
|
* - Async: evaluated asynchronously (e.g., database lookups)
|
|
339
340
|
*
|
|
341
|
+
*
|
|
340
342
|
* @param ctx - Request context passed to rule resolvers to determine dynamic values.
|
|
341
343
|
* @returns Promise resolving to the rate limit result. If debug mode is enabled,
|
|
342
344
|
* includes details about each evaluated rule and which rule failed (if any).
|
|
@@ -350,9 +352,11 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
350
352
|
* });
|
|
351
353
|
*
|
|
352
354
|
* if (!result.allowed) {
|
|
353
|
-
* console.log(`Rate limited. Retry
|
|
355
|
+
* console.log(`Rate limited. Retry at ${new Date(result.retryAt)}`);
|
|
354
356
|
* }
|
|
355
357
|
* ```
|
|
358
|
+
*
|
|
359
|
+
* @throws UndefinedKeyException if the key is empty or undefined
|
|
356
360
|
*/
|
|
357
361
|
consume(ctx: C): Promise<RateLimitResult>;
|
|
358
362
|
}
|
|
@@ -369,6 +373,10 @@ declare class UnknownAlgorithmException extends Error {
|
|
|
369
373
|
constructor(algorithm: string);
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
declare class UndefinedKeyException extends Error {
|
|
377
|
+
constructor(ruleName: string);
|
|
378
|
+
}
|
|
379
|
+
|
|
372
380
|
/**
|
|
373
381
|
* Prepend additional data to user-defined rate limiting keys, which include:
|
|
374
382
|
* * Rate limiting algorithm name e.g., `"fixed-window"`, `"sliding-window"`
|
|
@@ -377,22 +385,15 @@ declare class UnknownAlgorithmException extends Error {
|
|
|
377
385
|
* @warning Avoid nested or non-primitive key-value pairs to ensure deterministic hash value
|
|
378
386
|
*
|
|
379
387
|
* The modified key will have the format: `ratelimit:{algorithm_name}:{sha256_hash}:{key}`
|
|
388
|
+
*
|
|
389
|
+
* This prevents accessing corrupted states in the store if the policies are changed.
|
|
390
|
+
*
|
|
380
391
|
* @param config The algorithm config object
|
|
381
392
|
* @param key The user-defined key
|
|
382
393
|
* @returns {string} A modified key with the format above
|
|
383
394
|
*/
|
|
384
395
|
declare function addConfigToKey(config: AlgorithmConfig, key: string): string;
|
|
385
396
|
|
|
386
|
-
/**
|
|
387
|
-
* Merge two arrays of rules by name such that:
|
|
388
|
-
* * Local rules override global rules if the name matches
|
|
389
|
-
* * New local rules are appended
|
|
390
|
-
* @param globalRules The global rules to be overriden
|
|
391
|
-
* @param localRules The local rules to be appended or to override global rules
|
|
392
|
-
* @returns {LimitRule<C>[]} A new list of rules merged from `globalRules` and `localRules`
|
|
393
|
-
*/
|
|
394
|
-
declare function mergeRules<C>(globalRules?: LimitRule<C>[], localRules?: LimitRule<C>[]): LimitRule<C>[];
|
|
395
|
-
|
|
396
397
|
/**
|
|
397
398
|
* Base implementation of the **Fixed Window** rate limiting algorithm.
|
|
398
399
|
*
|
|
@@ -699,4 +700,4 @@ declare abstract class GCRA implements Algorithm<GCRAConfig> {
|
|
|
699
700
|
validate(): void;
|
|
700
701
|
}
|
|
701
702
|
|
|
702
|
-
export { type Algorithm, type AlgorithmConfig, type AlgorithmName, BadArgumentsException, type BaseConfig, type CustomConfig,
|
|
703
|
+
export { type Algorithm, type AlgorithmConfig, type AlgorithmName, BadArgumentsException, type BaseConfig, type CustomConfig, EmptyRulesException, FixedWindow, type FixedWindowConfig, GCRA, type GCRAConfig, type IdentifiedRateLimitRuleResult, LeakyBucket, type LeakyBucketConfig, type LimitRule, type Limiter, type RateLimitConfig, type RateLimitResult, type RateLimitRuleResult, RateLimiter, SlidingWindow, type SlidingWindowConfig, SlidingWindowCounter, type SlidingWindowCounterConfig, type Store, TokenBucket, type TokenBucketConfig, UndefinedKeyException, UnknownAlgorithmException, type WindowConfig, addConfigToKey };
|
package/dist/index.d.ts
CHANGED
|
@@ -158,12 +158,11 @@ interface LimitRule<C = unknown> {
|
|
|
158
158
|
type PolicyResolver<C> = Algorithm<AlgorithmConfig> | ((ctx: C) => Algorithm<AlgorithmConfig> | Promise<Algorithm<AlgorithmConfig>>);
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* Result of a rate limit
|
|
161
|
+
* Result of evaluating a single rate limit rule.
|
|
162
162
|
*
|
|
163
|
-
*
|
|
164
|
-
* window, when the limit resets and how many seconds to wait before retrying.
|
|
163
|
+
* Represents the state of one rule (e.g., per-IP, per-user).
|
|
165
164
|
*/
|
|
166
|
-
interface
|
|
165
|
+
interface RateLimitRuleResult {
|
|
167
166
|
/**
|
|
168
167
|
* Whether the request is allowed (true = within limits, false = limit exceeded).
|
|
169
168
|
*/
|
|
@@ -181,32 +180,40 @@ interface RateLimitResult {
|
|
|
181
180
|
* Unix timestamp (in milliseconds) when the rate limit counter fully resets.
|
|
182
181
|
* Useful for implementing client-side backoff strategies.
|
|
183
182
|
*/
|
|
184
|
-
|
|
183
|
+
resetAt: number;
|
|
185
184
|
/**
|
|
186
|
-
* If the request is rate limited, suggests
|
|
187
|
-
*
|
|
188
|
-
* Only present when `allowed` is false.
|
|
185
|
+
* If the request is rate limited, suggests the timestamp to retry.
|
|
186
|
+
* Defined only present when `allowed` is false.
|
|
189
187
|
*/
|
|
190
|
-
|
|
188
|
+
retryAt?: number;
|
|
189
|
+
}
|
|
190
|
+
interface IdentifiedRateLimitRuleResult extends RateLimitRuleResult {
|
|
191
|
+
/**
|
|
192
|
+
* Unique name of the rule.
|
|
193
|
+
*/
|
|
194
|
+
name: string;
|
|
191
195
|
}
|
|
192
196
|
/**
|
|
193
|
-
*
|
|
197
|
+
* Result of a rate limit check across all rules.
|
|
194
198
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
199
|
+
* This is a composable, lossless representation of all evaluated rules.
|
|
200
|
+
* No aggregation or interpretation is applied at this level.
|
|
197
201
|
*/
|
|
198
|
-
interface
|
|
202
|
+
interface RateLimitResult {
|
|
199
203
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
204
|
+
* Whether the request is allowed across all rules.
|
|
205
|
+
* Equivalent to: all(rule.allowed === true)
|
|
206
|
+
*/
|
|
207
|
+
allowed: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* The name of the rule that caused the rejection.
|
|
210
|
+
* Null if the request was allowed.
|
|
202
211
|
*/
|
|
203
212
|
failedRule: string | null;
|
|
204
213
|
/**
|
|
205
|
-
*
|
|
214
|
+
* Results for each evaluated rule, in order.
|
|
206
215
|
*/
|
|
207
|
-
|
|
208
|
-
name: string;
|
|
209
|
-
})[];
|
|
216
|
+
rules: IdentifiedRateLimitRuleResult[];
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
/**
|
|
@@ -249,9 +256,9 @@ interface Store {
|
|
|
249
256
|
* @param now - Unix timestamp in millisecond
|
|
250
257
|
* @param cost - The cost/weight of this request. Defaults to 1. Higher costs consume
|
|
251
258
|
* more quota (useful for charging different amounts for different operations).
|
|
252
|
-
* @returns A promise that resolves to the rate limit check result.
|
|
259
|
+
* @returns A promise that resolves to the rate limit check result for a particular rule.
|
|
253
260
|
*/
|
|
254
|
-
consume<TConfig extends AlgorithmConfig>(key: string, algorithm: Algorithm<TConfig>, now: number, cost?: number): Promise<
|
|
261
|
+
consume<TConfig extends AlgorithmConfig>(key: string, algorithm: Algorithm<TConfig>, now: number, cost?: number): Promise<RateLimitRuleResult>;
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
/**
|
|
@@ -266,11 +273,6 @@ interface RateLimitConfig<C = unknown> {
|
|
|
266
273
|
* The storage backend for tracking rate limit state.
|
|
267
274
|
*/
|
|
268
275
|
store: Store;
|
|
269
|
-
/**
|
|
270
|
-
* Optional. When true, returns detailed information about
|
|
271
|
-
* each evaluated rule. Useful for troubleshooting. Defaults to false.
|
|
272
|
-
*/
|
|
273
|
-
debug?: boolean;
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
/**
|
|
@@ -296,14 +298,14 @@ interface RateLimitConfig<C = unknown> {
|
|
|
296
298
|
* {
|
|
297
299
|
* name: 'per-user-limit',
|
|
298
300
|
* key: (ctx) => ctx.userId,
|
|
299
|
-
* policy:
|
|
301
|
+
* policy: fixedWindow({ window: 60, limit: 100 })
|
|
300
302
|
* }
|
|
301
303
|
* ]
|
|
302
304
|
* });
|
|
303
305
|
*
|
|
304
306
|
* const result = await limiter.consume({ userId: 'user-123' });
|
|
305
307
|
* if (!result.allowed) {
|
|
306
|
-
* return 429 with headers: Retry-After: result.
|
|
308
|
+
* return 429 with headers: Retry-After: result.retryAt
|
|
307
309
|
* }
|
|
308
310
|
* ```
|
|
309
311
|
* @see Limiter
|
|
@@ -312,7 +314,6 @@ interface RateLimitConfig<C = unknown> {
|
|
|
312
314
|
*/
|
|
313
315
|
declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
314
316
|
private rules;
|
|
315
|
-
private debug;
|
|
316
317
|
private store;
|
|
317
318
|
/**
|
|
318
319
|
* Create a new rate limiter instance.
|
|
@@ -320,7 +321,7 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
320
321
|
* @param config - Configuration for the rate limiter
|
|
321
322
|
* @see RateLimitConfig
|
|
322
323
|
*/
|
|
323
|
-
constructor({ rules,
|
|
324
|
+
constructor({ rules, store }: RateLimitConfig<C>);
|
|
324
325
|
/**
|
|
325
326
|
* Return the configuration object
|
|
326
327
|
* @returns {RateLimitConfig<C>}
|
|
@@ -329,14 +330,15 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
329
330
|
/**
|
|
330
331
|
* Check if a request should be allowed under the configured rate limits.
|
|
331
332
|
*
|
|
332
|
-
* Evaluates each rule in order from left to right. Returns as soon as a rule is exceeded (request denied).
|
|
333
|
-
* If all rules allow the request, returns the result
|
|
333
|
+
* Evaluates each rule in order from left to right. Returns the result of the failed rule as soon as a rule is exceeded (request denied).
|
|
334
|
+
* If all rules allow the request, returns the result aggregated from all the rules.
|
|
334
335
|
*
|
|
335
336
|
* Each rule resolution (key, cost, policy) can be static or dynamic:
|
|
336
337
|
* - Static: evaluated once and reused
|
|
337
338
|
* - Dynamic: evaluated per request based on context
|
|
338
339
|
* - Async: evaluated asynchronously (e.g., database lookups)
|
|
339
340
|
*
|
|
341
|
+
*
|
|
340
342
|
* @param ctx - Request context passed to rule resolvers to determine dynamic values.
|
|
341
343
|
* @returns Promise resolving to the rate limit result. If debug mode is enabled,
|
|
342
344
|
* includes details about each evaluated rule and which rule failed (if any).
|
|
@@ -350,9 +352,11 @@ declare class RateLimiter<C = unknown> implements Limiter<C> {
|
|
|
350
352
|
* });
|
|
351
353
|
*
|
|
352
354
|
* if (!result.allowed) {
|
|
353
|
-
* console.log(`Rate limited. Retry
|
|
355
|
+
* console.log(`Rate limited. Retry at ${new Date(result.retryAt)}`);
|
|
354
356
|
* }
|
|
355
357
|
* ```
|
|
358
|
+
*
|
|
359
|
+
* @throws UndefinedKeyException if the key is empty or undefined
|
|
356
360
|
*/
|
|
357
361
|
consume(ctx: C): Promise<RateLimitResult>;
|
|
358
362
|
}
|
|
@@ -369,6 +373,10 @@ declare class UnknownAlgorithmException extends Error {
|
|
|
369
373
|
constructor(algorithm: string);
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
declare class UndefinedKeyException extends Error {
|
|
377
|
+
constructor(ruleName: string);
|
|
378
|
+
}
|
|
379
|
+
|
|
372
380
|
/**
|
|
373
381
|
* Prepend additional data to user-defined rate limiting keys, which include:
|
|
374
382
|
* * Rate limiting algorithm name e.g., `"fixed-window"`, `"sliding-window"`
|
|
@@ -377,22 +385,15 @@ declare class UnknownAlgorithmException extends Error {
|
|
|
377
385
|
* @warning Avoid nested or non-primitive key-value pairs to ensure deterministic hash value
|
|
378
386
|
*
|
|
379
387
|
* The modified key will have the format: `ratelimit:{algorithm_name}:{sha256_hash}:{key}`
|
|
388
|
+
*
|
|
389
|
+
* This prevents accessing corrupted states in the store if the policies are changed.
|
|
390
|
+
*
|
|
380
391
|
* @param config The algorithm config object
|
|
381
392
|
* @param key The user-defined key
|
|
382
393
|
* @returns {string} A modified key with the format above
|
|
383
394
|
*/
|
|
384
395
|
declare function addConfigToKey(config: AlgorithmConfig, key: string): string;
|
|
385
396
|
|
|
386
|
-
/**
|
|
387
|
-
* Merge two arrays of rules by name such that:
|
|
388
|
-
* * Local rules override global rules if the name matches
|
|
389
|
-
* * New local rules are appended
|
|
390
|
-
* @param globalRules The global rules to be overriden
|
|
391
|
-
* @param localRules The local rules to be appended or to override global rules
|
|
392
|
-
* @returns {LimitRule<C>[]} A new list of rules merged from `globalRules` and `localRules`
|
|
393
|
-
*/
|
|
394
|
-
declare function mergeRules<C>(globalRules?: LimitRule<C>[], localRules?: LimitRule<C>[]): LimitRule<C>[];
|
|
395
|
-
|
|
396
397
|
/**
|
|
397
398
|
* Base implementation of the **Fixed Window** rate limiting algorithm.
|
|
398
399
|
*
|
|
@@ -699,4 +700,4 @@ declare abstract class GCRA implements Algorithm<GCRAConfig> {
|
|
|
699
700
|
validate(): void;
|
|
700
701
|
}
|
|
701
702
|
|
|
702
|
-
export { type Algorithm, type AlgorithmConfig, type AlgorithmName, BadArgumentsException, type BaseConfig, type CustomConfig,
|
|
703
|
+
export { type Algorithm, type AlgorithmConfig, type AlgorithmName, BadArgumentsException, type BaseConfig, type CustomConfig, EmptyRulesException, FixedWindow, type FixedWindowConfig, GCRA, type GCRAConfig, type IdentifiedRateLimitRuleResult, LeakyBucket, type LeakyBucketConfig, type LimitRule, type Limiter, type RateLimitConfig, type RateLimitResult, type RateLimitRuleResult, RateLimiter, SlidingWindow, type SlidingWindowConfig, SlidingWindowCounter, type SlidingWindowCounterConfig, type Store, TokenBucket, type TokenBucketConfig, UndefinedKeyException, UnknownAlgorithmException, type WindowConfig, addConfigToKey };
|
package/dist/index.js
CHANGED
|
@@ -29,9 +29,9 @@ __export(index_exports, {
|
|
|
29
29
|
SlidingWindow: () => SlidingWindow,
|
|
30
30
|
SlidingWindowCounter: () => SlidingWindowCounter,
|
|
31
31
|
TokenBucket: () => TokenBucket,
|
|
32
|
+
UndefinedKeyException: () => UndefinedKeyException,
|
|
32
33
|
UnknownAlgorithmException: () => UnknownAlgorithmException,
|
|
33
|
-
addConfigToKey: () => addConfigToKey
|
|
34
|
-
mergeRules: () => mergeRules
|
|
34
|
+
addConfigToKey: () => addConfigToKey
|
|
35
35
|
});
|
|
36
36
|
module.exports = __toCommonJS(index_exports);
|
|
37
37
|
|
|
@@ -59,10 +59,19 @@ var UnknownAlgorithmException = class extends Error {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
// src/exceptions/undefined-key-exception.ts
|
|
63
|
+
var UndefinedKeyException = class extends Error {
|
|
64
|
+
constructor(ruleName) {
|
|
65
|
+
super(
|
|
66
|
+
`Rule "${ruleName}" returned undefined or empty key. Double-check your key function.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
62
71
|
// src/utils/add-config-to-key.ts
|
|
63
72
|
var import_crypto = require("crypto");
|
|
64
73
|
function addConfigToKey(config, key) {
|
|
65
|
-
const sortedKeys = Object.keys(config).sort();
|
|
74
|
+
const sortedKeys = Object.keys(config ?? {}).sort();
|
|
66
75
|
const sortedConfig = sortedKeys.reduce((acc, k) => {
|
|
67
76
|
acc[k] = config[k];
|
|
68
77
|
return acc;
|
|
@@ -73,29 +82,9 @@ function addConfigToKey(config, key) {
|
|
|
73
82
|
return modifiedKey;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
// src/utils/merge-rules.ts
|
|
77
|
-
function mergeRules(globalRules = [], localRules = []) {
|
|
78
|
-
const map = /* @__PURE__ */ new Map();
|
|
79
|
-
for (const rule of globalRules) {
|
|
80
|
-
map.set(rule.name, rule);
|
|
81
|
-
}
|
|
82
|
-
for (const rule of localRules) {
|
|
83
|
-
if (map.has(rule.name)) {
|
|
84
|
-
map.set(rule.name, {
|
|
85
|
-
...map.get(rule.name),
|
|
86
|
-
...rule
|
|
87
|
-
});
|
|
88
|
-
} else {
|
|
89
|
-
map.set(rule.name, rule);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return [...map.values()];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
85
|
// src/rate-limiter.ts
|
|
96
86
|
var RateLimiter = class {
|
|
97
87
|
rules = [];
|
|
98
|
-
debug = false;
|
|
99
88
|
store;
|
|
100
89
|
/**
|
|
101
90
|
* Create a new rate limiter instance.
|
|
@@ -103,10 +92,9 @@ var RateLimiter = class {
|
|
|
103
92
|
* @param config - Configuration for the rate limiter
|
|
104
93
|
* @see RateLimitConfig
|
|
105
94
|
*/
|
|
106
|
-
constructor({ rules,
|
|
95
|
+
constructor({ rules, store }) {
|
|
107
96
|
if (rules.length === 0) throw new EmptyRulesException();
|
|
108
97
|
this.rules = rules ?? this.rules;
|
|
109
|
-
this.debug = debug ?? this.debug;
|
|
110
98
|
this.store = store;
|
|
111
99
|
}
|
|
112
100
|
/**
|
|
@@ -114,19 +102,20 @@ var RateLimiter = class {
|
|
|
114
102
|
* @returns {RateLimitConfig<C>}
|
|
115
103
|
*/
|
|
116
104
|
get config() {
|
|
117
|
-
return { rules: this.rules,
|
|
105
|
+
return { rules: this.rules, store: this.store };
|
|
118
106
|
}
|
|
119
107
|
/**
|
|
120
108
|
* Check if a request should be allowed under the configured rate limits.
|
|
121
109
|
*
|
|
122
|
-
* Evaluates each rule in order from left to right. Returns as soon as a rule is exceeded (request denied).
|
|
123
|
-
* If all rules allow the request, returns the result
|
|
110
|
+
* Evaluates each rule in order from left to right. Returns the result of the failed rule as soon as a rule is exceeded (request denied).
|
|
111
|
+
* If all rules allow the request, returns the result aggregated from all the rules.
|
|
124
112
|
*
|
|
125
113
|
* Each rule resolution (key, cost, policy) can be static or dynamic:
|
|
126
114
|
* - Static: evaluated once and reused
|
|
127
115
|
* - Dynamic: evaluated per request based on context
|
|
128
116
|
* - Async: evaluated asynchronously (e.g., database lookups)
|
|
129
117
|
*
|
|
118
|
+
*
|
|
130
119
|
* @param ctx - Request context passed to rule resolvers to determine dynamic values.
|
|
131
120
|
* @returns Promise resolving to the rate limit result. If debug mode is enabled,
|
|
132
121
|
* includes details about each evaluated rule and which rule failed (if any).
|
|
@@ -140,64 +129,43 @@ var RateLimiter = class {
|
|
|
140
129
|
* });
|
|
141
130
|
*
|
|
142
131
|
* if (!result.allowed) {
|
|
143
|
-
* console.log(`Rate limited. Retry
|
|
132
|
+
* console.log(`Rate limited. Retry at ${new Date(result.retryAt)}`);
|
|
144
133
|
* }
|
|
145
134
|
* ```
|
|
135
|
+
*
|
|
136
|
+
* @throws UndefinedKeyException if the key is empty or undefined
|
|
146
137
|
*/
|
|
147
138
|
async consume(ctx) {
|
|
148
|
-
|
|
149
|
-
let minRemaining = Infinity;
|
|
150
|
-
let maxReset = 0;
|
|
151
|
-
let minLimit = Infinity;
|
|
152
|
-
const debugRules = [];
|
|
139
|
+
const evaluatedRules = [];
|
|
153
140
|
for (const rule of this.rules) {
|
|
154
141
|
const algorithm = typeof rule.policy === "function" ? await rule.policy(ctx) : rule.policy;
|
|
155
142
|
const key = typeof rule.key === "function" ? await rule.key(ctx) : rule.key;
|
|
143
|
+
if (!key) throw new UndefinedKeyException(rule.name);
|
|
156
144
|
const cost = typeof rule.cost === "function" ? await rule.cost(ctx) : rule.cost;
|
|
157
145
|
if (cost !== void 0 && cost <= 0)
|
|
158
146
|
throw new BadArgumentsException(
|
|
159
147
|
`Cost must be a positive integer, got cost=${cost}`
|
|
160
148
|
);
|
|
161
149
|
const keyWithConfig = addConfigToKey(algorithm.config, key);
|
|
162
|
-
result = await this.store.consume(
|
|
150
|
+
const result = await this.store.consume(
|
|
163
151
|
keyWithConfig,
|
|
164
152
|
algorithm,
|
|
165
153
|
Date.now(),
|
|
166
154
|
cost ?? 1
|
|
167
155
|
);
|
|
168
|
-
|
|
169
|
-
minLimit = Math.min(result.limit, minLimit);
|
|
170
|
-
maxReset = Math.max(result.reset, maxReset);
|
|
171
|
-
if (this.debug) {
|
|
172
|
-
debugRules.push({ ...result, name: rule.name });
|
|
173
|
-
if (result.allowed) console.log(debugRules);
|
|
174
|
-
else console.error(debugRules);
|
|
175
|
-
}
|
|
156
|
+
evaluatedRules.push({ ...result, name: rule.name });
|
|
176
157
|
if (!result.allowed) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
};
|
|
183
|
-
return debugResults;
|
|
184
|
-
}
|
|
185
|
-
return result;
|
|
158
|
+
return {
|
|
159
|
+
allowed: result.allowed,
|
|
160
|
+
failedRule: rule.name,
|
|
161
|
+
rules: evaluatedRules
|
|
162
|
+
};
|
|
186
163
|
}
|
|
187
164
|
}
|
|
188
|
-
if (this.debug) {
|
|
189
|
-
const final = {
|
|
190
|
-
...result,
|
|
191
|
-
details: this.debug ? debugRules : void 0
|
|
192
|
-
};
|
|
193
|
-
console.log(final);
|
|
194
|
-
return final;
|
|
195
|
-
}
|
|
196
165
|
return {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
remaining: minRemaining
|
|
166
|
+
allowed: true,
|
|
167
|
+
failedRule: null,
|
|
168
|
+
rules: evaluatedRules
|
|
201
169
|
};
|
|
202
170
|
}
|
|
203
171
|
};
|
|
@@ -374,7 +342,7 @@ var GCRA = class {
|
|
|
374
342
|
SlidingWindow,
|
|
375
343
|
SlidingWindowCounter,
|
|
376
344
|
TokenBucket,
|
|
345
|
+
UndefinedKeyException,
|
|
377
346
|
UnknownAlgorithmException,
|
|
378
|
-
addConfigToKey
|
|
379
|
-
mergeRules
|
|
347
|
+
addConfigToKey
|
|
380
348
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -22,10 +22,19 @@ var UnknownAlgorithmException = class extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// src/exceptions/undefined-key-exception.ts
|
|
26
|
+
var UndefinedKeyException = class extends Error {
|
|
27
|
+
constructor(ruleName) {
|
|
28
|
+
super(
|
|
29
|
+
`Rule "${ruleName}" returned undefined or empty key. Double-check your key function.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
25
34
|
// src/utils/add-config-to-key.ts
|
|
26
35
|
import { createHash } from "crypto";
|
|
27
36
|
function addConfigToKey(config, key) {
|
|
28
|
-
const sortedKeys = Object.keys(config).sort();
|
|
37
|
+
const sortedKeys = Object.keys(config ?? {}).sort();
|
|
29
38
|
const sortedConfig = sortedKeys.reduce((acc, k) => {
|
|
30
39
|
acc[k] = config[k];
|
|
31
40
|
return acc;
|
|
@@ -36,29 +45,9 @@ function addConfigToKey(config, key) {
|
|
|
36
45
|
return modifiedKey;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
// src/utils/merge-rules.ts
|
|
40
|
-
function mergeRules(globalRules = [], localRules = []) {
|
|
41
|
-
const map = /* @__PURE__ */ new Map();
|
|
42
|
-
for (const rule of globalRules) {
|
|
43
|
-
map.set(rule.name, rule);
|
|
44
|
-
}
|
|
45
|
-
for (const rule of localRules) {
|
|
46
|
-
if (map.has(rule.name)) {
|
|
47
|
-
map.set(rule.name, {
|
|
48
|
-
...map.get(rule.name),
|
|
49
|
-
...rule
|
|
50
|
-
});
|
|
51
|
-
} else {
|
|
52
|
-
map.set(rule.name, rule);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return [...map.values()];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
48
|
// src/rate-limiter.ts
|
|
59
49
|
var RateLimiter = class {
|
|
60
50
|
rules = [];
|
|
61
|
-
debug = false;
|
|
62
51
|
store;
|
|
63
52
|
/**
|
|
64
53
|
* Create a new rate limiter instance.
|
|
@@ -66,10 +55,9 @@ var RateLimiter = class {
|
|
|
66
55
|
* @param config - Configuration for the rate limiter
|
|
67
56
|
* @see RateLimitConfig
|
|
68
57
|
*/
|
|
69
|
-
constructor({ rules,
|
|
58
|
+
constructor({ rules, store }) {
|
|
70
59
|
if (rules.length === 0) throw new EmptyRulesException();
|
|
71
60
|
this.rules = rules ?? this.rules;
|
|
72
|
-
this.debug = debug ?? this.debug;
|
|
73
61
|
this.store = store;
|
|
74
62
|
}
|
|
75
63
|
/**
|
|
@@ -77,19 +65,20 @@ var RateLimiter = class {
|
|
|
77
65
|
* @returns {RateLimitConfig<C>}
|
|
78
66
|
*/
|
|
79
67
|
get config() {
|
|
80
|
-
return { rules: this.rules,
|
|
68
|
+
return { rules: this.rules, store: this.store };
|
|
81
69
|
}
|
|
82
70
|
/**
|
|
83
71
|
* Check if a request should be allowed under the configured rate limits.
|
|
84
72
|
*
|
|
85
|
-
* Evaluates each rule in order from left to right. Returns as soon as a rule is exceeded (request denied).
|
|
86
|
-
* If all rules allow the request, returns the result
|
|
73
|
+
* Evaluates each rule in order from left to right. Returns the result of the failed rule as soon as a rule is exceeded (request denied).
|
|
74
|
+
* If all rules allow the request, returns the result aggregated from all the rules.
|
|
87
75
|
*
|
|
88
76
|
* Each rule resolution (key, cost, policy) can be static or dynamic:
|
|
89
77
|
* - Static: evaluated once and reused
|
|
90
78
|
* - Dynamic: evaluated per request based on context
|
|
91
79
|
* - Async: evaluated asynchronously (e.g., database lookups)
|
|
92
80
|
*
|
|
81
|
+
*
|
|
93
82
|
* @param ctx - Request context passed to rule resolvers to determine dynamic values.
|
|
94
83
|
* @returns Promise resolving to the rate limit result. If debug mode is enabled,
|
|
95
84
|
* includes details about each evaluated rule and which rule failed (if any).
|
|
@@ -103,64 +92,43 @@ var RateLimiter = class {
|
|
|
103
92
|
* });
|
|
104
93
|
*
|
|
105
94
|
* if (!result.allowed) {
|
|
106
|
-
* console.log(`Rate limited. Retry
|
|
95
|
+
* console.log(`Rate limited. Retry at ${new Date(result.retryAt)}`);
|
|
107
96
|
* }
|
|
108
97
|
* ```
|
|
98
|
+
*
|
|
99
|
+
* @throws UndefinedKeyException if the key is empty or undefined
|
|
109
100
|
*/
|
|
110
101
|
async consume(ctx) {
|
|
111
|
-
|
|
112
|
-
let minRemaining = Infinity;
|
|
113
|
-
let maxReset = 0;
|
|
114
|
-
let minLimit = Infinity;
|
|
115
|
-
const debugRules = [];
|
|
102
|
+
const evaluatedRules = [];
|
|
116
103
|
for (const rule of this.rules) {
|
|
117
104
|
const algorithm = typeof rule.policy === "function" ? await rule.policy(ctx) : rule.policy;
|
|
118
105
|
const key = typeof rule.key === "function" ? await rule.key(ctx) : rule.key;
|
|
106
|
+
if (!key) throw new UndefinedKeyException(rule.name);
|
|
119
107
|
const cost = typeof rule.cost === "function" ? await rule.cost(ctx) : rule.cost;
|
|
120
108
|
if (cost !== void 0 && cost <= 0)
|
|
121
109
|
throw new BadArgumentsException(
|
|
122
110
|
`Cost must be a positive integer, got cost=${cost}`
|
|
123
111
|
);
|
|
124
112
|
const keyWithConfig = addConfigToKey(algorithm.config, key);
|
|
125
|
-
result = await this.store.consume(
|
|
113
|
+
const result = await this.store.consume(
|
|
126
114
|
keyWithConfig,
|
|
127
115
|
algorithm,
|
|
128
116
|
Date.now(),
|
|
129
117
|
cost ?? 1
|
|
130
118
|
);
|
|
131
|
-
|
|
132
|
-
minLimit = Math.min(result.limit, minLimit);
|
|
133
|
-
maxReset = Math.max(result.reset, maxReset);
|
|
134
|
-
if (this.debug) {
|
|
135
|
-
debugRules.push({ ...result, name: rule.name });
|
|
136
|
-
if (result.allowed) console.log(debugRules);
|
|
137
|
-
else console.error(debugRules);
|
|
138
|
-
}
|
|
119
|
+
evaluatedRules.push({ ...result, name: rule.name });
|
|
139
120
|
if (!result.allowed) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
};
|
|
146
|
-
return debugResults;
|
|
147
|
-
}
|
|
148
|
-
return result;
|
|
121
|
+
return {
|
|
122
|
+
allowed: result.allowed,
|
|
123
|
+
failedRule: rule.name,
|
|
124
|
+
rules: evaluatedRules
|
|
125
|
+
};
|
|
149
126
|
}
|
|
150
127
|
}
|
|
151
|
-
if (this.debug) {
|
|
152
|
-
const final = {
|
|
153
|
-
...result,
|
|
154
|
-
details: this.debug ? debugRules : void 0
|
|
155
|
-
};
|
|
156
|
-
console.log(final);
|
|
157
|
-
return final;
|
|
158
|
-
}
|
|
159
128
|
return {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
remaining: minRemaining
|
|
129
|
+
allowed: true,
|
|
130
|
+
failedRule: null,
|
|
131
|
+
rules: evaluatedRules
|
|
164
132
|
};
|
|
165
133
|
}
|
|
166
134
|
};
|
|
@@ -336,7 +304,7 @@ export {
|
|
|
336
304
|
SlidingWindow,
|
|
337
305
|
SlidingWindowCounter,
|
|
338
306
|
TokenBucket,
|
|
307
|
+
UndefinedKeyException,
|
|
339
308
|
UnknownAlgorithmException,
|
|
340
|
-
addConfigToKey
|
|
341
|
-
mergeRules
|
|
309
|
+
addConfigToKey
|
|
342
310
|
};
|
package/package.json
CHANGED
|
@@ -1,29 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@limitkit/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"main": "dist/index.js",
|
|
5
|
-
"module": "dist/index.mjs",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@limitkit/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"module": "dist/index.mjs",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"rate-limiter",
|
|
9
|
+
"rate-limit",
|
|
10
|
+
"rate-limiting",
|
|
11
|
+
"throttling",
|
|
12
|
+
"token-bucket",
|
|
13
|
+
"leaky-bucket",
|
|
14
|
+
"sliding-window",
|
|
15
|
+
"sliding-window-counter",
|
|
16
|
+
"gcra",
|
|
17
|
+
"rules-engine",
|
|
18
|
+
"policy-based",
|
|
19
|
+
"nodejs"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"description": "Core rate limiting engine for LimitKit",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/alphatrann/limitkit"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
38
|
+
"test": "jest --silent"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.4.0"
|
|
42
|
+
}
|
|
43
|
+
}
|