@limitkit/core 0.1.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 +117 -0
- package/dist/index.d.mts +702 -0
- package/dist/index.d.ts +702 -0
- package/dist/index.js +385 -0
- package/dist/index.mjs +347 -0
- package/package.json +26 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// src/exceptions/bad-arguments-exception.ts
|
|
2
|
+
var BadArgumentsException = class extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "BAD_ARGUMENTS_EXCEPTION";
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/exceptions/empty-rules-exception.ts
|
|
10
|
+
var EmptyRulesException = class extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("The rate limit rules are empty. Ensure there is at least one rule");
|
|
13
|
+
this.name = "EMPTY_RULES_EXCEPTION";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/exceptions/unknown-algorithm-exception.ts
|
|
18
|
+
var UnknownAlgorithmException = class extends Error {
|
|
19
|
+
constructor(algorithm) {
|
|
20
|
+
super(`Found unknown algorithm: ${algorithm}`);
|
|
21
|
+
this.name = "UNKNOWN_ALGORITHM_EXCEPTION";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/utils/add-config-to-key.ts
|
|
26
|
+
import { createHash } from "crypto";
|
|
27
|
+
function addConfigToKey(config, key) {
|
|
28
|
+
const sortedKeys = Object.keys(config).sort();
|
|
29
|
+
const sortedConfig = sortedKeys.reduce((acc, k) => {
|
|
30
|
+
acc[k] = config[k];
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
const configJson = JSON.stringify(sortedConfig);
|
|
34
|
+
const hashedConfig = createHash("sha256").update(configJson).digest("hex");
|
|
35
|
+
const modifiedKey = `ratelimit:${config.name}:${hashedConfig}:${key}`;
|
|
36
|
+
return modifiedKey;
|
|
37
|
+
}
|
|
38
|
+
|
|
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
|
+
// src/rate-limiter.ts
|
|
59
|
+
var RateLimiter = class {
|
|
60
|
+
rules = [];
|
|
61
|
+
debug = false;
|
|
62
|
+
store;
|
|
63
|
+
/**
|
|
64
|
+
* Create a new rate limiter instance.
|
|
65
|
+
* @throws {EmptyRulesException} If the list of rules is empty
|
|
66
|
+
* @param config - Configuration for the rate limiter
|
|
67
|
+
* @see RateLimitConfig
|
|
68
|
+
*/
|
|
69
|
+
constructor({ rules, debug, store }) {
|
|
70
|
+
if (rules.length === 0) throw new EmptyRulesException();
|
|
71
|
+
this.rules = rules ?? this.rules;
|
|
72
|
+
this.debug = debug ?? this.debug;
|
|
73
|
+
this.store = store;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Return the configuration object
|
|
77
|
+
* @returns {RateLimitConfig<C>}
|
|
78
|
+
*/
|
|
79
|
+
get config() {
|
|
80
|
+
return { rules: this.rules, debug: this.debug, store: this.store };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if a request should be allowed under the configured rate limits.
|
|
84
|
+
*
|
|
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.
|
|
87
|
+
*
|
|
88
|
+
* Each rule resolution (key, cost, policy) can be static or dynamic:
|
|
89
|
+
* - Static: evaluated once and reused
|
|
90
|
+
* - Dynamic: evaluated per request based on context
|
|
91
|
+
* - Async: evaluated asynchronously (e.g., database lookups)
|
|
92
|
+
*
|
|
93
|
+
* @param ctx - Request context passed to rule resolvers to determine dynamic values.
|
|
94
|
+
* @returns Promise resolving to the rate limit result. If debug mode is enabled,
|
|
95
|
+
* includes details about each evaluated rule and which rule failed (if any).
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const result = await limiter.consume({
|
|
100
|
+
* userId: 'user-123',
|
|
101
|
+
* ip: '192.168.1.1',
|
|
102
|
+
* endpoint: '/api/search'
|
|
103
|
+
* });
|
|
104
|
+
*
|
|
105
|
+
* if (!result.allowed) {
|
|
106
|
+
* console.log(`Rate limited. Retry in ${result.retryAfter} seconds`);
|
|
107
|
+
* }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
async consume(ctx) {
|
|
111
|
+
let result;
|
|
112
|
+
let minRemaining = Infinity;
|
|
113
|
+
let maxReset = 0;
|
|
114
|
+
let minLimit = Infinity;
|
|
115
|
+
const debugRules = [];
|
|
116
|
+
for (const rule of this.rules) {
|
|
117
|
+
const algorithm = typeof rule.policy === "function" ? await rule.policy(ctx) : rule.policy;
|
|
118
|
+
const key = typeof rule.key === "function" ? await rule.key(ctx) : rule.key;
|
|
119
|
+
const cost = typeof rule.cost === "function" ? await rule.cost(ctx) : rule.cost;
|
|
120
|
+
if (cost && cost <= 0)
|
|
121
|
+
throw new BadArgumentsException(
|
|
122
|
+
`Cost must be a positive integer, got cost=${cost}`
|
|
123
|
+
);
|
|
124
|
+
const keyWithConfig = addConfigToKey(algorithm.config, key);
|
|
125
|
+
result = await this.store.consume(
|
|
126
|
+
keyWithConfig,
|
|
127
|
+
algorithm,
|
|
128
|
+
Date.now(),
|
|
129
|
+
cost ?? 1
|
|
130
|
+
);
|
|
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
|
+
}
|
|
139
|
+
if (result.remaining === 0) {
|
|
140
|
+
if (this.debug) {
|
|
141
|
+
const debugResults = {
|
|
142
|
+
failedRule: rule.name,
|
|
143
|
+
...result,
|
|
144
|
+
details: debugRules
|
|
145
|
+
};
|
|
146
|
+
return debugResults;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...result,
|
|
150
|
+
reset: maxReset,
|
|
151
|
+
limit: minLimit,
|
|
152
|
+
remaining: minRemaining
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (this.debug) {
|
|
157
|
+
const final = {
|
|
158
|
+
...result,
|
|
159
|
+
details: this.debug ? debugRules : void 0
|
|
160
|
+
};
|
|
161
|
+
console.log(final);
|
|
162
|
+
return final;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
...result,
|
|
166
|
+
reset: maxReset,
|
|
167
|
+
limit: minLimit,
|
|
168
|
+
remaining: minRemaining
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// src/algorithms/fixed-window.ts
|
|
174
|
+
var FixedWindow = class {
|
|
175
|
+
constructor(config) {
|
|
176
|
+
this.config = config;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Validates the fixed window configuration.
|
|
180
|
+
*
|
|
181
|
+
* Ensures the configured window size and request limit are positive values.
|
|
182
|
+
*
|
|
183
|
+
* @throws BadArgumentsException
|
|
184
|
+
* Thrown if:
|
|
185
|
+
* - `limit <= 0`
|
|
186
|
+
* - `window <= 0`
|
|
187
|
+
*/
|
|
188
|
+
validate() {
|
|
189
|
+
if (this.config.limit <= 0)
|
|
190
|
+
throw new BadArgumentsException(
|
|
191
|
+
`Expected limit to be positive, got limit=${this.config.limit}`
|
|
192
|
+
);
|
|
193
|
+
if (this.config.window <= 0)
|
|
194
|
+
throw new BadArgumentsException(
|
|
195
|
+
`Expected window to be positive, got window=${this.config.window}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/algorithms/sliding-window.ts
|
|
201
|
+
var SlidingWindow = class {
|
|
202
|
+
constructor(config) {
|
|
203
|
+
this.config = config;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Validates the sliding window configuration.
|
|
207
|
+
*
|
|
208
|
+
* Ensures the configured window size and request limit are positive values.
|
|
209
|
+
*
|
|
210
|
+
* @throws BadArgumentsException
|
|
211
|
+
* Thrown if:
|
|
212
|
+
* - `limit <= 0`
|
|
213
|
+
* - `window <= 0`
|
|
214
|
+
*/
|
|
215
|
+
validate() {
|
|
216
|
+
if (this.config.limit <= 0)
|
|
217
|
+
throw new BadArgumentsException(
|
|
218
|
+
`Expected limit to be positive, got limit=${this.config.limit}`
|
|
219
|
+
);
|
|
220
|
+
if (this.config.window <= 0)
|
|
221
|
+
throw new BadArgumentsException(
|
|
222
|
+
`Expected window to be positive, got window=${this.config.window}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// src/algorithms/sliding-window-counter.ts
|
|
228
|
+
var SlidingWindowCounter = class {
|
|
229
|
+
constructor(config) {
|
|
230
|
+
this.config = config;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Validates the sliding window counter configuration.
|
|
234
|
+
*
|
|
235
|
+
* Ensures the configured window size and request limit are positive values.
|
|
236
|
+
*
|
|
237
|
+
* @throws BadArgumentsException
|
|
238
|
+
* Thrown if:
|
|
239
|
+
* - `limit <= 0`
|
|
240
|
+
* - `window <= 0`
|
|
241
|
+
*/
|
|
242
|
+
validate() {
|
|
243
|
+
if (this.config.limit <= 0)
|
|
244
|
+
throw new BadArgumentsException(
|
|
245
|
+
`Expected limit to be positive, got limit=${this.config.limit}`
|
|
246
|
+
);
|
|
247
|
+
if (this.config.window <= 0)
|
|
248
|
+
throw new BadArgumentsException(
|
|
249
|
+
`Expected window to be positive, got window=${this.config.window}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/algorithms/token-bucket.ts
|
|
255
|
+
var TokenBucket = class {
|
|
256
|
+
constructor(config) {
|
|
257
|
+
this.config = config;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Validates the token bucket configuration.
|
|
261
|
+
*
|
|
262
|
+
* Ensures the configured capacity and refill rate are positive values.
|
|
263
|
+
*
|
|
264
|
+
* @throws BadArgumentsException
|
|
265
|
+
* Thrown if:
|
|
266
|
+
* - `capacity <= 0`
|
|
267
|
+
* - `refillRate <= 0`
|
|
268
|
+
*/
|
|
269
|
+
validate() {
|
|
270
|
+
if (this.config.capacity <= 0)
|
|
271
|
+
throw new BadArgumentsException(
|
|
272
|
+
`Expected capacity to be positive, got capacity=${this.config.capacity}`
|
|
273
|
+
);
|
|
274
|
+
if (this.config.refillRate <= 0)
|
|
275
|
+
throw new BadArgumentsException(
|
|
276
|
+
`Expected refillRate to be positive, got refillRate=${this.config.refillRate}`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/algorithms/leaky-bucket.ts
|
|
282
|
+
var LeakyBucket = class {
|
|
283
|
+
constructor(config) {
|
|
284
|
+
this.config = config;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Validates the leaky bucket configuration.
|
|
288
|
+
*
|
|
289
|
+
* Ensures the configured capacity and leak rate are positive values.
|
|
290
|
+
*
|
|
291
|
+
* @throws BadArgumentsException
|
|
292
|
+
* Thrown if:
|
|
293
|
+
* - `capacity <= 0`
|
|
294
|
+
* - `leakRate <= 0`
|
|
295
|
+
*/
|
|
296
|
+
validate() {
|
|
297
|
+
if (this.config.capacity <= 0)
|
|
298
|
+
throw new BadArgumentsException(
|
|
299
|
+
`Expected capacity to be positive, got capacity=${this.config.capacity}`
|
|
300
|
+
);
|
|
301
|
+
if (this.config.leakRate <= 0)
|
|
302
|
+
throw new BadArgumentsException(
|
|
303
|
+
`Expected leakRate to be positive, got leakRate=${this.config.leakRate}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// src/algorithms/gcra.ts
|
|
309
|
+
var GCRA = class {
|
|
310
|
+
constructor(config) {
|
|
311
|
+
this.config = config;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Validates the GCRA configuration.
|
|
315
|
+
*
|
|
316
|
+
* Ensures the configured burst and interval are positive values.
|
|
317
|
+
*
|
|
318
|
+
* @throws BadArgumentsException
|
|
319
|
+
* Thrown if:
|
|
320
|
+
* - `burst <= 0`
|
|
321
|
+
* - `interval <= 0`
|
|
322
|
+
*/
|
|
323
|
+
validate() {
|
|
324
|
+
if (this.config.burst <= 0)
|
|
325
|
+
throw new BadArgumentsException(
|
|
326
|
+
`Expected burst to be positive, got burst=${this.config.burst}`
|
|
327
|
+
);
|
|
328
|
+
if (this.config.interval <= 0)
|
|
329
|
+
throw new BadArgumentsException(
|
|
330
|
+
`Expected interval to be positive, got interval=${this.config.interval}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
export {
|
|
335
|
+
BadArgumentsException,
|
|
336
|
+
EmptyRulesException,
|
|
337
|
+
FixedWindow,
|
|
338
|
+
GCRA,
|
|
339
|
+
LeakyBucket,
|
|
340
|
+
RateLimiter,
|
|
341
|
+
SlidingWindow,
|
|
342
|
+
SlidingWindowCounter,
|
|
343
|
+
TokenBucket,
|
|
344
|
+
UnknownAlgorithmException,
|
|
345
|
+
addConfigToKey,
|
|
346
|
+
mergeRules
|
|
347
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@limitkit/core",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/alphatrann/limitkit"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
21
|
+
"test": "jest --silent"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^25.4.0"
|
|
25
|
+
}
|
|
26
|
+
}
|