nextlimiter 1.1.0 → 1.3.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.
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const https = require('https');
4
+
5
+ function parseArgs() {
6
+ const args = process.argv.slice(3);
7
+ const options = { url: null, duration: 10, concurrency: 10, rps: 0 };
8
+
9
+ for (let i = 0; i < args.length; i++) {
10
+ if (args[i] === '--url') options.url = args[++i];
11
+ else if (args[i] === '--duration') options.duration = parseInt(args[++i], 10);
12
+ else if (args[i] === '--concurrency') options.concurrency = parseInt(args[++i], 10);
13
+ else if (args[i] === '--rps') options.rps = parseInt(args[++i], 10);
14
+ }
15
+
16
+ if (!options.url) {
17
+ console.error('Usage: npx nextlimiter benchmark --url <url> [options]');
18
+ process.exit(1);
19
+ }
20
+ return options;
21
+ }
22
+
23
+ function fetchTarget(targetUrl) {
24
+ const t0 = Date.now();
25
+ return new Promise((resolve) => {
26
+ const parsed = new URL(targetUrl);
27
+ const lib = parsed.protocol === 'https:' ? https : http;
28
+ const req = lib.request({
29
+ hostname: parsed.hostname,
30
+ port: parsed.port,
31
+ path: parsed.pathname + parsed.search,
32
+ method: 'GET',
33
+ timeout: 10000
34
+ }, (res) => {
35
+ res.on('data', () => {});
36
+ res.on('end', () => resolve({ t: Date.now() - t0, s: res.statusCode }));
37
+ });
38
+ req.on('error', () => resolve({ t: Date.now() - t0, s: 0 }));
39
+ req.on('timeout', () => { req.destroy(); resolve({ t: Date.now() - t0, s: 0 }); });
40
+ req.end();
41
+ });
42
+ }
43
+
44
+ function calculatePercentile(arr, p) {
45
+ if (arr.length === 0) return 0;
46
+ arr.sort((a,b) => a - b);
47
+ const idx = Math.floor(arr.length * p);
48
+ return arr[idx];
49
+ }
50
+
51
+ function drawProgressBar(pct, text) {
52
+ if (!process.stdout.isTTY) return;
53
+ const cw = process.stdout.columns || 80;
54
+ const meta = ` ${pct.toFixed(0)}% | ${text}`;
55
+ const barLen = Math.max(10, cw - meta.length - 4);
56
+ const filled = Math.floor(barLen * (pct / 100));
57
+ const empty = barLen - filled;
58
+
59
+ const bar = '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
60
+ process.stdout.write(`\r${bar}${meta}`);
61
+ }
62
+
63
+ module.exports = async function() {
64
+ const opts = parseArgs();
65
+ console.log(`Benchmarking ${opts.url} for ${opts.duration} seconds with ${opts.concurrency} workers...`);
66
+
67
+ const endAt = Date.now() + (opts.duration * 1000);
68
+ let running = true;
69
+ let totalReqs = 0, blocked = 0;
70
+ const latencies = [];
71
+ const codes = { 200: 0, 429: 0 };
72
+ let intervalTimer;
73
+
74
+ const worker = async () => {
75
+ while (running && Date.now() < endAt) {
76
+ const res = await fetchTarget(opts.url);
77
+ totalReqs++;
78
+ latencies.push(res.t);
79
+ if (res.s === 429) { blocked++; codes[429] = (codes[429] || 0) + 1; }
80
+ else if (res.s > 0) { codes[res.s] = (codes[res.s] || 0) + 1; }
81
+
82
+ if (opts.rps > 0) {
83
+ const sleepMs = 1000 / opts.rps;
84
+ await new Promise(r => setTimeout(r, sleepMs));
85
+ }
86
+ }
87
+ };
88
+
89
+ const workers = [];
90
+ for (let i = 0; i < opts.concurrency; i++) {
91
+ workers.push(worker());
92
+ }
93
+
94
+ intervalTimer = setInterval(() => {
95
+ const remaining = endAt - Date.now();
96
+ if (remaining <= 0) return;
97
+ const elapsed = (opts.duration * 1000) - remaining;
98
+ const pct = Math.min(100, (elapsed / (opts.duration * 1000)) * 100);
99
+ const avg = latencies.length > 0 ? Math.round(latencies.reduce((a,b)=>a+b, 0) / latencies.length) : 0;
100
+ drawProgressBar(pct, `${totalReqs} req | ${blocked} blocked | ${avg}ms avg \x1b[K`);
101
+ }, 500);
102
+
103
+ await Promise.all(workers);
104
+ clearInterval(intervalTimer);
105
+ if (process.stdout.isTTY) process.stdout.write('\n\n');
106
+
107
+ const durationSec = opts.duration;
108
+ const throughput = (totalReqs / durationSec).toFixed(1);
109
+ const blockPct = totalReqs > 0 ? ((blocked / totalReqs) * 100).toFixed(2) : '0.00';
110
+ const avgLat = totalReqs > 0 ? Math.round(latencies.reduce((a,b)=>a+b,0) / latencies.length) : 0;
111
+ const p95 = calculatePercentile(latencies, 0.95);
112
+ const p99 = calculatePercentile(latencies, 0.99);
113
+
114
+ let codesText = Object.entries(codes).filter(([_,v])=>v>0).map(([k,v])=>`${k}: ${v}`).join(', ');
115
+
116
+ console.log(`Benchmark Results — ${opts.url}`);
117
+ console.log(`Duration: ${durationSec.toFixed(1)}s`);
118
+ console.log(`Total requests: ${totalReqs}`);
119
+ console.log(`Req/sec: ${throughput}`);
120
+ console.log(`Blocked: ${blocked} (${blockPct}%)`);
121
+ console.log(`Avg latency: ${avgLat}ms`);
122
+ console.log(`p95 latency: ${p95}ms`);
123
+ console.log(`p99 latency: ${p99}ms`);
124
+ console.log(`Status codes: ${codesText}`);
125
+ };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const https = require('https');
4
+
5
+ function parseArgs() {
6
+ const args = process.argv.slice(3);
7
+ const options = { url: args[0] };
8
+
9
+ if (!options.url || options.url.startsWith('-')) {
10
+ console.error('Usage: npx nextlimiter inspect <url>');
11
+ process.exit(1);
12
+ }
13
+ try { new URL(options.url); } catch(e) { console.error('Invalid URL'); process.exit(1); }
14
+ return options;
15
+ }
16
+
17
+ module.exports = async function() {
18
+ const opts = parseArgs();
19
+ const parsed = new URL(opts.url);
20
+ const lib = parsed.protocol === 'https:' ? https : http;
21
+
22
+ return new Promise((resolve) => {
23
+ const req = lib.request({
24
+ hostname: parsed.hostname,
25
+ port: parsed.port,
26
+ path: parsed.pathname + parsed.search,
27
+ method: 'GET',
28
+ timeout: 10000
29
+ }, (res) => {
30
+ res.on('data', () => {});
31
+ res.on('end', () => {
32
+ const h = res.headers;
33
+ const pad = (s, len=26) => String(s).padEnd(len);
34
+
35
+ console.log(`┌─ nextlimiter inspect ──────────────────┐`);
36
+ console.log(`│ URL: ${pad(opts.url)} │`);
37
+ console.log(`│ Status: ${pad(res.statusCode + ' ' + http.STATUS_CODES[res.statusCode])} │`);
38
+
39
+ if (h['x-ratelimit-limit']) console.log(`│ Limit: ${pad(h['x-ratelimit-limit'])} │`);
40
+ if (h['x-ratelimit-remaining']) console.log(`│ Remaining: ${pad(h['x-ratelimit-remaining'])} │`);
41
+ if (h['x-ratelimit-reset']) {
42
+ const date = new Date(parseInt(h['x-ratelimit-reset'], 10) * 1000);
43
+ const txt = !isNaN(date.getTime()) ? date.toISOString().replace('T', ' ').substring(0, 19) + ' UTC' : h['x-ratelimit-reset'];
44
+ console.log(`│ Reset: ${pad(txt)} │`);
45
+ }
46
+ if (h['x-ratelimit-strategy']) console.log(`│ Strategy: ${pad(h['x-ratelimit-strategy'])} │`);
47
+ if (h['retry-after']) console.log(`│ Retry-After: ${pad(h['retry-after'] + ' seconds')} │`);
48
+
49
+ const other = Object.keys(h).filter(k => k.startsWith('x-ratelimit-') && !['x-ratelimit-limit','x-ratelimit-remaining','x-ratelimit-reset','x-ratelimit-strategy'].includes(k));
50
+ other.forEach(k => {
51
+ const v = typeof h[k] === 'string' ? h[k].substring(0, 26) : h[k];
52
+ const keyTxt = k.substring('x-ratelimit-'.length);
53
+ console.log(`│ ${String(keyTxt + ':').padEnd(11)} ${pad(v)} │`);
54
+ });
55
+ console.log(`└────────────────────────────────────────┘`);
56
+ resolve();
57
+ });
58
+ });
59
+
60
+ req.on('error', (err) => { console.error('Error fetching:', err.message); process.exit(1); resolve(); });
61
+ req.on('timeout', () => { console.error('Request timed out'); process.exit(1); resolve(); });
62
+ req.end();
63
+ });
64
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const https = require('https');
4
+
5
+ function parseArgs() {
6
+ const args = process.argv.slice(3);
7
+ const options = { url: args[0], requests: 110, concurrency: 1, method: 'GET', headers: {}, delay: 0, json: false };
8
+
9
+ if (!options.url || options.url.startsWith('-')) {
10
+ console.error('Usage: npx nextlimiter test <url> [options]');
11
+ process.exit(1);
12
+ }
13
+ try { new URL(options.url); } catch(e) { console.error('Invalid URL'); process.exit(1); }
14
+
15
+ for (let i = 1; i < args.length; i++) {
16
+ if (args[i] === '--requests') options.requests = parseInt(args[++i], 10);
17
+ else if (args[i] === '--concurrency') options.concurrency = parseInt(args[++i], 10);
18
+ else if (args[i] === '--method') options.method = args[++i];
19
+ else if (args[i] === '--header') {
20
+ const [k, v] = args[++i].split(':');
21
+ if (k && v) options.headers[k.trim()] = v.trim();
22
+ }
23
+ else if (args[i] === '--delay') options.delay = parseInt(args[++i], 10);
24
+ else if (args[i] === '--json') options.json = true;
25
+ }
26
+ return options;
27
+ }
28
+
29
+ function doRequest(targetUrl, method, headers) {
30
+ return new Promise((resolve) => {
31
+ const parsed = new URL(targetUrl);
32
+ const lib = parsed.protocol === 'https:' ? https : http;
33
+ const req = lib.request({
34
+ hostname: parsed.hostname,
35
+ port: parsed.port,
36
+ path: parsed.pathname + parsed.search,
37
+ method,
38
+ headers,
39
+ timeout: 10000
40
+ }, (res) => {
41
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
42
+ // simple redirect follow
43
+ return resolve(doRequest(res.headers.location, method, headers));
44
+ }
45
+ res.on('data', () => {});
46
+ res.on('end', () => resolve({
47
+ status: res.statusCode,
48
+ limit: res.headers['x-ratelimit-limit'],
49
+ remaining: res.headers['x-ratelimit-remaining'],
50
+ strategy: res.headers['x-ratelimit-strategy'],
51
+ retryAfter: res.headers['retry-after'] || res.headers['x-ratelimit-reset'],
52
+ }));
53
+ });
54
+ req.on('error', (err) => resolve({ error: err.message }));
55
+ req.on('timeout', () => { req.destroy(); resolve({ error: 'timeout' }) });
56
+ req.end();
57
+ });
58
+ }
59
+
60
+ function delayBlock(ms) {
61
+ return new Promise(r => setTimeout(r, ms));
62
+ }
63
+
64
+ module.exports = async function() {
65
+ const opts = parseArgs();
66
+ const isTTY = process.stdout.isTTY;
67
+ const cGreen = isTTY ? '\x1b[32m' : '';
68
+ const cYellow = isTTY ? '\x1b[33m' : '';
69
+ const cRed = isTTY ? '\x1b[31m' : '';
70
+ const cReset = isTTY ? '\x1b[0m' : '';
71
+
72
+ let allowed = 0, blocked = 0, blockStrategy = '', firstBlock = -1;
73
+
74
+ for (let i = 1; i <= opts.requests; i += opts.concurrency) {
75
+ const batch = [];
76
+ for (let j = 0; j < opts.concurrency && (i + j) <= opts.requests; j++) {
77
+ batch.push(doRequest(opts.url, opts.method, opts.headers));
78
+ }
79
+
80
+ const results = await Promise.all(batch);
81
+ results.forEach((r, idx) => {
82
+ const reqNum = i + idx;
83
+ if (r.error) {
84
+ if (!opts.json) console.log(`Request ${reqNum}: ${cRed}Error: ${r.error}${cReset}`);
85
+ return;
86
+ }
87
+ if (r.status >= 200 && r.status < 300) {
88
+ allowed++;
89
+ if (!opts.json) console.log(`Request ${reqNum}:\t${cGreen}${r.status} OK${cReset} \t(remaining: ${r.remaining || '?'})`);
90
+ } else if (r.status === 429) {
91
+ blocked++;
92
+ if (firstBlock === -1) firstBlock = reqNum;
93
+ blockStrategy = blockStrategy || r.strategy || 'unknown';
94
+ if (!opts.json) {
95
+ console.log(`Request ${reqNum}:\t${cYellow}429 Too Many Requests${cReset}`);
96
+ if (idx === batch.length-1) { // print extra details on last item in block batch
97
+ console.log(` Retry-After:\t${r.retryAfter || '?'}`);
98
+ console.log(` Strategy:\t${blockStrategy}`);
99
+ console.log(` Limit:\t${r.limit || '?'}`);
100
+ }
101
+ }
102
+ } else {
103
+ if (!opts.json) console.log(`Request ${reqNum}:\t${cRed}${r.status}${cReset}`);
104
+ }
105
+ });
106
+
107
+ if (opts.delay) await delayBlock(opts.delay);
108
+ }
109
+
110
+ if (opts.json) {
111
+ console.log(JSON.stringify({ total: opts.requests, allowed, blocked, firstBlock, blockStrategy }));
112
+ } else {
113
+ const pad = (s, len=31) => String(s).padEnd(len);
114
+ console.log(`\n┌─────────────────────────────────┐`);
115
+ console.log(`│ Total requests: ${pad(opts.requests, 12)}│`);
116
+ console.log(`│ Allowed: ${pad(allowed, 12)}│`);
117
+ console.log(`│ Blocked (429): ${pad(blocked, 12)}│`);
118
+ console.log(`│ Block rate: ${pad(((blocked/opts.requests)*100).toFixed(2)+'%', 12)}│`);
119
+ console.log(`│ First block at: ${pad(firstBlock === -1 ? 'none' : 'request ' + firstBlock, 12)}│`);
120
+ console.log(`│ Strategy detected: ${pad(blockStrategy || 'N/A', 12)}│`);
121
+ console.log(`└─────────────────────────────────┘`);
122
+ }
123
+ };
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const cmd = process.argv[2];
8
+
9
+ if (!cmd || cmd === '--help' || cmd === '-h') {
10
+ console.log(`
11
+ Usage: npx nextlimiter <command> [options]
12
+
13
+ Commands:
14
+ test <url> Fire N requests and show rate limit behaviour
15
+ benchmark --url <url> Load test a URL and measure throughput
16
+ inspect <url> Show rate limit headers for a single request
17
+
18
+ Examples:
19
+ npx nextlimiter test https://api.example.com/users
20
+ npx nextlimiter test https://api.example.com --requests 200 --delay 10
21
+ npx nextlimiter benchmark --url http://localhost:3000 --duration 30
22
+ npx nextlimiter inspect https://api.example.com
23
+ `);
24
+ process.exit(0);
25
+ }
26
+
27
+ if (cmd === '--version' || cmd === '-v') {
28
+ const pkg = require('../package.json');
29
+ console.log('v' + pkg.version);
30
+ process.exit(0);
31
+ }
32
+
33
+ const commands = {
34
+ test: require('./commands/test'),
35
+ benchmark: require('./commands/benchmark'),
36
+ inspect: require('./commands/inspect')
37
+ };
38
+
39
+ if (!commands[cmd]) {
40
+ console.error(`Unknown command: ${cmd}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ commands[cmd]();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextlimiter",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
@@ -10,7 +10,11 @@
10
10
  "./next": "./src/adapters/next.js",
11
11
  "./hono": "./src/adapters/hono.js"
12
12
  },
13
+ "bin": {
14
+ "nextlimiter": "./bin/nextlimiter.js"
15
+ },
13
16
  "files": [
17
+ "bin/",
14
18
  "src/",
15
19
  "types/",
16
20
  "README.md",
@@ -88,6 +88,18 @@ 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,
100
+
101
+ drainRateMs: undefined, // leaky-bucket only
102
+ capacity: undefined, // leaky-bucket only
91
103
  };
92
104
 
93
105
  /**
@@ -98,13 +110,31 @@ const DEFAULT_CONFIG = {
98
110
  function resolveConfig(userOptions = {}) {
99
111
  let base = { ...DEFAULT_CONFIG };
100
112
 
101
- // Apply named preset first (lowest priority)
102
- if (userOptions.preset && PRESETS[userOptions.preset]) {
103
- base = { ...base, ...PRESETS[userOptions.preset] };
113
+ // Apply preset
114
+ if (userOptions.preset) { // Use userOptions.preset here to ensure it's applied
115
+ const presetCfg = PRESETS[userOptions.preset];
116
+ if (!presetCfg) throw new Error(`[NextLimiter] Unknown preset: ${userOptions.preset}`);
117
+ // Merge preset config into base, allowing userOptions to override later
118
+ base = { ...base, ...presetCfg };
119
+ }
120
+
121
+ // Mutually exclusive core configurations
122
+ const hasRules = userOptions.rules !== undefined && userOptions.rules !== null;
123
+ const hasSchedule = userOptions.schedule !== undefined && userOptions.schedule !== null;
124
+ const hasMax = userOptions.max !== undefined && userOptions.max !== null;
125
+
126
+ if (hasRules && hasSchedule) {
127
+ throw new Error('[NextLimiter] config.rules and config.schedule are mutually exclusive.');
128
+ }
129
+ if (hasRules && hasMax) {
130
+ throw new Error('[NextLimiter] config.max/windowMs and config.rules are mutually exclusive.');
131
+ }
132
+ if (hasSchedule && hasMax) {
133
+ throw new Error('[NextLimiter] config.max/windowMs and config.schedule are mutually exclusive.');
104
134
  }
105
135
 
106
- // Merge user options
107
- base = { ...base, ...userOptions };
136
+ // Apply user overrides
137
+ Object.assign(base, userOptions);
108
138
 
109
139
  // Apply plan limits (overrides windowMs and max if plan is set)
110
140
  if (base.plan) {
@@ -120,9 +150,45 @@ function resolveConfig(userOptions = {}) {
120
150
  base._burstMax = planCfg.burstMax;
121
151
  }
122
152
 
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');
153
+ // Deep validate
154
+ if (base.rules) {
155
+ if (!Array.isArray(base.rules) || base.rules.length === 0) {
156
+ throw new Error('[NextLimiter] config.rules must be a non-empty array.');
157
+ }
158
+ for (const rule of base.rules) {
159
+ if (!rule.keyBy) throw new Error('[NextLimiter] Each rule must have a keyBy property.');
160
+ if (typeof rule.max !== 'number' || rule.max <= 0) throw new Error('[NextLimiter] Each rule must have a positive max integer.');
161
+ if (typeof rule.windowMs !== 'number' || rule.windowMs <= 0) throw new Error('[NextLimiter] Each rule must have a positive windowMs integer.');
162
+ }
163
+ } else if (base.schedule) {
164
+ if (!Array.isArray(base.schedule) || base.schedule.length === 0) {
165
+ throw new Error('[NextLimiter] config.schedule must be a non-empty array.');
166
+ }
167
+ let prevEnd = -1;
168
+ for (let i = 0; i < base.schedule.length; i++) {
169
+ const entry = base.schedule[i];
170
+ if (!entry.hours) throw new Error('[NextLimiter] Each schedule entry must have an hours string (e.g., "9-17").');
171
+ if (typeof entry.max !== 'number' || entry.max <= 0) throw new Error('[NextLimiter] Each schedule entry must have a positive max integer.');
172
+
173
+ const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(entry.hours);
174
+ if (!match) throw new Error(`[NextLimiter] Invalid hours format: ${entry.hours}`);
175
+ let s = parseInt(match[1], 10), e = parseInt(match[2], 10);
176
+ if (s < 0 || s > 23 || e < 0 || e > 23) throw new Error('[NextLimiter] Schedule hours must be between 0 and 23. Got: ' + entry.hours);
177
+ if (e < s) throw new Error(`[NextLimiter] Invalid schedule: end hour (${e}) cannot be less than start hour (${s}).`);
178
+
179
+ // Basic overlap check
180
+ if (s <= prevEnd) console.warn(`[NextLimiter] Warning: Overlapping schedule entries detected.`);
181
+ prevEnd = e;
182
+ }
183
+ if (prevEnd < 23) console.warn(`[NextLimiter] Warning: Schedule entries do not cover a full 24-hr cycle.`);
184
+ // Base max/windowMs must still be valid for fallback
185
+ if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
186
+ if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
187
+ } else {
188
+ // Standard mode validation
189
+ if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
190
+ if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
191
+ }
126
192
 
127
193
  // Validate whitelist / blacklist (warn, never throw)
128
194
  for (const listName of ['whitelist', 'blacklist']) {
@@ -159,6 +225,36 @@ function resolveConfig(userOptions = {}) {
159
225
  }
160
226
  }
161
227
 
228
+ // Validate webhook
229
+ if (base.webhook) {
230
+ if (!base.webhook.startsWith('http://') && !base.webhook.startsWith('https://')) {
231
+ throw new Error('[NextLimiter] config.webhook must be a valid URL starting with http:// or https://');
232
+ }
233
+ if (base.webhook.startsWith('http://')) {
234
+ console.warn('[NextLimiter] Warning: Webhook URL is using insecure http://. Consider switching to https://');
235
+ }
236
+ if (base.webhookRetries < 0 || base.webhookRetries > 10) base.webhookRetries = 3;
237
+ if (base.webhookTimeout < 500) base.webhookTimeout = 5000;
238
+ }
239
+
240
+ // Validate new strategies
241
+ if (base.strategy === 'sliding-window-log' && base.max > 10000) {
242
+ console.warn('[NextLimiter] Warning: "sliding-window-log" stores an array of timestamps. Using max > 10000 may impact memory and performance.');
243
+ }
244
+
245
+ if (base.drainRateMs !== undefined || base.capacity !== undefined) {
246
+ if (base.strategy !== 'leaky-bucket') {
247
+ console.warn('[NextLimiter] Warning: drainRateMs and capacity are ignored for strategy "' + base.strategy + '"');
248
+ } else {
249
+ if (base.drainRateMs !== undefined && (typeof base.drainRateMs !== 'number' || base.drainRateMs <= 0)) {
250
+ throw new Error('[NextLimiter] drainRateMs must be a positive number');
251
+ }
252
+ if (base.capacity !== undefined && (typeof base.capacity !== 'number' || base.capacity <= 0 || !Number.isInteger(base.capacity))) {
253
+ throw new Error('[NextLimiter] capacity must be a positive integer');
254
+ }
255
+ }
256
+ }
257
+
162
258
  return base;
163
259
  }
164
260
 
@@ -6,6 +6,8 @@ const { MemoryStore } = require('../store/memoryStore');
6
6
  const { fixedWindowCheck } = require('../strategies/fixedWindow');
7
7
  const { slidingWindowCheck } = require('../strategies/slidingWindow');
8
8
  const { tokenBucketCheck } = require('../strategies/tokenBucket');
9
+ const { slidingWindowLog } = require('../strategies/slidingWindowLog');
10
+ const { leakyBucket } = require('../strategies/leakyBucket');
9
11
  const { resolveKeyGenerator } = require('../utils/keyGenerator');
10
12
  const { extractIp } = require('../utils/keyGenerator');
11
13
  const { createLogger } = require('../utils/logger');
@@ -14,11 +16,17 @@ const { SmartDetector } = require('../smart/detector');
14
16
  const { setHeaders } = require('../middleware/headers');
15
17
  const { checkAccess } = require('./accessControl');
16
18
  const { PrometheusFormatter } = require('../analytics/prometheus');
19
+ const { RuleEngine } = require('./ruleEngine');
20
+ const { WebhookSender } = require('../webhook/sender');
21
+ const { Scheduler } = require('./scheduler');
22
+ const { dashboardMiddleware } = require('../dashboard/dashboardMiddleware');
17
23
 
18
24
  const STRATEGY_MAP = {
19
25
  'fixed-window': fixedWindowCheck,
20
26
  'sliding-window': slidingWindowCheck,
21
27
  'token-bucket': tokenBucketCheck,
28
+ 'sliding-window-log': slidingWindowLog,
29
+ 'leaky-bucket': leakyBucket,
22
30
  };
23
31
 
24
32
  /**
@@ -77,6 +85,11 @@ class Limiter extends EventEmitter {
77
85
  this.emit('stats', this.getStats());
78
86
  }, this._config.statsInterval).unref();
79
87
  }
88
+
89
+ // Engine, Scheduler & Webhook
90
+ this.ruleEngine = this._config.rules ? new RuleEngine(this._config.rules, this._store, this, this._config) : null;
91
+ this.scheduler = this._config.schedule ? new Scheduler(this._config.schedule, this._config) : null;
92
+ this.webhookSender = this._config.webhook ? new WebhookSender(this._config) : null;
80
93
  }
81
94
 
82
95
  // ── Public API ─────────────────────────────────────────────────────────────
@@ -114,19 +127,54 @@ class Limiter extends EventEmitter {
114
127
  }
115
128
  // ────────────────────────────────────────────────────────────────────
116
129
 
117
- const rawKey = this._keyGenerator(req);
118
- const key = `${this._config.keyPrefix}${rawKey}`;
119
-
120
- const result = await this._runCheck(key);
130
+ let result;
131
+ let rawKey;
132
+ let key;
133
+ let failedRuleName;
134
+
135
+ if (this.ruleEngine) {
136
+ const ruleResult = await this.ruleEngine.check(req);
137
+ result = ruleResult.mostRestrictive;
138
+ rawKey = ruleResult.key; // composite key
139
+ key = ruleResult.key;
140
+ failedRuleName = ruleResult.failedRule ? ruleResult.failedRule.name : undefined;
141
+
142
+ if (this._config.headers) {
143
+ res.setHeader('X-RateLimit-Rule-Count', ruleResult.results.length);
144
+ if (!ruleResult.allowed && failedRuleName) {
145
+ res.setHeader('X-RateLimit-Failed-Rule', failedRuleName);
146
+ }
147
+ }
148
+ } else {
149
+ rawKey = this._keyGenerator(req);
150
+ key = `${this._config.keyPrefix}${rawKey}`;
151
+ result = await this._runCheck(key);
152
+ }
121
153
 
122
154
  // Emit events
123
- if (result.allowed) this.emit('allowed', rawKey, result);
124
- else this.emit('blocked', rawKey, result);
155
+ if (result.allowed) {
156
+ this.emit('allowed', rawKey, result);
157
+ } else {
158
+ this.emit('blocked', rawKey, result);
159
+ if (this.webhookSender) {
160
+ this.webhookSender.send({
161
+ event: 'blocked',
162
+ key,
163
+ ip: clientIp,
164
+ limit: result.limit,
165
+ count: result.limit - result.remaining,
166
+ timestamp: new Date().toISOString(),
167
+ retryAfter: result.retryAfter,
168
+ strategy: result.strategy,
169
+ ...(failedRuleName ? { ruleName: failedRuleName } : {})
170
+ });
171
+ }
172
+ }
125
173
 
126
174
  // Record analytics
127
175
  this._analytics.record(key, result.allowed);
128
176
 
129
- // Set headers
177
+ // Set headers (base headers overrides single rule headers if rule engine is active, that is fine)
130
178
  if (this._config.headers) {
131
179
  setHeaders(res, result);
132
180
  }
@@ -288,6 +336,19 @@ class Limiter extends EventEmitter {
288
336
  this._log.info(`Reset key: ${fullKey}`);
289
337
  }
290
338
 
339
+ /**
340
+ * Returns an Express middleware for displaying a live dashboard.
341
+ *
342
+ * @param {object} options
343
+ * @param {string} options.password - If set, enable HTTP Basic Auth
344
+ * @param {number} options.refreshMs - SSE push interval (default 2000ms)
345
+ * @param {string} options.path - Mount path prefix (default '/nextlimiter')
346
+ * @returns {function}
347
+ */
348
+ dashboardMiddleware(options = {}) {
349
+ return dashboardMiddleware(this, options);
350
+ }
351
+
291
352
  /**
292
353
  * Get analytics snapshot.
293
354
  *
@@ -307,6 +368,8 @@ class Limiter extends EventEmitter {
307
368
  };
308
369
  }
309
370
 
371
+ stats.activeSchedule = this.scheduler ? this.scheduler.resolve() : null;
372
+
310
373
  stats.config = {
311
374
  strategy: this._config.strategy,
312
375
  windowMs: this._config.windowMs,
@@ -342,14 +405,14 @@ class Limiter extends EventEmitter {
342
405
  * @returns {import('../core/result').RateLimitResult}
343
406
  */
344
407
  _runCheck(key) {
345
- let effectiveConfig = this._config;
408
+ let effectiveConfig = this.scheduler ? this.scheduler.resolve() : this._config;
346
409
 
347
410
  // Apply smart penalty if relevant
348
411
  if (this._smart) {
349
412
  const { penalized, effectiveMax } = this._smart.check(key);
350
413
  if (penalized) {
351
414
  // Create a shallow config override with reduced max
352
- effectiveConfig = { ...this._config, max: effectiveMax };
415
+ effectiveConfig = { ...effectiveConfig, max: effectiveMax };
353
416
  }
354
417
  }
355
418