nextlimiter 1.0.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 +438 -0
- package/package.json +70 -0
- package/src/analytics/tracker.js +107 -0
- package/src/core/config.js +127 -0
- package/src/core/limiter.js +229 -0
- package/src/core/result.js +49 -0
- package/src/index.js +106 -0
- package/src/middleware/headers.js +29 -0
- package/src/smart/detector.js +117 -0
- package/src/store/memoryStore.js +122 -0
- package/src/strategies/fixedWindow.js +40 -0
- package/src/strategies/slidingWindow.js +78 -0
- package/src/strategies/tokenBucket.js +85 -0
- package/src/utils/keyGenerator.js +95 -0
- package/src/utils/logger.js +76 -0
- package/types/index.d.ts +246 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in SaaS plan definitions.
|
|
5
|
+
* These can be overridden by passing `plans` in createLimiter options.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_PLANS = {
|
|
8
|
+
free: {
|
|
9
|
+
windowMs: 60_000, // 1 minute
|
|
10
|
+
max: 60, // 60 req/min
|
|
11
|
+
burstMax: 10, // allow short burst of 10 extra
|
|
12
|
+
description: 'Free tier — 60 requests per minute',
|
|
13
|
+
},
|
|
14
|
+
pro: {
|
|
15
|
+
windowMs: 60_000,
|
|
16
|
+
max: 600,
|
|
17
|
+
burstMax: 100,
|
|
18
|
+
description: 'Pro tier — 600 requests per minute',
|
|
19
|
+
},
|
|
20
|
+
enterprise: {
|
|
21
|
+
windowMs: 60_000,
|
|
22
|
+
max: 6000,
|
|
23
|
+
burstMax: 1000,
|
|
24
|
+
description: 'Enterprise tier — 6000 requests per minute',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Named presets for quick configuration.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* createLimiter({ preset: 'strict' })
|
|
33
|
+
*/
|
|
34
|
+
const PRESETS = {
|
|
35
|
+
strict: {
|
|
36
|
+
windowMs: 60_000,
|
|
37
|
+
max: 30,
|
|
38
|
+
strategy: 'sliding-window',
|
|
39
|
+
smart: true,
|
|
40
|
+
},
|
|
41
|
+
relaxed: {
|
|
42
|
+
windowMs: 60_000,
|
|
43
|
+
max: 300,
|
|
44
|
+
strategy: 'token-bucket',
|
|
45
|
+
smart: false,
|
|
46
|
+
},
|
|
47
|
+
api: {
|
|
48
|
+
windowMs: 60_000,
|
|
49
|
+
max: 100,
|
|
50
|
+
strategy: 'sliding-window',
|
|
51
|
+
smart: true,
|
|
52
|
+
keyBy: 'api-key',
|
|
53
|
+
},
|
|
54
|
+
auth: {
|
|
55
|
+
windowMs: 15 * 60_000, // 15 minutes
|
|
56
|
+
max: 10, // only 10 attempts per 15 min
|
|
57
|
+
strategy: 'fixed-window',
|
|
58
|
+
smart: true,
|
|
59
|
+
message: 'Too many authentication attempts. Please try again in 15 minutes.',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default configuration merged with user options.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_CONFIG = {
|
|
67
|
+
windowMs: 60_000,
|
|
68
|
+
max: 100,
|
|
69
|
+
strategy: 'sliding-window',
|
|
70
|
+
keyBy: 'ip',
|
|
71
|
+
keyPrefix: 'nexlimit:',
|
|
72
|
+
message: 'Too many requests, please try again later.',
|
|
73
|
+
statusCode: 429,
|
|
74
|
+
headers: true,
|
|
75
|
+
smart: false,
|
|
76
|
+
smartThreshold: 2.0, // trigger smart limiting at 2x normal rate
|
|
77
|
+
smartCooldownMs: 60_000, // how long smart penalty lasts
|
|
78
|
+
smartPenaltyFactor: 0.5, // reduce limit to 50% for suspicious keys
|
|
79
|
+
logging: false,
|
|
80
|
+
logPrefix: '[NexLimit]',
|
|
81
|
+
skip: null, // (req) => bool — skip rate limiting
|
|
82
|
+
onLimitReached: null, // (req, res, info) => void
|
|
83
|
+
store: null, // custom store instance
|
|
84
|
+
plan: null, // 'free' | 'pro' | 'enterprise' | null
|
|
85
|
+
plans: DEFAULT_PLANS, // plan map — override to define custom plans
|
|
86
|
+
preset: null, // 'strict' | 'relaxed' | 'api' | 'auth'
|
|
87
|
+
keyGenerator: null, // (req) => string — custom key fn
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve final config by merging preset → plan → user options → defaults.
|
|
92
|
+
* @param {object} userOptions
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
function resolveConfig(userOptions = {}) {
|
|
96
|
+
let base = { ...DEFAULT_CONFIG };
|
|
97
|
+
|
|
98
|
+
// Apply named preset first (lowest priority)
|
|
99
|
+
if (userOptions.preset && PRESETS[userOptions.preset]) {
|
|
100
|
+
base = { ...base, ...PRESETS[userOptions.preset] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Merge user options
|
|
104
|
+
base = { ...base, ...userOptions };
|
|
105
|
+
|
|
106
|
+
// Apply plan limits (overrides windowMs and max if plan is set)
|
|
107
|
+
if (base.plan) {
|
|
108
|
+
const planDefs = base.plans || DEFAULT_PLANS;
|
|
109
|
+
const planCfg = planDefs[base.plan];
|
|
110
|
+
if (!planCfg) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`[NexLimit] Unknown plan "${base.plan}". Available: ${Object.keys(planDefs).join(', ')}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
base.windowMs = planCfg.windowMs;
|
|
116
|
+
base.max = planCfg.max;
|
|
117
|
+
base._burstMax = planCfg.burstMax;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate
|
|
121
|
+
if (base.max <= 0) throw new Error('[NexLimit] config.max must be greater than 0');
|
|
122
|
+
if (base.windowMs <= 0) throw new Error('[NexLimit] config.windowMs must be greater than 0');
|
|
123
|
+
|
|
124
|
+
return base;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { DEFAULT_CONFIG, DEFAULT_PLANS, PRESETS, resolveConfig };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveConfig } = require('./config');
|
|
4
|
+
const { MemoryStore } = require('../store/memoryStore');
|
|
5
|
+
const { fixedWindowCheck } = require('../strategies/fixedWindow');
|
|
6
|
+
const { slidingWindowCheck } = require('../strategies/slidingWindow');
|
|
7
|
+
const { tokenBucketCheck } = require('../strategies/tokenBucket');
|
|
8
|
+
const { resolveKeyGenerator } = require('../utils/keyGenerator');
|
|
9
|
+
const { createLogger } = require('../utils/logger');
|
|
10
|
+
const { AnalyticsTracker } = require('../analytics/tracker');
|
|
11
|
+
const { SmartDetector } = require('../smart/detector');
|
|
12
|
+
const { setHeaders } = require('../middleware/headers');
|
|
13
|
+
|
|
14
|
+
const STRATEGY_MAP = {
|
|
15
|
+
'fixed-window': fixedWindowCheck,
|
|
16
|
+
'sliding-window': slidingWindowCheck,
|
|
17
|
+
'token-bucket': tokenBucketCheck,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* NexLimit — the main Limiter class.
|
|
22
|
+
*
|
|
23
|
+
* Instantiate via `createLimiter(options)` or `autoLimit()`.
|
|
24
|
+
* Do not call `new Limiter()` directly in application code.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const limiter = createLimiter({ windowMs: 60_000, max: 100 });
|
|
28
|
+
* app.use(limiter.middleware());
|
|
29
|
+
*
|
|
30
|
+
* // Programmatic check
|
|
31
|
+
* const result = await limiter.check('user:42');
|
|
32
|
+
*/
|
|
33
|
+
class Limiter {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} options - NexLimit configuration (see config.js for defaults)
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this._config = resolveConfig(options);
|
|
39
|
+
|
|
40
|
+
// Storage backend
|
|
41
|
+
this._store = this._config.store || new MemoryStore();
|
|
42
|
+
|
|
43
|
+
// Strategy function
|
|
44
|
+
const strategyFn = STRATEGY_MAP[this._config.strategy];
|
|
45
|
+
if (!strategyFn) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[NexLimit] Unknown strategy "${this._config.strategy}". ` +
|
|
48
|
+
`Valid options: ${Object.keys(STRATEGY_MAP).join(', ')}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
this._strategy = strategyFn;
|
|
52
|
+
|
|
53
|
+
// Key generator
|
|
54
|
+
const keyByFn = this._config.keyGenerator || resolveKeyGenerator(this._config.keyBy);
|
|
55
|
+
this._keyGenerator = keyByFn;
|
|
56
|
+
|
|
57
|
+
// Logger
|
|
58
|
+
this._log = createLogger(this._config.logPrefix, this._config.logging);
|
|
59
|
+
|
|
60
|
+
// Analytics
|
|
61
|
+
this._analytics = new AnalyticsTracker();
|
|
62
|
+
|
|
63
|
+
// Smart detector
|
|
64
|
+
this._smart = this._config.smart ? new SmartDetector(this._config) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns an Express middleware function.
|
|
71
|
+
*
|
|
72
|
+
* @returns {function} (req, res, next) => void
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* app.use('/api', limiter.middleware());
|
|
76
|
+
*/
|
|
77
|
+
middleware() {
|
|
78
|
+
return async (req, res, next) => {
|
|
79
|
+
try {
|
|
80
|
+
// Skip check if skip() returns true
|
|
81
|
+
if (this._config.skip && this._config.skip(req)) {
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rawKey = this._keyGenerator(req);
|
|
86
|
+
const key = `${this._config.keyPrefix}${rawKey}`;
|
|
87
|
+
|
|
88
|
+
const result = await this._runCheck(key);
|
|
89
|
+
|
|
90
|
+
// Record analytics
|
|
91
|
+
this._analytics.record(key, result.allowed);
|
|
92
|
+
|
|
93
|
+
// Set headers
|
|
94
|
+
if (this._config.headers) {
|
|
95
|
+
setHeaders(res, result, !result.allowed);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!result.allowed) {
|
|
99
|
+
this._log.blocked(
|
|
100
|
+
rawKey,
|
|
101
|
+
result.limit - result.remaining,
|
|
102
|
+
result.limit,
|
|
103
|
+
result.strategy,
|
|
104
|
+
result.smartBlocked
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Custom handler
|
|
108
|
+
if (this._config.onLimitReached) {
|
|
109
|
+
return this._config.onLimitReached(req, res, result);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return res.status(this._config.statusCode).json({
|
|
113
|
+
error: 'Too Many Requests',
|
|
114
|
+
message: this._config.message,
|
|
115
|
+
retryAfter: result.retryAfter,
|
|
116
|
+
limit: result.limit,
|
|
117
|
+
resetAt: new Date(result.resetAt).toISOString(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
next();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Never let rate limiter errors take down the application
|
|
124
|
+
this._log.warn(`Error in rate limiter: ${err.message}. Failing open.`);
|
|
125
|
+
next();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Programmatic rate limit check — use outside of HTTP middleware context.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} key - The rate limit key to check
|
|
134
|
+
* @returns {Promise<import('../core/result').RateLimitResult>}
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* const result = await limiter.check('user:42');
|
|
138
|
+
* if (!result.allowed) throw new Error('Rate limit exceeded');
|
|
139
|
+
*/
|
|
140
|
+
async check(key) {
|
|
141
|
+
const fullKey = `${this._config.keyPrefix}${key}`;
|
|
142
|
+
const result = await this._runCheck(fullKey);
|
|
143
|
+
this._analytics.record(fullKey, result.allowed);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Manually reset the rate limit for a specific key.
|
|
149
|
+
* Useful after a user upgrades their plan, or for admin overrides.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} key
|
|
152
|
+
*/
|
|
153
|
+
async reset(key) {
|
|
154
|
+
const fullKey = `${this._config.keyPrefix}${key}`;
|
|
155
|
+
await this._store.delete(fullKey);
|
|
156
|
+
if (this._smart) this._smart.reset();
|
|
157
|
+
this._log.info(`Reset key: ${fullKey}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get analytics snapshot.
|
|
162
|
+
*
|
|
163
|
+
* @returns {object}
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const stats = limiter.getStats();
|
|
167
|
+
* console.log(stats.totalRequests, stats.blockRate);
|
|
168
|
+
*/
|
|
169
|
+
getStats() {
|
|
170
|
+
const stats = this._analytics.getStats();
|
|
171
|
+
|
|
172
|
+
if (this._smart) {
|
|
173
|
+
stats.smartLimiting = {
|
|
174
|
+
enabled: true,
|
|
175
|
+
penalizedKeys: this._smart.getPenalizedKeys(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
stats.config = {
|
|
180
|
+
strategy: this._config.strategy,
|
|
181
|
+
windowMs: this._config.windowMs,
|
|
182
|
+
max: this._config.max,
|
|
183
|
+
keyBy: this._config.keyBy,
|
|
184
|
+
plan: this._config.plan || 'custom',
|
|
185
|
+
smart: this._config.smart,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return stats;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reset all analytics counters.
|
|
193
|
+
*/
|
|
194
|
+
resetStats() {
|
|
195
|
+
this._analytics.reset();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Expose the resolved configuration (read-only).
|
|
200
|
+
* @returns {object}
|
|
201
|
+
*/
|
|
202
|
+
get config() {
|
|
203
|
+
return Object.freeze({ ...this._config });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the strategy check, applying smart penalty if enabled.
|
|
210
|
+
* @param {string} key - Fully-prefixed key
|
|
211
|
+
* @returns {import('../core/result').RateLimitResult}
|
|
212
|
+
*/
|
|
213
|
+
_runCheck(key) {
|
|
214
|
+
let effectiveConfig = this._config;
|
|
215
|
+
|
|
216
|
+
// Apply smart penalty if relevant
|
|
217
|
+
if (this._smart) {
|
|
218
|
+
const { penalized, effectiveMax } = this._smart.check(key);
|
|
219
|
+
if (penalized) {
|
|
220
|
+
// Create a shallow config override with reduced max
|
|
221
|
+
effectiveConfig = { ...this._config, max: effectiveMax };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return this._strategy(key, effectiveConfig, this._store);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { Limiter };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Immutable result object returned by every strategy check.
|
|
5
|
+
* Passed to middleware for header generation and response decisions.
|
|
6
|
+
*/
|
|
7
|
+
class RateLimitResult {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} params
|
|
10
|
+
* @param {boolean} params.allowed - Whether the request should proceed
|
|
11
|
+
* @param {number} params.limit - Max requests allowed in window
|
|
12
|
+
* @param {number} params.remaining - Remaining requests in current window
|
|
13
|
+
* @param {number} params.resetAt - Unix timestamp (ms) when window resets
|
|
14
|
+
* @param {number} params.retryAfter - Seconds until next request allowed (0 if allowed)
|
|
15
|
+
* @param {string} params.key - The resolved rate limit key
|
|
16
|
+
* @param {string} params.strategy - Strategy name used
|
|
17
|
+
* @param {boolean} params.smartBlocked - True if blocked by smart limiting
|
|
18
|
+
*/
|
|
19
|
+
constructor({ allowed, limit, remaining, resetAt, retryAfter = 0, key, strategy, smartBlocked = false }) {
|
|
20
|
+
this.allowed = allowed;
|
|
21
|
+
this.limit = limit;
|
|
22
|
+
this.remaining = Math.max(0, remaining);
|
|
23
|
+
this.resetAt = resetAt;
|
|
24
|
+
this.retryAfter = retryAfter;
|
|
25
|
+
this.key = key;
|
|
26
|
+
this.strategy = strategy;
|
|
27
|
+
this.smartBlocked = smartBlocked;
|
|
28
|
+
|
|
29
|
+
// Freeze so nothing accidentally mutates the result downstream
|
|
30
|
+
Object.freeze(this);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Seconds until window resets (for Retry-After header) */
|
|
34
|
+
get retryAfterSeconds() {
|
|
35
|
+
return Math.ceil((this.resetAt - Date.now()) / 1000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toJSON() {
|
|
39
|
+
return {
|
|
40
|
+
allowed: this.allowed,
|
|
41
|
+
limit: this.limit,
|
|
42
|
+
remaining: this.remaining,
|
|
43
|
+
resetAt: this.resetAt,
|
|
44
|
+
retryAfter: this.retryAfter,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { RateLimitResult };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Limiter } = require('./core/limiter');
|
|
4
|
+
const { PRESETS, DEFAULT_PLANS } = require('./core/config');
|
|
5
|
+
const { MemoryStore } = require('./store/memoryStore');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a fully configured rate limiter instance.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {number} [options.windowMs=60000] - Time window in ms
|
|
12
|
+
* @param {number} [options.max=100] - Max requests per window
|
|
13
|
+
* @param {string} [options.strategy='sliding-window'] - 'fixed-window' | 'sliding-window' | 'token-bucket'
|
|
14
|
+
* @param {string} [options.keyBy='ip'] - 'ip' | 'user-id' | 'api-key' | function
|
|
15
|
+
* @param {string} [options.plan] - 'free' | 'pro' | 'enterprise'
|
|
16
|
+
* @param {string} [options.preset] - 'strict' | 'relaxed' | 'api' | 'auth'
|
|
17
|
+
* @param {boolean} [options.smart=false] - Enable behavior-based smart limiting
|
|
18
|
+
* @param {boolean} [options.logging=false] - Enable request logging
|
|
19
|
+
* @param {boolean} [options.headers=true] - Send X-RateLimit-* headers
|
|
20
|
+
* @param {string} [options.message] - Custom 429 message
|
|
21
|
+
* @param {number} [options.statusCode=429] - HTTP status for blocked requests
|
|
22
|
+
* @param {object} [options.store] - Custom store instance
|
|
23
|
+
* @param {function} [options.skip] - (req) => bool — skip rate limiting
|
|
24
|
+
* @param {function} [options.onLimitReached] - (req, res, result) => void
|
|
25
|
+
* @param {function} [options.keyGenerator] - (req) => string — custom key function
|
|
26
|
+
* @param {object} [options.plans] - Custom plan definitions
|
|
27
|
+
*
|
|
28
|
+
* @returns {Limiter}
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const limiter = createLimiter({ windowMs: 60_000, max: 100 });
|
|
32
|
+
* app.use(limiter.middleware());
|
|
33
|
+
*/
|
|
34
|
+
function createLimiter(options = {}) {
|
|
35
|
+
return new Limiter(options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Zero-config rate limiter.
|
|
40
|
+
* Returns an Express middleware directly — no .middleware() call needed.
|
|
41
|
+
*
|
|
42
|
+
* Defaults: 100 requests per minute, sliding window, IP-based, no logging.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [options] - Optional overrides
|
|
45
|
+
* @returns {function} Express middleware
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* app.use(autoLimit());
|
|
49
|
+
* app.use(autoLimit({ max: 50, logging: true }));
|
|
50
|
+
*/
|
|
51
|
+
function autoLimit(options = {}) {
|
|
52
|
+
const limiter = new Limiter({ logging: true, ...options });
|
|
53
|
+
return limiter.middleware();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a rate limiter pre-configured for a SaaS plan.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} planName - 'free' | 'pro' | 'enterprise'
|
|
60
|
+
* @param {object} [options] - Additional options to merge
|
|
61
|
+
* @returns {Limiter}
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const limiter = createPlanLimiter('pro', { keyBy: 'api-key', logging: true });
|
|
65
|
+
* app.use('/api', limiter.middleware());
|
|
66
|
+
*/
|
|
67
|
+
function createPlanLimiter(planName, options = {}) {
|
|
68
|
+
return new Limiter({ ...options, plan: planName });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a rate limiter from a named preset.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} presetName - 'strict' | 'relaxed' | 'api' | 'auth'
|
|
75
|
+
* @param {object} [options] - Overrides applied on top of the preset
|
|
76
|
+
* @returns {Limiter}
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* app.post('/login', createPresetLimiter('auth').middleware());
|
|
80
|
+
*/
|
|
81
|
+
function createPresetLimiter(presetName, options = {}) {
|
|
82
|
+
if (!PRESETS[presetName]) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[NexLimit] Unknown preset "${presetName}". Available: ${Object.keys(PRESETS).join(', ')}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return new Limiter({ ...options, preset: presetName });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
// Core factory functions
|
|
92
|
+
createLimiter,
|
|
93
|
+
autoLimit,
|
|
94
|
+
createPlanLimiter,
|
|
95
|
+
createPresetLimiter,
|
|
96
|
+
|
|
97
|
+
// Class — for advanced usage / subclassing
|
|
98
|
+
Limiter,
|
|
99
|
+
|
|
100
|
+
// Storage
|
|
101
|
+
MemoryStore,
|
|
102
|
+
|
|
103
|
+
// Constants
|
|
104
|
+
PRESETS,
|
|
105
|
+
DEFAULT_PLANS,
|
|
106
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apply standard rate limit headers to an Express response.
|
|
5
|
+
*
|
|
6
|
+
* Headers set:
|
|
7
|
+
* X-RateLimit-Limit — max requests per window
|
|
8
|
+
* X-RateLimit-Remaining — remaining requests in current window
|
|
9
|
+
* X-RateLimit-Reset — Unix timestamp (seconds) when window resets
|
|
10
|
+
* X-RateLimit-Strategy — algorithm name (informational)
|
|
11
|
+
* Retry-After — seconds to wait (only on 429 responses)
|
|
12
|
+
*
|
|
13
|
+
* @param {object} res - Express response object
|
|
14
|
+
* @param {object} result - RateLimitResult instance
|
|
15
|
+
* @param {boolean} includeRetry - Whether to add Retry-After header
|
|
16
|
+
*/
|
|
17
|
+
function setHeaders(res, result, includeRetry = false) {
|
|
18
|
+
res.setHeader('X-RateLimit-Limit', result.limit);
|
|
19
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
|
20
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
|
|
21
|
+
res.setHeader('X-RateLimit-Strategy', result.strategy);
|
|
22
|
+
|
|
23
|
+
if (includeRetry && !result.allowed) {
|
|
24
|
+
const retrySeconds = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
25
|
+
res.setHeader('Retry-After', retrySeconds);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { setHeaders };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SmartDetector — behavior-based dynamic rate limiting.
|
|
5
|
+
*
|
|
6
|
+
* Detects anomalous traffic patterns and temporarily reduces the limit
|
|
7
|
+
* for flagged keys without blocking them entirely.
|
|
8
|
+
*
|
|
9
|
+
* Detection logic:
|
|
10
|
+
* 1. Track request rate for each key over a short observation window
|
|
11
|
+
* 2. If the observed rate exceeds (normalRate × smartThreshold), flag the key
|
|
12
|
+
* 3. Flagged keys get a reduced limit: floor(max × smartPenaltyFactor)
|
|
13
|
+
* 4. The penalty expires after smartCooldownMs
|
|
14
|
+
*
|
|
15
|
+
* This lets legitimate burst-y users (e.g. someone running a data export)
|
|
16
|
+
* slow down gracefully rather than hitting a hard wall.
|
|
17
|
+
*/
|
|
18
|
+
class SmartDetector {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} config - Resolved NexLimit config
|
|
21
|
+
*/
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this._config = config;
|
|
24
|
+
|
|
25
|
+
// Map: key → { windowStart, count, penalized, penaltyExpires }
|
|
26
|
+
/** @type {Map<string, object>} */
|
|
27
|
+
this._state = new Map();
|
|
28
|
+
|
|
29
|
+
// Observation window = 10% of the rate limit window (min 1s, max 30s)
|
|
30
|
+
this._observationMs = Math.min(
|
|
31
|
+
30_000,
|
|
32
|
+
Math.max(1_000, Math.floor(config.windowMs * 0.1))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check whether a key should be penalized and what its effective limit is.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @returns {{ penalized: boolean, effectiveMax: number }}
|
|
41
|
+
*/
|
|
42
|
+
check(key) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const config = this._config;
|
|
45
|
+
let state = this._state.get(key);
|
|
46
|
+
|
|
47
|
+
if (!state) {
|
|
48
|
+
state = {
|
|
49
|
+
windowStart: now,
|
|
50
|
+
count: 0,
|
|
51
|
+
penalized: false,
|
|
52
|
+
penaltyExpires: 0,
|
|
53
|
+
};
|
|
54
|
+
this._state.set(key, state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Record this request in the observation window ---
|
|
58
|
+
if (now - state.windowStart > this._observationMs) {
|
|
59
|
+
// Slide the window
|
|
60
|
+
const normalRatePerObsWindow =
|
|
61
|
+
(config.max / config.windowMs) * this._observationMs;
|
|
62
|
+
|
|
63
|
+
// Was the previous window anomalous?
|
|
64
|
+
if (state.count > normalRatePerObsWindow * config.smartThreshold) {
|
|
65
|
+
state.penalized = true;
|
|
66
|
+
state.penaltyExpires = now + config.smartCooldownMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Reset observation window
|
|
70
|
+
state.windowStart = now;
|
|
71
|
+
state.count = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
state.count++;
|
|
75
|
+
|
|
76
|
+
// --- Check if currently under penalty ---
|
|
77
|
+
if (state.penalized && now < state.penaltyExpires) {
|
|
78
|
+
return {
|
|
79
|
+
penalized: true,
|
|
80
|
+
effectiveMax: Math.max(1, Math.floor(config.max * config.smartPenaltyFactor)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Penalty expired — lift it
|
|
85
|
+
if (state.penalized && now >= state.penaltyExpires) {
|
|
86
|
+
state.penalized = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { penalized: false, effectiveMax: config.max };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return a snapshot of currently penalized keys.
|
|
94
|
+
* @returns {Array<{key: string, expiresAt: number, remainingMs: number}>}
|
|
95
|
+
*/
|
|
96
|
+
getPenalizedKeys() {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const result = [];
|
|
99
|
+
for (const [key, state] of this._state) {
|
|
100
|
+
if (state.penalized && now < state.penaltyExpires) {
|
|
101
|
+
result.push({
|
|
102
|
+
key,
|
|
103
|
+
expiresAt: state.penaltyExpires,
|
|
104
|
+
remainingMs: state.penaltyExpires - now,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Clear all tracking state. */
|
|
112
|
+
reset() {
|
|
113
|
+
this._state.clear();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { SmartDetector };
|