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 +1 -1
- package/src/core/accessControl.js +3 -1
- package/src/core/config.js +12 -0
- package/src/core/limiter.js +49 -6
- package/src/smart/detector.js +17 -1
- package/types/index.d.ts +38 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextlimiter",
|
|
3
|
-
"version": "1.0
|
|
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
|
|
package/src/core/config.js
CHANGED
|
@@ -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
|
|
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');
|
|
@@ -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
|
|
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(`
|
|
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.
|
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
|
|
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 ────────────────────────────────────────────────────────
|