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.
- package/bin/commands/benchmark.js +125 -0
- package/bin/commands/inspect.js +64 -0
- package/bin/commands/test.js +123 -0
- package/bin/nextlimiter.js +44 -0
- package/package.json +5 -1
- package/src/core/config.js +104 -8
- package/src/core/limiter.js +72 -9
- package/src/core/ruleEngine.js +93 -0
- package/src/core/scheduler.js +49 -0
- package/src/dashboard/dashboardMiddleware.js +94 -0
- package/src/dashboard/html.js +382 -0
- package/src/strategies/leakyBucket.js +62 -0
- package/src/strategies/slidingWindowLog.js +54 -0
- package/src/webhook/sender.js +93 -0
- package/types/index.d.ts +64 -2
|
@@ -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.
|
|
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",
|
package/src/core/config.js
CHANGED
|
@@ -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
|
|
102
|
-
if (userOptions.preset
|
|
103
|
-
|
|
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
|
-
//
|
|
107
|
-
base
|
|
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
|
-
//
|
|
124
|
-
if (base.
|
|
125
|
-
|
|
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
|
|
package/src/core/limiter.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
124
|
-
|
|
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 = { ...
|
|
415
|
+
effectiveConfig = { ...effectiveConfig, max: effectiveMax };
|
|
353
416
|
}
|
|
354
417
|
}
|
|
355
418
|
|