nextlimiter 1.0.5 → 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.5",
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",
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure Node.js Prometheus Exposition Formatter
5
+ * Zero dependencies. Converts limiter stats to valid Prometheus text format.
6
+ */
7
+
8
+ class PrometheusFormatter {
9
+ constructor(limiter) {
10
+ this.limiter = limiter;
11
+ }
12
+
13
+ contentType() {
14
+ return 'text/plain; version=0.0.4; charset=utf-8';
15
+ }
16
+
17
+ /**
18
+ * Escape label values according to Prometheus rules:
19
+ * \ -> \\
20
+ * " -> \"
21
+ * \n -> \n
22
+ */
23
+ _escapeLabel(str) {
24
+ return String(str)
25
+ .replace(/\\/g, '\\\\')
26
+ .replace(/"/g, '\\"')
27
+ .replace(/\n/g, '\\n');
28
+ }
29
+
30
+ /**
31
+ * Format a single metric block (help, type, and samples).
32
+ */
33
+ _metricBlock(name, help, type, samples) {
34
+ if (!samples || samples.length === 0) return '';
35
+ let block = `# HELP ${name} ${help}\n`;
36
+ block += `# TYPE ${name} ${type}\n`;
37
+
38
+ for (const sample of samples) {
39
+ const labelEntries = Object.entries(sample.labels || {});
40
+ if (labelEntries.length > 0) {
41
+ const labelStrs = labelEntries.map(([k, v]) => `${k}="${this._escapeLabel(v)}"`);
42
+ block += `${name}{${labelStrs.join(',')}} ${sample.value}\n`;
43
+ } else {
44
+ block += `${name} ${sample.value}\n`;
45
+ }
46
+ }
47
+ return block + '\n';
48
+ }
49
+
50
+ format() {
51
+ const stats = this.limiter.getStats();
52
+ let output = '';
53
+
54
+ // 1. Total requests (Counter)
55
+ output += this._metricBlock(
56
+ 'nextlimiter_requests_total',
57
+ 'Total requests processed by nextlimiter',
58
+ 'counter',
59
+ [
60
+ { labels: { status: 'allowed' }, value: stats.allowedRequests },
61
+ { labels: { status: 'blocked' }, value: stats.blockedRequests }
62
+ ]
63
+ );
64
+
65
+ // 2. Block rate (Gauge)
66
+ output += this._metricBlock(
67
+ 'nextlimiter_block_rate',
68
+ 'Ratio of blocked to total requests (0.0 to 1.0)',
69
+ 'gauge',
70
+ [{ labels: {}, value: isNaN(stats.blockRate) ? 0 : stats.blockRate }]
71
+ );
72
+
73
+ // 3. Top blocked IPs (Gauge)
74
+ const topBlockedSamples = (stats.topBlocked || []).slice(0, 10).map((item) => {
75
+ // Clean up IP label
76
+ let ip = item.key.replace(/^nextlimiter:(ip:)?/, '');
77
+ return { labels: { ip }, value: item.count };
78
+ });
79
+ if (topBlockedSamples.length > 0) {
80
+ output += this._metricBlock(
81
+ 'nextlimiter_top_blocked_ip',
82
+ 'Block count per offending IP',
83
+ 'gauge',
84
+ topBlockedSamples
85
+ );
86
+ }
87
+
88
+ // 4. Top keys by volume (Gauge)
89
+ const topKeysSamples = (stats.topKeys || []).slice(0, 10).map((item) => {
90
+ return { labels: { key: item.key }, value: item.count };
91
+ });
92
+ if (topKeysSamples.length > 0) {
93
+ output += this._metricBlock(
94
+ 'nextlimiter_top_keys_total',
95
+ 'Total request count per key',
96
+ 'gauge',
97
+ topKeysSamples
98
+ );
99
+ }
100
+
101
+ // 5. Uptime seconds (Gauge)
102
+ output += this._metricBlock(
103
+ 'nextlimiter_uptime_seconds',
104
+ 'Seconds since limiter was created',
105
+ 'gauge',
106
+ [{ labels: {}, value: stats.uptimeMs / 1000 }]
107
+ );
108
+
109
+ // 6. Info (Gauge = 1)
110
+ const version = '1.0.5'; // Current pkg version
111
+ output += this._metricBlock(
112
+ 'nextlimiter_info',
113
+ 'Static info about the limiter instance',
114
+ 'gauge',
115
+ [{ labels: { strategy: stats.config.strategy, version }, value: 1 }]
116
+ );
117
+
118
+ return output;
119
+ }
120
+ }
121
+
122
+ module.exports = { PrometheusFormatter };
@@ -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');
@@ -12,6 +13,7 @@ const { AnalyticsTracker } = require('../analytics/tracker');
12
13
  const { SmartDetector } = require('../smart/detector');
13
14
  const { setHeaders } = require('../middleware/headers');
14
15
  const { checkAccess } = require('./accessControl');
16
+ const { PrometheusFormatter } = require('../analytics/prometheus');
15
17
 
16
18
  const STRATEGY_MAP = {
17
19
  'fixed-window': fixedWindowCheck,
@@ -32,11 +34,15 @@ const STRATEGY_MAP = {
32
34
  * // Programmatic check
33
35
  * const result = await limiter.check('user:42');
34
36
  */
35
- class Limiter {
37
+ class Limiter extends EventEmitter {
36
38
  /**
37
39
  * @param {object} options - NextLimiter configuration (see config.js for defaults)
38
40
  */
39
41
  constructor(options = {}) {
42
+ super();
43
+ this.setMaxListeners(50);
44
+ this.on('error', () => {}); // default no-op handler prevents crashes
45
+
40
46
  this._config = resolveConfig(options);
41
47
 
42
48
  // Storage backend
@@ -63,7 +69,14 @@ class Limiter {
63
69
  this._analytics = new AnalyticsTracker();
64
70
 
65
71
  // Smart detector
66
- 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
+ }
67
80
  }
68
81
 
69
82
  // ── Public API ─────────────────────────────────────────────────────────────
@@ -86,7 +99,7 @@ class Limiter {
86
99
 
87
100
  // ── Access control (whitelist / blacklist) ───────────────────────────
88
101
  const clientIp = extractIp(req);
89
- const access = checkAccess(clientIp, this._config);
102
+ const access = checkAccess(clientIp, this._config, this);
90
103
 
91
104
  if (access.action === 'block') {
92
105
  return res.status(403).json({
@@ -105,13 +118,17 @@ class Limiter {
105
118
  const key = `${this._config.keyPrefix}${rawKey}`;
106
119
 
107
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);
108
125
 
109
126
  // Record analytics
110
127
  this._analytics.record(key, result.allowed);
111
128
 
112
129
  // Set headers
113
130
  if (this._config.headers) {
114
- setHeaders(res, result, !result.allowed);
131
+ setHeaders(res, result);
115
132
  }
116
133
 
117
134
  if (!result.allowed) {
@@ -139,13 +156,49 @@ class Limiter {
139
156
 
140
157
  next();
141
158
  } catch (err) {
159
+ this.emit('error', err);
142
160
  // Never let rate limiter errors take down the application
143
- this._log.warn(`Error in rate limiter: ${err.message}. Failing open.`);
161
+ this._log.warn(`Middleware error: ${err.message}. Failing open.`);
144
162
  next();
145
163
  }
146
164
  };
147
165
  }
148
166
 
167
+ /**
168
+ * Express route handler for serving Prometheus metrics.
169
+ * Exposes getStats() data in plain text format (version=0.0.4).
170
+ *
171
+ * @example
172
+ * app.get('/metrics', limiter.metricsHandler());
173
+ */
174
+ metricsHandler() {
175
+ return (req, res) => {
176
+ try {
177
+ const formatter = new PrometheusFormatter(this);
178
+ res.set('Content-Type', formatter.contentType());
179
+ res.send(formatter.format());
180
+ } catch (err) {
181
+ res.status(500).type('text/plain').send(`Error generating metrics: ${err.message}`);
182
+ }
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Global middleware that automatically intercepts GET /metrics requests
188
+ * and serves the Prometheus exposition format. Passes through all other routes.
189
+ *
190
+ * @example
191
+ * app.use(limiter.metricsMiddleware());
192
+ */
193
+ metricsMiddleware() {
194
+ return (req, res, next) => {
195
+ if (req.method === 'GET' && req.path === '/metrics') {
196
+ return this.metricsHandler()(req, res);
197
+ }
198
+ next();
199
+ };
200
+ }
201
+
149
202
  /**
150
203
  * Programmatic rate limit check — use outside of HTTP middleware context.
151
204
  *
@@ -160,7 +213,7 @@ class Limiter {
160
213
  // Apply access control if the key looks like a plain IP address
161
214
  const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(String(key).trim());
162
215
  if (looksLikeIp) {
163
- const access = checkAccess(key, this._config);
216
+ const access = checkAccess(key, this._config, this);
164
217
  if (access.action === 'block') {
165
218
  return {
166
219
  allowed: false,
@@ -193,9 +246,35 @@ class Limiter {
193
246
  const fullKey = `${this._config.keyPrefix}${key}`;
194
247
  const result = await this._runCheck(fullKey);
195
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
+
196
253
  return result;
197
254
  }
198
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
+
199
278
  /**
200
279
  * Manually reset the rate limit for a specific key.
201
280
  * Useful after a user upgrades their plan, or for admin overrides.
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ const { PRESETS, DEFAULT_PLANS } = require('./core/config');
5
5
  const { MemoryStore } = require('./store/memoryStore');
6
6
  const { RedisStore } = require('./store/redisStore');
7
7
  const { ipMatchesCidr, ipMatchesList } = require('./utils/cidr');
8
+ const { PrometheusFormatter } = require('./analytics/prometheus');
8
9
 
9
10
  /**
10
11
  * Create a fully configured rate limiter instance.
@@ -107,6 +108,9 @@ module.exports = {
107
108
  ipMatchesCidr,
108
109
  ipMatchesList,
109
110
 
111
+ // Analytics export
112
+ PrometheusFormatter,
113
+
110
114
  // Constants
111
115
  PRESETS,
112
116
  DEFAULT_PLANS,
@@ -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,20 @@ 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
+
153
+ export declare class PrometheusFormatter {
154
+ constructor(limiter: Limiter);
155
+ format(): string;
156
+ contentType(): string;
157
+ }
158
+
142
159
  export interface KeyCount {
143
160
  key: string;
144
161
  count: number;
@@ -175,18 +192,36 @@ export interface LimiterStats {
175
192
 
176
193
  // ── Limiter class ────────────────────────────────────────────────────────────
177
194
 
178
- export declare class Limiter {
195
+ export declare class Limiter extends EventEmitter {
179
196
  constructor(options?: LimiterOptions);
180
197
 
181
198
  /** Returns an Express-compatible middleware function */
182
199
  middleware(): (req: Request, res: Response, next: NextFunction) => Promise<void>;
183
200
 
201
+ /**
202
+ * Express route handler for serving Prometheus metrics.
203
+ * Exposes getStats() data in plain text format (version=0.0.4).
204
+ */
205
+ metricsHandler(): (req: any, res: any) => void;
206
+
207
+ /**
208
+ * Global middleware that automatically intercepts GET /metrics requests.
209
+ * Passes through all other routes.
210
+ */
211
+ metricsMiddleware(): (req: any, res: any, next: any) => void;
212
+
184
213
  /** Programmatic rate limit check by key string */
185
214
  check(key: string): Promise<RateLimitResult>;
186
215
 
187
216
  /** Reset rate limit state for a specific key */
188
217
  reset(key: string): Promise<void>;
189
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
+
190
225
  /** Get analytics snapshot */
191
226
  getStats(): LimiterStats;
192
227
 
@@ -195,6 +230,25 @@ export declare class Limiter {
195
230
 
196
231
  /** Read-only resolved configuration */
197
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;
198
252
  }
199
253
 
200
254
  // ── Factory functions ────────────────────────────────────────────────────────