@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 CHANGED
@@ -1,117 +1,234 @@
1
- # @limitkit/core
1
+ # 📦 `@limitkit/core`
2
2
 
3
- Core rate limiting engine for **LimitKit**.
3
+ [![npm version](https://img.shields.io/npm/v/@limitkit/core)](https://www.npmjs.com/package/@limitkit/core)
4
+ [![downloads](https://img.shields.io/npm/dw/@limitkit/core)](https://www.npmjs.com/package/@limitkit/core)
5
+ [![license](https://img.shields.io/npm/l/@limitkit/core)](https://github.com/alphatrann/limitkit/blob/main/LICENSE)
4
6
 
5
- Provides the `RateLimiter`, rule system, and algorithm abstractions used by all LimitKit integrations.
7
+ **The core rate limiting engine for LimitKit.**
6
8
 
7
- 👉 Main project: https://github.com/alphatrann/limitkit
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
- ## Installation
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
- ## Basic Usage
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, InMemoryFixedWindow } from "@limitkit/memory"
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: (req) => req.ip,
31
- policy: new InMemoryFixedWindow({
32
- name: "fixed-window",
33
- window: 60,
34
- limit: 100
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
- ## Supported Algorithms
73
+ ## 🏗 Architecture
44
74
 
45
- LimitKit supports multiple rate limiting algorithms:
75
+ The engine follows a simple pipeline:
46
76
 
47
- * Fixed Window
48
- * Sliding Window
49
- * Sliding Window Counter
50
- * Token Bucket
51
- * Leaky Bucket
52
- * GCRA
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
- Algorithms are provided by store-specific packages such as `@limitkit/memory` or `@limitkit/redis`.
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
- ## Rules
98
+ ---
59
99
 
60
- Each rule defines how a request is evaluated.
100
+ ## 🧩 Rule Definition
61
101
 
62
- Example rule:
102
+ A rule in LimitKit consists of these main properties:
63
103
 
64
104
  ```ts
65
105
  {
66
- name: "per-ip",
67
- key: (req) => req.ip,
68
- policy: new InMemoryFixedWindow({
69
- name: "fixed-window",
70
- window: 60,
71
- limit: 100
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
- Rules can define:
134
+ This is particularly useful when you want to enforce:
77
135
 
78
- * `name` unique rule identifier
79
- * `key` — request key (e.g. IP or user ID)
80
- * `policy` — rate limit algorithm
136
+ * SaaS plan limits
137
+ * per-endpoint limits
138
+ * feature-based quotas
81
139
 
82
140
  ---
83
141
 
84
- ## Custom Algorithms
142
+ ## 🎯 Examples
143
+
144
+ Here are some common examples in LimitKit:
145
+
146
+ ### Layered Limits
85
147
 
86
- You can create custom algorithms by implementing the `Algorithm` interface.
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
- import { Algorithm } from "@limitkit/core"
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
- class MyAlgorithm implements Algorithm<MyConfig> {
164
+ This example introduces dynamic strategies depending on the user's subscription plans.
92
165
 
93
- constructor(public readonly config: MyConfig) {}
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
- validate(): void {
96
- if (this.config.limit <= 0) {
97
- throw new Error("Invalid configuration")
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
- ## Custom Stores
197
+ ## 📊 Result
107
198
 
108
- Stores control where rate limiting state is stored.
199
+ `consume(context)` returns a normalized result represented as `RateLimitResult` interface:
109
200
 
110
- Custom stores can implement the `Store` interface.
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
- Example use cases:
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
- * DynamoDB
115
- * PostgreSQL
116
- * MongoDB
117
- * Cloudflare KV
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 check for a single request.
161
+ * Result of evaluating a single rate limit rule.
162
162
  *
163
- * Indicates whether the request is allowed, the maximum number of requests can be made, how many requests remain in the current
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 RateLimitResult {
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
- reset: number;
183
+ resetAt: number;
185
184
  /**
186
- * If the request is rate limited, suggests how many seconds to wait before retrying.
187
- * Clients should use exponential backoff and add jitter, rather than strictly following this value.
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
- retryAfter?: number;
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
- * Extended rate limit result with debug information.
197
+ * Result of a rate limit check across all rules.
194
198
  *
195
- * Returned when debug mode is enabled on the RateLimiter. Includes details about
196
- * all evaluated rules and which rule caused the rate limit (if any).
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 DebugLimitResult extends RateLimitResult {
202
+ interface RateLimitResult {
199
203
  /**
200
- * The name of the rule that caused the rate limit to be exceeded.
201
- * If the request was allowed, this is null.
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
- * An array of results from the first rule to the first failed one
214
+ * Results for each evaluated rule, in order.
206
215
  */
207
- details: (RateLimitResult & {
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<RateLimitResult>;
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: new RedisFixedWindow({ name: 'fixed-window', window: 60, limit: 100 })
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.retryAfter
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, debug, store }: RateLimitConfig<C>);
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 of the last rule evaluated.
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 in ${result.retryAfter} seconds`);
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, type DebugLimitResult, EmptyRulesException, FixedWindow, type FixedWindowConfig, GCRA, type GCRAConfig, LeakyBucket, type LeakyBucketConfig, type LimitRule, type Limiter, type RateLimitConfig, type RateLimitResult, RateLimiter, SlidingWindow, type SlidingWindowConfig, SlidingWindowCounter, type SlidingWindowCounterConfig, type Store, TokenBucket, type TokenBucketConfig, UnknownAlgorithmException, type WindowConfig, addConfigToKey, mergeRules };
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 check for a single request.
161
+ * Result of evaluating a single rate limit rule.
162
162
  *
163
- * Indicates whether the request is allowed, the maximum number of requests can be made, how many requests remain in the current
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 RateLimitResult {
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
- reset: number;
183
+ resetAt: number;
185
184
  /**
186
- * If the request is rate limited, suggests how many seconds to wait before retrying.
187
- * Clients should use exponential backoff and add jitter, rather than strictly following this value.
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
- retryAfter?: number;
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
- * Extended rate limit result with debug information.
197
+ * Result of a rate limit check across all rules.
194
198
  *
195
- * Returned when debug mode is enabled on the RateLimiter. Includes details about
196
- * all evaluated rules and which rule caused the rate limit (if any).
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 DebugLimitResult extends RateLimitResult {
202
+ interface RateLimitResult {
199
203
  /**
200
- * The name of the rule that caused the rate limit to be exceeded.
201
- * If the request was allowed, this is null.
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
- * An array of results from the first rule to the first failed one
214
+ * Results for each evaluated rule, in order.
206
215
  */
207
- details: (RateLimitResult & {
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<RateLimitResult>;
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: new RedisFixedWindow({ name: 'fixed-window', window: 60, limit: 100 })
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.retryAfter
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, debug, store }: RateLimitConfig<C>);
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 of the last rule evaluated.
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 in ${result.retryAfter} seconds`);
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, type DebugLimitResult, EmptyRulesException, FixedWindow, type FixedWindowConfig, GCRA, type GCRAConfig, LeakyBucket, type LeakyBucketConfig, type LimitRule, type Limiter, type RateLimitConfig, type RateLimitResult, RateLimiter, SlidingWindow, type SlidingWindowConfig, SlidingWindowCounter, type SlidingWindowCounterConfig, type Store, TokenBucket, type TokenBucketConfig, UnknownAlgorithmException, type WindowConfig, addConfigToKey, mergeRules };
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, debug, store }) {
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, debug: this.debug, store: this.store };
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 of the last rule evaluated.
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 in ${result.retryAfter} seconds`);
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
- let result;
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
- minRemaining = Math.min(result.remaining, minRemaining);
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
- if (this.debug) {
178
- const debugResults = {
179
- failedRule: rule.name,
180
- ...result,
181
- details: debugRules
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
- ...result,
198
- reset: maxReset,
199
- limit: minLimit,
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, debug, store }) {
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, debug: this.debug, store: this.store };
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 of the last rule evaluated.
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 in ${result.retryAfter} seconds`);
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
- let result;
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
- minRemaining = Math.min(result.remaining, minRemaining);
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
- if (this.debug) {
141
- const debugResults = {
142
- failedRule: rule.name,
143
- ...result,
144
- details: debugRules
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
- ...result,
161
- reset: maxReset,
162
- limit: minLimit,
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.1.6",
4
- "main": "dist/index.js",
5
- "module": "dist/index.mjs",
6
- "types": "dist/index.d.ts",
7
- "exports": {
8
- ".": "./dist/index.js"
9
- },
10
- "description": "Core rate limiting engine for LimitKit",
11
- "files": [
12
- "dist"
13
- ],
14
- "publishConfig": {
15
- "access": "public"
16
- },
17
- "license": "MIT",
18
- "repository": {
19
- "type": "git",
20
- "url": "https://github.com/alphatrann/limitkit"
21
- },
22
- "scripts": {
23
- "build": "tsup src/index.ts --format esm,cjs --dts",
24
- "test": "jest --silent"
25
- },
26
- "devDependencies": {
27
- "@types/node": "^25.4.0"
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
+ }