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.
@@ -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.2.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",
@@ -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
 
@@ -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
  *
@@ -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
- /** Clean up stores and timers */
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