nextlimiter 1.0.6 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextlimiter",
3
- "version": "1.0.6",
3
+ "version": "1.1.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,7 @@ 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
90
91
  };
91
92
 
92
93
  /**
@@ -147,6 +148,17 @@ function resolveConfig(userOptions = {}) {
147
148
  base[listName] = valid.length > 0 ? valid : null;
148
149
  }
149
150
 
151
+ // Validate statsInterval
152
+ if (base.statsInterval !== undefined) {
153
+ if (typeof base.statsInterval !== 'number' || base.statsInterval <= 0) {
154
+ console.warn('[NextLimiter] config.statsInterval must be a positive number. Disabling.');
155
+ base.statsInterval = undefined;
156
+ } else if (base.statsInterval < 1000) {
157
+ console.warn('[NextLimiter] config.statsInterval must be at least 1000ms. Clamping to 1000ms.');
158
+ base.statsInterval = 1000;
159
+ }
160
+ }
161
+
150
162
  return base;
151
163
  }
152
164
 
@@ -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');
@@ -33,11 +34,15 @@ const STRATEGY_MAP = {
33
34
  * // Programmatic check
34
35
  * const result = await limiter.check('user:42');
35
36
  */
36
- class Limiter {
37
+ class Limiter extends EventEmitter {
37
38
  /**
38
39
  * @param {object} options - NextLimiter configuration (see config.js for defaults)
39
40
  */
40
41
  constructor(options = {}) {
42
+ super();
43
+ this.setMaxListeners(50);
44
+ this.on('error', () => {}); // default no-op handler prevents crashes
45
+
41
46
  this._config = resolveConfig(options);
42
47
 
43
48
  // Storage backend
@@ -64,7 +69,14 @@ class Limiter {
64
69
  this._analytics = new AnalyticsTracker();
65
70
 
66
71
  // Smart detector
67
- this._smart = this._config.smart ? new SmartDetector(this._config) : null;
72
+ this._smart = this._config.smart ? new SmartDetector(this._config, this) : null;
73
+
74
+ // Stats event interval
75
+ if (this._config.statsInterval) {
76
+ this._statsTimer = setInterval(() => {
77
+ this.emit('stats', this.getStats());
78
+ }, this._config.statsInterval).unref();
79
+ }
68
80
  }
69
81
 
70
82
  // ── Public API ─────────────────────────────────────────────────────────────
@@ -87,7 +99,7 @@ class Limiter {
87
99
 
88
100
  // ── Access control (whitelist / blacklist) ───────────────────────────
89
101
  const clientIp = extractIp(req);
90
- const access = checkAccess(clientIp, this._config);
102
+ const access = checkAccess(clientIp, this._config, this);
91
103
 
92
104
  if (access.action === 'block') {
93
105
  return res.status(403).json({
@@ -106,13 +118,17 @@ class Limiter {
106
118
  const key = `${this._config.keyPrefix}${rawKey}`;
107
119
 
108
120
  const result = await this._runCheck(key);
121
+
122
+ // Emit events
123
+ if (result.allowed) this.emit('allowed', rawKey, result);
124
+ else this.emit('blocked', rawKey, result);
109
125
 
110
126
  // Record analytics
111
127
  this._analytics.record(key, result.allowed);
112
128
 
113
129
  // Set headers
114
130
  if (this._config.headers) {
115
- setHeaders(res, result, !result.allowed);
131
+ setHeaders(res, result);
116
132
  }
117
133
 
118
134
  if (!result.allowed) {
@@ -140,8 +156,9 @@ class Limiter {
140
156
 
141
157
  next();
142
158
  } catch (err) {
159
+ this.emit('error', err);
143
160
  // Never let rate limiter errors take down the application
144
- this._log.warn(`Error in rate limiter: ${err.message}. Failing open.`);
161
+ this._log.warn(`Middleware error: ${err.message}. Failing open.`);
145
162
  next();
146
163
  }
147
164
  };
@@ -196,7 +213,7 @@ class Limiter {
196
213
  // Apply access control if the key looks like a plain IP address
197
214
  const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(String(key).trim());
198
215
  if (looksLikeIp) {
199
- const access = checkAccess(key, this._config);
216
+ const access = checkAccess(key, this._config, this);
200
217
  if (access.action === 'block') {
201
218
  return {
202
219
  allowed: false,
@@ -229,9 +246,35 @@ class Limiter {
229
246
  const fullKey = `${this._config.keyPrefix}${key}`;
230
247
  const result = await this._runCheck(fullKey);
231
248
  this._analytics.record(fullKey, result.allowed);
249
+
250
+ if (result.allowed) this.emit('allowed', key, result);
251
+ else this.emit('blocked', key, result);
252
+
232
253
  return result;
233
254
  }
234
255
 
256
+ /**
257
+ * Reset rate limit state for a key.
258
+ *
259
+ * @param {string} key
260
+ */
261
+ resetKey(key) {
262
+ const fullKey = `${this._config.keyPrefix}${key}`;
263
+ this._store.delete(fullKey);
264
+ this.emit('reset', key);
265
+ }
266
+
267
+ /**
268
+ * Cleanly dispose of stats cycles and store buffers.
269
+ */
270
+ destroy() {
271
+ if (this._statsTimer) clearInterval(this._statsTimer);
272
+ if (this._store && typeof this._store.destroy === 'function') {
273
+ this._store.destroy();
274
+ }
275
+ this.removeAllListeners();
276
+ }
277
+
235
278
  /**
236
279
  * Manually reset the rate limit for a specific key.
237
280
  * Useful after a user upgrades their plan, or for admin overrides.
@@ -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
 
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
 
@@ -102,6 +102,9 @@ export interface LimiterOptions {
102
102
 
103
103
  /** Array of IPs or CIDR ranges to block immediately (403) */
104
104
  blacklist?: string[];
105
+
106
+ /** ms interval to emit 'stats' event. undefined = disabled. */
107
+ statsInterval?: number;
105
108
  }
106
109
 
107
110
  // ── Rate limit result ────────────────────────────────────────────────────────
@@ -139,6 +142,14 @@ export interface RateLimitResult {
139
142
 
140
143
  // ── Analytics ────────────────────────────────────────────────────────────────
141
144
 
145
+ export interface PenaltyInfo {
146
+ key: string;
147
+ normalLimit: number;
148
+ reducedLimit: number;
149
+ cooldownMs: number;
150
+ detectedAt: string;
151
+ }
152
+
142
153
  export declare class PrometheusFormatter {
143
154
  constructor(limiter: Limiter);
144
155
  format(): string;
@@ -181,7 +192,7 @@ export interface LimiterStats {
181
192
 
182
193
  // ── Limiter class ────────────────────────────────────────────────────────────
183
194
 
184
- export declare class Limiter {
195
+ export declare class Limiter extends EventEmitter {
185
196
  constructor(options?: LimiterOptions);
186
197
 
187
198
  /** Returns an Express-compatible middleware function */
@@ -205,6 +216,12 @@ export declare class Limiter {
205
216
  /** Reset rate limit state for a specific key */
206
217
  reset(key: string): Promise<void>;
207
218
 
219
+ /** Reset rate limit state and clear from store immediately */
220
+ resetKey(key: string): void;
221
+
222
+ /** Clean up stores and timers */
223
+ destroy(): void;
224
+
208
225
  /** Get analytics snapshot */
209
226
  getStats(): LimiterStats;
210
227
 
@@ -213,6 +230,25 @@ export declare class Limiter {
213
230
 
214
231
  /** Read-only resolved configuration */
215
232
  readonly config: Readonly<Required<LimiterOptions>>;
233
+
234
+ // Typed EventEmitter overloads
235
+ on(event: 'blocked', listener: (key: string, result: RateLimitResult) => void): this;
236
+ on(event: 'allowed', listener: (key: string, result: RateLimitResult) => void): this;
237
+ on(event: 'penalized', listener: (key: string, info: PenaltyInfo) => void): this;
238
+ on(event: 'blacklisted', listener: (ip: string) => void): this;
239
+ on(event: 'whitelisted', listener: (ip: string) => void): this;
240
+ on(event: 'reset', listener: (key: string) => void): this;
241
+ on(event: 'stats', listener: (stats: LimiterStats) => void): this;
242
+ on(event: 'error', listener: (err: Error) => void): this;
243
+
244
+ once(event: 'blocked', listener: (key: string, result: RateLimitResult) => void): this;
245
+ once(event: 'allowed', listener: (key: string, result: RateLimitResult) => void): this;
246
+ once(event: 'penalized', listener: (key: string, info: PenaltyInfo) => void): this;
247
+ once(event: 'blacklisted', listener: (ip: string) => void): this;
248
+ once(event: 'whitelisted', listener: (ip: string) => void): this;
249
+ once(event: 'reset', listener: (key: string) => void): this;
250
+ once(event: 'stats', listener: (stats: LimiterStats) => void): this;
251
+ once(event: 'error', listener: (err: Error) => void): this;
216
252
  }
217
253
 
218
254
  // ── Factory functions ────────────────────────────────────────────────────────