nextlimiter 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/accessControl.js +3 -1
- package/src/core/config.js +95 -8
- package/src/core/limiter.js +101 -13
- package/src/core/ruleEngine.js +89 -0
- package/src/core/scheduler.js +49 -0
- package/src/smart/detector.js +17 -1
- package/src/webhook/sender.js +93 -0
- package/types/index.d.ts +81 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextlimiter",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Production-ready rate limiting for Node.js — sliding window, token bucket, SaaS plans, smart limiting, and built-in analytics.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
@@ -21,14 +21,16 @@ const { ipMatchesList } = require('../utils/cidr');
|
|
|
21
21
|
* checkAccess('1.2.3.4', {})
|
|
22
22
|
* // → { action: 'allow', reason: 'proceed' }
|
|
23
23
|
*/
|
|
24
|
-
function checkAccess(ip, config) {
|
|
24
|
+
function checkAccess(ip, config, emitter) {
|
|
25
25
|
// Blacklist wins — always checked first regardless of whitelist
|
|
26
26
|
if (config.blacklist && ipMatchesList(ip, config.blacklist)) {
|
|
27
|
+
if (emitter) emitter.emit('blacklisted', ip);
|
|
27
28
|
return { action: 'block', reason: 'blacklisted' };
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// Whitelist — bypass all rate limiting
|
|
31
32
|
if (config.whitelist && ipMatchesList(ip, config.whitelist)) {
|
|
33
|
+
if (emitter) emitter.emit('whitelisted', ip);
|
|
32
34
|
return { action: 'skip', reason: 'whitelisted' };
|
|
33
35
|
}
|
|
34
36
|
|
package/src/core/config.js
CHANGED
|
@@ -87,6 +87,16 @@ const DEFAULT_CONFIG = {
|
|
|
87
87
|
keyGenerator: null, // (req) => string — custom key fn
|
|
88
88
|
whitelist: null, // string[] — IPs/CIDRs that bypass rate limiting
|
|
89
89
|
blacklist: null, // string[] — IPs/CIDRs that always get 403
|
|
90
|
+
statsInterval: undefined, // ms interval to emit 'stats' event
|
|
91
|
+
|
|
92
|
+
rules: null, // RuleConfig[] — multiple rate limit constraints
|
|
93
|
+
schedule: null, // ScheduleEntry[] — time-based config overrides
|
|
94
|
+
|
|
95
|
+
webhook: null,
|
|
96
|
+
webhookRetries: 3,
|
|
97
|
+
webhookBackoff: 'exponential',
|
|
98
|
+
webhookTimeout: 5000,
|
|
99
|
+
webhookSecret: null,
|
|
90
100
|
};
|
|
91
101
|
|
|
92
102
|
/**
|
|
@@ -97,13 +107,31 @@ const DEFAULT_CONFIG = {
|
|
|
97
107
|
function resolveConfig(userOptions = {}) {
|
|
98
108
|
let base = { ...DEFAULT_CONFIG };
|
|
99
109
|
|
|
100
|
-
// Apply
|
|
101
|
-
if (userOptions.preset
|
|
102
|
-
|
|
110
|
+
// Apply preset
|
|
111
|
+
if (userOptions.preset) { // Use userOptions.preset here to ensure it's applied
|
|
112
|
+
const presetCfg = PRESETS[userOptions.preset];
|
|
113
|
+
if (!presetCfg) throw new Error(`[NextLimiter] Unknown preset: ${userOptions.preset}`);
|
|
114
|
+
// Merge preset config into base, allowing userOptions to override later
|
|
115
|
+
base = { ...base, ...presetCfg };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Mutually exclusive core configurations
|
|
119
|
+
const hasRules = userOptions.rules !== undefined && userOptions.rules !== null;
|
|
120
|
+
const hasSchedule = userOptions.schedule !== undefined && userOptions.schedule !== null;
|
|
121
|
+
const hasMax = userOptions.max !== undefined && userOptions.max !== null;
|
|
122
|
+
|
|
123
|
+
if (hasRules && hasSchedule) {
|
|
124
|
+
throw new Error('[NextLimiter] config.rules and config.schedule are mutually exclusive.');
|
|
125
|
+
}
|
|
126
|
+
if (hasRules && hasMax) {
|
|
127
|
+
throw new Error('[NextLimiter] config.max/windowMs and config.rules are mutually exclusive.');
|
|
128
|
+
}
|
|
129
|
+
if (hasSchedule && hasMax) {
|
|
130
|
+
throw new Error('[NextLimiter] config.max/windowMs and config.schedule are mutually exclusive.');
|
|
103
131
|
}
|
|
104
132
|
|
|
105
|
-
//
|
|
106
|
-
base
|
|
133
|
+
// Apply user overrides
|
|
134
|
+
Object.assign(base, userOptions);
|
|
107
135
|
|
|
108
136
|
// Apply plan limits (overrides windowMs and max if plan is set)
|
|
109
137
|
if (base.plan) {
|
|
@@ -119,9 +147,45 @@ function resolveConfig(userOptions = {}) {
|
|
|
119
147
|
base._burstMax = planCfg.burstMax;
|
|
120
148
|
}
|
|
121
149
|
|
|
122
|
-
//
|
|
123
|
-
if (base.
|
|
124
|
-
|
|
150
|
+
// Deep validate
|
|
151
|
+
if (base.rules) {
|
|
152
|
+
if (!Array.isArray(base.rules) || base.rules.length === 0) {
|
|
153
|
+
throw new Error('[NextLimiter] config.rules must be a non-empty array.');
|
|
154
|
+
}
|
|
155
|
+
for (const rule of base.rules) {
|
|
156
|
+
if (!rule.keyBy) throw new Error('[NextLimiter] Each rule must have a keyBy property.');
|
|
157
|
+
if (typeof rule.max !== 'number' || rule.max <= 0) throw new Error('[NextLimiter] Each rule must have a positive max integer.');
|
|
158
|
+
if (typeof rule.windowMs !== 'number' || rule.windowMs <= 0) throw new Error('[NextLimiter] Each rule must have a positive windowMs integer.');
|
|
159
|
+
}
|
|
160
|
+
} else if (base.schedule) {
|
|
161
|
+
if (!Array.isArray(base.schedule) || base.schedule.length === 0) {
|
|
162
|
+
throw new Error('[NextLimiter] config.schedule must be a non-empty array.');
|
|
163
|
+
}
|
|
164
|
+
let prevEnd = -1;
|
|
165
|
+
for (let i = 0; i < base.schedule.length; i++) {
|
|
166
|
+
const entry = base.schedule[i];
|
|
167
|
+
if (!entry.hours) throw new Error('[NextLimiter] Each schedule entry must have an hours string (e.g., "9-17").');
|
|
168
|
+
if (typeof entry.max !== 'number' || entry.max <= 0) throw new Error('[NextLimiter] Each schedule entry must have a positive max integer.');
|
|
169
|
+
|
|
170
|
+
const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(entry.hours);
|
|
171
|
+
if (!match) throw new Error(`[NextLimiter] Invalid hours format: ${entry.hours}`);
|
|
172
|
+
let s = parseInt(match[1], 10), e = parseInt(match[2], 10);
|
|
173
|
+
if (s < 0 || s > 23 || e < 0 || e > 23) throw new Error('[NextLimiter] Schedule hours must be between 0 and 23. Got: ' + entry.hours);
|
|
174
|
+
if (e < s) throw new Error(`[NextLimiter] Invalid schedule: end hour (${e}) cannot be less than start hour (${s}).`);
|
|
175
|
+
|
|
176
|
+
// Basic overlap check
|
|
177
|
+
if (s <= prevEnd) console.warn(`[NextLimiter] Warning: Overlapping schedule entries detected.`);
|
|
178
|
+
prevEnd = e;
|
|
179
|
+
}
|
|
180
|
+
if (prevEnd < 23) console.warn(`[NextLimiter] Warning: Schedule entries do not cover a full 24-hr cycle.`);
|
|
181
|
+
// Base max/windowMs must still be valid for fallback
|
|
182
|
+
if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
|
|
183
|
+
if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
|
|
184
|
+
} else {
|
|
185
|
+
// Standard mode validation
|
|
186
|
+
if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
|
|
187
|
+
if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
|
|
188
|
+
}
|
|
125
189
|
|
|
126
190
|
// Validate whitelist / blacklist (warn, never throw)
|
|
127
191
|
for (const listName of ['whitelist', 'blacklist']) {
|
|
@@ -147,6 +211,29 @@ function resolveConfig(userOptions = {}) {
|
|
|
147
211
|
base[listName] = valid.length > 0 ? valid : null;
|
|
148
212
|
}
|
|
149
213
|
|
|
214
|
+
// Validate statsInterval
|
|
215
|
+
if (base.statsInterval !== undefined) {
|
|
216
|
+
if (typeof base.statsInterval !== 'number' || base.statsInterval <= 0) {
|
|
217
|
+
console.warn('[NextLimiter] config.statsInterval must be a positive number. Disabling.');
|
|
218
|
+
base.statsInterval = undefined;
|
|
219
|
+
} else if (base.statsInterval < 1000) {
|
|
220
|
+
console.warn('[NextLimiter] config.statsInterval must be at least 1000ms. Clamping to 1000ms.');
|
|
221
|
+
base.statsInterval = 1000;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Validate webhook
|
|
226
|
+
if (base.webhook) {
|
|
227
|
+
if (!base.webhook.startsWith('http://') && !base.webhook.startsWith('https://')) {
|
|
228
|
+
throw new Error('[NextLimiter] config.webhook must be a valid URL starting with http:// or https://');
|
|
229
|
+
}
|
|
230
|
+
if (base.webhook.startsWith('http://')) {
|
|
231
|
+
console.warn('[NextLimiter] Warning: Webhook URL is using insecure http://. Consider switching to https://');
|
|
232
|
+
}
|
|
233
|
+
if (base.webhookRetries < 0 || base.webhookRetries > 10) base.webhookRetries = 3;
|
|
234
|
+
if (base.webhookTimeout < 500) base.webhookTimeout = 5000;
|
|
235
|
+
}
|
|
236
|
+
|
|
150
237
|
return base;
|
|
151
238
|
}
|
|
152
239
|
|
package/src/core/limiter.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const EventEmitter = require('events');
|
|
3
4
|
const { resolveConfig } = require('./config');
|
|
4
5
|
const { MemoryStore } = require('../store/memoryStore');
|
|
5
6
|
const { fixedWindowCheck } = require('../strategies/fixedWindow');
|
|
@@ -13,6 +14,9 @@ const { SmartDetector } = require('../smart/detector');
|
|
|
13
14
|
const { setHeaders } = require('../middleware/headers');
|
|
14
15
|
const { checkAccess } = require('./accessControl');
|
|
15
16
|
const { PrometheusFormatter } = require('../analytics/prometheus');
|
|
17
|
+
const { RuleEngine } = require('./ruleEngine');
|
|
18
|
+
const { WebhookSender } = require('../webhook/sender');
|
|
19
|
+
const { Scheduler } = require('./scheduler');
|
|
16
20
|
|
|
17
21
|
const STRATEGY_MAP = {
|
|
18
22
|
'fixed-window': fixedWindowCheck,
|
|
@@ -33,11 +37,15 @@ const STRATEGY_MAP = {
|
|
|
33
37
|
* // Programmatic check
|
|
34
38
|
* const result = await limiter.check('user:42');
|
|
35
39
|
*/
|
|
36
|
-
class Limiter {
|
|
40
|
+
class Limiter extends EventEmitter {
|
|
37
41
|
/**
|
|
38
42
|
* @param {object} options - NextLimiter configuration (see config.js for defaults)
|
|
39
43
|
*/
|
|
40
44
|
constructor(options = {}) {
|
|
45
|
+
super();
|
|
46
|
+
this.setMaxListeners(50);
|
|
47
|
+
this.on('error', () => {}); // default no-op handler prevents crashes
|
|
48
|
+
|
|
41
49
|
this._config = resolveConfig(options);
|
|
42
50
|
|
|
43
51
|
// Storage backend
|
|
@@ -64,7 +72,19 @@ class Limiter {
|
|
|
64
72
|
this._analytics = new AnalyticsTracker();
|
|
65
73
|
|
|
66
74
|
// Smart detector
|
|
67
|
-
this._smart = this._config.smart ? new SmartDetector(this._config) : null;
|
|
75
|
+
this._smart = this._config.smart ? new SmartDetector(this._config, this) : null;
|
|
76
|
+
|
|
77
|
+
// Stats event interval
|
|
78
|
+
if (this._config.statsInterval) {
|
|
79
|
+
this._statsTimer = setInterval(() => {
|
|
80
|
+
this.emit('stats', this.getStats());
|
|
81
|
+
}, this._config.statsInterval).unref();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Engine, Scheduler & Webhook
|
|
85
|
+
this.ruleEngine = this._config.rules ? new RuleEngine(this._config.rules, this._store, this, this._config) : null;
|
|
86
|
+
this.scheduler = this._config.schedule ? new Scheduler(this._config.schedule, this._config) : null;
|
|
87
|
+
this.webhookSender = this._config.webhook ? new WebhookSender(this._config) : null;
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
@@ -87,7 +107,7 @@ class Limiter {
|
|
|
87
107
|
|
|
88
108
|
// ── Access control (whitelist / blacklist) ───────────────────────────
|
|
89
109
|
const clientIp = extractIp(req);
|
|
90
|
-
const access = checkAccess(clientIp, this._config);
|
|
110
|
+
const access = checkAccess(clientIp, this._config, this);
|
|
91
111
|
|
|
92
112
|
if (access.action === 'block') {
|
|
93
113
|
return res.status(403).json({
|
|
@@ -102,17 +122,56 @@ class Limiter {
|
|
|
102
122
|
}
|
|
103
123
|
// ────────────────────────────────────────────────────────────────────
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
let result;
|
|
126
|
+
let rawKey;
|
|
127
|
+
let key;
|
|
128
|
+
let failedRuleName;
|
|
129
|
+
|
|
130
|
+
if (this.ruleEngine) {
|
|
131
|
+
const ruleResult = await this.ruleEngine.check(req);
|
|
132
|
+
result = ruleResult.mostRestrictive;
|
|
133
|
+
rawKey = ruleResult.key; // composite key
|
|
134
|
+
key = ruleResult.key;
|
|
135
|
+
failedRuleName = ruleResult.failedRule ? ruleResult.failedRule.name : undefined;
|
|
136
|
+
|
|
137
|
+
if (this._config.headers) {
|
|
138
|
+
res.setHeader('X-RateLimit-Rule-Count', ruleResult.results.length);
|
|
139
|
+
if (!ruleResult.allowed && failedRuleName) {
|
|
140
|
+
res.setHeader('X-RateLimit-Failed-Rule', failedRuleName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
rawKey = this._keyGenerator(req);
|
|
145
|
+
key = `${this._config.keyPrefix}${rawKey}`;
|
|
146
|
+
result = await this._runCheck(key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Emit events
|
|
150
|
+
if (result.allowed) {
|
|
151
|
+
this.emit('allowed', rawKey, result);
|
|
152
|
+
} else {
|
|
153
|
+
this.emit('blocked', rawKey, result);
|
|
154
|
+
if (this.webhookSender) {
|
|
155
|
+
this.webhookSender.send({
|
|
156
|
+
event: 'blocked',
|
|
157
|
+
key,
|
|
158
|
+
ip: clientIp,
|
|
159
|
+
limit: result.limit,
|
|
160
|
+
count: result.limit - result.remaining,
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
retryAfter: result.retryAfter,
|
|
163
|
+
strategy: result.strategy,
|
|
164
|
+
...(failedRuleName ? { ruleName: failedRuleName } : {})
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
109
168
|
|
|
110
169
|
// Record analytics
|
|
111
170
|
this._analytics.record(key, result.allowed);
|
|
112
171
|
|
|
113
|
-
// Set headers
|
|
172
|
+
// Set headers (base headers overrides single rule headers if rule engine is active, that is fine)
|
|
114
173
|
if (this._config.headers) {
|
|
115
|
-
setHeaders(res, result
|
|
174
|
+
setHeaders(res, result);
|
|
116
175
|
}
|
|
117
176
|
|
|
118
177
|
if (!result.allowed) {
|
|
@@ -140,8 +199,9 @@ class Limiter {
|
|
|
140
199
|
|
|
141
200
|
next();
|
|
142
201
|
} catch (err) {
|
|
202
|
+
this.emit('error', err);
|
|
143
203
|
// Never let rate limiter errors take down the application
|
|
144
|
-
this._log.warn(`
|
|
204
|
+
this._log.warn(`Middleware error: ${err.message}. Failing open.`);
|
|
145
205
|
next();
|
|
146
206
|
}
|
|
147
207
|
};
|
|
@@ -196,7 +256,7 @@ class Limiter {
|
|
|
196
256
|
// Apply access control if the key looks like a plain IP address
|
|
197
257
|
const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(String(key).trim());
|
|
198
258
|
if (looksLikeIp) {
|
|
199
|
-
const access = checkAccess(key, this._config);
|
|
259
|
+
const access = checkAccess(key, this._config, this);
|
|
200
260
|
if (access.action === 'block') {
|
|
201
261
|
return {
|
|
202
262
|
allowed: false,
|
|
@@ -229,9 +289,35 @@ class Limiter {
|
|
|
229
289
|
const fullKey = `${this._config.keyPrefix}${key}`;
|
|
230
290
|
const result = await this._runCheck(fullKey);
|
|
231
291
|
this._analytics.record(fullKey, result.allowed);
|
|
292
|
+
|
|
293
|
+
if (result.allowed) this.emit('allowed', key, result);
|
|
294
|
+
else this.emit('blocked', key, result);
|
|
295
|
+
|
|
232
296
|
return result;
|
|
233
297
|
}
|
|
234
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Reset rate limit state for a key.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} key
|
|
303
|
+
*/
|
|
304
|
+
resetKey(key) {
|
|
305
|
+
const fullKey = `${this._config.keyPrefix}${key}`;
|
|
306
|
+
this._store.delete(fullKey);
|
|
307
|
+
this.emit('reset', key);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Cleanly dispose of stats cycles and store buffers.
|
|
312
|
+
*/
|
|
313
|
+
destroy() {
|
|
314
|
+
if (this._statsTimer) clearInterval(this._statsTimer);
|
|
315
|
+
if (this._store && typeof this._store.destroy === 'function') {
|
|
316
|
+
this._store.destroy();
|
|
317
|
+
}
|
|
318
|
+
this.removeAllListeners();
|
|
319
|
+
}
|
|
320
|
+
|
|
235
321
|
/**
|
|
236
322
|
* Manually reset the rate limit for a specific key.
|
|
237
323
|
* Useful after a user upgrades their plan, or for admin overrides.
|
|
@@ -264,6 +350,8 @@ class Limiter {
|
|
|
264
350
|
};
|
|
265
351
|
}
|
|
266
352
|
|
|
353
|
+
stats.activeSchedule = this.scheduler ? this.scheduler.resolve() : null;
|
|
354
|
+
|
|
267
355
|
stats.config = {
|
|
268
356
|
strategy: this._config.strategy,
|
|
269
357
|
windowMs: this._config.windowMs,
|
|
@@ -299,14 +387,14 @@ class Limiter {
|
|
|
299
387
|
* @returns {import('../core/result').RateLimitResult}
|
|
300
388
|
*/
|
|
301
389
|
_runCheck(key) {
|
|
302
|
-
let effectiveConfig = this._config;
|
|
390
|
+
let effectiveConfig = this.scheduler ? this.scheduler.resolve() : this._config;
|
|
303
391
|
|
|
304
392
|
// Apply smart penalty if relevant
|
|
305
393
|
if (this._smart) {
|
|
306
394
|
const { penalized, effectiveMax } = this._smart.check(key);
|
|
307
395
|
if (penalized) {
|
|
308
396
|
// Create a shallow config override with reduced max
|
|
309
|
-
effectiveConfig = { ...
|
|
397
|
+
effectiveConfig = { ...effectiveConfig, max: effectiveMax };
|
|
310
398
|
}
|
|
311
399
|
}
|
|
312
400
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { resolveKeyGenerator } = require('../utils/keyGenerator');
|
|
3
|
+
const { fixedWindowCheck } = require('../strategies/fixedWindow');
|
|
4
|
+
const { slidingWindowCheck } = require('../strategies/slidingWindow');
|
|
5
|
+
const { tokenBucketCheck } = require('../strategies/tokenBucket');
|
|
6
|
+
|
|
7
|
+
const STRATEGY_MAP = {
|
|
8
|
+
'fixed-window': fixedWindowCheck,
|
|
9
|
+
'sliding-window': slidingWindowCheck,
|
|
10
|
+
'token-bucket': tokenBucketCheck,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class RuleEngine {
|
|
14
|
+
constructor(rules, store, emitter, globalConfig) {
|
|
15
|
+
this._rules = rules.map((r, i) => {
|
|
16
|
+
const strategyName = r.strategy || 'sliding-window';
|
|
17
|
+
const strategyFn = STRATEGY_MAP[strategyName];
|
|
18
|
+
if (!strategyFn) throw new Error(`[NextLimiter] Unknown strategy in rule: ${strategyName}`);
|
|
19
|
+
return {
|
|
20
|
+
...r,
|
|
21
|
+
name: r.name || `rule${i}`,
|
|
22
|
+
index: i,
|
|
23
|
+
keyGenerator: typeof r.keyBy === 'function' ? r.keyBy : resolveKeyGenerator(r.keyBy),
|
|
24
|
+
strategyFn
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
this._store = store;
|
|
28
|
+
this._emitter = emitter;
|
|
29
|
+
this._globalConfig = globalConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async check(req) {
|
|
33
|
+
const promises = this._rules.map(rule => {
|
|
34
|
+
const rawKey = rule.keyGenerator(req);
|
|
35
|
+
const fullKey = `${this._globalConfig.keyPrefix}${rule.name}:${rule.keyBy}:${rawKey}`;
|
|
36
|
+
|
|
37
|
+
const config = { max: rule.max, windowMs: rule.windowMs };
|
|
38
|
+
return Promise.resolve(rule.strategyFn(fullKey, config, this._store)).then(result => {
|
|
39
|
+
return {
|
|
40
|
+
...result,
|
|
41
|
+
key: fullKey,
|
|
42
|
+
rawKey,
|
|
43
|
+
rule
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const results = await Promise.all(promises);
|
|
49
|
+
|
|
50
|
+
let allowed = true;
|
|
51
|
+
let failedRule = null;
|
|
52
|
+
let mostRestrictive = results[0];
|
|
53
|
+
let mostRestrictiveRemaining = Infinity;
|
|
54
|
+
|
|
55
|
+
// Find the most restrictive outcome and if any caused a block.
|
|
56
|
+
for (const res of results) {
|
|
57
|
+
if (!res.allowed) {
|
|
58
|
+
allowed = false;
|
|
59
|
+
if (!failedRule) failedRule = res.rule;
|
|
60
|
+
}
|
|
61
|
+
if (res.remaining < mostRestrictiveRemaining) {
|
|
62
|
+
mostRestrictiveRemaining = res.remaining;
|
|
63
|
+
mostRestrictive = res;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!allowed) {
|
|
68
|
+
let longestRetry = -1;
|
|
69
|
+
for (const res of results) {
|
|
70
|
+
if (!res.allowed && res.retryAfter > longestRetry) {
|
|
71
|
+
longestRetry = res.retryAfter;
|
|
72
|
+
mostRestrictive = res; // prioritize the one with highest retry limit to wait for
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const compositeKey = results.map(r => r.key).join('|');
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
allowed,
|
|
81
|
+
failedRule,
|
|
82
|
+
results,
|
|
83
|
+
mostRestrictive,
|
|
84
|
+
key: compositeKey
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { RuleEngine };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Scheduler {
|
|
4
|
+
constructor(schedule, baseConfig) {
|
|
5
|
+
this._schedule = schedule;
|
|
6
|
+
this._baseConfig = baseConfig;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
resolve(now = new Date()) {
|
|
10
|
+
const currentHour = this._currentHourUTC(now);
|
|
11
|
+
|
|
12
|
+
for (const entry of this._schedule) {
|
|
13
|
+
const { start, end } = this._parseHours(entry.hours);
|
|
14
|
+
if (currentHour >= start && currentHour <= end) {
|
|
15
|
+
return {
|
|
16
|
+
...this._baseConfig,
|
|
17
|
+
max: entry.max,
|
|
18
|
+
windowMs: entry.windowMs !== undefined ? entry.windowMs : this._baseConfig.windowMs,
|
|
19
|
+
strategy: entry.strategy || this._baseConfig.strategy
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return this._baseConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_currentHourUTC(now) {
|
|
28
|
+
return now.getUTCHours();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_parseHours(rangeStr) {
|
|
32
|
+
const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(rangeStr);
|
|
33
|
+
if (!match) throw new Error(`[NextLimiter] Invalid schedule hours format: "${rangeStr}" (expected "start-end" e.g. "9-17")`);
|
|
34
|
+
|
|
35
|
+
const start = parseInt(match[1], 10);
|
|
36
|
+
const end = parseInt(match[2], 10);
|
|
37
|
+
|
|
38
|
+
if (start < 0 || start > 23 || end < 0 || end > 23) {
|
|
39
|
+
throw new Error(`[NextLimiter] Schedule hours must be between 0 and 23. Got: ${rangeStr}`);
|
|
40
|
+
}
|
|
41
|
+
if (end < start) {
|
|
42
|
+
throw new Error(`[NextLimiter] Schedule end hour cannot be less than start hour. Got: ${rangeStr}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { start, end };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { Scheduler };
|
package/src/smart/detector.js
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
class SmartDetector {
|
|
19
19
|
/**
|
|
20
20
|
* @param {object} config - Resolved NextLimiter config
|
|
21
|
+
* @param {import('events').EventEmitter} [emitter] - Optional event emitter
|
|
21
22
|
*/
|
|
22
|
-
constructor(config) {
|
|
23
|
+
constructor(config, emitter) {
|
|
23
24
|
this._config = config;
|
|
25
|
+
this._emitter = emitter;
|
|
26
|
+
this._penalizedSet = new Set();
|
|
24
27
|
|
|
25
28
|
// Map: key → { windowStart, count, penalized, penaltyExpires }
|
|
26
29
|
/** @type {Map<string, object>} */
|
|
@@ -64,6 +67,17 @@ class SmartDetector {
|
|
|
64
67
|
if (state.count > normalRatePerObsWindow * config.smartThreshold) {
|
|
65
68
|
state.penalized = true;
|
|
66
69
|
state.penaltyExpires = now + config.smartCooldownMs;
|
|
70
|
+
|
|
71
|
+
if (this._emitter && !this._penalizedSet.has(key)) {
|
|
72
|
+
this._penalizedSet.add(key);
|
|
73
|
+
this._emitter.emit('penalized', key, {
|
|
74
|
+
key,
|
|
75
|
+
normalLimit: config.max,
|
|
76
|
+
reducedLimit: Math.floor(config.max * config.smartPenaltyFactor),
|
|
77
|
+
cooldownMs: config.smartCooldownMs,
|
|
78
|
+
detectedAt: new Date().toISOString()
|
|
79
|
+
});
|
|
80
|
+
}
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
// Reset observation window
|
|
@@ -84,6 +98,7 @@ class SmartDetector {
|
|
|
84
98
|
// Penalty expired — lift it
|
|
85
99
|
if (state.penalized && now >= state.penaltyExpires) {
|
|
86
100
|
state.penalized = false;
|
|
101
|
+
this._penalizedSet.delete(key);
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
return { penalized: false, effectiveMax: config.max };
|
|
@@ -111,6 +126,7 @@ class SmartDetector {
|
|
|
111
126
|
/** Clear all tracking state. */
|
|
112
127
|
reset() {
|
|
113
128
|
this._state.clear();
|
|
129
|
+
this._penalizedSet.clear();
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
class WebhookSender {
|
|
6
|
+
constructor(config = {}) {
|
|
7
|
+
this.webhook = config.webhook;
|
|
8
|
+
this.webhookRetries = config.webhookRetries !== undefined ? config.webhookRetries : 3;
|
|
9
|
+
this.webhookBackoff = config.webhookBackoff || 'exponential';
|
|
10
|
+
this.webhookTimeout = config.webhookTimeout || 5000;
|
|
11
|
+
this.webhookSecret = config.webhookSecret;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async send(payload) {
|
|
15
|
+
// Fire-and-forget
|
|
16
|
+
this._sendWithRetry(payload, this.webhookRetries, this._getInitialDelay()).catch(() => {
|
|
17
|
+
// Intentionally swallow errors — background fire and forget
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async _sendWithRetry(payload, attemptsLeft, delayMs) {
|
|
22
|
+
try {
|
|
23
|
+
await this._makeRequest(payload);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (attemptsLeft > 0) {
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
27
|
+
const nextDelay = this._getNextDelay(delayMs);
|
|
28
|
+
await this._sendWithRetry(payload, attemptsLeft - 1, nextDelay);
|
|
29
|
+
} else {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_getInitialDelay() {
|
|
36
|
+
if (this.webhookBackoff === 'linear' || this.webhookBackoff === 'fixed') return 1000;
|
|
37
|
+
return 100; // exponential
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_getNextDelay(currentDelay) {
|
|
41
|
+
if (this.webhookBackoff === 'linear') return currentDelay + 1000;
|
|
42
|
+
if (this.webhookBackoff === 'exponential') return currentDelay * 2;
|
|
43
|
+
return currentDelay; // fixed
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_makeRequest(payload) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const body = JSON.stringify(payload);
|
|
49
|
+
const urlObj = new URL(this.webhook);
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
hostname: urlObj.hostname,
|
|
53
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
54
|
+
path: urlObj.pathname + urlObj.search,
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'Content-Length': Buffer.byteLength(body)
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (this.webhookSecret) {
|
|
63
|
+
const sig = crypto.createHmac('sha256', this.webhookSecret).update(body).digest('hex');
|
|
64
|
+
options.headers['X-nextlimiter-signature'] = `sha256=${sig}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// We only support https natively as per requirement, though http objects won't crash here they just might fail protocol
|
|
68
|
+
const req = https.request(options, (res) => {
|
|
69
|
+
let responseBody = '';
|
|
70
|
+
res.on('data', chunk => { responseBody += chunk; });
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
73
|
+
resolve({ statusCode: res.statusCode, body: responseBody });
|
|
74
|
+
} else {
|
|
75
|
+
reject(new Error(`Webhook failed with status: ${res.statusCode}`));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.on('error', (err) => reject(err));
|
|
81
|
+
|
|
82
|
+
req.setTimeout(this.webhookTimeout, () => {
|
|
83
|
+
req.destroy();
|
|
84
|
+
reject(new Error(`Webhook timed out after ${this.webhookTimeout}ms`));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.write(body);
|
|
88
|
+
req.end();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { WebhookSender };
|
package/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// NextLimiter — TypeScript type definitions
|
|
2
2
|
// Compatible with @types/node and @types/express
|
|
3
|
-
|
|
4
3
|
import { Request, Response, NextFunction } from 'express';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
5
|
|
|
6
6
|
// ── Store interface ──────────────────────────────────────────────────────────
|
|
7
7
|
|
|
@@ -23,6 +23,41 @@ export interface PlanDefinition {
|
|
|
23
23
|
description?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface RuleConfig {
|
|
27
|
+
keyBy: 'ip' | 'api-key' | 'user-id' | string | ((req: Request) => string);
|
|
28
|
+
max: number;
|
|
29
|
+
windowMs: number;
|
|
30
|
+
strategy?: 'sliding-window' | 'token-bucket' | 'fixed-window';
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RuleEngineResult {
|
|
35
|
+
allowed: boolean;
|
|
36
|
+
failedRule: RuleConfig | null;
|
|
37
|
+
results: RateLimitResult[];
|
|
38
|
+
mostRestrictive: RateLimitResult;
|
|
39
|
+
key: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ScheduleEntry {
|
|
43
|
+
hours: string;
|
|
44
|
+
max: number;
|
|
45
|
+
windowMs?: number;
|
|
46
|
+
strategy?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WebhookPayload {
|
|
50
|
+
event: string;
|
|
51
|
+
key: string;
|
|
52
|
+
ip: string;
|
|
53
|
+
limit: number;
|
|
54
|
+
count: number;
|
|
55
|
+
timestamp: string;
|
|
56
|
+
retryAfter: number;
|
|
57
|
+
strategy: string;
|
|
58
|
+
ruleName?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
export type BuiltInPlan = 'free' | 'pro' | 'enterprise';
|
|
27
62
|
export type BuiltInPreset = 'strict' | 'relaxed' | 'api' | 'auth';
|
|
28
63
|
export type Strategy = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
@@ -102,6 +137,17 @@ export interface LimiterOptions {
|
|
|
102
137
|
|
|
103
138
|
/** Array of IPs or CIDR ranges to block immediately (403) */
|
|
104
139
|
blacklist?: string[];
|
|
140
|
+
|
|
141
|
+
/** ms interval to emit 'stats' event. undefined = disabled. */
|
|
142
|
+
statsInterval?: number;
|
|
143
|
+
|
|
144
|
+
rules?: RuleConfig[];
|
|
145
|
+
schedule?: ScheduleEntry[];
|
|
146
|
+
webhook?: string;
|
|
147
|
+
webhookRetries?: number;
|
|
148
|
+
webhookBackoff?: 'exponential' | 'linear' | 'fixed';
|
|
149
|
+
webhookTimeout?: number;
|
|
150
|
+
webhookSecret?: string;
|
|
105
151
|
}
|
|
106
152
|
|
|
107
153
|
// ── Rate limit result ────────────────────────────────────────────────────────
|
|
@@ -139,6 +185,14 @@ export interface RateLimitResult {
|
|
|
139
185
|
|
|
140
186
|
// ── Analytics ────────────────────────────────────────────────────────────────
|
|
141
187
|
|
|
188
|
+
export interface PenaltyInfo {
|
|
189
|
+
key: string;
|
|
190
|
+
normalLimit: number;
|
|
191
|
+
reducedLimit: number;
|
|
192
|
+
cooldownMs: number;
|
|
193
|
+
detectedAt: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
142
196
|
export declare class PrometheusFormatter {
|
|
143
197
|
constructor(limiter: Limiter);
|
|
144
198
|
format(): string;
|
|
@@ -181,7 +235,7 @@ export interface LimiterStats {
|
|
|
181
235
|
|
|
182
236
|
// ── Limiter class ────────────────────────────────────────────────────────────
|
|
183
237
|
|
|
184
|
-
export declare class Limiter {
|
|
238
|
+
export declare class Limiter extends EventEmitter {
|
|
185
239
|
constructor(options?: LimiterOptions);
|
|
186
240
|
|
|
187
241
|
/** Returns an Express-compatible middleware function */
|
|
@@ -205,6 +259,12 @@ export declare class Limiter {
|
|
|
205
259
|
/** Reset rate limit state for a specific key */
|
|
206
260
|
reset(key: string): Promise<void>;
|
|
207
261
|
|
|
262
|
+
/** Reset rate limit state and clear from store immediately */
|
|
263
|
+
resetKey(key: string): void;
|
|
264
|
+
|
|
265
|
+
/** Clean up stores and timers */
|
|
266
|
+
destroy(): void;
|
|
267
|
+
|
|
208
268
|
/** Get analytics snapshot */
|
|
209
269
|
getStats(): LimiterStats;
|
|
210
270
|
|
|
@@ -213,6 +273,25 @@ export declare class Limiter {
|
|
|
213
273
|
|
|
214
274
|
/** Read-only resolved configuration */
|
|
215
275
|
readonly config: Readonly<Required<LimiterOptions>>;
|
|
276
|
+
|
|
277
|
+
// Typed EventEmitter overloads
|
|
278
|
+
on(event: 'blocked', listener: (key: string, result: RateLimitResult) => void): this;
|
|
279
|
+
on(event: 'allowed', listener: (key: string, result: RateLimitResult) => void): this;
|
|
280
|
+
on(event: 'penalized', listener: (key: string, info: PenaltyInfo) => void): this;
|
|
281
|
+
on(event: 'blacklisted', listener: (ip: string) => void): this;
|
|
282
|
+
on(event: 'whitelisted', listener: (ip: string) => void): this;
|
|
283
|
+
on(event: 'reset', listener: (key: string) => void): this;
|
|
284
|
+
on(event: 'stats', listener: (stats: LimiterStats) => void): this;
|
|
285
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
286
|
+
|
|
287
|
+
once(event: 'blocked', listener: (key: string, result: RateLimitResult) => void): this;
|
|
288
|
+
once(event: 'allowed', listener: (key: string, result: RateLimitResult) => void): this;
|
|
289
|
+
once(event: 'penalized', listener: (key: string, info: PenaltyInfo) => void): this;
|
|
290
|
+
once(event: 'blacklisted', listener: (ip: string) => void): this;
|
|
291
|
+
once(event: 'whitelisted', listener: (ip: string) => void): this;
|
|
292
|
+
once(event: 'reset', listener: (key: string) => void): this;
|
|
293
|
+
once(event: 'stats', listener: (stats: LimiterStats) => void): this;
|
|
294
|
+
once(event: 'error', listener: (err: Error) => void): this;
|
|
216
295
|
}
|
|
217
296
|
|
|
218
297
|
// ── Factory functions ────────────────────────────────────────────────────────
|