halt-rate 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 +911 -0
- package/dist/adapters/express.d.mts +21 -0
- package/dist/adapters/express.d.ts +21 -0
- package/dist/adapters/express.js +71 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/express.mjs +68 -0
- package/dist/adapters/express.mjs.map +1 -0
- package/dist/adapters/next.d.mts +21 -0
- package/dist/adapters/next.d.ts +21 -0
- package/dist/adapters/next.js +627 -0
- package/dist/adapters/next.js.map +1 -0
- package/dist/adapters/next.mjs +623 -0
- package/dist/adapters/next.mjs.map +1 -0
- package/dist/index.d.mts +83 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +782 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +773 -0
- package/dist/index.mjs.map +1 -0
- package/dist/limiter-qGH_X_KH.d.mts +128 -0
- package/dist/limiter-qGH_X_KH.d.ts +128 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __export = (target, all) => {
|
|
5
|
+
for (var name in all)
|
|
6
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/core/policy.ts
|
|
10
|
+
var KeyStrategy = /* @__PURE__ */ ((KeyStrategy2) => {
|
|
11
|
+
KeyStrategy2["IP"] = "ip";
|
|
12
|
+
KeyStrategy2["USER"] = "user";
|
|
13
|
+
KeyStrategy2["API_KEY"] = "api_key";
|
|
14
|
+
KeyStrategy2["COMPOSITE"] = "composite";
|
|
15
|
+
KeyStrategy2["CUSTOM"] = "custom";
|
|
16
|
+
return KeyStrategy2;
|
|
17
|
+
})(KeyStrategy || {});
|
|
18
|
+
var Algorithm = /* @__PURE__ */ ((Algorithm2) => {
|
|
19
|
+
Algorithm2["TOKEN_BUCKET"] = "token_bucket";
|
|
20
|
+
Algorithm2["FIXED_WINDOW"] = "fixed_window";
|
|
21
|
+
Algorithm2["SLIDING_WINDOW"] = "sliding_window";
|
|
22
|
+
Algorithm2["LEAKY_BUCKET"] = "leaky_bucket";
|
|
23
|
+
return Algorithm2;
|
|
24
|
+
})(Algorithm || {});
|
|
25
|
+
function normalizePolicy(policy) {
|
|
26
|
+
const normalized = {
|
|
27
|
+
name: policy.name,
|
|
28
|
+
limit: policy.limit,
|
|
29
|
+
window: policy.window,
|
|
30
|
+
algorithm: policy.algorithm ?? "token_bucket" /* TOKEN_BUCKET */,
|
|
31
|
+
keyStrategy: policy.keyStrategy ?? "ip" /* IP */,
|
|
32
|
+
burst: policy.burst ?? Math.floor(policy.limit * 1.2),
|
|
33
|
+
cost: policy.cost ?? 1,
|
|
34
|
+
blockDuration: policy.blockDuration ?? void 0,
|
|
35
|
+
keyExtractor: policy.keyExtractor ?? void 0,
|
|
36
|
+
exemptions: policy.exemptions ?? []
|
|
37
|
+
};
|
|
38
|
+
if (normalized.limit <= 0) {
|
|
39
|
+
throw new Error("limit must be positive");
|
|
40
|
+
}
|
|
41
|
+
if (normalized.window <= 0) {
|
|
42
|
+
throw new Error("window must be positive");
|
|
43
|
+
}
|
|
44
|
+
if (normalized.cost <= 0) {
|
|
45
|
+
throw new Error("cost must be positive");
|
|
46
|
+
}
|
|
47
|
+
if (normalized.burst < normalized.limit) {
|
|
48
|
+
throw new Error("burst must be >= limit");
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/core/extractors.ts
|
|
54
|
+
var extractors_exports = {};
|
|
55
|
+
__export(extractors_exports, {
|
|
56
|
+
HEALTH_CHECK_PATHS: () => HEALTH_CHECK_PATHS,
|
|
57
|
+
extractApiKey: () => extractApiKey,
|
|
58
|
+
extractIp: () => extractIp,
|
|
59
|
+
extractPath: () => extractPath,
|
|
60
|
+
extractUserId: () => extractUserId,
|
|
61
|
+
isHealthCheck: () => isHealthCheck,
|
|
62
|
+
isPrivateIp: () => isPrivateIp
|
|
63
|
+
});
|
|
64
|
+
var HEALTH_CHECK_PATHS = /* @__PURE__ */ new Set([
|
|
65
|
+
"/health",
|
|
66
|
+
"/ping",
|
|
67
|
+
"/ready",
|
|
68
|
+
"/healthz",
|
|
69
|
+
"/livez"
|
|
70
|
+
]);
|
|
71
|
+
var PRIVATE_IP_PATTERNS = [
|
|
72
|
+
/^127\./,
|
|
73
|
+
/^10\./,
|
|
74
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
75
|
+
/^192\.168\./,
|
|
76
|
+
/^::1$/,
|
|
77
|
+
/^fc00:/
|
|
78
|
+
];
|
|
79
|
+
function extractIp(request, trustedProxies = []) {
|
|
80
|
+
let clientIp = null;
|
|
81
|
+
if (request.socket?.remoteAddress) {
|
|
82
|
+
clientIp = request.socket.remoteAddress;
|
|
83
|
+
} else if (request.ip) {
|
|
84
|
+
clientIp = request.ip;
|
|
85
|
+
} else if (request.connection?.remoteAddress) {
|
|
86
|
+
clientIp = request.connection.remoteAddress;
|
|
87
|
+
}
|
|
88
|
+
if (!clientIp) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
clientIp = clientIp.replace(/^::ffff:/, "");
|
|
92
|
+
if (trustedProxies.length > 0 && isTrustedProxy(clientIp, trustedProxies)) {
|
|
93
|
+
const forwarded = getForwardedFor(request);
|
|
94
|
+
if (forwarded) {
|
|
95
|
+
return forwarded;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return clientIp;
|
|
99
|
+
}
|
|
100
|
+
function getForwardedFor(request) {
|
|
101
|
+
const headers = request.headers || {};
|
|
102
|
+
const forwarded = headers["x-forwarded-for"] || headers["X-Forwarded-For"];
|
|
103
|
+
if (forwarded) {
|
|
104
|
+
return forwarded.split(",")[0].trim();
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function isTrustedProxy(ip, trustedProxies) {
|
|
109
|
+
return trustedProxies.some((proxy) => {
|
|
110
|
+
if (proxy.includes("/")) {
|
|
111
|
+
return ip.startsWith(proxy.split("/")[0].split(".").slice(0, -1).join("."));
|
|
112
|
+
}
|
|
113
|
+
return ip === proxy;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function isPrivateIp(ip) {
|
|
117
|
+
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(ip));
|
|
118
|
+
}
|
|
119
|
+
function isHealthCheck(path) {
|
|
120
|
+
return HEALTH_CHECK_PATHS.has(path);
|
|
121
|
+
}
|
|
122
|
+
function extractUserId(request) {
|
|
123
|
+
if (request.user?.id) {
|
|
124
|
+
return String(request.user.id);
|
|
125
|
+
}
|
|
126
|
+
if (request.userId) {
|
|
127
|
+
return String(request.userId);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function extractApiKey(request) {
|
|
132
|
+
const headers = request.headers || {};
|
|
133
|
+
const apiKey = headers["x-api-key"] || headers["X-API-Key"] || headers["authorization"] || headers["Authorization"];
|
|
134
|
+
if (apiKey) {
|
|
135
|
+
if (apiKey.startsWith("Bearer ")) {
|
|
136
|
+
return apiKey.substring(7);
|
|
137
|
+
}
|
|
138
|
+
return apiKey;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function extractPath(request) {
|
|
143
|
+
if (request.nextUrl?.pathname) {
|
|
144
|
+
return request.nextUrl.pathname;
|
|
145
|
+
}
|
|
146
|
+
if (request.path) {
|
|
147
|
+
return request.path;
|
|
148
|
+
}
|
|
149
|
+
if (request.url) {
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(request.url, "http://localhost");
|
|
152
|
+
return url.pathname;
|
|
153
|
+
} catch {
|
|
154
|
+
return request.url;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/algorithms/token-bucket.ts
|
|
161
|
+
var TokenBucket = class {
|
|
162
|
+
constructor(capacity, rate, window) {
|
|
163
|
+
this.capacity = capacity;
|
|
164
|
+
this.rate = rate / window;
|
|
165
|
+
this.window = window;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Check if request is allowed and consume tokens.
|
|
169
|
+
*/
|
|
170
|
+
checkAndConsume(currentTokens, lastRefill, cost, now = Date.now() / 1e3) {
|
|
171
|
+
const elapsed = now - lastRefill;
|
|
172
|
+
const refillAmount = elapsed * this.rate;
|
|
173
|
+
const newTokens = Math.min(this.capacity, currentTokens + refillAmount);
|
|
174
|
+
const tokensNeeded = this.capacity - newTokens;
|
|
175
|
+
const resetAt = Math.floor(now + tokensNeeded / this.rate);
|
|
176
|
+
if (newTokens >= cost) {
|
|
177
|
+
const tokensAfterConsume = newTokens - cost;
|
|
178
|
+
const remaining = Math.floor(tokensAfterConsume);
|
|
179
|
+
return {
|
|
180
|
+
decision: {
|
|
181
|
+
allowed: true,
|
|
182
|
+
limit: Math.floor(this.rate * this.window),
|
|
183
|
+
remaining,
|
|
184
|
+
resetAt
|
|
185
|
+
},
|
|
186
|
+
newTokens: tokensAfterConsume,
|
|
187
|
+
newLastRefill: now
|
|
188
|
+
};
|
|
189
|
+
} else {
|
|
190
|
+
const tokensDeficit = cost - newTokens;
|
|
191
|
+
const retryAfter = Math.floor(tokensDeficit / this.rate) + 1;
|
|
192
|
+
return {
|
|
193
|
+
decision: {
|
|
194
|
+
allowed: false,
|
|
195
|
+
limit: Math.floor(this.rate * this.window),
|
|
196
|
+
remaining: 0,
|
|
197
|
+
resetAt,
|
|
198
|
+
retryAfter
|
|
199
|
+
},
|
|
200
|
+
newTokens,
|
|
201
|
+
newLastRefill: lastRefill
|
|
202
|
+
// Don't update last_refill on rejection
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get initial state for a new key.
|
|
208
|
+
*/
|
|
209
|
+
initialState(now = Date.now() / 1e3) {
|
|
210
|
+
return {
|
|
211
|
+
tokens: this.capacity,
|
|
212
|
+
lastRefill: now
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/algorithms/fixed-window.ts
|
|
218
|
+
var FixedWindow = class {
|
|
219
|
+
constructor(limit, window) {
|
|
220
|
+
this.limit = limit;
|
|
221
|
+
this.window = window;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Check if request is allowed and increment counter.
|
|
225
|
+
*/
|
|
226
|
+
checkAndConsume(currentCount, windowStart, cost, now = Date.now() / 1e3) {
|
|
227
|
+
const timeSinceStart = now - windowStart;
|
|
228
|
+
if (timeSinceStart >= this.window) {
|
|
229
|
+
currentCount = 0;
|
|
230
|
+
windowStart = now;
|
|
231
|
+
}
|
|
232
|
+
const resetAt = Math.floor(windowStart + this.window);
|
|
233
|
+
if (currentCount + cost <= this.limit) {
|
|
234
|
+
const newCount = currentCount + cost;
|
|
235
|
+
const remaining = this.limit - newCount;
|
|
236
|
+
return {
|
|
237
|
+
decision: {
|
|
238
|
+
allowed: true,
|
|
239
|
+
limit: this.limit,
|
|
240
|
+
remaining,
|
|
241
|
+
resetAt
|
|
242
|
+
},
|
|
243
|
+
newCount,
|
|
244
|
+
newWindowStart: windowStart
|
|
245
|
+
};
|
|
246
|
+
} else {
|
|
247
|
+
const retryAfter = Math.floor(resetAt - now) + 1;
|
|
248
|
+
return {
|
|
249
|
+
decision: {
|
|
250
|
+
allowed: false,
|
|
251
|
+
limit: this.limit,
|
|
252
|
+
remaining: 0,
|
|
253
|
+
resetAt,
|
|
254
|
+
retryAfter
|
|
255
|
+
},
|
|
256
|
+
newCount: currentCount,
|
|
257
|
+
newWindowStart: windowStart
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get initial state for a new key.
|
|
263
|
+
*/
|
|
264
|
+
initialState(now = Date.now() / 1e3) {
|
|
265
|
+
return {
|
|
266
|
+
count: 0,
|
|
267
|
+
windowStart: now
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// src/algorithms/sliding-window.ts
|
|
273
|
+
var SlidingWindow = class {
|
|
274
|
+
constructor(limit, window, precision = 10) {
|
|
275
|
+
this.limit = limit;
|
|
276
|
+
this.window = window;
|
|
277
|
+
this.precision = precision;
|
|
278
|
+
this.bucketSize = window / precision;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Check if request is allowed and update buckets.
|
|
282
|
+
*/
|
|
283
|
+
checkAndConsume(buckets, cost, now = Date.now() / 1e3) {
|
|
284
|
+
const currentBucket = Math.floor(now / this.bucketSize);
|
|
285
|
+
const cutoffBucket = currentBucket - this.precision;
|
|
286
|
+
const newBuckets = {};
|
|
287
|
+
for (const [bucketIdStr, count] of Object.entries(buckets)) {
|
|
288
|
+
const bucketId = parseInt(bucketIdStr, 10);
|
|
289
|
+
if (bucketId > cutoffBucket) {
|
|
290
|
+
newBuckets[bucketId] = count;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const totalCount = Object.values(newBuckets).reduce((sum, count) => sum + count, 0);
|
|
294
|
+
const bucketIds = Object.keys(newBuckets).map((id) => parseInt(id, 10));
|
|
295
|
+
const oldestBucket = bucketIds.length > 0 ? Math.min(...bucketIds) : currentBucket;
|
|
296
|
+
const resetAt = Math.floor((oldestBucket + this.precision + 1) * this.bucketSize);
|
|
297
|
+
if (totalCount + cost <= this.limit) {
|
|
298
|
+
newBuckets[currentBucket] = (newBuckets[currentBucket] || 0) + cost;
|
|
299
|
+
const remaining = this.limit - (totalCount + cost);
|
|
300
|
+
return {
|
|
301
|
+
decision: {
|
|
302
|
+
allowed: true,
|
|
303
|
+
limit: this.limit,
|
|
304
|
+
remaining,
|
|
305
|
+
resetAt
|
|
306
|
+
},
|
|
307
|
+
newBuckets
|
|
308
|
+
};
|
|
309
|
+
} else {
|
|
310
|
+
const retryAfter = Math.floor(this.bucketSize) + 1;
|
|
311
|
+
return {
|
|
312
|
+
decision: {
|
|
313
|
+
allowed: false,
|
|
314
|
+
limit: this.limit,
|
|
315
|
+
remaining: 0,
|
|
316
|
+
resetAt,
|
|
317
|
+
retryAfter
|
|
318
|
+
},
|
|
319
|
+
newBuckets
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Get initial state for a new key.
|
|
325
|
+
*/
|
|
326
|
+
initialState() {
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// src/algorithms/leaky-bucket.ts
|
|
332
|
+
var LeakyBucket = class {
|
|
333
|
+
constructor(capacity, leakRate, window) {
|
|
334
|
+
this.capacity = capacity;
|
|
335
|
+
this.leakRate = leakRate;
|
|
336
|
+
this.window = window;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Check if request is allowed and update bucket level.
|
|
340
|
+
*/
|
|
341
|
+
checkAndConsume(currentLevel, lastLeak, cost, now = Date.now() / 1e3) {
|
|
342
|
+
const elapsed = now - lastLeak;
|
|
343
|
+
const leaked = elapsed * this.leakRate;
|
|
344
|
+
let newLevel = Math.max(0, currentLevel - leaked);
|
|
345
|
+
let resetAt;
|
|
346
|
+
if (newLevel > 0) {
|
|
347
|
+
const timeToEmpty = newLevel / this.leakRate;
|
|
348
|
+
resetAt = Math.floor(now + timeToEmpty);
|
|
349
|
+
} else {
|
|
350
|
+
resetAt = Math.floor(now);
|
|
351
|
+
}
|
|
352
|
+
if (newLevel + cost <= this.capacity) {
|
|
353
|
+
newLevel += cost;
|
|
354
|
+
const remaining = Math.floor(this.capacity - newLevel);
|
|
355
|
+
return {
|
|
356
|
+
decision: {
|
|
357
|
+
allowed: true,
|
|
358
|
+
limit: this.capacity,
|
|
359
|
+
remaining,
|
|
360
|
+
resetAt
|
|
361
|
+
},
|
|
362
|
+
newLevel,
|
|
363
|
+
newLastLeak: now
|
|
364
|
+
};
|
|
365
|
+
} else {
|
|
366
|
+
const spaceNeeded = newLevel + cost - this.capacity;
|
|
367
|
+
const retryAfter = Math.floor(spaceNeeded / this.leakRate) + 1;
|
|
368
|
+
return {
|
|
369
|
+
decision: {
|
|
370
|
+
allowed: false,
|
|
371
|
+
limit: this.capacity,
|
|
372
|
+
remaining: 0,
|
|
373
|
+
resetAt,
|
|
374
|
+
retryAfter
|
|
375
|
+
},
|
|
376
|
+
newLevel,
|
|
377
|
+
newLastLeak: now
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Get initial state for a new key.
|
|
383
|
+
*/
|
|
384
|
+
initialState(now = Date.now() / 1e3) {
|
|
385
|
+
return {
|
|
386
|
+
level: 0,
|
|
387
|
+
lastLeak: now
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// src/core/limiter.ts
|
|
393
|
+
var RateLimiter = class {
|
|
394
|
+
constructor(options) {
|
|
395
|
+
this.store = options.store;
|
|
396
|
+
this.policyOrResolver = options.policy;
|
|
397
|
+
this.trustedProxies = options.trustedProxies ?? [];
|
|
398
|
+
this.exemptPrivateIps = options.exemptPrivateIps ?? true;
|
|
399
|
+
this.algorithmCache = /* @__PURE__ */ new Map();
|
|
400
|
+
this.otelTracer = options.otelTracer;
|
|
401
|
+
this.metricsRecorder = options.metricsRecorder;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Check if request is allowed under rate limit.
|
|
405
|
+
*/
|
|
406
|
+
/**
|
|
407
|
+
* Check if request is allowed under rate limit.
|
|
408
|
+
* This method is async because policy resolution may be async (e.g. DB lookup).
|
|
409
|
+
*/
|
|
410
|
+
async check(request, cost) {
|
|
411
|
+
const resolved = typeof this.policyOrResolver === "function" ? await this.policyOrResolver(request) : this.policyOrResolver;
|
|
412
|
+
const policy = normalizePolicy(resolved);
|
|
413
|
+
const requestCost = cost ?? policy.cost;
|
|
414
|
+
if (this.isExempt(request, policy)) {
|
|
415
|
+
const resp = {
|
|
416
|
+
allowed: true,
|
|
417
|
+
limit: policy.limit,
|
|
418
|
+
remaining: policy.limit,
|
|
419
|
+
resetAt: Math.floor(Date.now() / 1e3 + policy.window)
|
|
420
|
+
};
|
|
421
|
+
this.metricsRecorder?.("halt.request.exempt", { policy: policy.name }, 1);
|
|
422
|
+
return resp;
|
|
423
|
+
}
|
|
424
|
+
const key = this.extractKey(request, policy);
|
|
425
|
+
if (!key) {
|
|
426
|
+
const resp = {
|
|
427
|
+
allowed: true,
|
|
428
|
+
limit: policy.limit,
|
|
429
|
+
remaining: policy.limit,
|
|
430
|
+
resetAt: Math.floor(Date.now() / 1e3 + policy.window)
|
|
431
|
+
};
|
|
432
|
+
this.metricsRecorder?.("halt.request.no_key", { policy: policy.name }, 1);
|
|
433
|
+
return resp;
|
|
434
|
+
}
|
|
435
|
+
const storageKey = `halt:${policy.name}:${key}`;
|
|
436
|
+
let algorithm = this.algorithmCache.get(policy.name);
|
|
437
|
+
if (!algorithm) {
|
|
438
|
+
if (policy.algorithm === "token_bucket" /* TOKEN_BUCKET */) {
|
|
439
|
+
algorithm = new TokenBucket(policy.burst, policy.limit, policy.window);
|
|
440
|
+
} else if (policy.algorithm === "fixed_window" /* FIXED_WINDOW */) {
|
|
441
|
+
algorithm = new FixedWindow(policy.limit, policy.window);
|
|
442
|
+
} else if (policy.algorithm === "sliding_window" /* SLIDING_WINDOW */) {
|
|
443
|
+
algorithm = new SlidingWindow(policy.limit, policy.window);
|
|
444
|
+
} else if (policy.algorithm === "leaky_bucket" /* LEAKY_BUCKET */) {
|
|
445
|
+
const leakRate = policy.limit / policy.window;
|
|
446
|
+
algorithm = new LeakyBucket(policy.burst, leakRate, policy.window);
|
|
447
|
+
} else {
|
|
448
|
+
throw new Error(`Algorithm ${policy.algorithm} not implemented`);
|
|
449
|
+
}
|
|
450
|
+
this.algorithmCache.set(policy.name, algorithm);
|
|
451
|
+
}
|
|
452
|
+
const span = this.otelTracer?.startSpan?.("halt.check", { attributes: { policy: policy.name, key } });
|
|
453
|
+
const state = this.store.get(storageKey);
|
|
454
|
+
let decision;
|
|
455
|
+
if (algorithm instanceof TokenBucket) {
|
|
456
|
+
let tokens;
|
|
457
|
+
let lastRefill;
|
|
458
|
+
if (!state) {
|
|
459
|
+
const initialState = algorithm.initialState();
|
|
460
|
+
tokens = initialState.tokens;
|
|
461
|
+
lastRefill = initialState.lastRefill;
|
|
462
|
+
} else {
|
|
463
|
+
tokens = state.tokens;
|
|
464
|
+
lastRefill = state.lastRefill;
|
|
465
|
+
}
|
|
466
|
+
const result = algorithm.checkAndConsume(tokens, lastRefill, requestCost);
|
|
467
|
+
decision = result.decision;
|
|
468
|
+
const ttl = policy.window * 2;
|
|
469
|
+
this.store.set(storageKey, { tokens: result.newTokens, lastRefill: result.newLastRefill }, ttl);
|
|
470
|
+
} else if (algorithm instanceof FixedWindow) {
|
|
471
|
+
let count;
|
|
472
|
+
let windowStart;
|
|
473
|
+
if (!state) {
|
|
474
|
+
const initialState = algorithm.initialState();
|
|
475
|
+
count = initialState.count;
|
|
476
|
+
windowStart = initialState.windowStart;
|
|
477
|
+
} else {
|
|
478
|
+
count = state.count;
|
|
479
|
+
windowStart = state.windowStart;
|
|
480
|
+
}
|
|
481
|
+
const result = algorithm.checkAndConsume(count, windowStart, requestCost);
|
|
482
|
+
decision = result.decision;
|
|
483
|
+
const ttl = policy.window * 2;
|
|
484
|
+
this.store.set(storageKey, { count: result.newCount, windowStart: result.newWindowStart }, ttl);
|
|
485
|
+
} else if (algorithm instanceof SlidingWindow) {
|
|
486
|
+
const buckets = state || algorithm.initialState();
|
|
487
|
+
const result = algorithm.checkAndConsume(buckets, requestCost);
|
|
488
|
+
decision = result.decision;
|
|
489
|
+
const ttl = policy.window * 2;
|
|
490
|
+
this.store.set(storageKey, result.newBuckets, ttl);
|
|
491
|
+
} else if (algorithm instanceof LeakyBucket) {
|
|
492
|
+
let level;
|
|
493
|
+
let lastLeak;
|
|
494
|
+
if (!state) {
|
|
495
|
+
const initialState = algorithm.initialState();
|
|
496
|
+
level = initialState.level;
|
|
497
|
+
lastLeak = initialState.lastLeak;
|
|
498
|
+
} else {
|
|
499
|
+
level = state.level;
|
|
500
|
+
lastLeak = state.lastLeak;
|
|
501
|
+
}
|
|
502
|
+
const result = algorithm.checkAndConsume(level, lastLeak, requestCost);
|
|
503
|
+
decision = result.decision;
|
|
504
|
+
const ttl = policy.window * 2;
|
|
505
|
+
this.store.set(storageKey, { level: result.newLevel, lastLeak: result.newLastLeak }, ttl);
|
|
506
|
+
} else {
|
|
507
|
+
throw new Error(`Algorithm ${typeof algorithm} not supported`);
|
|
508
|
+
}
|
|
509
|
+
this.metricsRecorder?.("halt.request.checked", { policy: policy.name, allowed: String(decision.allowed) }, 1);
|
|
510
|
+
if (decision.allowed) {
|
|
511
|
+
this.metricsRecorder?.("halt.request.allowed", { policy: policy.name }, 1);
|
|
512
|
+
} else {
|
|
513
|
+
this.metricsRecorder?.("halt.request.blocked", { policy: policy.name }, 1);
|
|
514
|
+
}
|
|
515
|
+
span?.end?.();
|
|
516
|
+
return decision;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Extract rate limit key from request based on policy strategy.
|
|
520
|
+
*/
|
|
521
|
+
extractKey(request, policy) {
|
|
522
|
+
if (policy.keyExtractor) {
|
|
523
|
+
return policy.keyExtractor(request);
|
|
524
|
+
}
|
|
525
|
+
if (policy.keyStrategy === "ip" /* IP */) {
|
|
526
|
+
return extractIp(request, this.trustedProxies);
|
|
527
|
+
}
|
|
528
|
+
if (policy.keyStrategy === "user" /* USER */) {
|
|
529
|
+
return extractUserId(request);
|
|
530
|
+
}
|
|
531
|
+
if (policy.keyStrategy === "api_key" /* API_KEY */) {
|
|
532
|
+
return extractApiKey(request);
|
|
533
|
+
}
|
|
534
|
+
if (policy.keyStrategy === "composite" /* COMPOSITE */) {
|
|
535
|
+
const user = extractUserId(request);
|
|
536
|
+
const apiKey = extractApiKey(request);
|
|
537
|
+
const ip = extractIp(request, this.trustedProxies);
|
|
538
|
+
if (user && ip) {
|
|
539
|
+
return `${user}:${ip}`;
|
|
540
|
+
} else if (apiKey && ip) {
|
|
541
|
+
return `${apiKey}:${ip}`;
|
|
542
|
+
} else if (user) {
|
|
543
|
+
return user;
|
|
544
|
+
} else if (apiKey) {
|
|
545
|
+
return apiKey;
|
|
546
|
+
} else {
|
|
547
|
+
return ip;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Check if request is exempt from rate limiting.
|
|
554
|
+
*/
|
|
555
|
+
isExempt(request, policy) {
|
|
556
|
+
const path = extractPath(request);
|
|
557
|
+
if (path && isHealthCheck(path)) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (path && (policy.exemptions ?? []).includes(path)) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
if (this.exemptPrivateIps) {
|
|
564
|
+
const ip2 = extractIp(request, this.trustedProxies);
|
|
565
|
+
if (ip2 && isPrivateIp(ip2)) {
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const ip = extractIp(request, this.trustedProxies);
|
|
570
|
+
if (ip && (policy.exemptions ?? []).includes(ip)) {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/core/decision.ts
|
|
578
|
+
function toHeaders(decision) {
|
|
579
|
+
const headers = {
|
|
580
|
+
"RateLimit-Limit": String(decision.limit),
|
|
581
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
582
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
583
|
+
};
|
|
584
|
+
if (!decision.allowed && decision.retryAfter !== void 0) {
|
|
585
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
586
|
+
}
|
|
587
|
+
return headers;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/stores/memory.ts
|
|
591
|
+
var InMemoryStore = class {
|
|
592
|
+
constructor() {
|
|
593
|
+
this.data = /* @__PURE__ */ new Map();
|
|
594
|
+
}
|
|
595
|
+
get(key) {
|
|
596
|
+
this.cleanupExpired(key);
|
|
597
|
+
const entry = this.data.get(key);
|
|
598
|
+
return entry?.value ?? null;
|
|
599
|
+
}
|
|
600
|
+
set(key, value, ttl) {
|
|
601
|
+
const entry = { value };
|
|
602
|
+
if (ttl !== void 0) {
|
|
603
|
+
entry.expiry = Date.now() + ttl * 1e3;
|
|
604
|
+
}
|
|
605
|
+
this.data.set(key, entry);
|
|
606
|
+
}
|
|
607
|
+
increment(key, delta = 1, ttl) {
|
|
608
|
+
this.cleanupExpired(key);
|
|
609
|
+
const entry = this.data.get(key);
|
|
610
|
+
const current = typeof entry?.value === "number" ? entry.value : 0;
|
|
611
|
+
const newValue = current + delta;
|
|
612
|
+
const newEntry = { value: newValue };
|
|
613
|
+
if (!entry && ttl !== void 0) {
|
|
614
|
+
newEntry.expiry = Date.now() + ttl * 1e3;
|
|
615
|
+
} else if (entry?.expiry) {
|
|
616
|
+
newEntry.expiry = entry.expiry;
|
|
617
|
+
}
|
|
618
|
+
this.data.set(key, newEntry);
|
|
619
|
+
return newValue;
|
|
620
|
+
}
|
|
621
|
+
delete(key) {
|
|
622
|
+
this.data.delete(key);
|
|
623
|
+
}
|
|
624
|
+
cleanupExpired(key) {
|
|
625
|
+
const entry = this.data.get(key);
|
|
626
|
+
if (entry?.expiry && Date.now() >= entry.expiry) {
|
|
627
|
+
this.data.delete(key);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Clean up all expired keys.
|
|
632
|
+
*/
|
|
633
|
+
cleanupAllExpired() {
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
let count = 0;
|
|
636
|
+
for (const [key, entry] of this.data.entries()) {
|
|
637
|
+
if (entry.expiry && now >= entry.expiry) {
|
|
638
|
+
this.data.delete(key);
|
|
639
|
+
count++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return count;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// src/presets/index.ts
|
|
647
|
+
var presets_exports = {};
|
|
648
|
+
__export(presets_exports, {
|
|
649
|
+
AUTH_ENDPOINTS: () => AUTH_ENDPOINTS,
|
|
650
|
+
EXPENSIVE_OPS: () => EXPENSIVE_OPS,
|
|
651
|
+
GENEROUS_API: () => GENEROUS_API,
|
|
652
|
+
PLAN_BUSINESS: () => PLAN_BUSINESS,
|
|
653
|
+
PLAN_ENTERPRISE: () => PLAN_ENTERPRISE,
|
|
654
|
+
PLAN_FREE: () => PLAN_FREE,
|
|
655
|
+
PLAN_PRO: () => PLAN_PRO,
|
|
656
|
+
PLAN_STARTER: () => PLAN_STARTER,
|
|
657
|
+
PLAN_TIERS: () => PLAN_TIERS,
|
|
658
|
+
PUBLIC_API: () => PUBLIC_API,
|
|
659
|
+
STRICT_API: () => STRICT_API,
|
|
660
|
+
getPlanPolicy: () => getPlanPolicy
|
|
661
|
+
});
|
|
662
|
+
var PUBLIC_API = {
|
|
663
|
+
name: "public_api",
|
|
664
|
+
limit: 100,
|
|
665
|
+
window: 60,
|
|
666
|
+
// 1 minute
|
|
667
|
+
burst: 120,
|
|
668
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
669
|
+
keyStrategy: "ip" /* IP */
|
|
670
|
+
};
|
|
671
|
+
var AUTH_ENDPOINTS = {
|
|
672
|
+
name: "auth_endpoints",
|
|
673
|
+
limit: 5,
|
|
674
|
+
window: 60,
|
|
675
|
+
// 1 minute
|
|
676
|
+
burst: 10,
|
|
677
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
678
|
+
keyStrategy: "ip" /* IP */,
|
|
679
|
+
blockDuration: 300
|
|
680
|
+
// 5 minute cooldown after limit exceeded
|
|
681
|
+
};
|
|
682
|
+
var EXPENSIVE_OPS = {
|
|
683
|
+
name: "expensive_ops",
|
|
684
|
+
limit: 10,
|
|
685
|
+
window: 3600,
|
|
686
|
+
// 1 hour
|
|
687
|
+
burst: 15,
|
|
688
|
+
cost: 10,
|
|
689
|
+
// Each request costs 10 tokens
|
|
690
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
691
|
+
keyStrategy: "user" /* USER */
|
|
692
|
+
};
|
|
693
|
+
var STRICT_API = {
|
|
694
|
+
name: "strict_api",
|
|
695
|
+
limit: 20,
|
|
696
|
+
window: 60,
|
|
697
|
+
// 1 minute
|
|
698
|
+
burst: 25,
|
|
699
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
700
|
+
keyStrategy: "api_key" /* API_KEY */
|
|
701
|
+
};
|
|
702
|
+
var GENEROUS_API = {
|
|
703
|
+
name: "generous_api",
|
|
704
|
+
limit: 1e3,
|
|
705
|
+
window: 60,
|
|
706
|
+
// 1 minute
|
|
707
|
+
burst: 1200,
|
|
708
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
709
|
+
keyStrategy: "ip" /* IP */
|
|
710
|
+
};
|
|
711
|
+
var PLAN_FREE = {
|
|
712
|
+
name: "free_plan",
|
|
713
|
+
limit: 100,
|
|
714
|
+
window: 3600,
|
|
715
|
+
// 100 requests per hour
|
|
716
|
+
burst: 120,
|
|
717
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
718
|
+
keyStrategy: "user" /* USER */
|
|
719
|
+
};
|
|
720
|
+
var PLAN_STARTER = {
|
|
721
|
+
name: "starter_plan",
|
|
722
|
+
limit: 500,
|
|
723
|
+
window: 3600,
|
|
724
|
+
// 500 requests per hour
|
|
725
|
+
burst: 600,
|
|
726
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
727
|
+
keyStrategy: "user" /* USER */
|
|
728
|
+
};
|
|
729
|
+
var PLAN_PRO = {
|
|
730
|
+
name: "pro_plan",
|
|
731
|
+
limit: 2e3,
|
|
732
|
+
window: 3600,
|
|
733
|
+
// 2000 requests per hour
|
|
734
|
+
burst: 2500,
|
|
735
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
736
|
+
keyStrategy: "user" /* USER */
|
|
737
|
+
};
|
|
738
|
+
var PLAN_BUSINESS = {
|
|
739
|
+
name: "business_plan",
|
|
740
|
+
limit: 5e3,
|
|
741
|
+
window: 3600,
|
|
742
|
+
// 5000 requests per hour
|
|
743
|
+
burst: 6e3,
|
|
744
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
745
|
+
keyStrategy: "user" /* USER */
|
|
746
|
+
};
|
|
747
|
+
var PLAN_ENTERPRISE = {
|
|
748
|
+
name: "enterprise_plan",
|
|
749
|
+
limit: 2e4,
|
|
750
|
+
window: 3600,
|
|
751
|
+
// 20000 requests per hour
|
|
752
|
+
burst: 25e3,
|
|
753
|
+
algorithm: "token_bucket" /* TOKEN_BUCKET */,
|
|
754
|
+
keyStrategy: "user" /* USER */
|
|
755
|
+
};
|
|
756
|
+
var PLAN_TIERS = {
|
|
757
|
+
free: PLAN_FREE,
|
|
758
|
+
starter: PLAN_STARTER,
|
|
759
|
+
pro: PLAN_PRO,
|
|
760
|
+
business: PLAN_BUSINESS,
|
|
761
|
+
enterprise: PLAN_ENTERPRISE
|
|
762
|
+
};
|
|
763
|
+
function getPlanPolicy(planName) {
|
|
764
|
+
const normalized = planName.toLowerCase();
|
|
765
|
+
if (!(normalized in PLAN_TIERS)) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`Invalid plan: ${planName}. Valid plans: ${Object.keys(PLAN_TIERS).join(", ")}`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
return PLAN_TIERS[normalized];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
exports.Algorithm = Algorithm;
|
|
774
|
+
exports.InMemoryStore = InMemoryStore;
|
|
775
|
+
exports.KeyStrategy = KeyStrategy;
|
|
776
|
+
exports.RateLimiter = RateLimiter;
|
|
777
|
+
exports.extractors = extractors_exports;
|
|
778
|
+
exports.normalizePolicy = normalizePolicy;
|
|
779
|
+
exports.presets = presets_exports;
|
|
780
|
+
exports.toHeaders = toHeaders;
|
|
781
|
+
//# sourceMappingURL=index.js.map
|
|
782
|
+
//# sourceMappingURL=index.js.map
|