nextlimiter 1.1.0 → 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.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",
@@ -88,6 +88,15 @@ const DEFAULT_CONFIG = {
88
88
  whitelist: null, // string[] — IPs/CIDRs that bypass rate limiting
89
89
  blacklist: null, // string[] — IPs/CIDRs that always get 403
90
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,
91
100
  };
92
101
 
93
102
  /**
@@ -98,13 +107,31 @@ const DEFAULT_CONFIG = {
98
107
  function resolveConfig(userOptions = {}) {
99
108
  let base = { ...DEFAULT_CONFIG };
100
109
 
101
- // Apply named preset first (lowest priority)
102
- if (userOptions.preset && PRESETS[userOptions.preset]) {
103
- 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.');
104
131
  }
105
132
 
106
- // Merge user options
107
- base = { ...base, ...userOptions };
133
+ // Apply user overrides
134
+ Object.assign(base, userOptions);
108
135
 
109
136
  // Apply plan limits (overrides windowMs and max if plan is set)
110
137
  if (base.plan) {
@@ -120,9 +147,45 @@ function resolveConfig(userOptions = {}) {
120
147
  base._burstMax = planCfg.burstMax;
121
148
  }
122
149
 
123
- // Validate
124
- if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
125
- 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
+ }
126
189
 
127
190
  // Validate whitelist / blacklist (warn, never throw)
128
191
  for (const listName of ['whitelist', 'blacklist']) {
@@ -159,6 +222,18 @@ function resolveConfig(userOptions = {}) {
159
222
  }
160
223
  }
161
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
+
162
237
  return base;
163
238
  }
164
239
 
@@ -14,6 +14,9 @@ const { SmartDetector } = require('../smart/detector');
14
14
  const { setHeaders } = require('../middleware/headers');
15
15
  const { checkAccess } = require('./accessControl');
16
16
  const { PrometheusFormatter } = require('../analytics/prometheus');
17
+ const { RuleEngine } = require('./ruleEngine');
18
+ const { WebhookSender } = require('../webhook/sender');
19
+ const { Scheduler } = require('./scheduler');
17
20
 
18
21
  const STRATEGY_MAP = {
19
22
  'fixed-window': fixedWindowCheck,
@@ -77,6 +80,11 @@ class Limiter extends EventEmitter {
77
80
  this.emit('stats', this.getStats());
78
81
  }, this._config.statsInterval).unref();
79
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;
80
88
  }
81
89
 
82
90
  // ── Public API ─────────────────────────────────────────────────────────────
@@ -114,19 +122,54 @@ class Limiter extends EventEmitter {
114
122
  }
115
123
  // ────────────────────────────────────────────────────────────────────
116
124
 
117
- const rawKey = this._keyGenerator(req);
118
- const key = `${this._config.keyPrefix}${rawKey}`;
119
-
120
- 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
+ }
121
148
 
122
149
  // Emit events
123
- if (result.allowed) this.emit('allowed', rawKey, result);
124
- else this.emit('blocked', rawKey, result);
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
+ }
125
168
 
126
169
  // Record analytics
127
170
  this._analytics.record(key, result.allowed);
128
171
 
129
- // Set headers
172
+ // Set headers (base headers overrides single rule headers if rule engine is active, that is fine)
130
173
  if (this._config.headers) {
131
174
  setHeaders(res, result);
132
175
  }
@@ -307,6 +350,8 @@ class Limiter extends EventEmitter {
307
350
  };
308
351
  }
309
352
 
353
+ stats.activeSchedule = this.scheduler ? this.scheduler.resolve() : null;
354
+
310
355
  stats.config = {
311
356
  strategy: this._config.strategy,
312
357
  windowMs: this._config.windowMs,
@@ -342,14 +387,14 @@ class Limiter extends EventEmitter {
342
387
  * @returns {import('../core/result').RateLimitResult}
343
388
  */
344
389
  _runCheck(key) {
345
- let effectiveConfig = this._config;
390
+ let effectiveConfig = this.scheduler ? this.scheduler.resolve() : this._config;
346
391
 
347
392
  // Apply smart penalty if relevant
348
393
  if (this._smart) {
349
394
  const { penalized, effectiveMax } = this._smart.check(key);
350
395
  if (penalized) {
351
396
  // Create a shallow config override with reduced max
352
- effectiveConfig = { ...this._config, max: effectiveMax };
397
+ effectiveConfig = { ...effectiveConfig, max: effectiveMax };
353
398
  }
354
399
  }
355
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 };
@@ -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
@@ -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';
@@ -105,6 +140,14 @@ export interface LimiterOptions {
105
140
 
106
141
  /** ms interval to emit 'stats' event. undefined = disabled. */
107
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;
108
151
  }
109
152
 
110
153
  // ── Rate limit result ────────────────────────────────────────────────────────