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 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 | Default | Description |
481
- | --------------- | -------- | ------- | ------------------------------------------- |
482
- | `limit` / `max` | `number` | `60` | Maximum requests allowed within `windowMs`. |
483
- | `windowMs` | `number` | `60000` | Window duration in milliseconds. |
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 | Default | Description |
490
- | --------------- | -------- | ------- | ----------------------------------------------- |
491
- | `limit` / `max` | `number` | `60` | Maximum requests allowed in the rolling window. |
492
- | `windowMs` | `number` | `60000` | Rolling window duration in milliseconds. |
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 | Default | Description |
499
- | --------------- | -------- | ------- | --------------------------------------------------------- |
500
- | `limit` / `max` | `number` | `60` | Maximum estimated requests allowed in the sliding window. |
501
- | `windowMs` | `number` | `60000` | Window duration in milliseconds. |
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 | Default | Description |
508
- | ---------------------------- | -------- | ---------- | ---------------------------------------------- |
509
- | `capacity` / `limit` / `max` | `number` | `60` | Maximum burst capacity of the bucket. |
510
- | `refillRate` | `number` | `1` | Number of tokens refilled every interval. |
511
- | `refillInterval` | `number` | `1000` | Refill interval in milliseconds. |
512
- | `initialTokens` | `number` | `capacity` | Starting token balance when key is first seen. |
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 | Default | Description |
519
- | ---------------------------- | ------------------------- | ------------ | ------------------------------------------------------------------------ |
520
- | `capacity` / `limit` / `max` | `number` | `60` | Bucket water capacity (burst ceiling). |
521
- | `leakRate` | `number` | `1` | Number of requests/units leaked every interval. |
522
- | `leakInterval` | `number` | `1000` | Leak interval in milliseconds. |
523
- | `mode` | `'policing' \| 'shaping'` | `'policing'` | `'policing'` rejects immediately when full; `'shaping'` queues requests. |
524
- | `maxQueueSize` | `number` | `100` | Max queued requests in shaping mode before throwing `QueueFullError`. |
525
- | `queueTimeoutMs` | `number` | `30000` | Max wait time (ms) in shaping queue before `QueueTimeoutError`. |
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],[this.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<=this.limit){const nextCount=window.count+cost;await this.store.set(key,{count:nextCount,windowStart:window.windowStart,resetTime:window.resetTime},ttl);return this.normalizeResult(true,this.limit,this.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,this.limit-window.count);return this.normalizeResult(false,this.limit,remaining,window.count,retryAfter,window.resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[this.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,this.limit-window.count);const retryAfter=window.count>=this.limit?Math.max(1,Math.ceil((window.resetTime-now)/1000)):0;return this.normalizeResult(window.count<this.limit,this.limit,remaining,window.count,retryAfter,window.resetTime);}}export default FixedWindow;
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],[this.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(this.capacity/this.rate*2.0));if(water+cost<=this.capacity){water+=cost;const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(this.capacity-water));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(true,this.capacity,remaining,consumed,0,resetTime),waitMs:0};}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(this.capacity-water));const deficit=water+cost-this.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,this.capacity,remaining,consumed,retryAfter,resetTime),waitMs};});}async consume(key,cost=1,extra={}){const policingResult=await this._checkPolicing(key,cost);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);resolve(afterWaitResult);}catch{resolve({...immediateResult,allowed:false});}},waitMs);if(timer.unref){timer.unref();}item.timer=timer;queue.push(item);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[this.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(this.capacity-water));const resetTime=now+Math.ceil(water/this.rate);const retryAfter=water>=this.capacity?Math.max(1,Math.ceil(1/this.rate/1000)):0;return this.normalizeResult(water<this.capacity,this.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
+ 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],[this.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<=this.limit){currCount+=cost;const consumed=Math.round(estimated+cost);const remaining=Math.max(0,this.limit-consumed);await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(true,this.limit,remaining,consumed,0,resetTime);}const consumed=Math.round(estimated);const remaining=Math.max(0,this.limit-consumed);let waitMs=resetTime-now;if(prevCount>0){const neededWeight=(this.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,this.limit,remaining,consumed,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[this.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,this.limit-consumed);const retryAfter=estimated>=this.limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(estimated<this.limit,this.limit,remaining,consumed,retryAfter,resetTime);}}export default SlidingWindowCounter;
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],[this.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<=this.limit){log.push({time:now,cost});const consumed=current+cost;const remaining=this.limit-consumed;const oldestTime=log[0].time;const resetTime=oldestTime+this.windowMs;await this.store.set(key,{log},this.windowMs);return this.normalizeResult(true,this.limit,remaining,consumed,0,resetTime);}const deficit=current+cost-this.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,this.limit-current);await this.store.set(key,{log},this.windowMs);return this.normalizeResult(false,this.limit,remaining,current,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[this.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,this.limit-current);const oldestTime=log.length>0?log[0].time:now;const resetTime=oldestTime+this.windowMs;const retryAfter=current>=this.limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(current<this.limit,this.limit,remaining,current,retryAfter,resetTime);}}export default SlidingWindowLog;
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],[this.capacity,this.refillRate,this.refillInterval,cost,this.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=this.initialTokens;let lastRefill=now;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(this.capacity,state.tokens+elapsed*this.rate);lastRefill=now;}const ttlMs=Math.max(1000,Math.ceil(this.capacity/this.rate*2.0));if(tokens>=cost){tokens-=cost;const consumed=this.capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((this.capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(true,this.capacity,remaining,consumed,0,resetTime);}const consumed=this.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((this.capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(false,this.capacity,remaining,consumed,retryAfter,resetTime);});}async getState(key){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[this.capacity,this.refillRate,this.refillInterval,0,this.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=this.initialTokens;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(this.capacity,state.tokens+elapsed*this.rate);}const consumed=this.capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((this.capacity-tokens)/this.rate);const retryAfter=tokens<1?Math.max(1,Math.ceil((1-tokens)/this.rate/1000)):0;return this.normalizeResult(tokens>=1,this.capacity,remaining,consumed,retryAfter,resetTime);}}export default TokenBucket;
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'){return limiter.delete(k);}return false;};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;
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();}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;}const result=await algorithm.consume(key,cost,{req,res});req.rateLimit=result;setRateLimitHeaders(res,result,options);if(result.allowed){return next();}return handler(req,res,next,options);}catch(err){if(options.failOpen){req.rateLimit={allowed:true,limit:options.limit||options.capacity||0,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);}};middleware.resetKey=keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;return algorithm.reset(k);};middleware.getState=keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;return algorithm.getState(k);};middleware.close=()=>algorithm.close();middleware.algorithm=algorithm;middleware.store=algorithm.store;return middleware;}
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;
@@ -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?: number;
97
- max?: number;
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?: number;
104
- max?: number;
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?: number;
111
- max?: number;
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?: number;
118
- limit?: number;
119
- max?: number;
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?: number;
128
- limit?: number;
129
- max?: number;
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>;
@@ -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.1",
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",