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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextlimiter",
3
- "version": "1.0.6",
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
 
@@ -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 named preset first (lowest priority)
101
- if (userOptions.preset && PRESETS[userOptions.preset]) {
102
- base = { ...base, ...PRESETS[userOptions.preset] };
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
- // Merge user options
106
- base = { ...base, ...userOptions };
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
- // Validate
123
- if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
124
- if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
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
 
@@ -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
- const rawKey = this._keyGenerator(req);
106
- const key = `${this._config.keyPrefix}${rawKey}`;
107
-
108
- const result = await this._runCheck(key);
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, !result.allowed);
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(`Error in rate limiter: ${err.message}. Failing open.`);
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 = { ...this._config, max: effectiveMax };
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 };
@@ -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 ────────────────────────────────────────────────────────