ratelimit-engine 1.0.0 → 1.1.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/cli.js +248 -190
- package/bin/dashboard.js +92 -0
- package/package.json +3 -2
- package/src/dashboard/DashboardCollector.js +97 -0
- package/src/dashboard/DashboardRenderer.js +186 -0
package/bin/cli.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* ratelimit-engine CLI
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx ratelimit-engine — setup wizard
|
|
6
|
+
* npx ratelimit-engine dashboard — live 10ms dashboard
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import * as readline from 'readline'
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
import * as fs from 'fs'
|
|
11
|
+
import * as path from 'path'
|
|
12
|
+
import { createRequire } from 'module'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
|
|
15
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
16
|
+
|
|
17
|
+
// ── Route subcommands ─────────────────────────────────────────────
|
|
18
|
+
const subcommand = process.argv[2]
|
|
19
|
+
if (subcommand === 'dashboard') {
|
|
20
|
+
// Dynamically import and run the dashboard
|
|
21
|
+
await import(new URL('./dashboard.js', import.meta.url))
|
|
22
|
+
process.exit(0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── ANSI colours ──────────────────────────────────────────────────
|
|
18
26
|
const C = {
|
|
19
27
|
reset: '\x1b[0m',
|
|
20
28
|
bold: '\x1b[1m',
|
|
@@ -24,19 +32,18 @@ const C = {
|
|
|
24
32
|
yellow: '\x1b[33m',
|
|
25
33
|
cyan: '\x1b[36m',
|
|
26
34
|
magenta: '\x1b[35m',
|
|
27
|
-
|
|
28
|
-
bgDark: '\x1b[48;5;235m',
|
|
35
|
+
blue: '\x1b[34m',
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const bold = (s) => `${C.bold}${s}${C.reset}`
|
|
32
39
|
const dim = (s) => `${C.dim}${s}${C.reset}`
|
|
33
40
|
const green = (s) => `${C.green}${s}${C.reset}`
|
|
34
|
-
const red = (s) => `${C.red}${s}${C.reset}`
|
|
35
41
|
const cyan = (s) => `${C.cyan}${s}${C.reset}`
|
|
36
42
|
const yellow = (s) => `${C.yellow}${s}${C.reset}`
|
|
37
43
|
const magenta = (s) => `${C.magenta}${s}${C.reset}`
|
|
44
|
+
const blue = (s) => `${C.blue}${s}${C.reset}`
|
|
38
45
|
|
|
39
|
-
// ── Readline
|
|
46
|
+
// ── Readline helpers ──────────────────────────────────────────────
|
|
40
47
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
41
48
|
|
|
42
49
|
function ask(question) {
|
|
@@ -45,219 +52,270 @@ function ask(question) {
|
|
|
45
52
|
})
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
function askNumber(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
async function askNumber(label, defaultVal) {
|
|
56
|
+
const raw = await ask(` ${label} ${dim(`[${defaultVal}]`)}: `)
|
|
57
|
+
const num = parseInt(raw, 10)
|
|
58
|
+
return isNaN(num) || num <= 0 ? defaultVal : num
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function askChoice(choices) {
|
|
62
|
+
choices.forEach((c, i) => {
|
|
63
|
+
console.log(` ${bold(cyan(String(i + 1)))}. ${bold(c.label.padEnd(18))} ${dim(c.desc)}`)
|
|
53
64
|
})
|
|
65
|
+
const raw = await ask(`\n Pick [1-${choices.length}]: `)
|
|
66
|
+
const idx = parseInt(raw, 10) - 1
|
|
67
|
+
if (isNaN(idx) || idx < 0 || idx >= choices.length) return choices[0]
|
|
68
|
+
return choices[idx]
|
|
54
69
|
}
|
|
55
70
|
|
|
56
|
-
// ── Algorithm
|
|
71
|
+
// ── Algorithm definitions ─────────────────────────────────────────
|
|
57
72
|
const ALGORITHMS = [
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
color: yellow,
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
key: 'leaky-bucket',
|
|
78
|
-
label: 'Leaky Bucket',
|
|
79
|
-
desc: 'Strict output rate, drops excess',
|
|
80
|
-
color: magenta,
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
key: 'composite',
|
|
84
|
-
label: 'Composite',
|
|
85
|
-
desc: 'Combine two algorithms (ALL / ANY)',
|
|
86
|
-
color: (s) => `\x1b[35m${s}${C.reset}`,
|
|
87
|
-
},
|
|
73
|
+
{ key: 'TokenBucket', label: 'Token Bucket', desc: 'Burst-friendly — refills tokens over time' },
|
|
74
|
+
{ key: 'FixedWindow', label: 'Fixed Window', desc: 'Simple counter per time window' },
|
|
75
|
+
{ key: 'SlidingWindow', label: 'Sliding Window', desc: 'Smooth rolling window, no boundary spikes' },
|
|
76
|
+
{ key: 'LeakyBucket', label: 'Leaky Bucket', desc: 'Strict output rate, drops excess' },
|
|
77
|
+
{ key: 'Composite', label: 'Composite', desc: 'Combine two algorithms (ALL / ANY)' },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
const STORES = [
|
|
81
|
+
{ key: 'MemoryStore', label: 'Memory', desc: 'In-process store — single instance' },
|
|
82
|
+
{ key: 'RedisStore', label: 'Redis', desc: 'Distributed store — multi-instance / clustered' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const FRAMEWORKS = [
|
|
86
|
+
{ key: 'express', label: 'Express', desc: 'app.use(limiter.express())' },
|
|
87
|
+
{ key: 'fastify', label: 'Fastify', desc: 'fastify.addHook("onRequest", limiter.fastify())' },
|
|
88
|
+
{ key: 'manual', label: 'Manual', desc: 'limiter.check(ctx) — any framework' },
|
|
88
89
|
]
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
const KEY_STRATEGIES = [
|
|
92
|
+
{ key: 'byIP', label: 'By IP', desc: 'One counter per client IP (default)' },
|
|
93
|
+
{ key: 'byUser', label: 'By User', desc: 'One counter per authenticated user' },
|
|
94
|
+
{ key: 'byRoute', label: 'By Route', desc: 'One counter per URL path' },
|
|
95
|
+
{ key: 'compositeResolver', label: 'IP + Route', desc: 'Separate counter per IP+route combination' },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
// ── Config collectors ─────────────────────────────────────────────
|
|
99
|
+
async function collectAlgoConfig(key) {
|
|
92
100
|
switch (key) {
|
|
93
|
-
case '
|
|
94
|
-
const capacity = await askNumber('
|
|
95
|
-
const refillRate = await askNumber('
|
|
96
|
-
return
|
|
101
|
+
case 'TokenBucket': {
|
|
102
|
+
const capacity = await askNumber('Token bucket capacity (burst size)', 100)
|
|
103
|
+
const refillRate = await askNumber('Refill rate (tokens/sec)', 10)
|
|
104
|
+
return { capacity, refillRate }
|
|
97
105
|
}
|
|
98
|
-
case '
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
106
|
+
case 'FixedWindow':
|
|
107
|
+
case 'SlidingWindow': {
|
|
108
|
+
const limit = await askNumber('Max requests per window', 100)
|
|
109
|
+
const windowMs = (await askNumber('Window size (seconds)', 60)) * 1000
|
|
110
|
+
return { limit, windowMs }
|
|
102
111
|
}
|
|
103
|
-
case '
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
return
|
|
112
|
+
case 'LeakyBucket': {
|
|
113
|
+
const capacity = await askNumber('Queue capacity (max queued requests)', 100)
|
|
114
|
+
const leakRate = await askNumber('Leak rate (requests/sec)', 10)
|
|
115
|
+
return { capacity, leakRate }
|
|
107
116
|
}
|
|
108
|
-
case '
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return new Composite({ algorithms: [primary, secondary], mode })
|
|
117
|
+
case 'Composite': {
|
|
118
|
+
console.log(`\n ${bold('Primary algorithm:')}\n`)
|
|
119
|
+
const primary = await askChoice(ALGORITHMS.filter((a) => a.key !== 'Composite'))
|
|
120
|
+
const primaryConfig = await collectAlgoConfig(primary.key)
|
|
121
|
+
|
|
122
|
+
console.log(`\n ${bold('Secondary algorithm:')}\n`)
|
|
123
|
+
const secondary = await askChoice(ALGORITHMS.filter((a) => a.key !== 'Composite'))
|
|
124
|
+
const secondaryConfig = await collectAlgoConfig(secondary.key)
|
|
125
|
+
|
|
126
|
+
console.log(`\n ${bold('Composite mode:')}\n`)
|
|
127
|
+
const mode = await askChoice([
|
|
128
|
+
{ key: 'ALL', label: 'ALL', desc: 'Both algorithms must allow the request' },
|
|
129
|
+
{ key: 'ANY', label: 'ANY', desc: 'At least one algorithm must allow the request' },
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
return { primary: { key: primary.key, config: primaryConfig }, secondary: { key: secondary.key, config: secondaryConfig }, mode: mode.key }
|
|
126
133
|
}
|
|
127
134
|
default:
|
|
128
|
-
return
|
|
135
|
+
return {}
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
// ── Code generators ───────────────────────────────────────────────
|
|
140
|
+
function algoArgs(key, config) {
|
|
141
|
+
switch (key) {
|
|
142
|
+
case 'TokenBucket':
|
|
143
|
+
return `{ capacity: ${config.capacity}, refillRate: ${config.refillRate} }`
|
|
144
|
+
case 'FixedWindow':
|
|
145
|
+
case 'SlidingWindow':
|
|
146
|
+
return `{ limit: ${config.limit}, windowMs: ${config.windowMs} }`
|
|
147
|
+
case 'LeakyBucket':
|
|
148
|
+
return `{ capacity: ${config.capacity}, leakRate: ${config.leakRate} }`
|
|
149
|
+
default:
|
|
150
|
+
return '{}'
|
|
143
151
|
}
|
|
144
|
-
|
|
145
|
-
return list[idx].key
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
` ${String(row.retryAfter ?? '—').padEnd(cols[4].width)} `,
|
|
177
|
-
` ${String(row.resetAt).padEnd(cols[5].width)} `,
|
|
178
|
-
].join('|') + '|'
|
|
179
|
-
|
|
180
|
-
console.log(line)
|
|
154
|
+
function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey, failOpen = true, customOnBlocked = null }) {
|
|
155
|
+
const isRedis = storeKey === 'RedisStore'
|
|
156
|
+
const isComposite = algoKey === 'Composite'
|
|
157
|
+
|
|
158
|
+
const imports = isComposite
|
|
159
|
+
? [algoKey, algoConfig.primary.key, algoConfig.secondary.key]
|
|
160
|
+
: [algoKey]
|
|
161
|
+
|
|
162
|
+
const allImports = [...new Set(['createLimiter', ...imports, storeKey, keyStrategyKey])]
|
|
163
|
+
|
|
164
|
+
const redisImport = isRedis ? `\nimport Redis from 'ioredis'` : ''
|
|
165
|
+
const redisClient = isRedis ? `\nconst redis = new Redis() // configure your Redis connection\n` : ''
|
|
166
|
+
const storeInit = isRedis
|
|
167
|
+
? `new RedisStore({ client: redis })`
|
|
168
|
+
: `new MemoryStore()`
|
|
169
|
+
|
|
170
|
+
let algoInit
|
|
171
|
+
if (isComposite) {
|
|
172
|
+
const { primary, secondary, mode } = algoConfig
|
|
173
|
+
algoInit = `new Composite({
|
|
174
|
+
algorithms: [
|
|
175
|
+
new ${primary.key}(${algoArgs(primary.key, primary.config)}),
|
|
176
|
+
new ${secondary.key}(${algoArgs(secondary.key, secondary.config)}),
|
|
177
|
+
],
|
|
178
|
+
mode: COMPOSITE_MODES.${mode},
|
|
179
|
+
})`
|
|
180
|
+
} else {
|
|
181
|
+
algoInit = `new ${algoKey}(${algoArgs(algoKey, algoConfig)})`
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
const compositeImport = isComposite ? `, COMPOSITE_MODES` : ''
|
|
185
|
+
|
|
186
|
+
let frameworkUsage
|
|
187
|
+
switch (frameworkKey) {
|
|
188
|
+
case 'express':
|
|
189
|
+
frameworkUsage = `// Express — apply globally
|
|
190
|
+
app.use(limiter.express())
|
|
191
|
+
|
|
192
|
+
// or per-route
|
|
193
|
+
app.get('/api/resource', limiter.express(), handler)`
|
|
194
|
+
break
|
|
195
|
+
case 'fastify':
|
|
196
|
+
frameworkUsage = `// Fastify — apply globally
|
|
197
|
+
fastify.addHook('onRequest', limiter.fastify())
|
|
198
|
+
|
|
199
|
+
// or per-route
|
|
200
|
+
fastify.get('/api/resource', { onRequest: limiter.fastify() }, handler)`
|
|
201
|
+
break
|
|
202
|
+
case 'manual':
|
|
203
|
+
frameworkUsage = `// Manual — any framework
|
|
204
|
+
const result = await limiter.check({
|
|
205
|
+
ip: req.ip,
|
|
206
|
+
path: req.path,
|
|
207
|
+
user: req.user?.id, // optional
|
|
208
|
+
})
|
|
185
209
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
if (!result.allowed) {
|
|
211
|
+
return res.status(429).json({
|
|
212
|
+
error: 'Too Many Requests',
|
|
213
|
+
retryAfter: result.retryAfter,
|
|
214
|
+
})
|
|
215
|
+
}`
|
|
216
|
+
break
|
|
217
|
+
}
|
|
191
218
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
219
|
+
const onBlockedMsg = customOnBlocked
|
|
220
|
+
? `console.warn(\`${customOnBlocked}\`)`
|
|
221
|
+
: `console.warn(\`[RATE LIMIT] \${ctx.ip} blocked — retry in \${result.retryAfter}s\`)`
|
|
222
|
+
|
|
223
|
+
return `import { ${allImports.join(', ')}${compositeImport} } from 'ratelimit-engine'${redisImport}
|
|
224
|
+
${redisClient}
|
|
225
|
+
const limiter = createLimiter({
|
|
226
|
+
algorithm: ${algoInit},
|
|
227
|
+
store: ${storeInit},
|
|
228
|
+
keyBy: ${keyStrategyKey},
|
|
229
|
+
failOpen: ${failOpen},
|
|
230
|
+
onBlocked: (ctx, result) => {
|
|
231
|
+
${onBlockedMsg}
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
${frameworkUsage}
|
|
201
236
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
237
|
+
export { limiter }
|
|
238
|
+
`
|
|
205
239
|
}
|
|
206
240
|
|
|
207
241
|
// ── Main ──────────────────────────────────────────────────────────
|
|
208
242
|
async function main() {
|
|
209
243
|
console.clear()
|
|
210
244
|
console.log(`
|
|
211
|
-
${bold(cyan('⚡ ratelimit-engine'))} ${dim('—
|
|
212
|
-
${dim('
|
|
245
|
+
${bold(cyan('⚡ ratelimit-engine'))} ${dim('— Setup Wizard')}
|
|
246
|
+
${dim('─────────────────────────────────────────────')}
|
|
247
|
+
${dim('Generates a ready-to-use ratelimit.config.js')}
|
|
248
|
+
${dim('for your project in the current directory.')}
|
|
213
249
|
`)
|
|
214
250
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const algo
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const store =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const start = Date.now()
|
|
227
|
-
|
|
228
|
-
for (let i = 1; i <= requests; i++) {
|
|
229
|
-
const result = await algo.check(key, store)
|
|
230
|
-
const elapsed = Date.now() - start
|
|
231
|
-
|
|
232
|
-
rows.push({
|
|
233
|
-
req: i,
|
|
234
|
-
time: elapsed,
|
|
235
|
-
allowed: result.allowed,
|
|
236
|
-
remaining: result.remaining,
|
|
237
|
-
retryAfter: result.retryAfter !== undefined ? result.retryAfter : '—',
|
|
238
|
-
resetAt: result.resetAt.toTimeString().slice(0, 8),
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
if (i < requests) {
|
|
242
|
-
await sleep(delayMs)
|
|
243
|
-
}
|
|
251
|
+
// Step 1 — Algorithm
|
|
252
|
+
console.log(` ${bold('Step 1 — Choose an algorithm:')}\n`)
|
|
253
|
+
const algo = await askChoice(ALGORITHMS)
|
|
254
|
+
console.log()
|
|
255
|
+
const algoConfig = await collectAlgoConfig(algo.key)
|
|
256
|
+
|
|
257
|
+
// Step 2 — Store
|
|
258
|
+
console.log(`\n ${bold('Step 2 — Choose a store:')}\n`)
|
|
259
|
+
const store = await askChoice(STORES)
|
|
260
|
+
if (store.key === 'RedisStore') {
|
|
261
|
+
console.log(`\n ${yellow('Note:')} Make sure ${bold('ioredis')} is installed: ${dim('npm install ioredis')}`)
|
|
244
262
|
}
|
|
245
263
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
264
|
+
// Step 3 — Framework
|
|
265
|
+
console.log(`\n ${bold('Step 3 — Choose your framework:')}\n`)
|
|
266
|
+
const framework = await askChoice(FRAMEWORKS)
|
|
267
|
+
|
|
268
|
+
// Step 4 — Key strategy
|
|
269
|
+
console.log(`\n ${bold('Step 4 — How should requests be keyed?')}\n`)
|
|
270
|
+
const keyStrategy = await askChoice(KEY_STRATEGIES)
|
|
271
|
+
|
|
272
|
+
// Step 5 — Custom overrides
|
|
273
|
+
console.log(`\n ${bold('Step 5 — Custom options')} ${dim('(press Enter to skip each)')}`)
|
|
274
|
+
console.log(` ${dim('Override any value before the file is written.\n')}`)
|
|
275
|
+
|
|
276
|
+
const outputFile = await ask(` Output filename ${dim('[ratelimit.config.js]')}: `)
|
|
277
|
+
const fileName = outputFile || 'ratelimit.config.js'
|
|
278
|
+
|
|
279
|
+
const customOnBlocked = await ask(` onBlocked message ${dim('[Rate limit exceeded, retry in {n}s]')}: `)
|
|
280
|
+
const failOpenRaw = await ask(` failOpen — allow requests when store is down? ${dim('[yes]')}: `)
|
|
281
|
+
const failOpen = failOpenRaw.toLowerCase() === 'no' ? false : true
|
|
282
|
+
|
|
283
|
+
// Generate
|
|
284
|
+
const code = generateConfig({
|
|
285
|
+
algoKey: algo.key,
|
|
286
|
+
algoConfig,
|
|
287
|
+
storeKey: store.key,
|
|
288
|
+
frameworkKey: framework.key,
|
|
289
|
+
keyStrategyKey: keyStrategy.key,
|
|
290
|
+
failOpen,
|
|
291
|
+
customOnBlocked: customOnBlocked || null,
|
|
292
|
+
})
|
|
249
293
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
294
|
+
// Write file
|
|
295
|
+
const outPath = path.join(process.cwd(), fileName)
|
|
296
|
+
fs.writeFileSync(outPath, code, 'utf8')
|
|
297
|
+
|
|
298
|
+
// Output
|
|
299
|
+
console.log(`\n ${dim('─────────────────────────────────────────────')}`)
|
|
300
|
+
console.log(` ${green(bold('✅ Done!'))} Generated ${bold(fileName)}\n`)
|
|
301
|
+
console.log(` ${dim('─────────────────────────────────────────────')}`)
|
|
302
|
+
console.log(`\n${code}`)
|
|
303
|
+
console.log(` ${dim('─────────────────────────────────────────────')}`)
|
|
304
|
+
console.log(`\n ${bold('Next steps:')}\n`)
|
|
305
|
+
console.log(` ${cyan('1.')} Import the config into your app:`)
|
|
306
|
+
console.log(` ${dim(`import { limiter } from './${fileName}'`)}`)
|
|
307
|
+
console.log(`\n ${cyan('2.')} Wire it up — the usage snippet is inside the generated file.`)
|
|
308
|
+
if (store.key === 'RedisStore') {
|
|
309
|
+
console.log(`\n ${cyan('3.')} Install ioredis if not already:`)
|
|
310
|
+
console.log(` ${dim('npm install ioredis')}`)
|
|
256
311
|
}
|
|
312
|
+
console.log()
|
|
313
|
+
|
|
314
|
+
rl.close()
|
|
257
315
|
}
|
|
258
316
|
|
|
259
317
|
main().catch((err) => {
|
|
260
|
-
console.error(
|
|
318
|
+
console.error(`\nError: ${err.message}`)
|
|
261
319
|
rl.close()
|
|
262
320
|
process.exit(1)
|
|
263
321
|
})
|
package/bin/dashboard.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Live dashboard entry point.
|
|
3
|
+
* Starts a demo server with rate limiting wired to the DashboardCollector,
|
|
4
|
+
* then renders the terminal UI at 10ms intervals.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx ratelimit-engine dashboard
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DashboardCollector } from '../src/dashboard/DashboardCollector.js'
|
|
10
|
+
import { DashboardRenderer } from '../src/dashboard/DashboardRenderer.js'
|
|
11
|
+
import { createLimiter } from '../src/engine/createLimiter.js'
|
|
12
|
+
import { TokenBucket } from '../src/algorithms/TokenBucket.js'
|
|
13
|
+
import { FixedWindow } from '../src/algorithms/FixedWindow.js'
|
|
14
|
+
import { SlidingWindow } from '../src/algorithms/SlidingWindow.js'
|
|
15
|
+
import { MemoryStore } from '../src/stores/MemoryStore.js'
|
|
16
|
+
import { byIP } from '../src/resolvers/byIP.js'
|
|
17
|
+
|
|
18
|
+
const REFRESH_MS = 10
|
|
19
|
+
const DEMO_IPS = ['192.168.1.1', '10.0.0.5', '172.16.0.3', '127.0.0.1']
|
|
20
|
+
const DEMO_PATHS = ['/api/hello', '/api/login', '/api/search', '/api/admin/data']
|
|
21
|
+
const DEMO_METHOD = ['GET', 'POST', 'GET', 'DELETE']
|
|
22
|
+
|
|
23
|
+
// ── Collector + Renderer ──────────────────────────────────────────
|
|
24
|
+
const collector = new DashboardCollector()
|
|
25
|
+
const renderer = new DashboardRenderer()
|
|
26
|
+
|
|
27
|
+
// ── Demo limiter (Token Bucket: burst 5, refill 2/sec) ───────────
|
|
28
|
+
const limiter = createLimiter({
|
|
29
|
+
algorithm: new TokenBucket({ capacity: 5, refillRate: 2 }),
|
|
30
|
+
store: new MemoryStore(),
|
|
31
|
+
keyBy: byIP,
|
|
32
|
+
failOpen: true,
|
|
33
|
+
onAllowed: (ctx, result) => collector.recordAllowed(ctx),
|
|
34
|
+
onBlocked: (ctx, result) => collector.recordBlocked(ctx, result),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ── Simulate traffic — random bursts every 10–80ms ────────────────
|
|
38
|
+
function simulateRequest() {
|
|
39
|
+
const idx = Math.floor(Math.random() * DEMO_IPS.length)
|
|
40
|
+
const ip = DEMO_IPS[idx]
|
|
41
|
+
const path = DEMO_PATHS[idx]
|
|
42
|
+
const method = DEMO_METHOD[idx]
|
|
43
|
+
|
|
44
|
+
limiter.check({ ip, path, method, user: null })
|
|
45
|
+
|
|
46
|
+
// Schedule next request with variable interval to create burst patterns
|
|
47
|
+
const nextIn = Math.random() < 0.3
|
|
48
|
+
? Math.floor(Math.random() * 5) // burst: 0–5ms
|
|
49
|
+
: Math.floor(Math.random() * 80) // normal: 0–80ms
|
|
50
|
+
setTimeout(simulateRequest, nextIn)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Flush bucket every 10ms ───────────────────────────────────────
|
|
54
|
+
const flushInterval = setInterval(() => collector.flush(), REFRESH_MS)
|
|
55
|
+
|
|
56
|
+
// ── Render loop every 10ms ────────────────────────────────────────
|
|
57
|
+
let paused = false
|
|
58
|
+
const renderInterval = setInterval(() => {
|
|
59
|
+
if (!paused) renderer.render(collector.snapshot())
|
|
60
|
+
}, REFRESH_MS)
|
|
61
|
+
|
|
62
|
+
// ── Keyboard input ────────────────────────────────────────────────
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
process.stdin.setRawMode(true)
|
|
65
|
+
}
|
|
66
|
+
process.stdin.resume()
|
|
67
|
+
process.stdin.setEncoding('utf8')
|
|
68
|
+
|
|
69
|
+
process.stdin.on('data', (key) => {
|
|
70
|
+
if (key === 'q' || key === '\u0003') {
|
|
71
|
+
cleanup()
|
|
72
|
+
} else if (key === 'r') {
|
|
73
|
+
collector.reset()
|
|
74
|
+
} else if (key === 'p') {
|
|
75
|
+
paused = !paused
|
|
76
|
+
renderer.paused = paused
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function cleanup() {
|
|
81
|
+
clearInterval(flushInterval)
|
|
82
|
+
clearInterval(renderInterval)
|
|
83
|
+
renderer.cleanup()
|
|
84
|
+
limiter.destroy()
|
|
85
|
+
process.exit(0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
process.on('SIGINT', cleanup)
|
|
89
|
+
process.on('SIGTERM', cleanup)
|
|
90
|
+
|
|
91
|
+
// ── Start ─────────────────────────────────────────────────────────
|
|
92
|
+
simulateRequest()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ratelimit-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Enterprise-grade, algorithm-pluggable rate limiting engine for Node.js",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
37
|
"bin": {
|
|
38
|
-
"ratelimit-engine": "./bin/cli.js"
|
|
38
|
+
"ratelimit-engine": "./bin/cli.js",
|
|
39
|
+
"ratelimit-dashboard": "./bin/dashboard.js"
|
|
39
40
|
},
|
|
40
41
|
"author": "Abhinav710 <kumarabhinav5693@gmail.com>",
|
|
41
42
|
"license": "MIT",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview In-process event collector for the live dashboard.
|
|
3
|
+
* Receives allowed/blocked events from RateLimiter hooks and
|
|
4
|
+
* maintains a rolling 10ms-bucketed time series.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'events'
|
|
8
|
+
|
|
9
|
+
const BUCKET_MS = 10 // resolution: one bucket per 10ms
|
|
10
|
+
const HISTORY = 200 // keep last 200 buckets (2 seconds of sparkline)
|
|
11
|
+
const TOP_IPS = 8 // max IPs shown in top-IPs panel
|
|
12
|
+
const FEED_SIZE = 12 // max rows in live feed
|
|
13
|
+
|
|
14
|
+
export class DashboardCollector extends EventEmitter {
|
|
15
|
+
constructor() {
|
|
16
|
+
super()
|
|
17
|
+
this._buckets = [] // rolling array of { ts, allowed, blocked }
|
|
18
|
+
this._ipMap = new Map() // ip → { allowed, blocked, lastSeen }
|
|
19
|
+
this._feed = [] // last N events
|
|
20
|
+
this._startedAt = Date.now()
|
|
21
|
+
this._current = this._newBucket()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Called by onAllowed hook */
|
|
25
|
+
recordAllowed(context) {
|
|
26
|
+
this._current.allowed++
|
|
27
|
+
this._trackIP(context, true)
|
|
28
|
+
this._pushFeed({ allowed: true, context, ts: Date.now() })
|
|
29
|
+
this.emit('tick')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Called by onBlocked hook */
|
|
33
|
+
recordBlocked(context, result) {
|
|
34
|
+
this._current.blocked++
|
|
35
|
+
this._trackIP(context, false)
|
|
36
|
+
this._pushFeed({ allowed: false, context, result, ts: Date.now() })
|
|
37
|
+
this.emit('tick')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Flush current bucket and start a new one — called every 10ms */
|
|
41
|
+
flush() {
|
|
42
|
+
this._buckets.push(this._current)
|
|
43
|
+
if (this._buckets.length > HISTORY) this._buckets.shift()
|
|
44
|
+
this._current = this._newBucket()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Snapshot for the renderer */
|
|
48
|
+
snapshot() {
|
|
49
|
+
const buckets = [...this._buckets, this._current]
|
|
50
|
+
const last = buckets.slice(-20) // last 200ms window
|
|
51
|
+
|
|
52
|
+
const totalAllowed = last.reduce((s, b) => s + b.allowed, 0)
|
|
53
|
+
const totalBlocked = last.reduce((s, b) => s + b.blocked, 0)
|
|
54
|
+
const total = totalAllowed + totalBlocked
|
|
55
|
+
|
|
56
|
+
const topIPs = [...this._ipMap.entries()]
|
|
57
|
+
.map(([ip, v]) => ({ ip, ...v }))
|
|
58
|
+
.sort((a, b) => (b.allowed + b.blocked) - (a.allowed + a.blocked))
|
|
59
|
+
.slice(0, TOP_IPS)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
uptime: Date.now() - this._startedAt,
|
|
63
|
+
total,
|
|
64
|
+
allowed: totalAllowed,
|
|
65
|
+
blocked: totalBlocked,
|
|
66
|
+
sparkline: buckets.map((b) => b.allowed + b.blocked),
|
|
67
|
+
topIPs,
|
|
68
|
+
feed: [...this._feed],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
reset() {
|
|
73
|
+
this._buckets = []
|
|
74
|
+
this._ipMap = new Map()
|
|
75
|
+
this._feed = []
|
|
76
|
+
this._current = this._newBucket()
|
|
77
|
+
this._startedAt = Date.now()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_newBucket() {
|
|
81
|
+
return { ts: Date.now(), allowed: 0, blocked: 0 }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_trackIP(context, allowed) {
|
|
85
|
+
const ip = context?.ip ?? 'unknown'
|
|
86
|
+
const entry = this._ipMap.get(ip) ?? { allowed: 0, blocked: 0, lastSeen: 0 }
|
|
87
|
+
if (allowed) entry.allowed++
|
|
88
|
+
else entry.blocked++
|
|
89
|
+
entry.lastSeen = Date.now()
|
|
90
|
+
this._ipMap.set(ip, entry)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_pushFeed(event) {
|
|
94
|
+
this._feed.unshift(event)
|
|
95
|
+
if (this._feed.length > FEED_SIZE) this._feed.pop()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Terminal renderer for the live dashboard.
|
|
3
|
+
* Uses ANSI escape codes only — zero dependencies.
|
|
4
|
+
* Redraws the full screen every 10ms via process.stdout.write.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── ANSI helpers ──────────────────────────────────────────────────
|
|
8
|
+
const ESC = '\x1b['
|
|
9
|
+
const CLEAR = '\x1b[2J\x1b[H' // clear screen + move cursor to top
|
|
10
|
+
const HIDE = '\x1b[?25l' // hide cursor
|
|
11
|
+
const SHOW = '\x1b[?25h' // show cursor
|
|
12
|
+
|
|
13
|
+
const C = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
dim: '\x1b[2m',
|
|
17
|
+
green: '\x1b[32m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
cyan: '\x1b[36m',
|
|
21
|
+
magenta: '\x1b[35m',
|
|
22
|
+
blue: '\x1b[34m',
|
|
23
|
+
white: '\x1b[37m',
|
|
24
|
+
bgRed: '\x1b[41m',
|
|
25
|
+
bgGreen: '\x1b[42m',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bold = (s) => `${C.bold}${s}${C.reset}`
|
|
29
|
+
const dim = (s) => `${C.dim}${s}${C.reset}`
|
|
30
|
+
const green = (s) => `${C.green}${s}${C.reset}`
|
|
31
|
+
const red = (s) => `${C.red}${s}${C.reset}`
|
|
32
|
+
const yellow = (s) => `${C.yellow}${s}${C.reset}`
|
|
33
|
+
const cyan = (s) => `${C.cyan}${s}${C.reset}`
|
|
34
|
+
const magenta = (s) => `${C.magenta}${s}${C.reset}`
|
|
35
|
+
|
|
36
|
+
// ── Sparkline ─────────────────────────────────────────────────────
|
|
37
|
+
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
|
|
38
|
+
|
|
39
|
+
function sparkline(values, width = 50) {
|
|
40
|
+
const slice = values.slice(-width)
|
|
41
|
+
const max = Math.max(...slice, 1)
|
|
42
|
+
return slice
|
|
43
|
+
.map((v) => {
|
|
44
|
+
const idx = Math.round((v / max) * (SPARK_CHARS.length - 1))
|
|
45
|
+
return SPARK_CHARS[idx]
|
|
46
|
+
})
|
|
47
|
+
.join('')
|
|
48
|
+
.padStart(width)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Bar ───────────────────────────────────────────────────────────
|
|
52
|
+
function bar(value, max, width = 30, color = green) {
|
|
53
|
+
const filled = max > 0 ? Math.round((value / max) * width) : 0
|
|
54
|
+
const empty = width - filled
|
|
55
|
+
return color('█'.repeat(filled)) + dim('░'.repeat(empty))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Format helpers ────────────────────────────────────────────────
|
|
59
|
+
function fmtUptime(ms) {
|
|
60
|
+
const s = Math.floor(ms / 1000)
|
|
61
|
+
const m = Math.floor(s / 60)
|
|
62
|
+
const h = Math.floor(m / 60)
|
|
63
|
+
return [
|
|
64
|
+
String(h).padStart(2, '0'),
|
|
65
|
+
String(m % 60).padStart(2, '0'),
|
|
66
|
+
String(s % 60).padStart(2, '0'),
|
|
67
|
+
].join(':')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fmtTime(ts) {
|
|
71
|
+
return new Date(ts).toISOString().slice(11, 23) // HH:MM:SS.mmm
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pad(s, n) {
|
|
75
|
+
return String(s).slice(0, n).padEnd(n)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Box drawing ───────────────────────────────────────────────────
|
|
79
|
+
const W = 62 // inner box width
|
|
80
|
+
|
|
81
|
+
function boxTop(title) {
|
|
82
|
+
const t = title ? ` ${title} ` : ''
|
|
83
|
+
const dashes = '─'.repeat(W - t.length)
|
|
84
|
+
return ` ┌─${t}${dashes}┐`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function boxRow(content) {
|
|
88
|
+
const raw = content.replace(/\x1b\[[0-9;]*m/g, '') // strip ANSI for length
|
|
89
|
+
const pad = W - raw.length
|
|
90
|
+
return ` │ ${content}${' '.repeat(Math.max(0, pad - 1))}│`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function boxBot() {
|
|
94
|
+
return ` └${'─'.repeat(W + 1)}┘`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Main renderer ─────────────────────────────────────────────────
|
|
98
|
+
export class DashboardRenderer {
|
|
99
|
+
constructor({ paused = false } = {}) {
|
|
100
|
+
this.paused = paused
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(snapshot) {
|
|
104
|
+
const { uptime, total, allowed, blocked, sparkline: spark, topIPs, feed } = snapshot
|
|
105
|
+
const blockPct = total > 0 ? ((blocked / total) * 100).toFixed(1) : '0.0'
|
|
106
|
+
const maxBar = Math.max(total, 1)
|
|
107
|
+
const lines = []
|
|
108
|
+
|
|
109
|
+
// ── Header ────────────────────────────────────────────────────
|
|
110
|
+
lines.push('')
|
|
111
|
+
lines.push(
|
|
112
|
+
` ${bold(cyan('⚡ ratelimit-engine'))} ${dim('Live Dashboard')}` +
|
|
113
|
+
`${' '.repeat(18)}${dim('Refresh: 10ms')} ${this.paused ? yellow(bold('[PAUSED]')) : ''}`
|
|
114
|
+
)
|
|
115
|
+
lines.push(` ${dim('─'.repeat(W + 2))}`)
|
|
116
|
+
lines.push(
|
|
117
|
+
` Uptime: ${bold(fmtUptime(uptime))}` +
|
|
118
|
+
`${' '.repeat(10)}Window: last 200ms` +
|
|
119
|
+
`${' '.repeat(8)}${blocked > allowed ? red('⚠ HIGH BLOCK RATE') : green('● healthy')}`
|
|
120
|
+
)
|
|
121
|
+
lines.push('')
|
|
122
|
+
|
|
123
|
+
// ── Traffic panel ─────────────────────────────────────────────
|
|
124
|
+
lines.push(boxTop('Traffic — per 10ms bucket'))
|
|
125
|
+
lines.push(boxRow(`${pad('Total', 9)} ${bar(total, maxBar, 28, cyan)} ${bold(pad(total, 5))} req`))
|
|
126
|
+
lines.push(boxRow(`${pad('Allowed', 9)} ${bar(allowed, maxBar, 28, green)} ${bold(green(pad(allowed, 5)))} req`))
|
|
127
|
+
lines.push(boxRow(`${pad('Blocked', 9)} ${bar(blocked, maxBar, 28, red)} ${bold(red(pad(blocked, 5)))} req ${dim(`${blockPct}%`)}`))
|
|
128
|
+
lines.push(boxBot())
|
|
129
|
+
lines.push('')
|
|
130
|
+
|
|
131
|
+
// ── Sparkline panel ───────────────────────────────────────────
|
|
132
|
+
lines.push(boxTop('Sparkline — last 2s (200 buckets × 10ms)'))
|
|
133
|
+
lines.push(boxRow(cyan(sparkline(spark, W - 2))))
|
|
134
|
+
lines.push(boxBot())
|
|
135
|
+
lines.push('')
|
|
136
|
+
|
|
137
|
+
// ── Top IPs ───────────────────────────────────────────────────
|
|
138
|
+
lines.push(boxTop('Top IPs'))
|
|
139
|
+
if (topIPs.length === 0) {
|
|
140
|
+
lines.push(boxRow(dim('No traffic yet...')))
|
|
141
|
+
} else {
|
|
142
|
+
for (const entry of topIPs) {
|
|
143
|
+
const ipTotal = entry.allowed + entry.blocked
|
|
144
|
+
const label = entry.blocked > entry.allowed
|
|
145
|
+
? red('🔴 high block')
|
|
146
|
+
: green('🟢 normal ')
|
|
147
|
+
const ipBar = bar(entry.blocked, Math.max(ipTotal, 1), 12, red)
|
|
148
|
+
lines.push(boxRow(
|
|
149
|
+
`${pad(entry.ip, 16)} ` +
|
|
150
|
+
`${pad(ipTotal, 5)} req ` +
|
|
151
|
+
`${red(pad(entry.blocked, 4))} blocked ` +
|
|
152
|
+
`${ipBar} ${label}`
|
|
153
|
+
))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
lines.push(boxBot())
|
|
157
|
+
lines.push('')
|
|
158
|
+
|
|
159
|
+
// ── Live feed ─────────────────────────────────────────────────
|
|
160
|
+
lines.push(boxTop('Live Feed'))
|
|
161
|
+
if (feed.length === 0) {
|
|
162
|
+
lines.push(boxRow(dim('Waiting for requests...')))
|
|
163
|
+
} else {
|
|
164
|
+
for (const ev of feed) {
|
|
165
|
+
const status = ev.allowed ? green('✅ ALLOWED') : red('❌ BLOCKED')
|
|
166
|
+
const ip = pad(ev.context?.ip ?? 'unknown', 15)
|
|
167
|
+
const method = pad(ev.context?.method ?? 'GET', 6)
|
|
168
|
+
const path = pad(ev.context?.path ?? '/', 20)
|
|
169
|
+
const time = dim(fmtTime(ev.ts))
|
|
170
|
+
lines.push(boxRow(`${time} ${status} ${ip} ${method} ${path}`))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
lines.push(boxBot())
|
|
174
|
+
lines.push('')
|
|
175
|
+
|
|
176
|
+
// ── Footer ────────────────────────────────────────────────────
|
|
177
|
+
lines.push(` ${dim('[q]')} quit ${dim('[r]')} reset counters ${dim('[p]')} pause/resume`)
|
|
178
|
+
lines.push('')
|
|
179
|
+
|
|
180
|
+
process.stdout.write(HIDE + CLEAR + lines.join('\n'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cleanup() {
|
|
184
|
+
process.stdout.write(SHOW + '\n')
|
|
185
|
+
}
|
|
186
|
+
}
|