express-adaptive-rate-limiter 2.0.1 → 2.2.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 +469 -26
- package/dist/adaptive/adaptiveController.js +1 -0
- package/dist/algorithms/baseAlgorithm.js +1 -1
- package/dist/algorithms/fixedWindow.js +1 -1
- package/dist/algorithms/leakyBucket.js +1 -1
- package/dist/algorithms/slidingWindowCounter.js +1 -1
- package/dist/algorithms/slidingWindowLog.js +1 -1
- package/dist/algorithms/tokenBucket.js +1 -1
- package/dist/core/coreMiddleware.js +1 -1
- package/dist/core/wrappers/adaptiveLimiter.js +1 -0
- package/dist/index.js +1 -1
- package/dist/middleware/expressMiddleware.js +1 -1
- package/dist/rateLimit.js +1 -1
- package/dist/types/index.d.ts +93 -15
- package/dist/utils/validation.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,22 @@ Every backend in the core tier is atomic: the counter increment and the window c
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## What's new in 2.2.0 (Pending Release)
|
|
15
|
+
|
|
16
|
+
- **Genuine Adaptive Throttling (`adaptive: true` & `algorithm: 'adaptive'`)**: Zero-dependency event loop latency monitoring (`perf_hooks.monitorEventLoopDelay`) that continuously assesses server health and automatically scales down rate limits proportionally during high load or lag spikes to protect the Node.js process, restoring quotas smoothly when latency stabilizes.
|
|
17
|
+
- **Dual-Store In-Memory Fallback (`fallbackStore: 'memory'`)**: Solves the security risks of unbounded `failOpen` by automatically failing over to an in-memory limiter when Redis or Redis Cluster becomes unreachable, enforcing local quotas and emitting `'fallback'` instead of leaving endpoints unprotected.
|
|
18
|
+
- **Core Tier `AdaptiveRateLimiter` Wrapper**: Wrap any core limiter (`MemoryLimiter`, `RedisLimiter`, `PostgresLimiter`, etc.) with `AdaptiveRateLimiter` to dynamically scale allowed points based on event loop latency.
|
|
19
|
+
- **New Lifecycle Events**: Emits `'adaptiveThrottled'` when event loop load shedding triggers and `'fallback'` when store failover activates.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What's new in 2.1.0 (Current Release)
|
|
24
|
+
|
|
25
|
+
- **Dynamic Request-Based Limits**: Pass a function or async resolver `(req, res) => number | Promise<number>` as `limit` or `capacity`. Easily apply VIP, customer tier, or route-based quotas dynamically across all 5 rate-limiting algorithms.
|
|
26
|
+
- **Lifecycle Event Emitters**: Express and Core middlewares now inherit from Node.js `EventEmitter`. Listen to `'consumed'`, `'limitReached'`, `'blocked'`, `'reset'`, and `'error'` events for real-time telemetry, Prometheus metrics, and security auditing.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
14
30
|
## What's new in 2.0.1
|
|
15
31
|
|
|
16
32
|
- **Full Redis Cluster support**:
|
|
@@ -120,6 +136,7 @@ app.use(
|
|
|
120
136
|
|
|
121
137
|
| Algorithm | Identifier | Best For | Memory | Redis | Atomic |
|
|
122
138
|
| --------------------------- | ------------------------ | -------------------------------------------- | :----: | :------: | :----: |
|
|
139
|
+
| **Adaptive Throttling** | `adaptive` | Auto-tuning limit based on event loop latency| ✅ | ✅ (Lua) | ✅ |
|
|
123
140
|
| **Fixed Window Counter** | `fixed-window` | Low-overhead, window anchored on first hit | ✅ | ✅ (Lua) | ✅ |
|
|
124
141
|
| **Sliding Window Log** | `sliding-window-log` | 100% strict precision rolling window | ✅ | ✅ (Lua) | ✅ |
|
|
125
142
|
| **Sliding Window Counter** | `sliding-window-counter` | Smooth rolling limits with O(1) memory | ✅ | ✅ (Lua) | ✅ |
|
|
@@ -129,6 +146,279 @@ app.use(
|
|
|
129
146
|
|
|
130
147
|
---
|
|
131
148
|
|
|
149
|
+
## 💡 Real-World Production Problems & Recipes
|
|
150
|
+
|
|
151
|
+
Why do developers adopt `express-adaptive-rate-limiter`? Here are real production scenarios, what breaks in standard rate limiters, and how this package solves each problem:
|
|
152
|
+
|
|
153
|
+
### 1. Self-Healing Adaptive Throttling (Event Loop Protection) `[Tag: v2.2.0+]`
|
|
154
|
+
|
|
155
|
+
**The Real-World Issue**: Node.js relies on a single-threaded event loop. When your application experiences sudden traffic surges or CPU-intensive workloads (cryptographic hashing, heavy JSON serialization), event loop lag spikes. Traditional rate limiters keep allowing full traffic, causing pods to fail Kubernetes health checks and crash.
|
|
156
|
+
|
|
157
|
+
**The Solution**: Use `algorithm: 'adaptive'` or `adaptive: true`. The built-in `AdaptiveController` uses native `perf_hooks.monitorEventLoopDelay` (zero external dependencies). When event loop lag exceeds `targetLagMs` (e.g., 20ms), it automatically and proportionally throttles traffic down towards `minFactor` (e.g., 20% of normal limit). Once the event loop recovers, quotas automatically restore to 100%.
|
|
158
|
+
|
|
159
|
+
```javascript
|
|
160
|
+
import express from 'express';
|
|
161
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
162
|
+
|
|
163
|
+
const app = express();
|
|
164
|
+
|
|
165
|
+
const adaptiveLimiter = rateLimit({
|
|
166
|
+
algorithm: 'adaptive', // Shorthand for auto-tuning sliding window
|
|
167
|
+
limit: 100, // Base limit per client
|
|
168
|
+
windowMs: 60 * 1000, // 1 minute window
|
|
169
|
+
adaptive: {
|
|
170
|
+
targetLagMs: 20, // Throttle if event loop lag exceeds 20ms
|
|
171
|
+
maxLagMs: 100, // Max throttling at 100ms lag
|
|
172
|
+
minFactor: 0.2 // Scale down to 20% of limit under peak load
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Real-time telemetry: notify your monitoring/alerting pipeline
|
|
177
|
+
adaptiveLimiter.on('adaptiveThrottled', (req, res, stats) => {
|
|
178
|
+
console.warn(
|
|
179
|
+
`[ADAPTIVE THROTTLE] Client ${req.ip} throttled. ` +
|
|
180
|
+
`Current lag: ${stats.currentLagMs}ms | Factor: ${stats.factor}`
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
app.use('/api', adaptiveLimiter);
|
|
185
|
+
|
|
186
|
+
app.get('/api/resource', (req, res) => {
|
|
187
|
+
res.json({
|
|
188
|
+
data: 'Processed successfully',
|
|
189
|
+
rateLimit: {
|
|
190
|
+
remaining: req.rateLimit.remaining,
|
|
191
|
+
effectiveLimit: req.rateLimit.limit,
|
|
192
|
+
adaptiveStats: req.rateLimit.adaptive
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.listen(3000);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**How to Adapt**:
|
|
201
|
+
- **Low-latency microservices**: Use `targetLagMs: 15` and `maxLagMs: 50`.
|
|
202
|
+
- **Monoliths with heavy sync work**: Use `targetLagMs: 30` and `maxLagMs: 150`.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### 2. Authentication Protection with Dual-Store Failover `[Tag: v2.2.0+]`
|
|
207
|
+
|
|
208
|
+
**The Real-World Issue**: Brute-force and credential-stuffing attacks target `/login`. Most architectures use Redis for rate limiting and set `failOpen: true`. If Redis restarts or drops, `failOpen` disables rate limiting completely, exposing your authentication database to millions of password guesses without any throttle.
|
|
209
|
+
|
|
210
|
+
**The Solution**: Configure `fallbackStore: 'memory'`. If Redis becomes unreachable due to timeouts, network blips, or maintenance failovers, `express-adaptive-rate-limiter` catches the connection error and automatically reroutes requests to a local in-memory store in process memory. Attacks remain blocked with HTTP 429, valid users are protected, and a `'fallback'` event is emitted for SRE alerting.
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
import express from 'express';
|
|
214
|
+
import Redis from 'ioredis';
|
|
215
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
216
|
+
|
|
217
|
+
const app = express();
|
|
218
|
+
app.use(express.json());
|
|
219
|
+
|
|
220
|
+
const redisClient = new Redis({
|
|
221
|
+
host: process.env.REDIS_HOST || '127.0.0.1',
|
|
222
|
+
port: 6379,
|
|
223
|
+
connectTimeout: 2000,
|
|
224
|
+
maxRetriesPerRequest: 1
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const authLimiter = rateLimit({
|
|
228
|
+
store: 'redis',
|
|
229
|
+
client: redisClient,
|
|
230
|
+
fallbackStore: 'memory', // Failover to local memory instead of opening the floodgates!
|
|
231
|
+
limit: 5, // Max 5 login attempts
|
|
232
|
+
windowMs: 15 * 60 * 1000,
|
|
233
|
+
// Compound key combining IP and email to prevent distributed account attacks
|
|
234
|
+
keyGenerator: (req) => `auth:${req.ip}:${req.body?.email || 'anon'}`
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
authLimiter.on('fallback', (err, req) => {
|
|
238
|
+
console.error(`[ALERT] Redis down, memory fallback active for ${req.ip}:`, err.message);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.post('/api/auth/login', authLimiter, (req, res) => {
|
|
242
|
+
res.json({ status: 'ok' });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
app.listen(3000);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**How to Adapt**:
|
|
249
|
+
- **Shared Office/NAT Networks**: Always combine IP and email in `keyGenerator` so multiple users behind one IP aren't locked out.
|
|
250
|
+
- **SMS / OTP Verification**: Set `limit: 3` and `windowMs: 10 * 60 * 1000` to prevent SMS toll fraud and SIM swapping.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
### 3. Multi-Tenant SaaS Dynamic API Plans `[Tag: v2.1.0+]`
|
|
255
|
+
|
|
256
|
+
**The Real-World Issue**: SaaS APIs charge by customer tier (Free: 30 req/min, Pro: 300 req/min, Enterprise: 2000 req/min). Hardcoded limits or multiple middleware layers make route definitions messy and hard to maintain.
|
|
257
|
+
|
|
258
|
+
**The Solution**: Pass an async function to `limit: async (req) => ...`. Dynamically look up the user's tier from their JWT or API key, and return their plan quota. Standard RFC rate limit headers automatically reflect the customer's actual plan.
|
|
259
|
+
|
|
260
|
+
```javascript
|
|
261
|
+
import express from 'express';
|
|
262
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
263
|
+
|
|
264
|
+
const app = express();
|
|
265
|
+
|
|
266
|
+
const tieredLimiter = rateLimit({
|
|
267
|
+
windowMs: 60 * 1000,
|
|
268
|
+
limit: async (req) => {
|
|
269
|
+
const tier = req.user?.tier; // Extracted by auth middleware
|
|
270
|
+
if (tier === 'enterprise') return 2000;
|
|
271
|
+
if (tier === 'pro') return 300;
|
|
272
|
+
return 30; // Free tier
|
|
273
|
+
},
|
|
274
|
+
keyGenerator: (req) => req.user?.id || req.ip,
|
|
275
|
+
standardHeaders: true
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
app.use('/v1', tieredLimiter);
|
|
279
|
+
app.listen(3000);
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**How to Adapt**:
|
|
283
|
+
- **Database Caching**: Cache customer plan lookups in Redis or memory with a 5-minute TTL to avoid DB queries on every request.
|
|
284
|
+
- **Over-Quota Monetization**: Listen to `tieredLimiter.on('limitReached')` to flag upsell opportunities for active users.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
### 4. Variable-Cost Limiting for AI & Heavy Endpoints `[Tag: v2.0.0+]`
|
|
289
|
+
|
|
290
|
+
**The Real-World Issue**: Standard limiters count all requests equally. A `GET /ping` takes 1ms, while a `POST /ai/generate` or PDF export takes seconds of GPU/CPU time and costs real cloud money. Malicious or automated clients can easily exhaust compute quotas with just a few heavy requests.
|
|
291
|
+
|
|
292
|
+
**The Solution**: Use `algorithm: 'token-bucket'` with dynamic `cost: (req) => ...`. Simple queries cost 1 token, while expensive AI calls or large exports cost 15–25 tokens. Heavy consumers drain their bucket quickly, protecting backends without penalizing light traffic.
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
import express from 'express';
|
|
296
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
297
|
+
|
|
298
|
+
const app = express();
|
|
299
|
+
app.use(express.json());
|
|
300
|
+
|
|
301
|
+
const aiLimiter = rateLimit({
|
|
302
|
+
algorithm: 'token-bucket',
|
|
303
|
+
capacity: 100,
|
|
304
|
+
refillRate: 10, // 10 tokens refilled per second
|
|
305
|
+
cost: (req) => {
|
|
306
|
+
if (req.body?.model === 'gpt-4o' || req.body?.model === 'gemini-pro') {
|
|
307
|
+
return req.body?.stream ? 25 : 15;
|
|
308
|
+
}
|
|
309
|
+
return 1;
|
|
310
|
+
},
|
|
311
|
+
keyGenerator: (req) => req.headers['x-api-key'] || req.ip
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
app.post('/api/ai/chat', aiLimiter, (req, res) => {
|
|
315
|
+
res.json({ reply: 'AI response', remaining: req.rateLimit.remaining });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
app.listen(3000);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**How to Adapt**:
|
|
322
|
+
- **Pagination Protection**: Set cost based on page size: `cost: (req) => Math.ceil((Number(req.query.limit) || 10) / 10)`.
|
|
323
|
+
- **File Upload Protection**: Inspect `req.headers['content-length']` to deduct tokens based on uploaded file megabytes.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### 5. Multi-Node Redis Cluster Protection (CROSSSLOT Errors) `[Tag: v2.0.1+]`
|
|
328
|
+
|
|
329
|
+
**The Real-World Issue**: High-traffic Kubernetes clusters partition Redis across 16,384 hash slots. Running distributed rate limiting across a Redis Cluster often crashes with `CROSSSLOT Keys in request don't hash to the same slot` because multi-key commands or Lua scripts span multiple nodes.
|
|
330
|
+
|
|
331
|
+
**The Solution**: Use `store: 'redis-cluster'` with `hashTag: true`. Keys are automatically wrapped in `{keyPrefix:key}` hash tags (e.g. `{rl:192.168.1.1}`). Redis calculates the slot based only on the hash tag, guaranteeing all keys map to the exact same slot and eliminating `CROSSSLOT` errors. Batch key cleanups run via per-slot pipelines, and discovery scans all masters concurrently.
|
|
332
|
+
|
|
333
|
+
```javascript
|
|
334
|
+
import express from 'express';
|
|
335
|
+
import Redis from 'ioredis';
|
|
336
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
337
|
+
|
|
338
|
+
const app = express();
|
|
339
|
+
|
|
340
|
+
const clusterClient = new Redis.Cluster([
|
|
341
|
+
{ host: 'redis-node-1', port: 6379 },
|
|
342
|
+
{ host: 'redis-node-2', port: 6379 },
|
|
343
|
+
{ host: 'redis-node-3', port: 6379 }
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
const clusterLimiter = rateLimit({
|
|
347
|
+
store: 'redis-cluster',
|
|
348
|
+
client: clusterClient,
|
|
349
|
+
algorithm: 'sliding-window-counter',
|
|
350
|
+
limit: 500,
|
|
351
|
+
windowMs: 60 * 1000,
|
|
352
|
+
hashTag: true, // Prevents CROSSSLOT errors across cluster shards
|
|
353
|
+
fallbackStore: 'memory', // Local failover during cluster rebalancing
|
|
354
|
+
keyGenerator: (req) => req.headers['x-client-id'] || req.ip
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
app.use(clusterLimiter);
|
|
358
|
+
app.listen(3000);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**How to Adapt**:
|
|
362
|
+
- **AWS ElastiCache / MemoryDB**: Pass the configuration endpoint in `new Redis.Cluster([{ host: 'clustercfg.endpoint', port: 6379 }])`.
|
|
363
|
+
- **Node-Redis (`@redis/client`)**: Fully compatible via `createCluster({ rootNodes: [...] })`.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
### 6. Observability, Prometheus Metrics & Threat Detection `[Tag: v2.1.0+]`
|
|
368
|
+
|
|
369
|
+
**The Real-World Issue**: In production, rate limiters act as first-line defenses. Silent HTTP 429 rejections make it impossible to tell whether an attacker is hitting the API or a paying enterprise customer is experiencing a quota configuration bug.
|
|
370
|
+
|
|
371
|
+
**The Solution**: Listen to built-in `EventEmitter` lifecycle events on the limiter (`'consumed'`, `'limitReached'`, `'blocked'`, `'reset'`, `'error'`). Export real-time metrics directly to Prometheus, Datadog, or Slack alerts without writing custom middleware wrappers.
|
|
372
|
+
|
|
373
|
+
```javascript
|
|
374
|
+
import express from 'express';
|
|
375
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
376
|
+
import client from 'prom-client';
|
|
377
|
+
|
|
378
|
+
const app = express();
|
|
379
|
+
const register = new client.Registry();
|
|
380
|
+
|
|
381
|
+
const blockedCounter = new client.Counter({
|
|
382
|
+
name: 'rate_limit_blocked_total',
|
|
383
|
+
help: 'Total rate limit rejections (HTTP 429)',
|
|
384
|
+
labelNames: ['path', 'reason'],
|
|
385
|
+
registers: [register]
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const consumedCounter = new client.Counter({
|
|
389
|
+
name: 'rate_limit_consumed_total',
|
|
390
|
+
help: 'Total allowed rate limit requests',
|
|
391
|
+
labelNames: ['path'],
|
|
392
|
+
registers: [register]
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const limiter = rateLimit({
|
|
396
|
+
algorithm: 'sliding-window-counter',
|
|
397
|
+
limit: 100,
|
|
398
|
+
windowMs: 60 * 1000
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
limiter.on('consumed', (req, res, info) => {
|
|
402
|
+
consumedCounter.inc({ path: req.path });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
limiter.on('limitReached', (req, res, info) => {
|
|
406
|
+
blockedCounter.inc({ path: req.path, reason: 'quota_exceeded' });
|
|
407
|
+
console.warn(`[ALERT] IP ${req.ip} exceeded quota on ${req.path}`);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
app.use(limiter);
|
|
411
|
+
app.get('/metrics', async (req, res) => res.send(await register.metrics()));
|
|
412
|
+
app.listen(3000);
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**How to Adapt**:
|
|
416
|
+
- **Datadog / StatsD**: Hook `limiter.on('limitReached')` to `dogstatsd.increment('api.rate_limit.blocked')`.
|
|
417
|
+
- **Slack Alerting**: Fire a webhook alert if an IP triggers more than 100 rejections in 1 minute.
|
|
418
|
+
- **v2.2.0+ Event Additions**: In v2.2.0+, the limiter also emits `'adaptiveThrottled'` (during event loop load shedding) and `'fallback'` (when store failover activates).
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
132
422
|
## Core Tier: Framework-Agnostic Limiters
|
|
133
423
|
|
|
134
424
|
The core tier is a high-performance points-based limiter. A limiter holds a counter per key; the counter is reset when its window expires.
|
|
@@ -477,52 +767,205 @@ All options below can be passed to `rateLimit(options)`:
|
|
|
477
767
|
|
|
478
768
|
Counts requests in a window that starts with the **first request of a key** and lasts `windowMs`. The window never slides, so a client can never consume two windows worth of quota in quick succession.
|
|
479
769
|
|
|
480
|
-
| Option | Type
|
|
481
|
-
| --------------- |
|
|
482
|
-
| `limit` / `max` | `number` | `60` | Maximum requests allowed within `windowMs
|
|
483
|
-
| `windowMs` | `number`
|
|
770
|
+
| Option | Type | Default | Description |
|
|
771
|
+
| --------------- | ------------------------------------------- | ------- | ------------------------------------------- |
|
|
772
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum requests allowed within `windowMs` (static or dynamic). |
|
|
773
|
+
| `windowMs` | `number` | `60000` | Window duration in milliseconds. |
|
|
484
774
|
|
|
485
775
|
#### Sliding Window Log (`algorithm: 'sliding-window-log'`)
|
|
486
776
|
|
|
487
777
|
Maintains timestamped log of each request. Provides 100% precision rolling window without boundary reset spikes.
|
|
488
778
|
|
|
489
|
-
| Option | Type
|
|
490
|
-
| --------------- |
|
|
491
|
-
| `limit` / `max` | `number` | `60` | Maximum requests allowed in the rolling window. |
|
|
492
|
-
| `windowMs` | `number`
|
|
779
|
+
| Option | Type | Default | Description |
|
|
780
|
+
| --------------- | ------------------------------------------- | ------- | ----------------------------------------------- |
|
|
781
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum requests allowed in the rolling window (static or dynamic). |
|
|
782
|
+
| `windowMs` | `number` | `60000` | Rolling window duration in milliseconds. |
|
|
493
783
|
|
|
494
784
|
#### Sliding Window Counter (`algorithm: 'sliding-window-counter'`)
|
|
495
785
|
|
|
496
786
|
Approximates rolling window by weighting previous window count with current elapsed time. Combines accuracy of sliding log with O(1) memory overhead of fixed window.
|
|
497
787
|
|
|
498
|
-
| Option | Type
|
|
499
|
-
| --------------- |
|
|
500
|
-
| `limit` / `max` | `number` | `60` | Maximum estimated requests allowed in the sliding window. |
|
|
501
|
-
| `windowMs` | `number`
|
|
788
|
+
| Option | Type | Default | Description |
|
|
789
|
+
| --------------- | ------------------------------------------- | ------- | --------------------------------------------------------- |
|
|
790
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum estimated requests allowed in the sliding window (static or dynamic). |
|
|
791
|
+
| `windowMs` | `number` | `60000` | Window duration in milliseconds. |
|
|
502
792
|
|
|
503
793
|
#### Token Bucket (`algorithm: 'token-bucket'`)
|
|
504
794
|
|
|
505
795
|
Tokens refill continuously at `refillRate` per `refillInterval` up to `capacity`. Allows bursts up to `capacity`.
|
|
506
796
|
|
|
507
|
-
| Option | Type
|
|
508
|
-
| ---------------------------- |
|
|
509
|
-
| `capacity` / `limit` / `max` | `number` | `60` | Maximum burst capacity of the bucket.
|
|
510
|
-
| `refillRate` | `number`
|
|
511
|
-
| `refillInterval` | `number`
|
|
512
|
-
| `initialTokens` | `number`
|
|
797
|
+
| Option | Type | Default | Description |
|
|
798
|
+
| ---------------------------- | ------------------------------------------- | ---------- | ---------------------------------------------- |
|
|
799
|
+
| `capacity` / `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum burst capacity of the bucket (static or dynamic). |
|
|
800
|
+
| `refillRate` | `number` | `1` | Number of tokens refilled every interval. |
|
|
801
|
+
| `refillInterval` | `number` | `1000` | Refill interval in milliseconds. |
|
|
802
|
+
| `initialTokens` | `number` | `capacity` | Starting token balance when key is first seen. |
|
|
513
803
|
|
|
514
804
|
#### Leaky Bucket (`algorithm: 'leaky-bucket'`)
|
|
515
805
|
|
|
516
806
|
Requests fill a bucket that leaks at a constant rate. Supports policing (drop excess immediately) and shaping (queue excess and process smoothly).
|
|
517
807
|
|
|
518
|
-
| Option | Type
|
|
519
|
-
| ---------------------------- |
|
|
520
|
-
| `capacity` / `limit` / `max` | `number`
|
|
521
|
-
| `leakRate` | `number`
|
|
522
|
-
| `leakInterval` | `number`
|
|
523
|
-
| `mode` | `'policing' \| 'shaping'`
|
|
524
|
-
| `maxQueueSize` | `number`
|
|
525
|
-
| `queueTimeoutMs` | `number`
|
|
808
|
+
| Option | Type | Default | Description |
|
|
809
|
+
| ---------------------------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------ |
|
|
810
|
+
| `capacity` / `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Bucket water capacity / burst ceiling (static or dynamic). |
|
|
811
|
+
| `leakRate` | `number` | `1` | Number of requests/units leaked every interval. |
|
|
812
|
+
| `leakInterval` | `number` | `1000` | Leak interval in milliseconds. |
|
|
813
|
+
| `mode` | `'policing' \| 'shaping'` | `'policing'` | `'policing'` rejects immediately when full; `'shaping'` queues requests. |
|
|
814
|
+
| `maxQueueSize` | `number` | `100` | Max queued requests in shaping mode before throwing `QueueFullError`. |
|
|
815
|
+
| `queueTimeoutMs` | `number` | `30000` | Max wait time (ms) in shaping queue before `QueueTimeoutError`. |
|
|
816
|
+
|
|
817
|
+
#### Dynamic Request-Based Limits (`limit` as a Function)
|
|
818
|
+
|
|
819
|
+
`limit` and `capacity` accept a synchronous or asynchronous function `(req, res) => number | Promise<number>`. This allows you to dynamically scale limits based on client tiers, API plans, authenticated roles, or time-of-day:
|
|
820
|
+
|
|
821
|
+
```javascript
|
|
822
|
+
import express from 'express';
|
|
823
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
824
|
+
|
|
825
|
+
const app = express();
|
|
826
|
+
|
|
827
|
+
const limiter = rateLimit({
|
|
828
|
+
algorithm: 'sliding-window-counter',
|
|
829
|
+
windowMs: 60 * 1000,
|
|
830
|
+
// Dynamic limit based on user subscription tier or auth headers
|
|
831
|
+
limit: async (req) => {
|
|
832
|
+
if (req.user?.tier === 'enterprise') return 1000;
|
|
833
|
+
if (req.user?.tier === 'pro') return 200;
|
|
834
|
+
return 30; // free tier
|
|
835
|
+
},
|
|
836
|
+
keyGenerator: (req) => req.user?.id || req.ip
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
app.use(limiter);
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
#### Middleware Lifecycle Events (`EventEmitter`)
|
|
843
|
+
|
|
844
|
+
Both `rateLimit()` and `createCoreMiddleware()` return Express middleware functions that inherit from Node.js `EventEmitter`. You can register listeners for real-time observability, Prometheus/Datadog metrics, audit logging, or security alerts without modifying route handlers:
|
|
845
|
+
|
|
846
|
+
```javascript
|
|
847
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
848
|
+
|
|
849
|
+
const limiter = rateLimit({
|
|
850
|
+
limit: 100,
|
|
851
|
+
windowMs: 60000
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Emitted when a request is within limit and allowed
|
|
855
|
+
limiter.on('consumed', (req, res, info) => {
|
|
856
|
+
console.log(`Allowed ${req.ip} - Remaining: ${info.remaining}/${info.limit}`);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// Emitted when a client reaches or exceeds its quota
|
|
860
|
+
limiter.on('limitReached', (req, res, info) => {
|
|
861
|
+
console.warn(`Rate limit reached for ${req.ip}. Retry-After: ${info.retryAfter}s`);
|
|
862
|
+
// metrics.increment('rate_limit_exceeded_total', { path: req.path });
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Emitted whenever a request is blocked (HTTP 429)
|
|
866
|
+
limiter.on('blocked', (req, res, info) => {
|
|
867
|
+
// auditLogger.warn({ ip: req.ip, path: req.path, user: req.user });
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// Emitted when an IP or key is manually reset via resetKey()
|
|
871
|
+
limiter.on('reset', (key) => {
|
|
872
|
+
console.log(`Rate limit quota reset for key: ${key}`);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Emitted if an error occurs in the underlying storage store
|
|
876
|
+
limiter.on('error', (err, req, res) => {
|
|
877
|
+
console.error(`Rate limiter store error:`, err);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
app.use(limiter);
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
#### Genuine Adaptive Throttling (`adaptive`)
|
|
884
|
+
|
|
885
|
+
The package includes a built-in **`AdaptiveController`** that uses Node.js native `perf_hooks.monitorEventLoopDelay` to continuously monitor event loop latency with zero external dependencies. When your server experiences high load, traffic spikes, or event loop lag, the limiter automatically scales down the allowed request limit proportionally to protect Node.js from event loop starvation and cascading failure. Once lag normalizes, limits automatically return to full capacity:
|
|
886
|
+
|
|
887
|
+
```javascript
|
|
888
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
889
|
+
|
|
890
|
+
// Enable adaptive load shedding on any algorithm:
|
|
891
|
+
const limiter = rateLimit({
|
|
892
|
+
algorithm: 'sliding-window-counter',
|
|
893
|
+
limit: 100,
|
|
894
|
+
windowMs: 60 * 1000,
|
|
895
|
+
adaptive: {
|
|
896
|
+
enabled: true,
|
|
897
|
+
targetLagMs: 20, // healthy threshold (ms) -> 100% of quota
|
|
898
|
+
maxLagMs: 100, // severe lag threshold (ms) -> drops to minFactor
|
|
899
|
+
minFactor: 0.2, // limit will not drop below 20% (e.g. 20 requests)
|
|
900
|
+
metric: 'p99' // 'p99' | 'mean' | 'max'
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// Or use the top-level 'adaptive' algorithm shorthand:
|
|
905
|
+
const adaptiveLimiter = rateLimit({
|
|
906
|
+
algorithm: 'adaptive',
|
|
907
|
+
limit: 100,
|
|
908
|
+
windowMs: 60000
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// Emitted when traffic is dynamically throttled due to event loop latency
|
|
912
|
+
limiter.on('adaptiveThrottled', (req, res, stats) => {
|
|
913
|
+
console.warn(`Server under pressure: p99 lag ${stats.currentLagMs}ms. Factor: ${stats.factor}`);
|
|
914
|
+
});
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
`req.rateLimit.adaptive` provides runtime metrics:
|
|
918
|
+
|
|
919
|
+
```javascript
|
|
920
|
+
app.get('/metrics', (req, res) => {
|
|
921
|
+
res.json({
|
|
922
|
+
lagMs: req.rateLimit.adaptive.currentLagMs,
|
|
923
|
+
factor: req.rateLimit.adaptive.factor,
|
|
924
|
+
isThrottled: req.rateLimit.adaptive.isThrottled
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
#### Dual-Store In-Memory Fallback (`fallbackStore: 'memory'`)
|
|
930
|
+
|
|
931
|
+
For sensitive routes (such as `/login`, `/auth`, `/checkout`), standard `failOpen: true` is risky during a Redis or Redis Cluster outage because it allows unlimited traffic.
|
|
932
|
+
|
|
933
|
+
Setting `fallbackStore: 'memory'` provides an automatic in-memory safety net. If Redis is unreachable, the middleware immediately and transparently fails over to a local in-memory store so traffic is still strictly rate-limited per process:
|
|
934
|
+
|
|
935
|
+
```javascript
|
|
936
|
+
const secureLimiter = rateLimit({
|
|
937
|
+
store: 'redis',
|
|
938
|
+
client: redisClient,
|
|
939
|
+
fallbackStore: 'memory', // Fail over to memory if Redis is down
|
|
940
|
+
limit: 5,
|
|
941
|
+
windowMs: 60000
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
secureLimiter.on('fallback', (err, req, res) => {
|
|
945
|
+
console.error('Redis unavailable; fell back to in-memory rate limiting', err);
|
|
946
|
+
});
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
#### Core Tier: `AdaptiveRateLimiter` Wrapper
|
|
950
|
+
|
|
951
|
+
Wrap any framework-agnostic core limiter (`MemoryLimiter`, `RedisLimiter`, `PostgresLimiter`, etc.) with `AdaptiveRateLimiter`:
|
|
952
|
+
|
|
953
|
+
```javascript
|
|
954
|
+
import { RedisLimiter, AdaptiveRateLimiter } from 'express-adaptive-rate-limiter';
|
|
955
|
+
|
|
956
|
+
const redisLimiter = new RedisLimiter({ points: 100, duration: 60, storeClient: redis });
|
|
957
|
+
|
|
958
|
+
// Wrap with adaptive event loop protection
|
|
959
|
+
const adaptiveLimiter = new AdaptiveRateLimiter(redisLimiter, {
|
|
960
|
+
targetLagMs: 20,
|
|
961
|
+
maxLagMs: 100,
|
|
962
|
+
minFactor: 0.25
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Points automatically scale between 25 and 100 depending on server lag
|
|
966
|
+
const res = await adaptiveLimiter.consume('user-1');
|
|
967
|
+
console.log(res.remainingPoints, res.adaptive);
|
|
968
|
+
```
|
|
526
969
|
|
|
527
970
|
---
|
|
528
971
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{monitorEventLoopDelay}from'node:perf_hooks';export class AdaptiveController{constructor(options={}){const opts=typeof options==='boolean'?{}:options;this.enabled=options===true||Boolean(opts.enabled);this.targetLagMs=typeof opts.targetLagMs==='number'&&opts.targetLagMs>0?opts.targetLagMs:20;this.maxLagMs=typeof opts.maxLagMs==='number'&&opts.maxLagMs>this.targetLagMs?opts.maxLagMs:100;this.minFactor=typeof opts.minFactor==='number'&&opts.minFactor>=0.01&&opts.minFactor<=1.0?opts.minFactor:0.2;this.metric=opts.metric||'p99';this.sampleIntervalMs=typeof opts.sampleIntervalMs==='number'&&opts.sampleIntervalMs>=100?opts.sampleIntervalMs:1000;this.customCheck=typeof opts.check==='function'?opts.check:null;this.currentLagMs=0;this.currentFactor=1.0;this.histogram=null;this.timer=null;if(this.enabled){this.start();}}start(){if(this.histogram)return;try{this.histogram=monitorEventLoopDelay({resolution:20});this.histogram.enable();}catch{this.histogram=null;}this.timer=setInterval(()=>{this.updateMetrics();},this.sampleIntervalMs);if(this.timer&&typeof this.timer.unref==='function'){this.timer.unref();}}updateMetrics(){let lag=0;if(this.histogram){if(this.metric==='mean'){lag=this.histogram.mean/1e6;}else if(this.metric==='max'){lag=this.histogram.max/1e6;}else{lag=this.histogram.percentile(99)/1e6;}this.histogram.reset();}if(!Number.isFinite(lag)||lag<0){lag=0;}this.currentLagMs=Math.round(lag*100)/100;let factor=1.0;if(this.customCheck){try{const res=this.customCheck({lagMs:this.currentLagMs});if(typeof res==='number'&&Number.isFinite(res)){factor=Math.max(this.minFactor,Math.min(1.0,res));}}catch{factor=1.0;}}else{if(this.currentLagMs<=this.targetLagMs){factor=1.0;}else if(this.currentLagMs>=this.maxLagMs){factor=this.minFactor;}else{const excess=this.currentLagMs-this.targetLagMs;const span=this.maxLagMs-this.targetLagMs;const normalized=excess/span;factor=Math.max(this.minFactor,1.0-normalized*(1.0-this.minFactor));}}this.currentFactor=Math.round(factor*1000)/1000;}setLag(lagMs){this.currentLagMs=Math.max(0,Number(lagMs)||0);if(this.customCheck){try{const res=this.customCheck({lagMs:this.currentLagMs});if(typeof res==='number'&&Number.isFinite(res)){this.currentFactor=Math.max(this.minFactor,Math.min(1.0,res));return;}}catch{this.currentFactor=1.0;return;}}if(this.currentLagMs<=this.targetLagMs){this.currentFactor=1.0;}else if(this.currentLagMs>=this.maxLagMs){this.currentFactor=this.minFactor;}else{const excess=this.currentLagMs-this.targetLagMs;const span=this.maxLagMs-this.targetLagMs;const normalized=excess/span;this.currentFactor=Math.round(Math.max(this.minFactor,1.0-normalized*(1.0-this.minFactor))*1000)/1000;}}getFactor(){if(!this.enabled)return 1.0;return this.currentFactor;}getStats(){return{enabled:this.enabled,currentLagMs:this.currentLagMs,targetLagMs:this.targetLagMs,maxLagMs:this.maxLagMs,factor:this.currentFactor,isThrottled:this.currentFactor<1.0};}close(){if(this.timer){clearInterval(this.timer);this.timer=null;}if(this.histogram){try{this.histogram.disable();}catch{}this.histogram=null;}}}export default AdaptiveController;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export class BaseAlgorithm{constructor(options,store){this.options=options;this.store=store;this.locks=new Map();}isRedis(){return this.store&&typeof this.store.runScript==='function';}normalizeResult(allowed,limit,remaining,consumed,retryAfter,resetTime){const resetMs=Number(resetTime);const resetDate=new Date(resetMs);return{allowed:Boolean(allowed),limit:Number(limit),remaining:Math.max(0,Number(remaining)),consumed:Math.max(0,Number(consumed)),retryAfter:Math.max(0,Number(retryAfter)),resetTime:resetDate};}async withLock(key,fn){const prev=this.locks.get(key)||Promise.resolve();let release;const current=new Promise(resolve=>{release=resolve;});this.locks.set(key,current);try{await prev;return await fn();}finally{release();if(this.locks.get(key)===current){this.locks.delete(key);}}}async consume(_key,_cost=1){throw new Error('Algorithm.consume() must be implemented');}async reset(_key){return this.store.delete(_key);}async getState(_key){throw new Error('Algorithm.getState() must be implemented');}async close(){this.locks.clear();if(this.store&&typeof this.store.close==='function'){await this.store.close();}}}export default BaseAlgorithm;
|
|
1
|
+
import{AdaptiveController}from'../adaptive/adaptiveController.js';export class BaseAlgorithm{constructor(options,store){this.options=options;this.store=store;this.locks=new Map();const adaptiveOpt=options?.adaptive!==undefined?options.adaptive:options?.algorithm==='adaptive';this.adaptiveController=adaptiveOpt?new AdaptiveController(adaptiveOpt):null;}async resolveLimit(context={}){const rawLimit=this.options?.limit!==undefined?this.options.limit:this.options?.capacity!==undefined?this.options.capacity:this.options?.max;let baseLimit=60;if(typeof rawLimit==='function'){const res=await Promise.resolve(rawLimit(context?.req,context?.res));baseLimit=Number.isFinite(res)&&res>0?Number(res):60;}else if(typeof this.limit==='number'){baseLimit=this.limit;}else if(typeof this.capacity==='number'){baseLimit=this.capacity;}else if(typeof rawLimit==='number'){baseLimit=rawLimit;}if(this.adaptiveController&&this.adaptiveController.enabled){const factor=this.adaptiveController.getFactor();const scaled=Math.floor(baseLimit*factor);return Math.max(1,scaled);}return baseLimit;}isRedis(){return this.store&&typeof this.store.runScript==='function';}normalizeResult(allowed,limit,remaining,consumed,retryAfter,resetTime){const resetMs=Number(resetTime);const resetDate=new Date(resetMs);return{allowed:Boolean(allowed),limit:Number(limit),remaining:Math.max(0,Number(remaining)),consumed:Math.max(0,Number(consumed)),retryAfter:Math.max(0,Number(retryAfter)),resetTime:resetDate};}async withLock(key,fn){const prev=this.locks.get(key)||Promise.resolve();let release;const current=new Promise(resolve=>{release=resolve;});this.locks.set(key,current);try{await prev;return await fn();}finally{release();if(this.locks.get(key)===current){this.locks.delete(key);}}}async consume(_key,_cost=1){throw new Error('Algorithm.consume() must be implemented');}async reset(_key){return this.store.delete(_key);}async getState(_key){throw new Error('Algorithm.getState() must be implemented');}async close(){if(this.adaptiveController){this.adaptiveController.close();}this.locks.clear();if(this.store&&typeof this.store.close==='function'){await this.store.close();}}}export default BaseAlgorithm;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class FixedWindow extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}resolveWindow(state,now){if(state&&typeof state.resetTime==='number'&&state.resetTime>now){return{count:state.count||0,windowStart:state.windowStart,resetTime:state.resetTime};}return{count:0,windowStart:now,resetTime:now+this.windowMs};}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class FixedWindow extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}resolveWindow(state,now){if(state&&typeof state.resetTime==='number'&&state.resetTime>now){return{count:state.count||0,windowStart:state.windowStart,resetTime:state.resetTime};}return{count:0,windowStart:now,resetTime:now+this.windowMs};}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const ttl=Math.max(1,window.resetTime-now);if(window.count+cost<=limit){const nextCount=window.count+cost;await this.store.set(key,{count:nextCount,windowStart:window.windowStart,resetTime:window.resetTime},ttl);return this.normalizeResult(true,limit,limit-nextCount,nextCount,0,window.resetTime);}await this.store.set(key,{count:window.count,windowStart:window.windowStart,resetTime:window.resetTime},ttl);const retryAfter=Math.max(1,Math.ceil(ttl/1000));const remaining=Math.max(0,limit-window.count);return this.normalizeResult(false,limit,remaining,window.count,retryAfter,window.resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const remaining=Math.max(0,limit-window.count);const retryAfter=window.count>=limit?Math.max(1,Math.ceil((window.resetTime-now)/1000)):0;return this.normalizeResult(window.count<limit,limit,remaining,window.count,retryAfter,window.resetTime);}}export default FixedWindow;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';import{QueueFullError,QueueTimeoutError}from'../utils/errors.js';export class LeakyBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.leakRate=options.leakRate;this.leakInterval=options.leakInterval;this.mode=options.mode||'policing';this.maxQueueSize=options.maxQueueSize||100;this.queueTimeoutMs=options.queueTimeoutMs||30000;this.rate=this.leakRate/this.leakInterval;this.queues=new Map();}async _checkPolicing(key,cost){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';import{QueueFullError,QueueTimeoutError}from'../utils/errors.js';export class LeakyBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.leakRate=options.leakRate;this.leakInterval=options.leakInterval;this.mode=options.mode||'policing';this.maxQueueSize=options.maxQueueSize||100;this.queueTimeoutMs=options.queueTimeoutMs||30000;this.rate=this.leakRate/this.leakInterval;this.queues=new Map();}async _checkPolicing(key,cost,context={}){const capacity=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[capacity,this.leakRate,this.leakInterval,cost,nowArg]);const normalized=this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);return{...normalized,waitMs:Number(res[6]||0)};}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let water=0;let lastLeak=now;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);lastLeak=now;}const ttlMs=Math.max(1000,Math.ceil(capacity/this.rate*2.0));if(water+cost<=capacity){water+=cost;const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(true,capacity,remaining,consumed,0,resetTime),waitMs:0};}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const deficit=water+cost-capacity;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(false,capacity,remaining,consumed,retryAfter,resetTime),waitMs};});}async consume(key,cost=1,extra={}){const policingResult=await this._checkPolicing(key,cost,extra);if(this.mode==='policing'||policingResult.allowed){return policingResult;}return this._enqueue(key,cost,policingResult,extra);}_enqueue(key,cost,immediateResult,extra={}){if(extra.req&&(extra.req.destroyed||extra.req.closed||extra.req.aborted)){return{...immediateResult,allowed:false,aborted:true};}let queue=this.queues.get(key);if(!queue){queue=[];this.queues.set(key,queue);}if(queue.length>=this.maxQueueSize){return{...immediateResult,allowed:false,error:new QueueFullError()};}const waitMs=immediateResult.waitMs;if(waitMs>this.queueTimeoutMs){return{...immediateResult,allowed:false,retryAfter:Math.max(1,Math.ceil(waitMs/1000)),error:new QueueTimeoutError()};}return new Promise(resolve=>{let timer=null;let cleanedUp=false;const item={cost,resolve,timer:null};const cleanup=()=>{if(cleanedUp)return;cleanedUp=true;if(timer){clearTimeout(timer);timer=null;}const currentQueue=this.queues.get(key);if(currentQueue){const idx=currentQueue.indexOf(item);if(idx!==-1){currentQueue.splice(idx,1);}if(currentQueue.length===0){this.queues.delete(key);}}};if(extra.req&&typeof extra.req.on==='function'){extra.req.once('close',()=>{if(!cleanedUp){cleanup();resolve({...immediateResult,allowed:false,aborted:true});}});}timer=setTimeout(async()=>{cleanup();try{const afterWaitResult=await this._checkPolicing(key,cost,extra);resolve(afterWaitResult);}catch{resolve({...immediateResult,allowed:false});}},waitMs);if(timer.unref){timer.unref();}item.timer=timer;queue.push(item);});}async getState(key,context={}){const capacity=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[capacity,this.leakRate,this.leakInterval,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let water=0;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const resetTime=now+Math.ceil(water/this.rate);const retryAfter=water>=capacity?Math.max(1,Math.ceil(1/this.rate/1000)):0;return this.normalizeResult(water<capacity,capacity,remaining,consumed,retryAfter,resetTime);}async close(){for(const[,queue]of this.queues){for(const item of queue){if(item.timer){clearTimeout(item.timer);}item.resolve({allowed:false,limit:this.capacity,remaining:0,consumed:this.capacity,retryAfter:1,resetTime:new Date()});}}this.queues.clear();await super.close();}}export default LeakyBucket;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowCounter extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowCounter extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;if(estimated+cost<=limit){currCount+=cost;const consumed=Math.round(estimated+cost);const remaining=Math.max(0,limit-consumed);await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(true,limit,remaining,consumed,0,resetTime);}const consumed=Math.round(estimated);const remaining=Math.max(0,limit-consumed);let waitMs=resetTime-now;if(prevCount>0){const neededWeight=(limit-currCount-cost)/prevCount;if(neededWeight<1&&neededWeight>=0){const t=this.windowMs*(1-neededWeight);waitMs=Math.max(1,Math.ceil(t-elapsed));}}if(waitMs<=0||waitMs>this.windowMs){waitMs=Math.max(1,resetTime-now);}const retryAfter=Math.max(1,Math.ceil(waitMs/1000));await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(false,limit,remaining,consumed,retryAfter,resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;const consumed=Math.round(estimated);const remaining=Math.max(0,limit-consumed);const retryAfter=estimated>=limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(estimated<limit,limit,remaining,consumed,retryAfter,resetTime);}}export default SlidingWindowCounter;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowLog extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const uid=`${Date.now()}:${Math.random().toString(36).slice(2,8)}`;const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowLog extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const uid=`${Date.now()}:${Math.random().toString(36).slice(2,8)}`;const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[limit,this.windowMs,cost,uid,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}if(current+cost<=limit){log.push({time:now,cost});const consumed=current+cost;const remaining=limit-consumed;const oldestTime=log[0].time;const resetTime=oldestTime+this.windowMs;await this.store.set(key,{log},this.windowMs);return this.normalizeResult(true,limit,remaining,consumed,0,resetTime);}const deficit=current+cost-limit;let accumulated=0;let expireTime=now;for(let i=0;i<log.length;i++){accumulated+=log[i].cost;if(accumulated>=deficit){expireTime=log[i].time;break;}}const resetTime=expireTime+this.windowMs;const waitMs=Math.max(1,resetTime-now);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const remaining=Math.max(0,limit-current);await this.store.set(key,{log},this.windowMs);return this.normalizeResult(false,limit,remaining,current,retryAfter,resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[limit,this.windowMs,0,'inspect',nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}const remaining=Math.max(0,limit-current);const oldestTime=log.length>0?log[0].time:now;const resetTime=oldestTime+this.windowMs;const retryAfter=current>=limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(current<limit,limit,remaining,current,retryAfter,resetTime);}}export default SlidingWindowLog;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class TokenBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.refillRate=options.refillRate;this.refillInterval=options.refillInterval;this.initialTokens=options.initialTokens!==undefined?options.initialTokens:this.capacity;this.rate=this.refillRate/this.refillInterval;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class TokenBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity!==undefined?options.capacity:options.limit;this.refillRate=options.refillRate;this.refillInterval=options.refillInterval;this.initialTokens=options.initialTokens!==undefined?options.initialTokens:typeof this.capacity==='number'?this.capacity:undefined;this.rate=this.refillRate/this.refillInterval;}async consume(key,cost=1,context={}){const capacity=await this.resolveLimit(context);const initialTokens=this.initialTokens!==undefined?this.initialTokens:capacity;if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[capacity,this.refillRate,this.refillInterval,cost,initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let tokens=initialTokens;let lastRefill=now;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(capacity,state.tokens+elapsed*this.rate);lastRefill=now;}const ttlMs=Math.max(1000,Math.ceil(capacity/this.rate*2.0));if(tokens>=cost){tokens-=cost;const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(true,capacity,remaining,consumed,0,resetTime);}const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const deficit=cost-tokens;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil((capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(false,capacity,remaining,consumed,retryAfter,resetTime);});}async getState(key,context={}){const capacity=await this.resolveLimit(context);const initialTokens=this.initialTokens!==undefined?this.initialTokens:capacity;if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[capacity,this.refillRate,this.refillInterval,0,initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let tokens=initialTokens;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(capacity,state.tokens+elapsed*this.rate);}const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((capacity-tokens)/this.rate);const retryAfter=tokens<1?Math.max(1,Math.ceil((1-tokens)/this.rate/1000)):0;return this.normalizeResult(tokens>=1,capacity,remaining,consumed,retryAfter,resetTime);}}export default TokenBucket;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{RateLimiterRes}from'../core/rateLimiterRes.js';export function createCoreMiddleware(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('createCoreMiddleware requires a limiter with a consume() method');}let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const resolvedOptions={standardHeaders:options.standardHeaders!==undefined?options.standardHeaders:true,legacyHeaders:options.legacyHeaders!==undefined?options.legacyHeaders:false,...options,statusCode:options.statusCode!==undefined?options.statusCode:429,message:options.message!==undefined?options.message:'Too many requests, please try again later.'};const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}const key=await Promise.resolve(keyGen(req,res));let cost=1;if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}let res1;try{res1=await limiter.consume(key,cost,{req,res});}catch(err){if(err instanceof RateLimiterRes){res1=err;}else if(err&&typeof err==='object'&&Object.values(err).some(v=>v instanceof RateLimiterRes)){const resValues=Object.values(err).filter(v=>v instanceof RateLimiterRes);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,false);res1._unionRejections=err;}else{throw err;}}if(res1&&!(res1 instanceof RateLimiterRes)&&typeof res1==='object'&&Object.values(res1).every(v=>v instanceof RateLimiterRes)){const resValues=Object.values(res1);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,resValues.some(v=>v.isFirstInDuration));}const result=toMiddlewareResult(res1,limiter,cost);req.rateLimit=result;setCoreHeaders(res,result,{...resolvedOptions,limiter});if(result.allowed){return next();}return handler(req,res,next,resolvedOptions);}catch(err){if(options.failOpen){req.rateLimit={allowed:true,limit:limiter.points||60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+(limiter.msDuration||60000)),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.delete==='function'){
|
|
1
|
+
import{EventEmitter}from'node:events';import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{RateLimiterRes}from'../core/rateLimiterRes.js';export function createCoreMiddleware(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('createCoreMiddleware requires a limiter with a consume() method');}let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const resolvedOptions={standardHeaders:options.standardHeaders!==undefined?options.standardHeaders:true,legacyHeaders:options.legacyHeaders!==undefined?options.legacyHeaders:false,...options,statusCode:options.statusCode!==undefined?options.statusCode:429,message:options.message!==undefined?options.message:'Too many requests, please try again later.'};const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}const key=await Promise.resolve(keyGen(req,res));let cost=1;if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}let res1;try{res1=await limiter.consume(key,cost,{req,res});}catch(err){if(err instanceof RateLimiterRes){res1=err;}else if(err&&typeof err==='object'&&Object.values(err).some(v=>v instanceof RateLimiterRes)){const resValues=Object.values(err).filter(v=>v instanceof RateLimiterRes);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,false);res1._unionRejections=err;}else{throw err;}}if(res1&&!(res1 instanceof RateLimiterRes)&&typeof res1==='object'&&Object.values(res1).every(v=>v instanceof RateLimiterRes)){const resValues=Object.values(res1);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,resValues.some(v=>v.isFirstInDuration));}const result=toMiddlewareResult(res1,limiter,cost);req.rateLimit=result;setCoreHeaders(res,result,{...resolvedOptions,limiter});if(result.allowed){middleware.emit('consumed',req,res,result);return next();}middleware.emit('limitReached',req,res,result);middleware.emit('blocked',req,res,result);return handler(req,res,next,resolvedOptions);}catch(err){if(middleware.listenerCount('error')>0){middleware.emit('error',err,req,res);}if(options.failOpen){req.rateLimit={allowed:true,limit:limiter.points||60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+(limiter.msDuration||60000)),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};Object.setPrototypeOf(middleware,EventEmitter.prototype);EventEmitter.call(middleware);middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;let res=false;if(typeof limiter.delete==='function'){res=await limiter.delete(k);}middleware.emit('reset',k);return res;};middleware.getState=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.get==='function'){return limiter.get(k);}return null;};middleware.limiter=limiter;return middleware;}function toMiddlewareResult(res,limiter,cost){const limit=typeof limiter.points==='number'?limiter.points:typeof res.consumedPoints==='number'&&typeof res.remainingPoints==='number'?res.consumedPoints+res.remainingPoints:60;const consumed=typeof res.consumedPoints==='number'?res.consumedPoints:cost;const msBeforeNext=typeof res.msBeforeNext==='number'?res.msBeforeNext:0;const rawReset=Number.isFinite(msBeforeNext)&&msBeforeNext>0?Date.now()+Math.min(msBeforeNext,31536000000):Date.now();const resetTime=new Date(rawReset);return{allowed:consumed<=limit,limit,remaining:Math.max(0,limit-consumed),consumed,retryAfter:msBeforeNext>0?Math.max(1,Math.ceil(msBeforeNext/1000)):0,resetTime:Number.isNaN(resetTime.getTime())?new Date(Date.now()+3600000):resetTime,cost};}function setCoreHeaders(res,result,options={}){if(!res||typeof res.setHeader!=='function'||res.headersSent){return;}const rawDiff=result.resetTime?(result.resetTime.getTime()-Date.now())/1000:0;const resetSeconds=Number.isFinite(rawDiff)?Math.max(0,Math.ceil(rawDiff)):0;if(options.standardHeaders){res.setHeader('RateLimit-Limit',String(result.limit));res.setHeader('RateLimit-Remaining',String(result.remaining));res.setHeader('RateLimit-Reset',String(resetSeconds));const policyWindowMs=options.windowMs||options.limiter&&(options.limiter.msDuration||(options.limiter.duration?options.limiter.duration*1000:null))||60000;if(options.standardHeaders!=='draft-6'){const windowSec=Math.ceil(policyWindowMs/1000);res.setHeader('RateLimit-Policy',`${result.limit};w=${windowSec}`);}}if(options.legacyHeaders){res.setHeader('X-RateLimit-Limit',String(result.limit));res.setHeader('X-RateLimit-Remaining',String(result.remaining));res.setHeader('X-RateLimit-Reset',String(resetSeconds));}if(!result.allowed){const retryAfter=Math.max(1,Number(result.retryAfter)||resetSeconds||1);res.setHeader('Retry-After',String(retryAfter));}}export default createCoreMiddleware;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{RateLimiterRes}from'../rateLimiterRes.js';import{AdaptiveController}from'../../adaptive/adaptiveController.js';export class AdaptiveRateLimiter{constructor(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('AdaptiveRateLimiter requires a limiter with a consume() method');}this.limiter=limiter;this.adaptiveController=options instanceof AdaptiveController?options:new AdaptiveController(options);}get points(){const factor=this.adaptiveController.getFactor();const base=typeof this.limiter.points==='number'?this.limiter.points:60;return Math.max(1,Math.floor(base*factor));}get basePoints(){return typeof this.limiter.points==='number'?this.limiter.points:60;}get duration(){return this.limiter.duration||60;}get msDuration(){return this.limiter.msDuration||60000;}get blockDuration(){return this.limiter.blockDuration||0;}get keyPrefix(){return this.limiter.keyPrefix||'';}getFactor(){return this.adaptiveController.getFactor();}getStats(){return this.adaptiveController.getStats();}async consume(key,points=1,options={}){const factor=this.adaptiveController.getFactor();const res=await this.limiter.consume(key,points,options);const basePoints=typeof this.limiter.points==='number'?this.limiter.points:60;const effectivePoints=Math.max(1,Math.floor(basePoints*factor));if(res.consumedPoints>effectivePoints){const remaining=Math.max(0,effectivePoints-res.consumedPoints);const rejected=new RateLimiterRes(remaining,res.msBeforeNext,res.consumedPoints,res.isFirstInDuration);rejected.adaptive=this.getStats();throw rejected;}res.remainingPoints=Math.max(0,effectivePoints-res.consumedPoints);res.adaptive=this.getStats();return res;}async get(key,options={}){if(typeof this.limiter.get==='function'){return this.limiter.get(key,options);}return null;}async set(key,points,secDuration){if(typeof this.limiter.set==='function'){return this.limiter.set(key,points,secDuration);}return null;}async block(key,secDuration){if(typeof this.limiter.block==='function'){return this.limiter.block(key,secDuration);}return null;}async delete(key,options={}){if(typeof this.limiter.delete==='function'){return this.limiter.delete(key,options);}return false;}async penalty(key,points=1,options={}){if(typeof this.limiter.penalty==='function'){return this.limiter.penalty(key,points,options);}return null;}async reward(key,points=1,options={}){if(typeof this.limiter.reward==='function'){return this.limiter.reward(key,points,options);}return null;}close(){this.adaptiveController.close();if(typeof this.limiter.close==='function'){return this.limiter.close();}}}export default AdaptiveRateLimiter;
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{rateLimit,createLimiter}from'./rateLimit.js';import{RateLimitManager,createRateLimiters}from'./rateLimitManager.js';import{BaseStore}from'./stores/baseStore.js';import{MemoryStore}from'./stores/memoryStore.js';import{RedisStore}from'./stores/redisStore.js';import{BaseAlgorithm}from'./algorithms/baseAlgorithm.js';import{FixedWindow}from'./algorithms/fixedWindow.js';import{SlidingWindowLog}from'./algorithms/slidingWindowLog.js';import{SlidingWindowCounter}from'./algorithms/slidingWindowCounter.js';import{TokenBucket}from'./algorithms/tokenBucket.js';import{LeakyBucket}from'./algorithms/leakyBucket.js';import*as errors from'./utils/errors.js';import{defaultKeyGenerator,ipKeyGenerator,getEndpoint,normalizeIp,graphQLKeyGenerator}from'./utils/keyGenerator.js';import{extractGraphQLRootFieldFast}from'./utils/graphQLParser.js';import{validateIpRangeV4,validateSecondaryIps,compileWhitelist,isIpInWhitelist,cleanIp,ipToInt}from'./utils/ipUtils.js';import{evaluateSkipRule,checkHeaderCriteria,checkOriginCriteria,checkIpCriteria,checkKeyCriteria}from'./utils/skipUtils.js';import{RateLimiter}from'./core/rateLimiter.js';import{RateLimiterRes}from'./core/rateLimiterRes.js';import{RateLimiterStore}from'./core/rateLimiterStore.js';import{BlockedKeys}from'./core/blockedKeys.js';import{createCoreMiddleware}from'./core/coreMiddleware.js';import{MemoryLimiter}from'./core/limiters/memoryLimiter.js';import{RedisLimiter}from'./core/limiters/redisLimiter.js';import{ValkeyLimiter}from'./core/limiters/valkeyLimiter.js';import{ClusterLimiter,RedisClusterLimiter,RateLimiterRedisCluster}from'./core/limiters/clusterLimiter.js';import{RateLimiterCluster,RateLimiterClusterMaster,RateLimiterClusterMasterPM2}from'./core/limiters/nodeClusterLimiter.js';import{MongoLimiter}from'./core/limiters/mongoLimiter.js';import{MongooseLimiter}from'./core/limiters/mongooseLimiter.js';import{DynamoDbLimiter}from'./core/limiters/dynamoDbLimiter.js';import{MySqlLimiter}from'./core/limiters/mySqlLimiter.js';import{PostgresLimiter}from'./core/limiters/postgresLimiter.js';import{SqliteLimiter}from'./core/limiters/sqliteLimiter.js';import{PrismaLimiter}from'./core/limiters/prismaLimiter.js';import{MemcachedLimiter}from'./core/limiters/memcachedLimiter.js';import{EtcdLimiter}from'./core/limiters/etcdLimiter.js';import{RateLimiterQueue,RateLimiterQueueError}from'./core/wrappers/rateLimiterQueue.js';import{RateLimiterUnion}from'./core/wrappers/rateLimiterUnion.js';import{BurstyRateLimiter}from'./core/wrappers/burstyRateLimiter.js';import{BlackAndWhiteLimiter}from'./core/wrappers/blackAndWhiteLimiter.js';import{TimeoutLimiter}from'./core/wrappers/timeoutLimiter.js';import{RedisClientAdapter,detectRedisClientKind}from'./utils/redisClient.js';const RateLimiterMemory=MemoryLimiter;const RateLimiterRedis=RedisLimiter;const RateLimiterMongo=MongoLimiter;const RateLimiterMongoose=MongooseLimiter;const RateLimiterMySQL=MySqlLimiter;const RateLimiterPostgres=PostgresLimiter;const RateLimiterSQLite=SqliteLimiter;const RateLimiterPrisma=PrismaLimiter;const RateLimiterMemcache=MemcachedLimiter;const RateLimiterMemcached=MemcachedLimiter;const RateLimiterDynamo=DynamoDbLimiter;const RateLimiterDynamoDb=DynamoDbLimiter;const RateLimiterValkey=ValkeyLimiter;const RateLimiterEtcd=EtcdLimiter;const RateLimiterAbstract=RateLimiter;const RateLimiterStoreAbstract=RateLimiterStore;const RateLimiterBlackAndWhite=BlackAndWhiteLimiter;export{rateLimit,rateLimit as default,createLimiter,RateLimitManager,createRateLimiters,BaseStore,MemoryStore,RedisStore,BaseAlgorithm,FixedWindow,SlidingWindowLog,SlidingWindowCounter,TokenBucket,LeakyBucket,errors,defaultKeyGenerator,ipKeyGenerator,getEndpoint,normalizeIp,graphQLKeyGenerator,extractGraphQLRootFieldFast,validateIpRangeV4,validateSecondaryIps,compileWhitelist,isIpInWhitelist,cleanIp,ipToInt,evaluateSkipRule,checkHeaderCriteria,checkOriginCriteria,checkIpCriteria,checkKeyCriteria,RateLimiter,RateLimiterRes,RateLimiterStore,BlockedKeys,createCoreMiddleware,MemoryLimiter,RedisLimiter,ValkeyLimiter,ClusterLimiter,RedisClusterLimiter,RateLimiterRedisCluster,RateLimiterCluster,RateLimiterClusterMaster,RateLimiterClusterMasterPM2,MongoLimiter,MongooseLimiter,DynamoDbLimiter,MySqlLimiter,PostgresLimiter,SqliteLimiter,PrismaLimiter,MemcachedLimiter,EtcdLimiter,RateLimiterMemory,RateLimiterRedis,RateLimiterMongo,RateLimiterMongoose,RateLimiterMySQL,RateLimiterPostgres,RateLimiterSQLite,RateLimiterPrisma,RateLimiterMemcache,RateLimiterMemcached,RateLimiterDynamo,RateLimiterDynamoDb,RateLimiterValkey,RateLimiterEtcd,RateLimiterAbstract,RateLimiterStoreAbstract,RateLimiterBlackAndWhite,RateLimiterQueue,RateLimiterQueueError,RateLimiterUnion,BurstyRateLimiter,BlackAndWhiteLimiter,TimeoutLimiter,RedisClientAdapter,detectRedisClientKind};export*from'./utils/errors.js';
|
|
1
|
+
import{rateLimit,createLimiter}from'./rateLimit.js';import{RateLimitManager,createRateLimiters}from'./rateLimitManager.js';import{BaseStore}from'./stores/baseStore.js';import{MemoryStore}from'./stores/memoryStore.js';import{RedisStore}from'./stores/redisStore.js';import{BaseAlgorithm}from'./algorithms/baseAlgorithm.js';import{FixedWindow}from'./algorithms/fixedWindow.js';import{SlidingWindowLog}from'./algorithms/slidingWindowLog.js';import{SlidingWindowCounter}from'./algorithms/slidingWindowCounter.js';import{TokenBucket}from'./algorithms/tokenBucket.js';import{LeakyBucket}from'./algorithms/leakyBucket.js';import*as errors from'./utils/errors.js';import{defaultKeyGenerator,ipKeyGenerator,getEndpoint,normalizeIp,graphQLKeyGenerator}from'./utils/keyGenerator.js';import{extractGraphQLRootFieldFast}from'./utils/graphQLParser.js';import{validateIpRangeV4,validateSecondaryIps,compileWhitelist,isIpInWhitelist,cleanIp,ipToInt}from'./utils/ipUtils.js';import{evaluateSkipRule,checkHeaderCriteria,checkOriginCriteria,checkIpCriteria,checkKeyCriteria}from'./utils/skipUtils.js';import{RateLimiter}from'./core/rateLimiter.js';import{RateLimiterRes}from'./core/rateLimiterRes.js';import{RateLimiterStore}from'./core/rateLimiterStore.js';import{BlockedKeys}from'./core/blockedKeys.js';import{createCoreMiddleware}from'./core/coreMiddleware.js';import{MemoryLimiter}from'./core/limiters/memoryLimiter.js';import{RedisLimiter}from'./core/limiters/redisLimiter.js';import{ValkeyLimiter}from'./core/limiters/valkeyLimiter.js';import{ClusterLimiter,RedisClusterLimiter,RateLimiterRedisCluster}from'./core/limiters/clusterLimiter.js';import{RateLimiterCluster,RateLimiterClusterMaster,RateLimiterClusterMasterPM2}from'./core/limiters/nodeClusterLimiter.js';import{MongoLimiter}from'./core/limiters/mongoLimiter.js';import{MongooseLimiter}from'./core/limiters/mongooseLimiter.js';import{DynamoDbLimiter}from'./core/limiters/dynamoDbLimiter.js';import{MySqlLimiter}from'./core/limiters/mySqlLimiter.js';import{PostgresLimiter}from'./core/limiters/postgresLimiter.js';import{SqliteLimiter}from'./core/limiters/sqliteLimiter.js';import{PrismaLimiter}from'./core/limiters/prismaLimiter.js';import{MemcachedLimiter}from'./core/limiters/memcachedLimiter.js';import{EtcdLimiter}from'./core/limiters/etcdLimiter.js';import{RateLimiterQueue,RateLimiterQueueError}from'./core/wrappers/rateLimiterQueue.js';import{RateLimiterUnion}from'./core/wrappers/rateLimiterUnion.js';import{BurstyRateLimiter}from'./core/wrappers/burstyRateLimiter.js';import{AdaptiveRateLimiter}from'./core/wrappers/adaptiveLimiter.js';import{AdaptiveController}from'./adaptive/adaptiveController.js';import{BlackAndWhiteLimiter}from'./core/wrappers/blackAndWhiteLimiter.js';import{TimeoutLimiter}from'./core/wrappers/timeoutLimiter.js';import{RedisClientAdapter,detectRedisClientKind}from'./utils/redisClient.js';const RateLimiterMemory=MemoryLimiter;const RateLimiterRedis=RedisLimiter;const RateLimiterMongo=MongoLimiter;const RateLimiterMongoose=MongooseLimiter;const RateLimiterMySQL=MySqlLimiter;const RateLimiterPostgres=PostgresLimiter;const RateLimiterSQLite=SqliteLimiter;const RateLimiterPrisma=PrismaLimiter;const RateLimiterMemcache=MemcachedLimiter;const RateLimiterMemcached=MemcachedLimiter;const RateLimiterDynamo=DynamoDbLimiter;const RateLimiterDynamoDb=DynamoDbLimiter;const RateLimiterValkey=ValkeyLimiter;const RateLimiterEtcd=EtcdLimiter;const RateLimiterAbstract=RateLimiter;const RateLimiterStoreAbstract=RateLimiterStore;const RateLimiterBlackAndWhite=BlackAndWhiteLimiter;export{rateLimit,rateLimit as default,createLimiter,RateLimitManager,createRateLimiters,BaseStore,MemoryStore,RedisStore,BaseAlgorithm,FixedWindow,SlidingWindowLog,SlidingWindowCounter,TokenBucket,LeakyBucket,errors,defaultKeyGenerator,ipKeyGenerator,getEndpoint,normalizeIp,graphQLKeyGenerator,extractGraphQLRootFieldFast,validateIpRangeV4,validateSecondaryIps,compileWhitelist,isIpInWhitelist,cleanIp,ipToInt,evaluateSkipRule,checkHeaderCriteria,checkOriginCriteria,checkIpCriteria,checkKeyCriteria,RateLimiter,RateLimiterRes,RateLimiterStore,BlockedKeys,createCoreMiddleware,MemoryLimiter,RedisLimiter,ValkeyLimiter,ClusterLimiter,RedisClusterLimiter,RateLimiterRedisCluster,RateLimiterCluster,RateLimiterClusterMaster,RateLimiterClusterMasterPM2,MongoLimiter,MongooseLimiter,DynamoDbLimiter,MySqlLimiter,PostgresLimiter,SqliteLimiter,PrismaLimiter,MemcachedLimiter,EtcdLimiter,RateLimiterMemory,RateLimiterRedis,RateLimiterMongo,RateLimiterMongoose,RateLimiterMySQL,RateLimiterPostgres,RateLimiterSQLite,RateLimiterPrisma,RateLimiterMemcache,RateLimiterMemcached,RateLimiterDynamo,RateLimiterDynamoDb,RateLimiterValkey,RateLimiterEtcd,RateLimiterAbstract,RateLimiterStoreAbstract,RateLimiterBlackAndWhite,RateLimiterQueue,RateLimiterQueueError,RateLimiterUnion,BurstyRateLimiter,AdaptiveRateLimiter,AdaptiveController,BlackAndWhiteLimiter,TimeoutLimiter,RedisClientAdapter,detectRedisClientKind};export*from'./utils/errors.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{setRateLimitHeaders}from'./headers.js';import{evaluateSkipRule}from'../utils/skipUtils.js';export function createMiddleware(algorithm,options){let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}else if(options.keyGenerator==='ip-endpoint'){keyGen=defaultKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}
|
|
1
|
+
import{EventEmitter}from'node:events';import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{setRateLimitHeaders}from'./headers.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{MemoryStore}from'../stores/memoryStore.js';export function createMiddleware(algorithm,options){let fallbackAlgorithm=null;const getFallbackAlgorithm=()=>{if(!fallbackAlgorithm){fallbackAlgorithm=new algorithm.constructor(options,new MemoryStore(options));}return fallbackAlgorithm;};let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}else if(options.keyGenerator==='ip-endpoint'){keyGen=defaultKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const middleware=async(req,res,next)=>{let key;let cost=1;try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}key=await Promise.resolve(keyGen(req,res));if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}const result=await algorithm.consume(key,cost,{req,res});if(algorithm.adaptiveController&&algorithm.adaptiveController.enabled){result.adaptive=algorithm.adaptiveController.getStats();if(result.adaptive.isThrottled){middleware.emit('adaptiveThrottled',req,res,result.adaptive);}}req.rateLimit=result;setRateLimitHeaders(res,result,options);if(result.allowed){middleware.emit('consumed',req,res,result);return next();}middleware.emit('limitReached',req,res,result);middleware.emit('blocked',req,res,result);return handler(req,res,next,options);}catch(err){if(options.fallbackStore==='memory'||options.fallbackToMemory){try{if(!key){key=await Promise.resolve(keyGen(req,res));}const fb=getFallbackAlgorithm();const fbResult=await fb.consume(key,cost,{req,res});fbResult.fallback=true;if(fb.adaptiveController&&fb.adaptiveController.enabled){fbResult.adaptive=fb.adaptiveController.getStats();}req.rateLimit=fbResult;setRateLimitHeaders(res,fbResult,options);middleware.emit('fallback',err,req,res);if(fbResult.allowed){middleware.emit('consumed',req,res,fbResult);return next();}middleware.emit('limitReached',req,res,fbResult);middleware.emit('blocked',req,res,fbResult);return handler(req,res,next,options);}catch{}}if(middleware.listenerCount('error')>0){middleware.emit('error',err,req,res);}if(options.failOpen){const resolvedLimit=typeof options.limit==='function'?await Promise.resolve(options.limit(req,res)).catch(()=>60):options.limit||options.capacity||0;req.rateLimit={allowed:true,limit:Number.isFinite(resolvedLimit)?Number(resolvedLimit):60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+60000),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};Object.setPrototypeOf(middleware,EventEmitter.prototype);EventEmitter.call(middleware);middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;const res=await algorithm.reset(k);middleware.emit('reset',k);return res;};middleware.getState=keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;return algorithm.getState(k);};middleware.close=async()=>{if(fallbackAlgorithm){await fallbackAlgorithm.close();}return algorithm.close();};middleware.algorithm=algorithm;middleware.store=algorithm.store;return middleware;}
|
package/dist/rateLimit.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{validateOptions}from'./utils/validation.js';import{MemoryStore}from'./stores/memoryStore.js';import{RedisStore}from'./stores/redisStore.js';import{FixedWindow}from'./algorithms/fixedWindow.js';import{SlidingWindowLog}from'./algorithms/slidingWindowLog.js';import{SlidingWindowCounter}from'./algorithms/slidingWindowCounter.js';import{TokenBucket}from'./algorithms/tokenBucket.js';import{LeakyBucket}from'./algorithms/leakyBucket.js';import{createMiddleware}from'./middleware/expressMiddleware.js';const ALGORITHM_MAP={'fixed-window':FixedWindow,'sliding-window-log':SlidingWindowLog,'sliding-window-counter':SlidingWindowCounter,'token-bucket':TokenBucket,'leaky-bucket':LeakyBucket};export function rateLimit(rawOptions={}){const options=validateOptions(rawOptions);let store;if(typeof options.store==='string'){const s=options.store.toLowerCase();if(s==='redis'||s==='redis-cluster'||s==='cluster'){const isClusterStore=s==='redis-cluster'||s==='cluster';store=new RedisStore({...options,isCluster:options.isCluster!==undefined?options.isCluster:isClusterStore,hashTag:options.hashTag!==undefined?options.hashTag:isClusterStore?true:undefined,redis:options.redis||{}});}else{store=new MemoryStore(options);}}else if(options.store){store=options.store;}else if(options.client||options.redisClient||options.clusterClient||options.redisCluster||options.cluster||options.clusters||options.clusterNodes){store=new RedisStore(options);}else{store=new MemoryStore(options);}const AlgorithmClass=ALGORITHM_MAP[options.algorithm];const algorithm=new AlgorithmClass(options,store);return createMiddleware(algorithm,options);}export const createLimiter=rateLimit;export default rateLimit;
|
|
1
|
+
import{validateOptions}from'./utils/validation.js';import{MemoryStore}from'./stores/memoryStore.js';import{RedisStore}from'./stores/redisStore.js';import{FixedWindow}from'./algorithms/fixedWindow.js';import{SlidingWindowLog}from'./algorithms/slidingWindowLog.js';import{SlidingWindowCounter}from'./algorithms/slidingWindowCounter.js';import{TokenBucket}from'./algorithms/tokenBucket.js';import{LeakyBucket}from'./algorithms/leakyBucket.js';import{createMiddleware}from'./middleware/expressMiddleware.js';const ALGORITHM_MAP={'fixed-window':FixedWindow,'sliding-window-log':SlidingWindowLog,'sliding-window-counter':SlidingWindowCounter,'token-bucket':TokenBucket,'leaky-bucket':LeakyBucket,adaptive:SlidingWindowCounter};export function rateLimit(rawOptions={}){const options=validateOptions(rawOptions);if(options.algorithm==='adaptive'&&options.adaptive===undefined){options.adaptive=true;}let store;if(typeof options.store==='string'){const s=options.store.toLowerCase();if(s==='redis'||s==='redis-cluster'||s==='cluster'){const isClusterStore=s==='redis-cluster'||s==='cluster';store=new RedisStore({...options,isCluster:options.isCluster!==undefined?options.isCluster:isClusterStore,hashTag:options.hashTag!==undefined?options.hashTag:isClusterStore?true:undefined,redis:options.redis||{}});}else{store=new MemoryStore(options);}}else if(options.store){store=options.store;}else if(options.client||options.redisClient||options.clusterClient||options.redisCluster||options.cluster||options.clusters||options.clusterNodes){store=new RedisStore(options);}else{store=new MemoryStore(options);}const AlgorithmClass=ALGORITHM_MAP[options.algorithm];const algorithm=new AlgorithmClass(options,store);return createMiddleware(algorithm,options);}export const createLimiter=rateLimit;export default rateLimit;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { EventEmitter } from 'node:events';
|
|
3
|
+
|
|
4
|
+
export interface AdaptiveStats {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
currentLagMs: number;
|
|
7
|
+
targetLagMs: number;
|
|
8
|
+
maxLagMs: number;
|
|
9
|
+
factor: number;
|
|
10
|
+
isThrottled: boolean;
|
|
11
|
+
}
|
|
2
12
|
|
|
3
13
|
export interface RateLimitInfo {
|
|
4
14
|
allowed: boolean;
|
|
@@ -8,6 +18,8 @@ export interface RateLimitInfo {
|
|
|
8
18
|
retryAfter: number;
|
|
9
19
|
resetTime: Date;
|
|
10
20
|
failedOpen?: boolean;
|
|
21
|
+
fallback?: boolean;
|
|
22
|
+
adaptive?: AdaptiveStats;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
declare global {
|
|
@@ -25,6 +37,7 @@ export type KeyGenerator =
|
|
|
25
37
|
| ((req: Request, res: Response) => string | Promise<string>);
|
|
26
38
|
export type SkipFunction = (req: Request, res: Response) => boolean | Promise<boolean>;
|
|
27
39
|
export type CostFunction = (req: Request, res: Response) => number | Promise<number>;
|
|
40
|
+
export type DynamicLimit = number | ((req: Request, res: Response) => number | Promise<number>);
|
|
28
41
|
export type RateLimitHandler = (
|
|
29
42
|
req: Request,
|
|
30
43
|
res: Response,
|
|
@@ -62,8 +75,21 @@ export interface SkipRuleObject {
|
|
|
62
75
|
|
|
63
76
|
export type SkipOption = SkipFunction | SkipRuleObject | Array<SkipFunction | SkipRuleObject>;
|
|
64
77
|
|
|
78
|
+
export interface AdaptiveControllerOptions {
|
|
79
|
+
enabled?: boolean;
|
|
80
|
+
targetLagMs?: number;
|
|
81
|
+
maxLagMs?: number;
|
|
82
|
+
minFactor?: number;
|
|
83
|
+
metric?: 'p99' | 'mean' | 'max';
|
|
84
|
+
sampleIntervalMs?: number;
|
|
85
|
+
check?: (status: { lagMs: number }) => number | void;
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
export interface BaseOptions {
|
|
66
89
|
store?: 'memory' | 'redis' | 'redis-cluster' | 'cluster' | BaseStore;
|
|
90
|
+
fallbackStore?: 'memory' | BaseStore;
|
|
91
|
+
fallbackToMemory?: boolean;
|
|
92
|
+
adaptive?: boolean | AdaptiveControllerOptions;
|
|
67
93
|
keyPrefix?: string;
|
|
68
94
|
prefix?: string;
|
|
69
95
|
storePrefix?: string;
|
|
@@ -93,30 +119,30 @@ export interface BaseOptions {
|
|
|
93
119
|
|
|
94
120
|
export interface FixedWindowOptions extends BaseOptions {
|
|
95
121
|
algorithm?: 'fixed-window';
|
|
96
|
-
limit?:
|
|
97
|
-
max?:
|
|
122
|
+
limit?: DynamicLimit;
|
|
123
|
+
max?: DynamicLimit;
|
|
98
124
|
windowMs?: number;
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
export interface SlidingWindowLogOptions extends BaseOptions {
|
|
102
128
|
algorithm: 'sliding-window-log';
|
|
103
|
-
limit?:
|
|
104
|
-
max?:
|
|
129
|
+
limit?: DynamicLimit;
|
|
130
|
+
max?: DynamicLimit;
|
|
105
131
|
windowMs?: number;
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
export interface SlidingWindowCounterOptions extends BaseOptions {
|
|
109
135
|
algorithm: 'sliding-window-counter';
|
|
110
|
-
limit?:
|
|
111
|
-
max?:
|
|
136
|
+
limit?: DynamicLimit;
|
|
137
|
+
max?: DynamicLimit;
|
|
112
138
|
windowMs?: number;
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
export interface TokenBucketOptions extends BaseOptions {
|
|
116
142
|
algorithm: 'token-bucket';
|
|
117
|
-
capacity?:
|
|
118
|
-
limit?:
|
|
119
|
-
max?:
|
|
143
|
+
capacity?: DynamicLimit;
|
|
144
|
+
limit?: DynamicLimit;
|
|
145
|
+
max?: DynamicLimit;
|
|
120
146
|
refillRate?: number;
|
|
121
147
|
refillInterval?: number;
|
|
122
148
|
initialTokens?: number;
|
|
@@ -124,9 +150,9 @@ export interface TokenBucketOptions extends BaseOptions {
|
|
|
124
150
|
|
|
125
151
|
export interface LeakyBucketOptions extends BaseOptions {
|
|
126
152
|
algorithm: 'leaky-bucket';
|
|
127
|
-
capacity?:
|
|
128
|
-
limit?:
|
|
129
|
-
max?:
|
|
153
|
+
capacity?: DynamicLimit;
|
|
154
|
+
limit?: DynamicLimit;
|
|
155
|
+
max?: DynamicLimit;
|
|
130
156
|
leakRate?: number;
|
|
131
157
|
leakInterval?: number;
|
|
132
158
|
mode?: 'policing' | 'shaping';
|
|
@@ -134,14 +160,22 @@ export interface LeakyBucketOptions extends BaseOptions {
|
|
|
134
160
|
queueTimeoutMs?: number;
|
|
135
161
|
}
|
|
136
162
|
|
|
163
|
+
export interface AdaptiveAlgorithmOptions extends BaseOptions {
|
|
164
|
+
algorithm: 'adaptive';
|
|
165
|
+
limit?: DynamicLimit;
|
|
166
|
+
max?: DynamicLimit;
|
|
167
|
+
windowMs?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
export type RateLimitOptions =
|
|
138
171
|
| FixedWindowOptions
|
|
139
172
|
| SlidingWindowLogOptions
|
|
140
173
|
| SlidingWindowCounterOptions
|
|
141
174
|
| TokenBucketOptions
|
|
142
|
-
| LeakyBucketOptions
|
|
175
|
+
| LeakyBucketOptions
|
|
176
|
+
| AdaptiveAlgorithmOptions;
|
|
143
177
|
|
|
144
|
-
export interface RateLimitMiddleware {
|
|
178
|
+
export interface RateLimitMiddleware extends EventEmitter {
|
|
145
179
|
(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
146
180
|
resetKey(key: string): Promise<boolean>;
|
|
147
181
|
getState(key: string): Promise<RateLimitInfo>;
|
|
@@ -195,6 +229,7 @@ export declare class RedisStore extends BaseStore {
|
|
|
195
229
|
|
|
196
230
|
export declare class BaseAlgorithm {
|
|
197
231
|
constructor(options: any, store: BaseStore);
|
|
232
|
+
resolveLimit(context?: { req?: Request; res?: Response }): Promise<number>;
|
|
198
233
|
isRedis(): boolean;
|
|
199
234
|
normalizeResult(
|
|
200
235
|
allowed: boolean,
|
|
@@ -700,6 +735,49 @@ export declare class TimeoutLimiter {
|
|
|
700
735
|
delete(key: string, options?: CoreLimiterOptions): Promise<boolean>;
|
|
701
736
|
}
|
|
702
737
|
|
|
738
|
+
export declare class AdaptiveController {
|
|
739
|
+
constructor(options?: boolean | AdaptiveControllerOptions);
|
|
740
|
+
enabled: boolean;
|
|
741
|
+
targetLagMs: number;
|
|
742
|
+
maxLagMs: number;
|
|
743
|
+
minFactor: number;
|
|
744
|
+
metric: 'p99' | 'mean' | 'max';
|
|
745
|
+
sampleIntervalMs: number;
|
|
746
|
+
currentLagMs: number;
|
|
747
|
+
currentFactor: number;
|
|
748
|
+
start(): void;
|
|
749
|
+
updateMetrics(): void;
|
|
750
|
+
setLag(lagMs: number): void;
|
|
751
|
+
getFactor(): number;
|
|
752
|
+
getStats(): AdaptiveStats;
|
|
753
|
+
close(): void;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export declare class AdaptiveRateLimiter {
|
|
757
|
+
constructor(
|
|
758
|
+
limiter: RateLimiterLike,
|
|
759
|
+
options?: boolean | AdaptiveControllerOptions | AdaptiveController
|
|
760
|
+
);
|
|
761
|
+
limiter: RateLimiterLike;
|
|
762
|
+
adaptiveController: AdaptiveController;
|
|
763
|
+
readonly points: number;
|
|
764
|
+
readonly basePoints: number;
|
|
765
|
+
readonly duration: number;
|
|
766
|
+
readonly msDuration: number;
|
|
767
|
+
readonly blockDuration: number;
|
|
768
|
+
readonly keyPrefix: string;
|
|
769
|
+
getFactor(): number;
|
|
770
|
+
getStats(): AdaptiveStats;
|
|
771
|
+
consume(key: string, points?: number, options?: any): Promise<RateLimiterRes>;
|
|
772
|
+
get(key: string, options?: any): Promise<RateLimiterRes | null>;
|
|
773
|
+
set(key: string, points: number, secDuration?: number): Promise<RateLimiterRes | null>;
|
|
774
|
+
block(key: string, secDuration: number): Promise<RateLimiterRes | null>;
|
|
775
|
+
delete(key: string, options?: any): Promise<boolean>;
|
|
776
|
+
penalty(key: string, points?: number, options?: any): Promise<RateLimiterRes | null>;
|
|
777
|
+
reward(key: string, points?: number, options?: any): Promise<RateLimiterRes | null>;
|
|
778
|
+
close(): Promise<void> | void;
|
|
779
|
+
}
|
|
780
|
+
|
|
703
781
|
export interface CoreMiddlewareOptions {
|
|
704
782
|
keyGenerator?: KeyGenerator;
|
|
705
783
|
cost?: number | CostFunction;
|
|
@@ -716,7 +794,7 @@ export interface CoreMiddlewareOptions {
|
|
|
716
794
|
windowMs?: number;
|
|
717
795
|
}
|
|
718
796
|
|
|
719
|
-
export interface CoreMiddleware {
|
|
797
|
+
export interface CoreMiddleware extends EventEmitter {
|
|
720
798
|
(req: Request, res: Response, next: NextFunction): Promise<any>;
|
|
721
799
|
resetKey(keyOrReq: string | Request): Promise<boolean>;
|
|
722
800
|
getState(keyOrReq: string | Request): Promise<RateLimiterRes | null>;
|
package/dist/utils/validation.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ConfigurationError}from'./errors.js';export const VALID_ALGORITHMS=new Set(['fixed-window','sliding-window-log','sliding-window-counter','token-bucket','leaky-bucket']);export function validateOptions(rawOptions={}){const options={...rawOptions};options.algorithm=options.algorithm||'fixed-window';if(!VALID_ALGORITHMS.has(options.algorithm)){throw new ConfigurationError(`Invalid algorithm: "${options.algorithm}". Supported: ${Array.from(VALID_ALGORITHMS).join(', ')}`);}if(typeof options.store==='string'){const s=options.store.toLowerCase();if(s!=='memory'&&s!=='redis'&&s!=='redis-cluster'&&s!=='cluster'){throw new ConfigurationError(`Invalid store: "${options.store}". Supported string stores: "memory", "redis", "redis-cluster", "cluster"`);}}else if(options.store&&typeof options.store!=='object'){throw new ConfigurationError('Store must be "memory", "redis", "redis-cluster", "cluster", or a custom store instance');}else if(!options.store){options.store='memory';}const rawKeyPrefix=options.keyPrefix!==undefined?options.keyPrefix:options.prefix!==undefined?options.prefix:options.storePrefix!==undefined?options.storePrefix:options._storePrefix;if(rawKeyPrefix!==undefined&&typeof rawKeyPrefix!=='string'){throw new ConfigurationError('keyPrefix must be a string');}options.keyPrefix=typeof rawKeyPrefix==='string'?rawKeyPrefix:'rl:';if(options.cost!==undefined){if(typeof options.cost!=='function'&&(typeof options.cost!=='number'||options.cost<=0||!Number.isFinite(options.cost))){throw new ConfigurationError('Cost must be a positive number or a function returning a positive number');}}else{options.cost=1;}if(options.keyGenerator!==undefined){if(typeof options.keyGenerator!=='function'&&options.keyGenerator!=='ip'&&options.keyGenerator!=='ip-endpoint'&&options.keyGenerator!=='graphql'){throw new ConfigurationError('keyGenerator must be a function or one of "ip", "ip-endpoint", "graphql"');}}if(options.skip===undefined){if(options.customSkip!==undefined){options.skip=options.customSkip;}else if(options.customskip!==undefined){options.skip=options.customskip;}}if(options.skip!==undefined&&typeof options.skip!=='function'&&(typeof options.skip!=='object'||options.skip===null)){throw new ConfigurationError('skip must be a function, object, or array');}if(options.handler!==undefined&&typeof options.handler!=='function'){throw new ConfigurationError('handler must be a function');}if(options.onError!==undefined&&typeof options.onError!=='function'){throw new ConfigurationError('onError must be a function');}options.statusCode=options.statusCode!==undefined?options.statusCode:429;if(!Number.isInteger(options.statusCode)||options.statusCode<100||options.statusCode>599){throw new ConfigurationError('statusCode must be a valid HTTP status code (100-599)');}if(options.standardHeaders!==undefined&&typeof options.standardHeaders!=='boolean'&&options.standardHeaders!=='draft-6'&&options.standardHeaders!=='draft-7'){throw new ConfigurationError('standardHeaders must be a boolean or "draft-6" or "draft-7"');}options.standardHeaders=options.standardHeaders!==undefined?options.standardHeaders:true;options.legacyHeaders=options.legacyHeaders!==undefined?Boolean(options.legacyHeaders):false;options.failOpen=Boolean(options.failOpen||options.passOnStoreError);options.message=options.message!==undefined?options.message:'Too many requests, please try again later.';if(options.maxEntries!==undefined){if(!Number.isInteger(options.maxEntries)||options.maxEntries<=0){throw new ConfigurationError('maxEntries must be a positive integer');}}if(options.cleanupIntervalMs!==undefined){if(typeof options.cleanupIntervalMs!=='number'||options.cleanupIntervalMs<=0||!Number.isFinite(options.cleanupIntervalMs)){throw new ConfigurationError('cleanupIntervalMs must be a positive number');}}if(options.useRedisTime!==undefined&&typeof options.useRedisTime!=='boolean'){throw new ConfigurationError('useRedisTime must be a boolean');}if(options.algorithm==='fixed-window'||options.algorithm==='sliding-window-log'||options.algorithm==='sliding-window-counter'){options.windowMs=options.windowMs!==undefined?options.windowMs:60000;if(typeof options.windowMs!=='number'||options.windowMs<=0||!Number.isFinite(options.windowMs)){throw new ConfigurationError(`${options.algorithm} requires "windowMs" to be a positive number`);}options.limit=options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.limit!=='number'||options.limit<=0||!Number.isFinite(options.limit)){throw new ConfigurationError(`${options.algorithm} requires "limit" to be a positive number`);}}if(options.algorithm==='token-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity)){throw new ConfigurationError('token-bucket requires "capacity" to be a positive number');}options.refillRate=options.refillRate!==undefined?options.refillRate:1;if(typeof options.refillRate!=='number'||options.refillRate<=0||!Number.isFinite(options.refillRate)){throw new ConfigurationError('token-bucket requires "refillRate" to be a positive number');}options.refillInterval=options.refillInterval!==undefined?options.refillInterval:1000;if(typeof options.refillInterval!=='number'||options.refillInterval<=0||!Number.isFinite(options.refillInterval)){throw new ConfigurationError('token-bucket requires "refillInterval" to be a positive number in milliseconds');}if(options.initialTokens!==undefined){if(typeof options.initialTokens!=='number'||options.initialTokens<0||!Number.isFinite(options.initialTokens)){throw new ConfigurationError('token-bucket "initialTokens" must be a non-negative number');}}else{options.initialTokens=options.capacity;}}if(options.algorithm==='leaky-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity)){throw new ConfigurationError('leaky-bucket requires "capacity" to be a positive number');}options.leakRate=options.leakRate!==undefined?options.leakRate:1;if(typeof options.leakRate!=='number'||options.leakRate<=0||!Number.isFinite(options.leakRate)){throw new ConfigurationError('leaky-bucket requires "leakRate" to be a positive number');}options.leakInterval=options.leakInterval!==undefined?options.leakInterval:1000;if(typeof options.leakInterval!=='number'||options.leakInterval<=0||!Number.isFinite(options.leakInterval)){throw new ConfigurationError('leaky-bucket requires "leakInterval" to be a positive number in milliseconds');}options.mode=options.mode||'policing';if(options.mode!=='policing'&&options.mode!=='shaping'){throw new ConfigurationError('leaky-bucket "mode" must be either "policing" or "shaping"');}if(options.mode==='shaping'){options.maxQueueSize=options.maxQueueSize!==undefined?options.maxQueueSize:100;if(!Number.isInteger(options.maxQueueSize)||options.maxQueueSize<=0){throw new ConfigurationError('leaky-bucket in shaping mode requires "maxQueueSize" to be a positive integer');}options.queueTimeoutMs=options.queueTimeoutMs!==undefined?options.queueTimeoutMs:30000;if(typeof options.queueTimeoutMs!=='number'||options.queueTimeoutMs<=0||!Number.isFinite(options.queueTimeoutMs)){throw new ConfigurationError('leaky-bucket in shaping mode requires "queueTimeoutMs" to be a positive number');}}}return options;}
|
|
1
|
+
import{ConfigurationError}from'./errors.js';export const VALID_ALGORITHMS=new Set(['fixed-window','sliding-window-log','sliding-window-counter','token-bucket','leaky-bucket','adaptive']);export function validateOptions(rawOptions={}){const options={...rawOptions};options.algorithm=options.algorithm||'fixed-window';if(!VALID_ALGORITHMS.has(options.algorithm)){throw new ConfigurationError(`Invalid algorithm: "${options.algorithm}". Supported: ${Array.from(VALID_ALGORITHMS).join(', ')}`);}if(typeof options.store==='string'){const s=options.store.toLowerCase();if(s!=='memory'&&s!=='redis'&&s!=='redis-cluster'&&s!=='cluster'){throw new ConfigurationError(`Invalid store: "${options.store}". Supported string stores: "memory", "redis", "redis-cluster", "cluster"`);}}else if(options.store&&typeof options.store!=='object'){throw new ConfigurationError('Store must be "memory", "redis", "redis-cluster", "cluster", or a custom store instance');}else if(!options.store){options.store='memory';}const rawKeyPrefix=options.keyPrefix!==undefined?options.keyPrefix:options.prefix!==undefined?options.prefix:options.storePrefix!==undefined?options.storePrefix:options._storePrefix;if(rawKeyPrefix!==undefined&&typeof rawKeyPrefix!=='string'){throw new ConfigurationError('keyPrefix must be a string');}options.keyPrefix=typeof rawKeyPrefix==='string'?rawKeyPrefix:'rl:';if(options.cost!==undefined){if(typeof options.cost!=='function'&&(typeof options.cost!=='number'||options.cost<=0||!Number.isFinite(options.cost))){throw new ConfigurationError('Cost must be a positive number or a function returning a positive number');}}else{options.cost=1;}if(options.keyGenerator!==undefined){if(typeof options.keyGenerator!=='function'&&options.keyGenerator!=='ip'&&options.keyGenerator!=='ip-endpoint'&&options.keyGenerator!=='graphql'){throw new ConfigurationError('keyGenerator must be a function or one of "ip", "ip-endpoint", "graphql"');}}if(options.skip===undefined){if(options.customSkip!==undefined){options.skip=options.customSkip;}else if(options.customskip!==undefined){options.skip=options.customskip;}}if(options.skip!==undefined&&typeof options.skip!=='function'&&(typeof options.skip!=='object'||options.skip===null)){throw new ConfigurationError('skip must be a function, object, or array');}if(options.handler!==undefined&&typeof options.handler!=='function'){throw new ConfigurationError('handler must be a function');}if(options.onError!==undefined&&typeof options.onError!=='function'){throw new ConfigurationError('onError must be a function');}options.statusCode=options.statusCode!==undefined?options.statusCode:429;if(!Number.isInteger(options.statusCode)||options.statusCode<100||options.statusCode>599){throw new ConfigurationError('statusCode must be a valid HTTP status code (100-599)');}if(options.standardHeaders!==undefined&&typeof options.standardHeaders!=='boolean'&&options.standardHeaders!=='draft-6'&&options.standardHeaders!=='draft-7'){throw new ConfigurationError('standardHeaders must be a boolean or "draft-6" or "draft-7"');}options.standardHeaders=options.standardHeaders!==undefined?options.standardHeaders:true;options.legacyHeaders=options.legacyHeaders!==undefined?Boolean(options.legacyHeaders):false;options.failOpen=Boolean(options.failOpen||options.passOnStoreError);options.message=options.message!==undefined?options.message:'Too many requests, please try again later.';if(options.maxEntries!==undefined){if(!Number.isInteger(options.maxEntries)||options.maxEntries<=0){throw new ConfigurationError('maxEntries must be a positive integer');}}if(options.cleanupIntervalMs!==undefined){if(typeof options.cleanupIntervalMs!=='number'||options.cleanupIntervalMs<=0||!Number.isFinite(options.cleanupIntervalMs)){throw new ConfigurationError('cleanupIntervalMs must be a positive number');}}if(options.useRedisTime!==undefined&&typeof options.useRedisTime!=='boolean'){throw new ConfigurationError('useRedisTime must be a boolean');}if(options.adaptive!==undefined){if(typeof options.adaptive!=='boolean'&&(typeof options.adaptive!=='object'||options.adaptive===null)){throw new ConfigurationError('adaptive must be a boolean or an options object');}}if(options.fallbackStore!==undefined){if(options.fallbackStore!=='memory'&&typeof options.fallbackStore!=='object'){throw new ConfigurationError('fallbackStore must be "memory" or a store instance');}}if(options.algorithm==='fixed-window'||options.algorithm==='sliding-window-log'||options.algorithm==='sliding-window-counter'||options.algorithm==='adaptive'){options.windowMs=options.windowMs!==undefined?options.windowMs:60000;if(typeof options.windowMs!=='number'||options.windowMs<=0||!Number.isFinite(options.windowMs)){throw new ConfigurationError(`${options.algorithm} requires "windowMs" to be a positive number`);}options.limit=options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.limit!=='function'&&(typeof options.limit!=='number'||options.limit<=0||!Number.isFinite(options.limit))){throw new ConfigurationError(`${options.algorithm} requires "limit" to be a positive number or a function`);}}if(options.algorithm==='token-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='function'&&(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity))){throw new ConfigurationError('token-bucket requires "capacity" to be a positive number or a function');}options.refillRate=options.refillRate!==undefined?options.refillRate:1;if(typeof options.refillRate!=='number'||options.refillRate<=0||!Number.isFinite(options.refillRate)){throw new ConfigurationError('token-bucket requires "refillRate" to be a positive number');}options.refillInterval=options.refillInterval!==undefined?options.refillInterval:1000;if(typeof options.refillInterval!=='number'||options.refillInterval<=0||!Number.isFinite(options.refillInterval)){throw new ConfigurationError('token-bucket requires "refillInterval" to be a positive number in milliseconds');}if(options.initialTokens!==undefined){if(typeof options.initialTokens!=='number'||options.initialTokens<0||!Number.isFinite(options.initialTokens)){throw new ConfigurationError('token-bucket "initialTokens" must be a non-negative number');}}else if(typeof options.capacity==='number'){options.initialTokens=options.capacity;}}if(options.algorithm==='leaky-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='function'&&(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity))){throw new ConfigurationError('leaky-bucket requires "capacity" to be a positive number or a function');}options.leakRate=options.leakRate!==undefined?options.leakRate:1;if(typeof options.leakRate!=='number'||options.leakRate<=0||!Number.isFinite(options.leakRate)){throw new ConfigurationError('leaky-bucket requires "leakRate" to be a positive number');}options.leakInterval=options.leakInterval!==undefined?options.leakInterval:1000;if(typeof options.leakInterval!=='number'||options.leakInterval<=0||!Number.isFinite(options.leakInterval)){throw new ConfigurationError('leaky-bucket requires "leakInterval" to be a positive number in milliseconds');}options.mode=options.mode||'policing';if(options.mode!=='policing'&&options.mode!=='shaping'){throw new ConfigurationError('leaky-bucket "mode" must be either "policing" or "shaping"');}if(options.mode==='shaping'){options.maxQueueSize=options.maxQueueSize!==undefined?options.maxQueueSize:100;if(!Number.isInteger(options.maxQueueSize)||options.maxQueueSize<=0){throw new ConfigurationError('leaky-bucket in shaping mode requires "maxQueueSize" to be a positive integer');}options.queueTimeoutMs=options.queueTimeoutMs!==undefined?options.queueTimeoutMs:30000;if(typeof options.queueTimeoutMs!=='number'||options.queueTimeoutMs<=0||!Number.isFinite(options.queueTimeoutMs)){throw new ConfigurationError('leaky-bucket in shaping mode requires "queueTimeoutMs" to be a positive number');}}}return options;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-adaptive-rate-limiter",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Production-ready rate limiting for Node.js: five rate limiting algorithms plus a framework-agnostic points-based core with Memory, Redis, Mongo, Mongoose, MySQL, Postgres, SQLite, Prisma, Memcached, DynamoDB, Valkey, etcd and Redis Cluster backends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|