express-adaptive-rate-limiter 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -26
- package/dist/algorithms/baseAlgorithm.js +1 -1
- package/dist/algorithms/fixedWindow.js +1 -1
- package/dist/algorithms/leakyBucket.js +1 -1
- package/dist/algorithms/slidingWindowCounter.js +1 -1
- package/dist/algorithms/slidingWindowLog.js +1 -1
- package/dist/algorithms/tokenBucket.js +1 -1
- package/dist/core/coreMiddleware.js +1 -1
- package/dist/middleware/expressMiddleware.js +1 -1
- package/dist/types/index.d.ts +17 -14
- package/dist/utils/validation.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,13 @@ 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.1.0
|
|
15
|
+
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
14
21
|
## What's new in 2.0.1
|
|
15
22
|
|
|
16
23
|
- **Full Redis Cluster support**:
|
|
@@ -477,52 +484,118 @@ All options below can be passed to `rateLimit(options)`:
|
|
|
477
484
|
|
|
478
485
|
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
486
|
|
|
480
|
-
| Option | Type
|
|
481
|
-
| --------------- |
|
|
482
|
-
| `limit` / `max` | `number` | `60` | Maximum requests allowed within `windowMs
|
|
483
|
-
| `windowMs` | `number`
|
|
487
|
+
| Option | Type | Default | Description |
|
|
488
|
+
| --------------- | ------------------------------------------- | ------- | ------------------------------------------- |
|
|
489
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum requests allowed within `windowMs` (static or dynamic). |
|
|
490
|
+
| `windowMs` | `number` | `60000` | Window duration in milliseconds. |
|
|
484
491
|
|
|
485
492
|
#### Sliding Window Log (`algorithm: 'sliding-window-log'`)
|
|
486
493
|
|
|
487
494
|
Maintains timestamped log of each request. Provides 100% precision rolling window without boundary reset spikes.
|
|
488
495
|
|
|
489
|
-
| Option | Type
|
|
490
|
-
| --------------- |
|
|
491
|
-
| `limit` / `max` | `number` | `60` | Maximum requests allowed in the rolling window. |
|
|
492
|
-
| `windowMs` | `number`
|
|
496
|
+
| Option | Type | Default | Description |
|
|
497
|
+
| --------------- | ------------------------------------------- | ------- | ----------------------------------------------- |
|
|
498
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum requests allowed in the rolling window (static or dynamic). |
|
|
499
|
+
| `windowMs` | `number` | `60000` | Rolling window duration in milliseconds. |
|
|
493
500
|
|
|
494
501
|
#### Sliding Window Counter (`algorithm: 'sliding-window-counter'`)
|
|
495
502
|
|
|
496
503
|
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
504
|
|
|
498
|
-
| Option | Type
|
|
499
|
-
| --------------- |
|
|
500
|
-
| `limit` / `max` | `number` | `60` | Maximum estimated requests allowed in the sliding window. |
|
|
501
|
-
| `windowMs` | `number`
|
|
505
|
+
| Option | Type | Default | Description |
|
|
506
|
+
| --------------- | ------------------------------------------- | ------- | --------------------------------------------------------- |
|
|
507
|
+
| `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum estimated requests allowed in the sliding window (static or dynamic). |
|
|
508
|
+
| `windowMs` | `number` | `60000` | Window duration in milliseconds. |
|
|
502
509
|
|
|
503
510
|
#### Token Bucket (`algorithm: 'token-bucket'`)
|
|
504
511
|
|
|
505
512
|
Tokens refill continuously at `refillRate` per `refillInterval` up to `capacity`. Allows bursts up to `capacity`.
|
|
506
513
|
|
|
507
|
-
| Option | Type
|
|
508
|
-
| ---------------------------- |
|
|
509
|
-
| `capacity` / `limit` / `max` | `number` | `60` | Maximum burst capacity of the bucket.
|
|
510
|
-
| `refillRate` | `number`
|
|
511
|
-
| `refillInterval` | `number`
|
|
512
|
-
| `initialTokens` | `number`
|
|
514
|
+
| Option | Type | Default | Description |
|
|
515
|
+
| ---------------------------- | ------------------------------------------- | ---------- | ---------------------------------------------- |
|
|
516
|
+
| `capacity` / `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Maximum burst capacity of the bucket (static or dynamic). |
|
|
517
|
+
| `refillRate` | `number` | `1` | Number of tokens refilled every interval. |
|
|
518
|
+
| `refillInterval` | `number` | `1000` | Refill interval in milliseconds. |
|
|
519
|
+
| `initialTokens` | `number` | `capacity` | Starting token balance when key is first seen. |
|
|
513
520
|
|
|
514
521
|
#### Leaky Bucket (`algorithm: 'leaky-bucket'`)
|
|
515
522
|
|
|
516
523
|
Requests fill a bucket that leaks at a constant rate. Supports policing (drop excess immediately) and shaping (queue excess and process smoothly).
|
|
517
524
|
|
|
518
|
-
| Option | Type
|
|
519
|
-
| ---------------------------- |
|
|
520
|
-
| `capacity` / `limit` / `max` | `number`
|
|
521
|
-
| `leakRate` | `number`
|
|
522
|
-
| `leakInterval` | `number`
|
|
523
|
-
| `mode` | `'policing' \| 'shaping'`
|
|
524
|
-
| `maxQueueSize` | `number`
|
|
525
|
-
| `queueTimeoutMs` | `number`
|
|
525
|
+
| Option | Type | Default | Description |
|
|
526
|
+
| ---------------------------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------ |
|
|
527
|
+
| `capacity` / `limit` / `max` | `number \| ((req, res) => number \| Promise<number>)` | `60` | Bucket water capacity / burst ceiling (static or dynamic). |
|
|
528
|
+
| `leakRate` | `number` | `1` | Number of requests/units leaked every interval. |
|
|
529
|
+
| `leakInterval` | `number` | `1000` | Leak interval in milliseconds. |
|
|
530
|
+
| `mode` | `'policing' \| 'shaping'` | `'policing'` | `'policing'` rejects immediately when full; `'shaping'` queues requests. |
|
|
531
|
+
| `maxQueueSize` | `number` | `100` | Max queued requests in shaping mode before throwing `QueueFullError`. |
|
|
532
|
+
| `queueTimeoutMs` | `number` | `30000` | Max wait time (ms) in shaping queue before `QueueTimeoutError`. |
|
|
533
|
+
|
|
534
|
+
#### Dynamic Request-Based Limits (`limit` as a Function)
|
|
535
|
+
|
|
536
|
+
`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:
|
|
537
|
+
|
|
538
|
+
```javascript
|
|
539
|
+
import express from 'express';
|
|
540
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
541
|
+
|
|
542
|
+
const app = express();
|
|
543
|
+
|
|
544
|
+
const limiter = rateLimit({
|
|
545
|
+
algorithm: 'sliding-window-counter',
|
|
546
|
+
windowMs: 60 * 1000,
|
|
547
|
+
// Dynamic limit based on user subscription tier or auth headers
|
|
548
|
+
limit: async (req) => {
|
|
549
|
+
if (req.user?.tier === 'enterprise') return 1000;
|
|
550
|
+
if (req.user?.tier === 'pro') return 200;
|
|
551
|
+
return 30; // free tier
|
|
552
|
+
},
|
|
553
|
+
keyGenerator: (req) => req.user?.id || req.ip
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
app.use(limiter);
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
#### Middleware Lifecycle Events (`EventEmitter`)
|
|
560
|
+
|
|
561
|
+
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:
|
|
562
|
+
|
|
563
|
+
```javascript
|
|
564
|
+
import { rateLimit } from 'express-adaptive-rate-limiter';
|
|
565
|
+
|
|
566
|
+
const limiter = rateLimit({
|
|
567
|
+
limit: 100,
|
|
568
|
+
windowMs: 60000
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Emitted when a request is within limit and allowed
|
|
572
|
+
limiter.on('consumed', (req, res, info) => {
|
|
573
|
+
console.log(`Allowed ${req.ip} - Remaining: ${info.remaining}/${info.limit}`);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Emitted when a client reaches or exceeds its quota
|
|
577
|
+
limiter.on('limitReached', (req, res, info) => {
|
|
578
|
+
console.warn(`Rate limit reached for ${req.ip}. Retry-After: ${info.retryAfter}s`);
|
|
579
|
+
// metrics.increment('rate_limit_exceeded_total', { path: req.path });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Emitted whenever a request is blocked (HTTP 429)
|
|
583
|
+
limiter.on('blocked', (req, res, info) => {
|
|
584
|
+
// auditLogger.warn({ ip: req.ip, path: req.path, user: req.user });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Emitted when an IP or key is manually reset via resetKey()
|
|
588
|
+
limiter.on('reset', (key) => {
|
|
589
|
+
console.log(`Rate limit quota reset for key: ${key}`);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Emitted if an error occurs in the underlying storage store
|
|
593
|
+
limiter.on('error', (err, req, res) => {
|
|
594
|
+
console.error(`Rate limiter store error:`, err);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
app.use(limiter);
|
|
598
|
+
```
|
|
526
599
|
|
|
527
600
|
---
|
|
528
601
|
|
|
@@ -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
|
+
export class BaseAlgorithm{constructor(options,store){this.options=options;this.store=store;this.locks=new Map();}async resolveLimit(context={}){const rawLimit=this.options?.limit!==undefined?this.options.limit:this.options?.capacity!==undefined?this.options.capacity:this.options?.max;if(typeof rawLimit==='function'){const res=await Promise.resolve(rawLimit(context?.req,context?.res));return Number.isFinite(res)&&res>0?Number(res):60;}if(typeof this.limit==='number')return this.limit;if(typeof this.capacity==='number')return this.capacity;return typeof rawLimit==='number'?rawLimit:60;}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 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class FixedWindow extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}resolveWindow(state,now){if(state&&typeof state.resetTime==='number'&&state.resetTime>now){return{count:state.count||0,windowStart:state.windowStart,resetTime:state.resetTime};}return{count:0,windowStart:now,resetTime:now+this.windowMs};}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class FixedWindow extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}resolveWindow(state,now){if(state&&typeof state.resetTime==='number'&&state.resetTime>now){return{count:state.count||0,windowStart:state.windowStart,resetTime:state.resetTime};}return{count:0,windowStart:now,resetTime:now+this.windowMs};}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const ttl=Math.max(1,window.resetTime-now);if(window.count+cost<=limit){const nextCount=window.count+cost;await this.store.set(key,{count:nextCount,windowStart:window.windowStart,resetTime:window.resetTime},ttl);return this.normalizeResult(true,limit,limit-nextCount,nextCount,0,window.resetTime);}await this.store.set(key,{count:window.count,windowStart:window.windowStart,resetTime:window.resetTime},ttl);const retryAfter=Math.max(1,Math.ceil(ttl/1000));const remaining=Math.max(0,limit-window.count);return this.normalizeResult(false,limit,remaining,window.count,retryAfter,window.resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('fixed-window',[key],[limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);const window=this.resolveWindow(state,now);const remaining=Math.max(0,limit-window.count);const retryAfter=window.count>=limit?Math.max(1,Math.ceil((window.resetTime-now)/1000)):0;return this.normalizeResult(window.count<limit,limit,remaining,window.count,retryAfter,window.resetTime);}}export default FixedWindow;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';import{QueueFullError,QueueTimeoutError}from'../utils/errors.js';export class LeakyBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.leakRate=options.leakRate;this.leakInterval=options.leakInterval;this.mode=options.mode||'policing';this.maxQueueSize=options.maxQueueSize||100;this.queueTimeoutMs=options.queueTimeoutMs||30000;this.rate=this.leakRate/this.leakInterval;this.queues=new Map();}async _checkPolicing(key,cost){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';import{QueueFullError,QueueTimeoutError}from'../utils/errors.js';export class LeakyBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.leakRate=options.leakRate;this.leakInterval=options.leakInterval;this.mode=options.mode||'policing';this.maxQueueSize=options.maxQueueSize||100;this.queueTimeoutMs=options.queueTimeoutMs||30000;this.rate=this.leakRate/this.leakInterval;this.queues=new Map();}async _checkPolicing(key,cost,context={}){const capacity=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[capacity,this.leakRate,this.leakInterval,cost,nowArg]);const normalized=this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);return{...normalized,waitMs:Number(res[6]||0)};}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let water=0;let lastLeak=now;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);lastLeak=now;}const ttlMs=Math.max(1000,Math.ceil(capacity/this.rate*2.0));if(water+cost<=capacity){water+=cost;const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(true,capacity,remaining,consumed,0,resetTime),waitMs:0};}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const deficit=water+cost-capacity;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil(water/this.rate);await this.store.set(key,{water,lastLeak},ttlMs);return{...this.normalizeResult(false,capacity,remaining,consumed,retryAfter,resetTime),waitMs};});}async consume(key,cost=1,extra={}){const policingResult=await this._checkPolicing(key,cost,extra);if(this.mode==='policing'||policingResult.allowed){return policingResult;}return this._enqueue(key,cost,policingResult,extra);}_enqueue(key,cost,immediateResult,extra={}){if(extra.req&&(extra.req.destroyed||extra.req.closed||extra.req.aborted)){return{...immediateResult,allowed:false,aborted:true};}let queue=this.queues.get(key);if(!queue){queue=[];this.queues.set(key,queue);}if(queue.length>=this.maxQueueSize){return{...immediateResult,allowed:false,error:new QueueFullError()};}const waitMs=immediateResult.waitMs;if(waitMs>this.queueTimeoutMs){return{...immediateResult,allowed:false,retryAfter:Math.max(1,Math.ceil(waitMs/1000)),error:new QueueTimeoutError()};}return new Promise(resolve=>{let timer=null;let cleanedUp=false;const item={cost,resolve,timer:null};const cleanup=()=>{if(cleanedUp)return;cleanedUp=true;if(timer){clearTimeout(timer);timer=null;}const currentQueue=this.queues.get(key);if(currentQueue){const idx=currentQueue.indexOf(item);if(idx!==-1){currentQueue.splice(idx,1);}if(currentQueue.length===0){this.queues.delete(key);}}};if(extra.req&&typeof extra.req.on==='function'){extra.req.once('close',()=>{if(!cleanedUp){cleanup();resolve({...immediateResult,allowed:false,aborted:true});}});}timer=setTimeout(async()=>{cleanup();try{const afterWaitResult=await this._checkPolicing(key,cost,extra);resolve(afterWaitResult);}catch{resolve({...immediateResult,allowed:false});}},waitMs);if(timer.unref){timer.unref();}item.timer=timer;queue.push(item);});}async getState(key,context={}){const capacity=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('leaky-bucket',[key],[capacity,this.leakRate,this.leakInterval,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let water=0;if(state&&typeof state.water==='number'){const elapsed=Math.max(0,now-state.lastLeak);water=Math.max(0,state.water-elapsed*this.rate);}const consumed=Math.ceil(water);const remaining=Math.max(0,Math.floor(capacity-water));const resetTime=now+Math.ceil(water/this.rate);const retryAfter=water>=capacity?Math.max(1,Math.ceil(1/this.rate/1000)):0;return this.normalizeResult(water<capacity,capacity,remaining,consumed,retryAfter,resetTime);}async close(){for(const[,queue]of this.queues){for(const item of queue){if(item.timer){clearTimeout(item.timer);}item.resolve({allowed:false,limit:this.capacity,remaining:0,consumed:this.capacity,retryAfter:1,resetTime:new Date()});}}this.queues.clear();await super.close();}}export default LeakyBucket;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowCounter extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowCounter extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[limit,this.windowMs,cost,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;if(estimated+cost<=limit){currCount+=cost;const consumed=Math.round(estimated+cost);const remaining=Math.max(0,limit-consumed);await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(true,limit,remaining,consumed,0,resetTime);}const consumed=Math.round(estimated);const remaining=Math.max(0,limit-consumed);let waitMs=resetTime-now;if(prevCount>0){const neededWeight=(limit-currCount-cost)/prevCount;if(neededWeight<1&&neededWeight>=0){const t=this.windowMs*(1-neededWeight);waitMs=Math.max(1,Math.ceil(t-elapsed));}}if(waitMs<=0||waitMs>this.windowMs){waitMs=Math.max(1,resetTime-now);}const retryAfter=Math.max(1,Math.ceil(waitMs/1000));await this.store.set(key,{windowStart:currWindowStart,prevCount,currCount},this.windowMs*2);return this.normalizeResult(false,limit,remaining,consumed,retryAfter,resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-counter',[key],[limit,this.windowMs,0,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const currWindowStart=Math.floor(now/this.windowMs)*this.windowMs;const resetTime=currWindowStart+this.windowMs;const state=await this.store.get(key);let prevCount=0;let currCount=0;if(state){if(state.windowStart===currWindowStart){prevCount=state.prevCount||0;currCount=state.currCount||0;}else if(currWindowStart===state.windowStart+this.windowMs){prevCount=state.currCount||0;currCount=0;}}const elapsed=now-currWindowStart;const weight=Math.max(0,1-elapsed/this.windowMs);const estimated=prevCount*weight+currCount;const consumed=Math.round(estimated);const remaining=Math.max(0,limit-consumed);const retryAfter=estimated>=limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(estimated<limit,limit,remaining,consumed,retryAfter,resetTime);}}export default SlidingWindowCounter;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowLog extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1){if(this.isRedis()){const uid=`${Date.now()}:${Math.random().toString(36).slice(2,8)}`;const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class SlidingWindowLog extends BaseAlgorithm{constructor(options,store){super(options,store);this.limit=options.limit;this.windowMs=options.windowMs;}async consume(key,cost=1,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const uid=`${Date.now()}:${Math.random().toString(36).slice(2,8)}`;const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[limit,this.windowMs,cost,uid,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}if(current+cost<=limit){log.push({time:now,cost});const consumed=current+cost;const remaining=limit-consumed;const oldestTime=log[0].time;const resetTime=oldestTime+this.windowMs;await this.store.set(key,{log},this.windowMs);return this.normalizeResult(true,limit,remaining,consumed,0,resetTime);}const deficit=current+cost-limit;let accumulated=0;let expireTime=now;for(let i=0;i<log.length;i++){accumulated+=log[i].cost;if(accumulated>=deficit){expireTime=log[i].time;break;}}const resetTime=expireTime+this.windowMs;const waitMs=Math.max(1,resetTime-now);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const remaining=Math.max(0,limit-current);await this.store.set(key,{log},this.windowMs);return this.normalizeResult(false,limit,remaining,current,retryAfter,resetTime);});}async getState(key,context={}){const limit=await this.resolveLimit(context);if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('sliding-window-log',[key],[limit,this.windowMs,0,'inspect',nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const windowStart=now-this.windowMs;const state=await this.store.get(key);let log=[];if(state&&Array.isArray(state.log)){log=state.log.filter(entry=>entry.time>windowStart);}let current=0;for(let i=0;i<log.length;i++){current+=log[i].cost;}const remaining=Math.max(0,limit-current);const oldestTime=log.length>0?log[0].time:now;const resetTime=oldestTime+this.windowMs;const retryAfter=current>=limit?Math.max(1,Math.ceil((resetTime-now)/1000)):0;return this.normalizeResult(current<limit,limit,remaining,current,retryAfter,resetTime);}}export default SlidingWindowLog;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BaseAlgorithm}from'./baseAlgorithm.js';export class TokenBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity;this.refillRate=options.refillRate;this.refillInterval=options.refillInterval;this.initialTokens=options.initialTokens!==undefined?options.initialTokens:this.capacity;this.rate=this.refillRate/this.refillInterval;}async consume(key,cost=1){if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[
|
|
1
|
+
import{BaseAlgorithm}from'./baseAlgorithm.js';export class TokenBucket extends BaseAlgorithm{constructor(options,store){super(options,store);this.capacity=options.capacity!==undefined?options.capacity:options.limit;this.refillRate=options.refillRate;this.refillInterval=options.refillInterval;this.initialTokens=options.initialTokens!==undefined?options.initialTokens:typeof this.capacity==='number'?this.capacity:undefined;this.rate=this.refillRate/this.refillInterval;}async consume(key,cost=1,context={}){const capacity=await this.resolveLimit(context);const initialTokens=this.initialTokens!==undefined?this.initialTokens:capacity;if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[capacity,this.refillRate,this.refillInterval,cost,initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}return this.withLock(key,async()=>{const now=Date.now();const state=await this.store.get(key);let tokens=initialTokens;let lastRefill=now;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(capacity,state.tokens+elapsed*this.rate);lastRefill=now;}const ttlMs=Math.max(1000,Math.ceil(capacity/this.rate*2.0));if(tokens>=cost){tokens-=cost;const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(true,capacity,remaining,consumed,0,resetTime);}const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const deficit=cost-tokens;const waitMs=Math.ceil(deficit/this.rate);const retryAfter=Math.max(1,Math.ceil(waitMs/1000));const resetTime=now+Math.ceil((capacity-tokens)/this.rate);await this.store.set(key,{tokens,lastRefill},ttlMs);return this.normalizeResult(false,capacity,remaining,consumed,retryAfter,resetTime);});}async getState(key,context={}){const capacity=await this.resolveLimit(context);const initialTokens=this.initialTokens!==undefined?this.initialTokens:capacity;if(this.isRedis()){const nowArg=typeof this.store.getNow==='function'?this.store.getNow():0;const res=await this.store.runScript('token-bucket',[key],[capacity,this.refillRate,this.refillInterval,0,initialTokens,nowArg]);return this.normalizeResult(res[0]===1,res[1],res[2],res[3],res[4],res[5]);}const now=Date.now();const state=await this.store.get(key);let tokens=initialTokens;if(state&&typeof state.tokens==='number'){const elapsed=Math.max(0,now-state.lastRefill);tokens=Math.min(capacity,state.tokens+elapsed*this.rate);}const consumed=capacity-Math.floor(tokens);const remaining=Math.floor(tokens);const resetTime=now+Math.ceil((capacity-tokens)/this.rate);const retryAfter=tokens<1?Math.max(1,Math.ceil((1-tokens)/this.rate/1000)):0;return this.normalizeResult(tokens>=1,capacity,remaining,consumed,retryAfter,resetTime);}}export default TokenBucket;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{RateLimiterRes}from'../core/rateLimiterRes.js';export function createCoreMiddleware(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('createCoreMiddleware requires a limiter with a consume() method');}let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const resolvedOptions={standardHeaders:options.standardHeaders!==undefined?options.standardHeaders:true,legacyHeaders:options.legacyHeaders!==undefined?options.legacyHeaders:false,...options,statusCode:options.statusCode!==undefined?options.statusCode:429,message:options.message!==undefined?options.message:'Too many requests, please try again later.'};const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}const key=await Promise.resolve(keyGen(req,res));let cost=1;if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}let res1;try{res1=await limiter.consume(key,cost,{req,res});}catch(err){if(err instanceof RateLimiterRes){res1=err;}else if(err&&typeof err==='object'&&Object.values(err).some(v=>v instanceof RateLimiterRes)){const resValues=Object.values(err).filter(v=>v instanceof RateLimiterRes);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,false);res1._unionRejections=err;}else{throw err;}}if(res1&&!(res1 instanceof RateLimiterRes)&&typeof res1==='object'&&Object.values(res1).every(v=>v instanceof RateLimiterRes)){const resValues=Object.values(res1);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,resValues.some(v=>v.isFirstInDuration));}const result=toMiddlewareResult(res1,limiter,cost);req.rateLimit=result;setCoreHeaders(res,result,{...resolvedOptions,limiter});if(result.allowed){return next();}return handler(req,res,next,resolvedOptions);}catch(err){if(options.failOpen){req.rateLimit={allowed:true,limit:limiter.points||60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+(limiter.msDuration||60000)),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.delete==='function'){
|
|
1
|
+
import{EventEmitter}from'node:events';import{defaultKeyGenerator,ipKeyGenerator,graphQLKeyGenerator}from'../utils/keyGenerator.js';import{evaluateSkipRule}from'../utils/skipUtils.js';import{RateLimiterRes}from'../core/rateLimiterRes.js';export function createCoreMiddleware(limiter,options={}){if(!limiter||typeof limiter.consume!=='function'){throw new TypeError('createCoreMiddleware requires a limiter with a consume() method');}let keyGen=defaultKeyGenerator;if(typeof options.keyGenerator==='function'){keyGen=options.keyGenerator;}else if(options.keyGenerator==='ip'){keyGen=ipKeyGenerator;}else if(options.keyGenerator==='graphql'){keyGen=graphQLKeyGenerator;}const rawSkip=options.skip!==undefined?options.skip:options.customSkip!==undefined?options.customSkip:options.customskip;const skipFn=typeof rawSkip==='function'?rawSkip:typeof rawSkip==='object'&&rawSkip!==null?(req,res)=>evaluateSkipRule(rawSkip,req,res,{generateKey:keyGen}):()=>false;const defaultHandler=(req,res,next,opts)=>{res.status(opts.statusCode);const msg=typeof opts.message==='function'?opts.message(req,res):opts.message;if(typeof msg==='object'){res.json(msg);}else{res.send(msg);}};const handler=options.handler||defaultHandler;const resolvedOptions={standardHeaders:options.standardHeaders!==undefined?options.standardHeaders:true,legacyHeaders:options.legacyHeaders!==undefined?options.legacyHeaders:false,...options,statusCode:options.statusCode!==undefined?options.statusCode:429,message:options.message!==undefined?options.message:'Too many requests, please try again later.'};const middleware=async(req,res,next)=>{try{const shouldSkip=await Promise.resolve(skipFn(req,res));if(shouldSkip){return next();}const key=await Promise.resolve(keyGen(req,res));let cost=1;if(typeof options.cost==='function'){cost=await Promise.resolve(options.cost(req,res));}else if(typeof options.cost==='number'){cost=options.cost;}if(!Number.isFinite(cost)||cost<=0){cost=1;}let res1;try{res1=await limiter.consume(key,cost,{req,res});}catch(err){if(err instanceof RateLimiterRes){res1=err;}else if(err&&typeof err==='object'&&Object.values(err).some(v=>v instanceof RateLimiterRes)){const resValues=Object.values(err).filter(v=>v instanceof RateLimiterRes);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,false);res1._unionRejections=err;}else{throw err;}}if(res1&&!(res1 instanceof RateLimiterRes)&&typeof res1==='object'&&Object.values(res1).every(v=>v instanceof RateLimiterRes)){const resValues=Object.values(res1);const maxMsBeforeNext=Math.max(...resValues.map(v=>v.msBeforeNext||0));const minRemaining=Math.min(...resValues.map(v=>v.remainingPoints||0));const maxConsumed=Math.max(...resValues.map(v=>v.consumedPoints||0));res1=new RateLimiterRes(minRemaining,maxMsBeforeNext,maxConsumed,resValues.some(v=>v.isFirstInDuration));}const result=toMiddlewareResult(res1,limiter,cost);req.rateLimit=result;setCoreHeaders(res,result,{...resolvedOptions,limiter});if(result.allowed){middleware.emit('consumed',req,res,result);return next();}middleware.emit('limitReached',req,res,result);middleware.emit('blocked',req,res,result);return handler(req,res,next,resolvedOptions);}catch(err){if(middleware.listenerCount('error')>0){middleware.emit('error',err,req,res);}if(options.failOpen){req.rateLimit={allowed:true,limit:limiter.points||60,remaining:1,consumed:0,retryAfter:0,resetTime:new Date(Date.now()+(limiter.msDuration||60000)),failedOpen:true};if(typeof options.onError==='function'){options.onError(err,req,res,next);}return next();}if(typeof options.onError==='function'){return options.onError(err,req,res,next);}return next(err);}};Object.setPrototypeOf(middleware,EventEmitter.prototype);EventEmitter.call(middleware);middleware.resetKey=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;let res=false;if(typeof limiter.delete==='function'){res=await limiter.delete(k);}middleware.emit('reset',k);return res;};middleware.getState=async keyOrReq=>{const k=typeof keyOrReq==='object'&&keyOrReq!==null?keyGen(keyOrReq):keyOrReq;if(typeof limiter.get==='function'){return limiter.get(k);}return null;};middleware.limiter=limiter;return middleware;}function toMiddlewareResult(res,limiter,cost){const limit=typeof limiter.points==='number'?limiter.points:typeof res.consumedPoints==='number'&&typeof res.remainingPoints==='number'?res.consumedPoints+res.remainingPoints:60;const consumed=typeof res.consumedPoints==='number'?res.consumedPoints:cost;const msBeforeNext=typeof res.msBeforeNext==='number'?res.msBeforeNext:0;const rawReset=Number.isFinite(msBeforeNext)&&msBeforeNext>0?Date.now()+Math.min(msBeforeNext,31536000000):Date.now();const resetTime=new Date(rawReset);return{allowed:consumed<=limit,limit,remaining:Math.max(0,limit-consumed),consumed,retryAfter:msBeforeNext>0?Math.max(1,Math.ceil(msBeforeNext/1000)):0,resetTime:Number.isNaN(resetTime.getTime())?new Date(Date.now()+3600000):resetTime,cost};}function setCoreHeaders(res,result,options={}){if(!res||typeof res.setHeader!=='function'||res.headersSent){return;}const rawDiff=result.resetTime?(result.resetTime.getTime()-Date.now())/1000:0;const resetSeconds=Number.isFinite(rawDiff)?Math.max(0,Math.ceil(rawDiff)):0;if(options.standardHeaders){res.setHeader('RateLimit-Limit',String(result.limit));res.setHeader('RateLimit-Remaining',String(result.remaining));res.setHeader('RateLimit-Reset',String(resetSeconds));const policyWindowMs=options.windowMs||options.limiter&&(options.limiter.msDuration||(options.limiter.duration?options.limiter.duration*1000:null))||60000;if(options.standardHeaders!=='draft-6'){const windowSec=Math.ceil(policyWindowMs/1000);res.setHeader('RateLimit-Policy',`${result.limit};w=${windowSec}`);}}if(options.legacyHeaders){res.setHeader('X-RateLimit-Limit',String(result.limit));res.setHeader('X-RateLimit-Remaining',String(result.remaining));res.setHeader('X-RateLimit-Reset',String(resetSeconds));}if(!result.allowed){const retryAfter=Math.max(1,Number(result.retryAfter)||resetSeconds||1);res.setHeader('Retry-After',String(retryAfter));}}export default createCoreMiddleware;
|
|
@@ -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.
|
|
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';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){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(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=()=>algorithm.close();middleware.algorithm=algorithm;middleware.store=algorithm.store;return middleware;}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { EventEmitter } from 'node:events';
|
|
2
3
|
|
|
3
4
|
export interface RateLimitInfo {
|
|
4
5
|
allowed: boolean;
|
|
@@ -25,6 +26,7 @@ export type KeyGenerator =
|
|
|
25
26
|
| ((req: Request, res: Response) => string | Promise<string>);
|
|
26
27
|
export type SkipFunction = (req: Request, res: Response) => boolean | Promise<boolean>;
|
|
27
28
|
export type CostFunction = (req: Request, res: Response) => number | Promise<number>;
|
|
29
|
+
export type DynamicLimit = number | ((req: Request, res: Response) => number | Promise<number>);
|
|
28
30
|
export type RateLimitHandler = (
|
|
29
31
|
req: Request,
|
|
30
32
|
res: Response,
|
|
@@ -93,30 +95,30 @@ export interface BaseOptions {
|
|
|
93
95
|
|
|
94
96
|
export interface FixedWindowOptions extends BaseOptions {
|
|
95
97
|
algorithm?: 'fixed-window';
|
|
96
|
-
limit?:
|
|
97
|
-
max?:
|
|
98
|
+
limit?: DynamicLimit;
|
|
99
|
+
max?: DynamicLimit;
|
|
98
100
|
windowMs?: number;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export interface SlidingWindowLogOptions extends BaseOptions {
|
|
102
104
|
algorithm: 'sliding-window-log';
|
|
103
|
-
limit?:
|
|
104
|
-
max?:
|
|
105
|
+
limit?: DynamicLimit;
|
|
106
|
+
max?: DynamicLimit;
|
|
105
107
|
windowMs?: number;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
export interface SlidingWindowCounterOptions extends BaseOptions {
|
|
109
111
|
algorithm: 'sliding-window-counter';
|
|
110
|
-
limit?:
|
|
111
|
-
max?:
|
|
112
|
+
limit?: DynamicLimit;
|
|
113
|
+
max?: DynamicLimit;
|
|
112
114
|
windowMs?: number;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export interface TokenBucketOptions extends BaseOptions {
|
|
116
118
|
algorithm: 'token-bucket';
|
|
117
|
-
capacity?:
|
|
118
|
-
limit?:
|
|
119
|
-
max?:
|
|
119
|
+
capacity?: DynamicLimit;
|
|
120
|
+
limit?: DynamicLimit;
|
|
121
|
+
max?: DynamicLimit;
|
|
120
122
|
refillRate?: number;
|
|
121
123
|
refillInterval?: number;
|
|
122
124
|
initialTokens?: number;
|
|
@@ -124,9 +126,9 @@ export interface TokenBucketOptions extends BaseOptions {
|
|
|
124
126
|
|
|
125
127
|
export interface LeakyBucketOptions extends BaseOptions {
|
|
126
128
|
algorithm: 'leaky-bucket';
|
|
127
|
-
capacity?:
|
|
128
|
-
limit?:
|
|
129
|
-
max?:
|
|
129
|
+
capacity?: DynamicLimit;
|
|
130
|
+
limit?: DynamicLimit;
|
|
131
|
+
max?: DynamicLimit;
|
|
130
132
|
leakRate?: number;
|
|
131
133
|
leakInterval?: number;
|
|
132
134
|
mode?: 'policing' | 'shaping';
|
|
@@ -141,7 +143,7 @@ export type RateLimitOptions =
|
|
|
141
143
|
| TokenBucketOptions
|
|
142
144
|
| LeakyBucketOptions;
|
|
143
145
|
|
|
144
|
-
export interface RateLimitMiddleware {
|
|
146
|
+
export interface RateLimitMiddleware extends EventEmitter {
|
|
145
147
|
(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
146
148
|
resetKey(key: string): Promise<boolean>;
|
|
147
149
|
getState(key: string): Promise<RateLimitInfo>;
|
|
@@ -195,6 +197,7 @@ export declare class RedisStore extends BaseStore {
|
|
|
195
197
|
|
|
196
198
|
export declare class BaseAlgorithm {
|
|
197
199
|
constructor(options: any, store: BaseStore);
|
|
200
|
+
resolveLimit(context?: { req?: Request; res?: Response }): Promise<number>;
|
|
198
201
|
isRedis(): boolean;
|
|
199
202
|
normalizeResult(
|
|
200
203
|
allowed: boolean,
|
|
@@ -716,7 +719,7 @@ export interface CoreMiddlewareOptions {
|
|
|
716
719
|
windowMs?: number;
|
|
717
720
|
}
|
|
718
721
|
|
|
719
|
-
export interface CoreMiddleware {
|
|
722
|
+
export interface CoreMiddleware extends EventEmitter {
|
|
720
723
|
(req: Request, res: Response, next: NextFunction): Promise<any>;
|
|
721
724
|
resetKey(keyOrReq: string | Request): Promise<boolean>;
|
|
722
725
|
getState(keyOrReq: string | Request): Promise<RateLimiterRes | null>;
|
package/dist/utils/validation.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ConfigurationError}from'./errors.js';export const VALID_ALGORITHMS=new Set(['fixed-window','sliding-window-log','sliding-window-counter','token-bucket','leaky-bucket']);export function validateOptions(rawOptions={}){const options={...rawOptions};options.algorithm=options.algorithm||'fixed-window';if(!VALID_ALGORITHMS.has(options.algorithm)){throw new ConfigurationError(`Invalid algorithm: "${options.algorithm}". Supported: ${Array.from(VALID_ALGORITHMS).join(', ')}`);}if(typeof options.store==='string'){const s=options.store.toLowerCase();if(s!=='memory'&&s!=='redis'&&s!=='redis-cluster'&&s!=='cluster'){throw new ConfigurationError(`Invalid store: "${options.store}". Supported string stores: "memory", "redis", "redis-cluster", "cluster"`);}}else if(options.store&&typeof options.store!=='object'){throw new ConfigurationError('Store must be "memory", "redis", "redis-cluster", "cluster", or a custom store instance');}else if(!options.store){options.store='memory';}const rawKeyPrefix=options.keyPrefix!==undefined?options.keyPrefix:options.prefix!==undefined?options.prefix:options.storePrefix!==undefined?options.storePrefix:options._storePrefix;if(rawKeyPrefix!==undefined&&typeof rawKeyPrefix!=='string'){throw new ConfigurationError('keyPrefix must be a string');}options.keyPrefix=typeof rawKeyPrefix==='string'?rawKeyPrefix:'rl:';if(options.cost!==undefined){if(typeof options.cost!=='function'&&(typeof options.cost!=='number'||options.cost<=0||!Number.isFinite(options.cost))){throw new ConfigurationError('Cost must be a positive number or a function returning a positive number');}}else{options.cost=1;}if(options.keyGenerator!==undefined){if(typeof options.keyGenerator!=='function'&&options.keyGenerator!=='ip'&&options.keyGenerator!=='ip-endpoint'&&options.keyGenerator!=='graphql'){throw new ConfigurationError('keyGenerator must be a function or one of "ip", "ip-endpoint", "graphql"');}}if(options.skip===undefined){if(options.customSkip!==undefined){options.skip=options.customSkip;}else if(options.customskip!==undefined){options.skip=options.customskip;}}if(options.skip!==undefined&&typeof options.skip!=='function'&&(typeof options.skip!=='object'||options.skip===null)){throw new ConfigurationError('skip must be a function, object, or array');}if(options.handler!==undefined&&typeof options.handler!=='function'){throw new ConfigurationError('handler must be a function');}if(options.onError!==undefined&&typeof options.onError!=='function'){throw new ConfigurationError('onError must be a function');}options.statusCode=options.statusCode!==undefined?options.statusCode:429;if(!Number.isInteger(options.statusCode)||options.statusCode<100||options.statusCode>599){throw new ConfigurationError('statusCode must be a valid HTTP status code (100-599)');}if(options.standardHeaders!==undefined&&typeof options.standardHeaders!=='boolean'&&options.standardHeaders!=='draft-6'&&options.standardHeaders!=='draft-7'){throw new ConfigurationError('standardHeaders must be a boolean or "draft-6" or "draft-7"');}options.standardHeaders=options.standardHeaders!==undefined?options.standardHeaders:true;options.legacyHeaders=options.legacyHeaders!==undefined?Boolean(options.legacyHeaders):false;options.failOpen=Boolean(options.failOpen||options.passOnStoreError);options.message=options.message!==undefined?options.message:'Too many requests, please try again later.';if(options.maxEntries!==undefined){if(!Number.isInteger(options.maxEntries)||options.maxEntries<=0){throw new ConfigurationError('maxEntries must be a positive integer');}}if(options.cleanupIntervalMs!==undefined){if(typeof options.cleanupIntervalMs!=='number'||options.cleanupIntervalMs<=0||!Number.isFinite(options.cleanupIntervalMs)){throw new ConfigurationError('cleanupIntervalMs must be a positive number');}}if(options.useRedisTime!==undefined&&typeof options.useRedisTime!=='boolean'){throw new ConfigurationError('useRedisTime must be a boolean');}if(options.algorithm==='fixed-window'||options.algorithm==='sliding-window-log'||options.algorithm==='sliding-window-counter'){options.windowMs=options.windowMs!==undefined?options.windowMs:60000;if(typeof options.windowMs!=='number'||options.windowMs<=0||!Number.isFinite(options.windowMs)){throw new ConfigurationError(`${options.algorithm} requires "windowMs" to be a positive number`);}options.limit=options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.limit!=='number'||options.limit<=0||!Number.isFinite(options.limit)){throw new ConfigurationError(`${options.algorithm} requires "limit" to be a positive number`);}}if(options.algorithm==='token-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity)){throw new ConfigurationError('token-bucket requires "capacity" to be a positive number');}options.refillRate=options.refillRate!==undefined?options.refillRate:1;if(typeof options.refillRate!=='number'||options.refillRate<=0||!Number.isFinite(options.refillRate)){throw new ConfigurationError('token-bucket requires "refillRate" to be a positive number');}options.refillInterval=options.refillInterval!==undefined?options.refillInterval:1000;if(typeof options.refillInterval!=='number'||options.refillInterval<=0||!Number.isFinite(options.refillInterval)){throw new ConfigurationError('token-bucket requires "refillInterval" to be a positive number in milliseconds');}if(options.initialTokens!==undefined){if(typeof options.initialTokens!=='number'||options.initialTokens<0||!Number.isFinite(options.initialTokens)){throw new ConfigurationError('token-bucket "initialTokens" must be a non-negative number');}}else{options.initialTokens=options.capacity;}}if(options.algorithm==='leaky-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity)){throw new ConfigurationError('leaky-bucket requires "capacity" to be a positive number');}options.leakRate=options.leakRate!==undefined?options.leakRate:1;if(typeof options.leakRate!=='number'||options.leakRate<=0||!Number.isFinite(options.leakRate)){throw new ConfigurationError('leaky-bucket requires "leakRate" to be a positive number');}options.leakInterval=options.leakInterval!==undefined?options.leakInterval:1000;if(typeof options.leakInterval!=='number'||options.leakInterval<=0||!Number.isFinite(options.leakInterval)){throw new ConfigurationError('leaky-bucket requires "leakInterval" to be a positive number in milliseconds');}options.mode=options.mode||'policing';if(options.mode!=='policing'&&options.mode!=='shaping'){throw new ConfigurationError('leaky-bucket "mode" must be either "policing" or "shaping"');}if(options.mode==='shaping'){options.maxQueueSize=options.maxQueueSize!==undefined?options.maxQueueSize:100;if(!Number.isInteger(options.maxQueueSize)||options.maxQueueSize<=0){throw new ConfigurationError('leaky-bucket in shaping mode requires "maxQueueSize" to be a positive integer');}options.queueTimeoutMs=options.queueTimeoutMs!==undefined?options.queueTimeoutMs:30000;if(typeof options.queueTimeoutMs!=='number'||options.queueTimeoutMs<=0||!Number.isFinite(options.queueTimeoutMs)){throw new ConfigurationError('leaky-bucket in shaping mode requires "queueTimeoutMs" to be a positive number');}}}return options;}
|
|
1
|
+
import{ConfigurationError}from'./errors.js';export const VALID_ALGORITHMS=new Set(['fixed-window','sliding-window-log','sliding-window-counter','token-bucket','leaky-bucket']);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!=='function'&&(typeof options.limit!=='number'||options.limit<=0||!Number.isFinite(options.limit))){throw new ConfigurationError(`${options.algorithm} requires "limit" to be a positive number or a function`);}}if(options.algorithm==='token-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='function'&&(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity))){throw new ConfigurationError('token-bucket requires "capacity" to be a positive number or a function');}options.refillRate=options.refillRate!==undefined?options.refillRate:1;if(typeof options.refillRate!=='number'||options.refillRate<=0||!Number.isFinite(options.refillRate)){throw new ConfigurationError('token-bucket requires "refillRate" to be a positive number');}options.refillInterval=options.refillInterval!==undefined?options.refillInterval:1000;if(typeof options.refillInterval!=='number'||options.refillInterval<=0||!Number.isFinite(options.refillInterval)){throw new ConfigurationError('token-bucket requires "refillInterval" to be a positive number in milliseconds');}if(options.initialTokens!==undefined){if(typeof options.initialTokens!=='number'||options.initialTokens<0||!Number.isFinite(options.initialTokens)){throw new ConfigurationError('token-bucket "initialTokens" must be a non-negative number');}}else if(typeof options.capacity==='number'){options.initialTokens=options.capacity;}}if(options.algorithm==='leaky-bucket'){options.capacity=options.capacity!==undefined?options.capacity:options.limit!==undefined?options.limit:options.max!==undefined?options.max:60;if(typeof options.capacity!=='function'&&(typeof options.capacity!=='number'||options.capacity<=0||!Number.isFinite(options.capacity))){throw new ConfigurationError('leaky-bucket requires "capacity" to be a positive number or a function');}options.leakRate=options.leakRate!==undefined?options.leakRate:1;if(typeof options.leakRate!=='number'||options.leakRate<=0||!Number.isFinite(options.leakRate)){throw new ConfigurationError('leaky-bucket requires "leakRate" to be a positive number');}options.leakInterval=options.leakInterval!==undefined?options.leakInterval:1000;if(typeof options.leakInterval!=='number'||options.leakInterval<=0||!Number.isFinite(options.leakInterval)){throw new ConfigurationError('leaky-bucket requires "leakInterval" to be a positive number in milliseconds');}options.mode=options.mode||'policing';if(options.mode!=='policing'&&options.mode!=='shaping'){throw new ConfigurationError('leaky-bucket "mode" must be either "policing" or "shaping"');}if(options.mode==='shaping'){options.maxQueueSize=options.maxQueueSize!==undefined?options.maxQueueSize:100;if(!Number.isInteger(options.maxQueueSize)||options.maxQueueSize<=0){throw new ConfigurationError('leaky-bucket in shaping mode requires "maxQueueSize" to be a positive integer');}options.queueTimeoutMs=options.queueTimeoutMs!==undefined?options.queueTimeoutMs:30000;if(typeof options.queueTimeoutMs!=='number'||options.queueTimeoutMs<=0||!Number.isFinite(options.queueTimeoutMs)){throw new ConfigurationError('leaky-bucket in shaping mode requires "queueTimeoutMs" to be a positive number');}}}return options;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-adaptive-rate-limiter",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.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",
|