nextlimiter 1.2.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 +21 -0
- package/src/core/limiter.js +18 -0
- package/src/core/ruleEngine.js +4 -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/types/index.d.ts +22 -3
|
@@ -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
|
@@ -97,6 +97,9 @@ const DEFAULT_CONFIG = {
|
|
|
97
97
|
webhookBackoff: 'exponential',
|
|
98
98
|
webhookTimeout: 5000,
|
|
99
99
|
webhookSecret: null,
|
|
100
|
+
|
|
101
|
+
drainRateMs: undefined, // leaky-bucket only
|
|
102
|
+
capacity: undefined, // leaky-bucket only
|
|
100
103
|
};
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -234,6 +237,24 @@ function resolveConfig(userOptions = {}) {
|
|
|
234
237
|
if (base.webhookTimeout < 500) base.webhookTimeout = 5000;
|
|
235
238
|
}
|
|
236
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
|
+
|
|
237
258
|
return base;
|
|
238
259
|
}
|
|
239
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');
|
|
@@ -17,11 +19,14 @@ const { PrometheusFormatter } = require('../analytics/prometheus');
|
|
|
17
19
|
const { RuleEngine } = require('./ruleEngine');
|
|
18
20
|
const { WebhookSender } = require('../webhook/sender');
|
|
19
21
|
const { Scheduler } = require('./scheduler');
|
|
22
|
+
const { dashboardMiddleware } = require('../dashboard/dashboardMiddleware');
|
|
20
23
|
|
|
21
24
|
const STRATEGY_MAP = {
|
|
22
25
|
'fixed-window': fixedWindowCheck,
|
|
23
26
|
'sliding-window': slidingWindowCheck,
|
|
24
27
|
'token-bucket': tokenBucketCheck,
|
|
28
|
+
'sliding-window-log': slidingWindowLog,
|
|
29
|
+
'leaky-bucket': leakyBucket,
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -331,6 +336,19 @@ class Limiter extends EventEmitter {
|
|
|
331
336
|
this._log.info(`Reset key: ${fullKey}`);
|
|
332
337
|
}
|
|
333
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
|
+
|
|
334
352
|
/**
|
|
335
353
|
* Get analytics snapshot.
|
|
336
354
|
*
|
package/src/core/ruleEngine.js
CHANGED
|
@@ -3,11 +3,15 @@ const { resolveKeyGenerator } = require('../utils/keyGenerator');
|
|
|
3
3
|
const { fixedWindowCheck } = require('../strategies/fixedWindow');
|
|
4
4
|
const { slidingWindowCheck } = require('../strategies/slidingWindow');
|
|
5
5
|
const { tokenBucketCheck } = require('../strategies/tokenBucket');
|
|
6
|
+
const { slidingWindowLog } = require('../strategies/slidingWindowLog');
|
|
7
|
+
const { leakyBucket } = require('../strategies/leakyBucket');
|
|
6
8
|
|
|
7
9
|
const STRATEGY_MAP = {
|
|
8
10
|
'fixed-window': fixedWindowCheck,
|
|
9
11
|
'sliding-window': slidingWindowCheck,
|
|
10
12
|
'token-bucket': tokenBucketCheck,
|
|
13
|
+
'sliding-window-log': slidingWindowLog,
|
|
14
|
+
'leaky-bucket': leakyBucket,
|
|
11
15
|
};
|
|
12
16
|
|
|
13
17
|
class RuleEngine {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Buffer } = require('buffer');
|
|
4
|
+
const { generateDashboardHTML } = require('./html');
|
|
5
|
+
|
|
6
|
+
function dashboardMiddleware(limiter, options = {}) {
|
|
7
|
+
const mountPath = options.path || '/nextlimiter';
|
|
8
|
+
const refreshMs = options.refreshMs || 2000;
|
|
9
|
+
|
|
10
|
+
// Ring buffer for history (max 60 entries)
|
|
11
|
+
const history = [];
|
|
12
|
+
|
|
13
|
+
const historyInterval = setInterval(() => {
|
|
14
|
+
history.push({ timestamp: Date.now(), stats: limiter.getStats() });
|
|
15
|
+
if (history.length > 60) history.shift();
|
|
16
|
+
}, refreshMs);
|
|
17
|
+
if (historyInterval.unref) historyInterval.unref();
|
|
18
|
+
|
|
19
|
+
return function(req, res, next) {
|
|
20
|
+
const fullPath = req.originalUrl || req.url || '';
|
|
21
|
+
if (!fullPath.startsWith(mountPath)) {
|
|
22
|
+
return next();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.password) {
|
|
26
|
+
const authHeader = req.headers.authorization;
|
|
27
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
28
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="nextlimiter dashboard"');
|
|
29
|
+
res.statusCode = 401;
|
|
30
|
+
return res.end('Unauthorized');
|
|
31
|
+
}
|
|
32
|
+
const b64 = authHeader.substring(6);
|
|
33
|
+
const credentials = Buffer.from(b64, 'base64').toString();
|
|
34
|
+
const matchPos = credentials.indexOf(':');
|
|
35
|
+
const pass = matchPos >= 0 ? credentials.substring(matchPos + 1) : credentials;
|
|
36
|
+
if (pass !== options.password) {
|
|
37
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="nextlimiter dashboard"');
|
|
38
|
+
res.statusCode = 401;
|
|
39
|
+
return res.end('Unauthorized');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const subPath = fullPath.substring(fullPath.indexOf(mountPath) + mountPath.length).split('?')[0] || '/';
|
|
44
|
+
|
|
45
|
+
if (req.method === 'GET' && (subPath === '/' || subPath === '')) {
|
|
46
|
+
res.setHeader('Content-Type', 'text/html');
|
|
47
|
+
res.statusCode = 200;
|
|
48
|
+
return res.end(generateDashboardHTML({ refreshMs }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (req.method === 'GET' && subPath === '/api/stats') {
|
|
52
|
+
res.setHeader('Content-Type', 'application/json');
|
|
53
|
+
res.statusCode = 200;
|
|
54
|
+
return res.end(JSON.stringify(limiter.getStats()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (req.method === 'GET' && subPath === '/api/history') {
|
|
58
|
+
res.setHeader('Content-Type', 'application/json');
|
|
59
|
+
res.statusCode = 200;
|
|
60
|
+
return res.end(JSON.stringify(history));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (req.method === 'POST' && subPath.startsWith('/api/reset/')) {
|
|
64
|
+
const keyToReset = decodeURIComponent(subPath.substring('/api/reset/'.length));
|
|
65
|
+
limiter.resetKey(keyToReset);
|
|
66
|
+
res.setHeader('Content-Type', 'application/json');
|
|
67
|
+
res.statusCode = 200;
|
|
68
|
+
return res.end(JSON.stringify({ success: true }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (req.method === 'GET' && subPath === '/api/stream') {
|
|
72
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
73
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
74
|
+
res.setHeader('Connection', 'keep-alive');
|
|
75
|
+
res.statusCode = 200;
|
|
76
|
+
|
|
77
|
+
const sendData = () => {
|
|
78
|
+
res.write(`data: ${JSON.stringify(limiter.getStats())}\n\n`);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const interval = setInterval(sendData, refreshMs);
|
|
82
|
+
sendData(); // Send initial state immediately
|
|
83
|
+
|
|
84
|
+
req.on('close', () => clearInterval(interval));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 404 for unknown dashboard routes
|
|
89
|
+
res.statusCode = 404;
|
|
90
|
+
res.end('Not Found');
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { dashboardMiddleware };
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function generateDashboardHTML(options) {
|
|
4
|
+
return `<!DOCTYPE html>
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="UTF-8">
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
9
|
+
<title>nextlimiter dashboard</title>
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0d1117;
|
|
13
|
+
--card: #161b22;
|
|
14
|
+
--text: #c9d1d9;
|
|
15
|
+
--accent: #58a6ff;
|
|
16
|
+
--border: #30363d;
|
|
17
|
+
--red: #f85149;
|
|
18
|
+
--green: #2ea043;
|
|
19
|
+
--yellow: #d29922;
|
|
20
|
+
--font: 'Courier New', monospace;
|
|
21
|
+
}
|
|
22
|
+
body {
|
|
23
|
+
background: var(--bg);
|
|
24
|
+
color: var(--text);
|
|
25
|
+
font-family: var(--font);
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 20px;
|
|
28
|
+
}
|
|
29
|
+
.header {
|
|
30
|
+
display: flex;
|
|
31
|
+
justify-content: space-between;
|
|
32
|
+
align-items: center;
|
|
33
|
+
border-bottom: 1px solid var(--border);
|
|
34
|
+
padding-bottom: 10px;
|
|
35
|
+
margin-bottom: 20px;
|
|
36
|
+
}
|
|
37
|
+
.title {
|
|
38
|
+
font-size: 24px;
|
|
39
|
+
font-weight: bold;
|
|
40
|
+
color: var(--accent);
|
|
41
|
+
}
|
|
42
|
+
.controls {
|
|
43
|
+
display: flex;
|
|
44
|
+
gap: 10px;
|
|
45
|
+
align-items: center;
|
|
46
|
+
}
|
|
47
|
+
.dot {
|
|
48
|
+
width: 12px;
|
|
49
|
+
height: 12px;
|
|
50
|
+
border-radius: 50%;
|
|
51
|
+
display: inline-block;
|
|
52
|
+
}
|
|
53
|
+
.dot.green { background: var(--green); }
|
|
54
|
+
.dot.red { background: var(--red); }
|
|
55
|
+
.btn {
|
|
56
|
+
background: var(--card);
|
|
57
|
+
border: 1px solid var(--border);
|
|
58
|
+
color: var(--text);
|
|
59
|
+
padding: 6px 12px;
|
|
60
|
+
font-family: inherit;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
border-radius: 4px;
|
|
63
|
+
}
|
|
64
|
+
.btn:hover { background: var(--border); }
|
|
65
|
+
.btn.danger { color: var(--red); border-color: var(--red); }
|
|
66
|
+
.btn.danger:hover { background: rgba(248, 81, 73, 0.1); }
|
|
67
|
+
|
|
68
|
+
.cards {
|
|
69
|
+
display: grid;
|
|
70
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
71
|
+
gap: 15px;
|
|
72
|
+
margin-bottom: 20px;
|
|
73
|
+
}
|
|
74
|
+
.card {
|
|
75
|
+
background: var(--card);
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
padding: 15px;
|
|
78
|
+
border-radius: 6px;
|
|
79
|
+
}
|
|
80
|
+
.card-title {
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
color: #8b949e;
|
|
83
|
+
margin-bottom: 10px;
|
|
84
|
+
}
|
|
85
|
+
.card-val {
|
|
86
|
+
font-size: 28px;
|
|
87
|
+
font-weight: bold;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.chart-container {
|
|
91
|
+
background: var(--card);
|
|
92
|
+
border: 1px solid var(--border);
|
|
93
|
+
padding: 15px;
|
|
94
|
+
border-radius: 6px;
|
|
95
|
+
margin-bottom: 20px;
|
|
96
|
+
height: 200px;
|
|
97
|
+
position: relative;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.tables {
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
103
|
+
gap: 20px;
|
|
104
|
+
}
|
|
105
|
+
.table-card {
|
|
106
|
+
background: var(--card);
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: 6px;
|
|
109
|
+
padding: 15px;
|
|
110
|
+
overflow-x: auto;
|
|
111
|
+
}
|
|
112
|
+
table {
|
|
113
|
+
width: 100%;
|
|
114
|
+
border-collapse: collapse;
|
|
115
|
+
margin-top: 10px;
|
|
116
|
+
}
|
|
117
|
+
th, td {
|
|
118
|
+
padding: 8px;
|
|
119
|
+
text-align: left;
|
|
120
|
+
border-bottom: 1px solid var(--border);
|
|
121
|
+
}
|
|
122
|
+
th {
|
|
123
|
+
color: #8b949e;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
user-select: none;
|
|
126
|
+
}
|
|
127
|
+
th:hover { color: var(--text); }
|
|
128
|
+
tr.highlight td { color: var(--red); }
|
|
129
|
+
|
|
130
|
+
</style>
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
|
|
134
|
+
<div class="header">
|
|
135
|
+
<div class="title">nextlimiter dashboard</div>
|
|
136
|
+
<div class="controls">
|
|
137
|
+
<span id="timestamp"></span>
|
|
138
|
+
<span id="statusDot" class="dot red"></span>
|
|
139
|
+
<button id="pauseBtn" class="btn">Pause Updates</button>
|
|
140
|
+
<button id="resetAllBtn" class="btn danger" onclick="resetAll()">Reset All Blocks</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div class="cards">
|
|
145
|
+
<div class="card">
|
|
146
|
+
<div class="card-title">Total Requests</div>
|
|
147
|
+
<div id="valTotal" class="card-val">0</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="card">
|
|
150
|
+
<div class="card-title">Blocked Requests</div>
|
|
151
|
+
<div id="valBlocked" class="card-val" style="color: var(--text);">0</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="card">
|
|
154
|
+
<div class="card-title">Block Rate %</div>
|
|
155
|
+
<div id="valRate" class="card-val" style="color: var(--green);">0.0%</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="card">
|
|
158
|
+
<div class="card-title">Uptime</div>
|
|
159
|
+
<div id="valUptime" class="card-val">0s</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="chart-container" id="sparkline">
|
|
164
|
+
<!-- SVG injected here -->
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div class="tables">
|
|
168
|
+
<div class="table-card">
|
|
169
|
+
<div class="card-title">Top 10 Blocked IPs</div>
|
|
170
|
+
<table id="tblBlocked">
|
|
171
|
+
<thead><tr><th onclick="sortTbl('tblBlocked', 0)">IP / Key</th><th onclick="sortTbl('tblBlocked', 1)">Count</th><th onclick="sortTbl('tblBlocked', 2)">%</th><th>Action</th></tr></thead>
|
|
172
|
+
<tbody></tbody>
|
|
173
|
+
</table>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="table-card">
|
|
176
|
+
<div class="card-title">Top 10 Keys by Volume</div>
|
|
177
|
+
<table id="tblVol">
|
|
178
|
+
<thead><tr><th onclick="sortTbl('tblVol', 0)">Key</th><th onclick="sortTbl('tblVol', 1)">Requests</th><th>Action</th></tr></thead>
|
|
179
|
+
<tbody></tbody>
|
|
180
|
+
</table>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<script>
|
|
185
|
+
let es = null;
|
|
186
|
+
let isPaused = false;
|
|
187
|
+
let historyData = [];
|
|
188
|
+
let sortState = { tblBlocked: [1, false], tblVol: [1, false] };
|
|
189
|
+
|
|
190
|
+
const basePath = window.location.pathname.replace(/\/$/, '');
|
|
191
|
+
|
|
192
|
+
function parseUptime(ms) {
|
|
193
|
+
if (!ms) return '0s';
|
|
194
|
+
const totalSec = Math.floor(ms / 1000);
|
|
195
|
+
const h = Math.floor(totalSec / 3600);
|
|
196
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
197
|
+
const s = totalSec % 60;
|
|
198
|
+
if (h > 0) return h + 'h ' + m + 'm';
|
|
199
|
+
if (m > 0) return m + 'm ' + s + 's';
|
|
200
|
+
return s + 's';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function mountSvgChart(containerId, historyArr) {
|
|
204
|
+
const container = document.getElementById(containerId);
|
|
205
|
+
if (!historyArr || historyArr.length === 0) {
|
|
206
|
+
container.innerHTML = '<div style="color:#8b949e; text-align:center; padding-top:80px;">Awaiting data...</div>';
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Compute diffs
|
|
211
|
+
const diffs = [];
|
|
212
|
+
for (let i = 1; i < historyArr.length; i++) {
|
|
213
|
+
const prev = historyArr[i-1].stats;
|
|
214
|
+
const curr = historyArr[i].stats;
|
|
215
|
+
const allowedDiff = Math.max(0, curr.totalRequests - prev.totalRequests - (curr.blockedRequests - prev.blockedRequests));
|
|
216
|
+
const blockedDiff = Math.max(0, curr.blockedRequests - prev.blockedRequests);
|
|
217
|
+
diffs.push({ a: allowedDiff, b: blockedDiff });
|
|
218
|
+
}
|
|
219
|
+
if (diffs.length === 0) return;
|
|
220
|
+
|
|
221
|
+
let maxVal = 10;
|
|
222
|
+
diffs.forEach(d => {
|
|
223
|
+
if (d.a + d.b > maxVal) maxVal = d.a + d.b;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const w = container.clientWidth - 20;
|
|
227
|
+
const h = container.clientHeight - 40;
|
|
228
|
+
const pxPerStep = w / Math.max(1, diffs.length - 1);
|
|
229
|
+
|
|
230
|
+
let ptsA = '0,'+h+' ';
|
|
231
|
+
let ptsB = '0,'+h+' ';
|
|
232
|
+
|
|
233
|
+
diffs.forEach((d, i) => {
|
|
234
|
+
const x = i * pxPerStep;
|
|
235
|
+
const ya = h - ((d.a / maxVal) * h);
|
|
236
|
+
const yb = h - ((d.b / maxVal) * h);
|
|
237
|
+
ptsA += x+','+ya+' ';
|
|
238
|
+
ptsB += x+','+yb+' ';
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const lastX = (diffs.length-1)*pxPerStep;
|
|
242
|
+
ptsA += lastX+','+h; // close path for area
|
|
243
|
+
ptsB += lastX+','+h;
|
|
244
|
+
|
|
245
|
+
const svg = '<svg width="100%" height="100%" viewBox="-10 -20 ' + (container.clientWidth) + ' ' + (container.clientHeight) + '">'+
|
|
246
|
+
'<text x="0" y="-5" fill="#8b949e" font-size="12">Requests / interval (Max: '+maxVal+')</text>'+
|
|
247
|
+
'<polyline fill="rgba(46,160,67,0.2)" stroke="#2ea043" stroke-width="2" points="'+ptsA+'" />'+
|
|
248
|
+
'<polyline fill="rgba(248,81,73,0.3)" stroke="#f85149" stroke-width="2" points="'+ptsB+'" />'+
|
|
249
|
+
'</svg>';
|
|
250
|
+
|
|
251
|
+
container.innerHTML = svg;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderTable(tableId, data, isBlockedTbl) {
|
|
255
|
+
const tbody = document.querySelector('#' + tableId + ' tbody');
|
|
256
|
+
tbody.innerHTML = '';
|
|
257
|
+
|
|
258
|
+
const [sortCol, asc] = sortState[tableId];
|
|
259
|
+
data.sort((a,b) => {
|
|
260
|
+
let valA, valB;
|
|
261
|
+
if (sortCol === 0) { valA = a.key; valB = b.key; }
|
|
262
|
+
else if (sortCol === 1) { valA = a.count; valB = b.count; }
|
|
263
|
+
else if (sortCol === 2) { valA = a.rate; valB = b.rate; }
|
|
264
|
+
if (valA < valB) return asc ? -1 : 1;
|
|
265
|
+
if (valA > valB) return asc ? 1 : -1;
|
|
266
|
+
return 0;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let html = '';
|
|
270
|
+
data.forEach(row => {
|
|
271
|
+
const hl = row.count > 1000 && isBlockedTbl ? 'highlight' : '';
|
|
272
|
+
const pct = isBlockedTbl ? '<td>' + (row.rate*100).toFixed(1) + '%</td>' : '';
|
|
273
|
+
html += '<tr class="'+hl+'">'+
|
|
274
|
+
'<td>'+row.key+'</td>'+
|
|
275
|
+
'<td>'+row.count+'</td>'+
|
|
276
|
+
pct +
|
|
277
|
+
'<td><button class="btn" style="padding: 2px 8px;" onclick="resetKey(\\''+encodeURIComponent(row.key)+'\\')">Reset</button></td>'+
|
|
278
|
+
'</tr>';
|
|
279
|
+
});
|
|
280
|
+
tbody.innerHTML = html;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function sortTbl(tableId, colIndex) {
|
|
284
|
+
if (sortState[tableId][0] === colIndex) {
|
|
285
|
+
sortState[tableId][1] = !sortState[tableId][1];
|
|
286
|
+
} else {
|
|
287
|
+
sortState[tableId] = [colIndex, false];
|
|
288
|
+
}
|
|
289
|
+
updateAllPanels(historyData[historyData.length-1].stats);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function updateAllPanels(stats) {
|
|
293
|
+
const d = new Date();
|
|
294
|
+
document.getElementById('timestamp').innerText = d.toLocaleTimeString();
|
|
295
|
+
|
|
296
|
+
document.getElementById('valTotal').innerText = stats.totalRequests || 0;
|
|
297
|
+
document.getElementById('valBlocked').innerText = stats.blockedRequests || 0;
|
|
298
|
+
document.getElementById('valBlocked').style.color = stats.blockedRequests > 0 ? 'var(--red)' : 'var(--text)';
|
|
299
|
+
|
|
300
|
+
const blockRate = stats.totalRequests ? (stats.blockedRequests / stats.totalRequests) : 0;
|
|
301
|
+
const rateTxt = (blockRate * 100).toFixed(2) + '%';
|
|
302
|
+
document.getElementById('valRate').innerText = rateTxt;
|
|
303
|
+
document.getElementById('valRate').style.color = blockRate > 0.15 ? 'var(--red)' : (blockRate > 0.05 ? 'var(--yellow)' : 'var(--green)');
|
|
304
|
+
|
|
305
|
+
document.getElementById('valUptime').innerText = parseUptime(stats.uptimeMs);
|
|
306
|
+
|
|
307
|
+
// Map top objects to array for table renderer
|
|
308
|
+
const topBlockedSource = Array.isArray(stats.topBlocked) ? stats.topBlocked : [];
|
|
309
|
+
const bArr = topBlockedSource.map(item => ({ key: item.key, count: item.count, rate: stats.blockedRequests ? item.count/stats.blockedRequests : 0 })).slice(0, 10);
|
|
310
|
+
renderTable('tblBlocked', bArr, true);
|
|
311
|
+
|
|
312
|
+
const topKeysSource = Array.isArray(stats.topKeys) ? stats.topKeys : [];
|
|
313
|
+
const vArr = topKeysSource.map(item => ({ key: item.key, count: item.count })).slice(0, 10);
|
|
314
|
+
renderTable('tblVol', vArr, false);
|
|
315
|
+
|
|
316
|
+
mountSvgChart('sparkline', historyData);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function resetKey(keyUri) {
|
|
320
|
+
try {
|
|
321
|
+
await fetch(basePath + '/api/reset/' + keyUri, { method: 'POST' });
|
|
322
|
+
// Assume successful reset, next SSE tick will update UI
|
|
323
|
+
} catch(err) {
|
|
324
|
+
console.error(err);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function resetAll() {
|
|
329
|
+
if (!historyData.length) return;
|
|
330
|
+
const stats = historyData[historyData.length-1].stats;
|
|
331
|
+
const items = Array.isArray(stats.topBlocked) ? stats.topBlocked : [];
|
|
332
|
+
for (let i=0; i < items.length; i++) {
|
|
333
|
+
await resetKey(encodeURIComponent(items[i].key));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function showDisconnectedState() {
|
|
338
|
+
document.getElementById('statusDot').className = 'dot red';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function fetchHistory() {
|
|
342
|
+
fetch(basePath + '/api/history').then(r=>r.json()).then(data => {
|
|
343
|
+
historyData = data;
|
|
344
|
+
if (historyData.length) {
|
|
345
|
+
updateAllPanels(historyData[historyData.length-1].stats);
|
|
346
|
+
}
|
|
347
|
+
}).catch(() => showDisconnectedState());
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function connectSSE() {
|
|
351
|
+
es = new EventSource(basePath + '/api/stream');
|
|
352
|
+
es.onmessage = (e) => {
|
|
353
|
+
if (isPaused) return;
|
|
354
|
+
const stats = JSON.parse(e.data);
|
|
355
|
+
historyData.push({ timestamp: Date.now(), stats });
|
|
356
|
+
if (historyData.length > 60) historyData.shift();
|
|
357
|
+
|
|
358
|
+
document.getElementById('statusDot').className = 'dot green';
|
|
359
|
+
updateAllPanels(stats);
|
|
360
|
+
};
|
|
361
|
+
es.onerror = () => {
|
|
362
|
+
showDisconnectedState();
|
|
363
|
+
es.close();
|
|
364
|
+
setTimeout(connectSSE, 5000);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
document.getElementById('pauseBtn').addEventListener('click', (e) => {
|
|
369
|
+
isPaused = !isPaused;
|
|
370
|
+
e.target.innerText = isPaused ? 'Resume Updates' : 'Pause Updates';
|
|
371
|
+
e.target.style.background = isPaused ? 'var(--border)' : 'var(--card)';
|
|
372
|
+
document.getElementById('statusDot').className = isPaused ? 'dot yellow' : 'dot green';
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
fetchHistory();
|
|
376
|
+
connectSSE();
|
|
377
|
+
</script>
|
|
378
|
+
</body>
|
|
379
|
+
</html>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = { generateDashboardHTML };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { RateLimitResult } = require('../core/result');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Leaky Bucket strategy.
|
|
7
|
+
* Constant output rate, queues overflow.
|
|
8
|
+
* Key difference from token bucket: drains at constant rate (output is smooth, no bursts).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} key - Rate limit key
|
|
11
|
+
* @param {object} config - Resolved NextLimiter config
|
|
12
|
+
* @param {object} store - Store instance
|
|
13
|
+
* @returns {Promise<RateLimitResult>}
|
|
14
|
+
*/
|
|
15
|
+
async function leakyBucket(key, config, store) {
|
|
16
|
+
const drainRateMs = config.drainRateMs || (config.windowMs / config.max);
|
|
17
|
+
const capacity = config.capacity || config.max;
|
|
18
|
+
|
|
19
|
+
let state = await Promise.resolve(store.get(key));
|
|
20
|
+
if (!state || typeof state.queue !== 'number') {
|
|
21
|
+
state = { queue: 0, lastDrain: Date.now() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const elapsed = now - state.lastDrain;
|
|
26
|
+
|
|
27
|
+
// Drain: reduce queue by elapsed time / drain rate
|
|
28
|
+
const drained = elapsed / drainRateMs;
|
|
29
|
+
state.queue = Math.max(0, state.queue - drained);
|
|
30
|
+
state.lastDrain = now;
|
|
31
|
+
|
|
32
|
+
if (state.queue >= capacity) {
|
|
33
|
+
// Bucket full — DROP
|
|
34
|
+
const msUntilSpace = (state.queue - capacity + 1) * drainRateMs;
|
|
35
|
+
const retryAfter = Math.ceil(msUntilSpace / 1000);
|
|
36
|
+
return new RateLimitResult({
|
|
37
|
+
allowed: false,
|
|
38
|
+
limit: capacity,
|
|
39
|
+
remaining: 0,
|
|
40
|
+
resetAt: now + msUntilSpace,
|
|
41
|
+
retryAfter,
|
|
42
|
+
key,
|
|
43
|
+
strategy: 'leaky-bucket'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
state.queue += 1;
|
|
48
|
+
const ttl = config.windowMs * 2;
|
|
49
|
+
await Promise.resolve(store.set(key, state, ttl));
|
|
50
|
+
|
|
51
|
+
return new RateLimitResult({
|
|
52
|
+
allowed: true,
|
|
53
|
+
limit: capacity,
|
|
54
|
+
remaining: Math.floor(capacity - state.queue),
|
|
55
|
+
resetAt: now + drainRateMs,
|
|
56
|
+
retryAfter: 0,
|
|
57
|
+
key,
|
|
58
|
+
strategy: 'leaky-bucket'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { leakyBucket };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { RateLimitResult } = require('../core/result');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sliding Window Log strategy.
|
|
7
|
+
* Perfect accuracy, O(n) memory. Stores a sorted list of timestamps.
|
|
8
|
+
* Note: O(n) memory usage where n = max requests per window.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} key - Rate limit key
|
|
11
|
+
* @param {object} config - Resolved NextLimiter config
|
|
12
|
+
* @param {object} store - Store instance
|
|
13
|
+
* @returns {Promise<RateLimitResult>}
|
|
14
|
+
*/
|
|
15
|
+
async function slidingWindowLog(key, config, store) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
let log = await Promise.resolve(store.get(key));
|
|
18
|
+
if (!log || !Array.isArray(log)) {
|
|
19
|
+
log = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Prune old timestamps
|
|
23
|
+
const windowStart = now - config.windowMs;
|
|
24
|
+
log = log.filter(t => t > windowStart);
|
|
25
|
+
|
|
26
|
+
if (log.length >= config.max) {
|
|
27
|
+
const oldestInWindow = log[0];
|
|
28
|
+
const retryAfterMs = oldestInWindow + config.windowMs - now;
|
|
29
|
+
return new RateLimitResult({
|
|
30
|
+
allowed: false,
|
|
31
|
+
limit: config.max,
|
|
32
|
+
remaining: 0,
|
|
33
|
+
resetAt: oldestInWindow + config.windowMs,
|
|
34
|
+
retryAfter: Math.ceil(retryAfterMs / 1000),
|
|
35
|
+
key,
|
|
36
|
+
strategy: 'sliding-window-log'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log.push(now);
|
|
41
|
+
await Promise.resolve(store.set(key, log, config.windowMs));
|
|
42
|
+
|
|
43
|
+
return new RateLimitResult({
|
|
44
|
+
allowed: true,
|
|
45
|
+
limit: config.max,
|
|
46
|
+
remaining: config.max - log.length,
|
|
47
|
+
resetAt: (log[0] || now) + config.windowMs,
|
|
48
|
+
retryAfter: 0,
|
|
49
|
+
key,
|
|
50
|
+
strategy: 'sliding-window-log'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { slidingWindowLog };
|
package/types/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface RuleConfig {
|
|
|
27
27
|
keyBy: 'ip' | 'api-key' | 'user-id' | string | ((req: Request) => string);
|
|
28
28
|
max: number;
|
|
29
29
|
windowMs: number;
|
|
30
|
-
strategy?: 'sliding-window' | 'token-bucket' | 'fixed-window';
|
|
30
|
+
strategy?: 'sliding-window' | 'token-bucket' | 'fixed-window' | 'sliding-window-log' | 'leaky-bucket';
|
|
31
31
|
name?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -58,9 +58,15 @@ export interface WebhookPayload {
|
|
|
58
58
|
ruleName?: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export interface DashboardOptions {
|
|
62
|
+
password?: string;
|
|
63
|
+
refreshMs?: number;
|
|
64
|
+
path?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
export type BuiltInPlan = 'free' | 'pro' | 'enterprise';
|
|
62
68
|
export type BuiltInPreset = 'strict' | 'relaxed' | 'api' | 'auth';
|
|
63
|
-
export type Strategy = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
69
|
+
export type Strategy = 'fixed-window' | 'sliding-window' | 'token-bucket' | 'sliding-window-log' | 'leaky-bucket';
|
|
64
70
|
export type KeyBy = 'ip' | 'user-id' | 'api-key';
|
|
65
71
|
|
|
66
72
|
// ── Configuration ────────────────────────────────────────────────────────────
|
|
@@ -138,6 +144,12 @@ export interface LimiterOptions {
|
|
|
138
144
|
/** Array of IPs or CIDR ranges to block immediately (403) */
|
|
139
145
|
blacklist?: string[];
|
|
140
146
|
|
|
147
|
+
/** Leaky bucket only: ms per request drain rate (default: windowMs / max) */
|
|
148
|
+
drainRateMs?: number;
|
|
149
|
+
|
|
150
|
+
/** Leaky bucket only: max queue size (default: max) */
|
|
151
|
+
capacity?: number;
|
|
152
|
+
|
|
141
153
|
/** ms interval to emit 'stats' event. undefined = disabled. */
|
|
142
154
|
statsInterval?: number;
|
|
143
155
|
|
|
@@ -262,9 +274,16 @@ export declare class Limiter extends EventEmitter {
|
|
|
262
274
|
/** Reset rate limit state and clear from store immediately */
|
|
263
275
|
resetKey(key: string): void;
|
|
264
276
|
|
|
265
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Destroys all internal timers appropriately gracefully.
|
|
279
|
+
*/
|
|
266
280
|
destroy(): void;
|
|
267
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Returns an Express middleware that provides a live tracking dashboard.
|
|
284
|
+
*/
|
|
285
|
+
dashboardMiddleware(options?: DashboardOptions): RequestHandler;
|
|
286
|
+
|
|
268
287
|
/** Get analytics snapshot */
|
|
269
288
|
getStats(): LimiterStats;
|
|
270
289
|
|