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 CHANGED
@@ -1,20 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * ratelimit-engine CLI
4
- * Interactive wizard to simulate any built-in algorithm.
5
- * Usage: npx ratelimit-engine simulate
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 { FixedWindow } from '../src/algorithms/FixedWindow.js'
10
- import { SlidingWindow } from '../src/algorithms/SlidingWindow.js'
11
- import { TokenBucket } from '../src/algorithms/TokenBucket.js'
12
- import { LeakyBucket } from '../src/algorithms/LeakyBucket.js'
13
- import { Composite } from '../src/algorithms/Composite.js'
14
- import { MemoryStore } from '../src/stores/MemoryStore.js'
15
- import { COMPOSITE_MODES } from '../src/constants/algorithms.js'
16
-
17
- // ── ANSI colours ─────────────────────────────────────────────────
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
- white: '\x1b[37m',
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 helper ───────────────────────────────────────────────
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(question, defaultVal) {
49
- return new Promise(async (resolve) => {
50
- const raw = await ask(`${question} ${dim(`[${defaultVal}]`)}: `)
51
- const num = parseInt(raw, 10)
52
- resolve(isNaN(num) || num <= 0 ? defaultVal : num)
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 menu ────────────────────────────────────────────────
71
+ // ── Algorithm definitions ─────────────────────────────────────────
57
72
  const ALGORITHMS = [
58
- {
59
- key: 'token-bucket',
60
- label: 'Token Bucket',
61
- desc: 'Burst-friendly — refills tokens over time',
62
- color: cyan,
63
- },
64
- {
65
- key: 'fixed-window',
66
- label: 'Fixed Window',
67
- desc: 'Simple counter per time window',
68
- color: green,
69
- },
70
- {
71
- key: 'sliding-window',
72
- label: 'Sliding Window',
73
- desc: 'Smooth rolling window, no boundary spikes',
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
- // ── Build algorithm from user input ──────────────────────────────
91
- async function buildAlgorithm(key) {
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 'token-bucket': {
94
- const capacity = await askNumber(' Bucket capacity (max tokens)', 10)
95
- const refillRate = await askNumber(' Refill rate (tokens/sec)', 2)
96
- return new TokenBucket({ capacity, refillRate })
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 'fixed-window': {
99
- const limit = await askNumber(' Max requests', 10)
100
- const windowMs = (await askNumber(' Window (seconds)', 60)) * 1000
101
- return new FixedWindow({ limit, windowMs })
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 'sliding-window': {
104
- const limit = await askNumber(' Max requests', 10)
105
- const windowMs = (await askNumber(' Window (seconds)', 60)) * 1000
106
- return new SlidingWindow({ limit, windowMs })
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 'leaky-bucket': {
109
- const capacity = await askNumber(' Bucket capacity (queue depth)', 10)
110
- const leakRate = await askNumber(' Leak rate (requests/sec)', 2)
111
- return new LeakyBucket({ capacity, leakRate })
112
- }
113
- case 'composite': {
114
- console.log(`\n ${bold('Primary algorithm:')}`)
115
- const primaryKey = await pickAlgorithm(ALGORITHMS.filter((a) => a.key !== 'composite'))
116
- const primary = await buildAlgorithm(primaryKey)
117
-
118
- console.log(`\n ${bold('Secondary algorithm:')}`)
119
- const secondaryKey = await pickAlgorithm(ALGORITHMS.filter((a) => a.key !== 'composite'))
120
- const secondary = await buildAlgorithm(secondaryKey)
121
-
122
- const modeRaw = await ask(`\n Mode — ${cyan('1')} ALL (both must pass) / ${yellow('2')} ANY (one must pass) [1]: `)
123
- const mode = modeRaw === '2' ? COMPOSITE_MODES.ANY : COMPOSITE_MODES.ALL
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 new FixedWindow()
135
+ return {}
129
136
  }
130
137
  }
131
138
 
132
- async function pickAlgorithm(list = ALGORITHMS) {
133
- list.forEach((a, i) => {
134
- console.log(` ${bold(cyan(String(i + 1)))}. ${bold(a.label.padEnd(18))} ${dim(a.desc)}`)
135
- })
136
-
137
- const raw = await ask(`\n Pick [1-${list.length}]: `)
138
- const idx = parseInt(raw, 10) - 1
139
-
140
- if (isNaN(idx) || idx < 0 || idx >= list.length) {
141
- console.log(dim(' Invalid choice — defaulting to Token Bucket'))
142
- return 'token-bucket'
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
- // ── Table renderer ────────────────────────────────────────────────
149
- function renderTable(rows) {
150
- const cols = [
151
- { key: 'req', label: 'Req', width: 5 },
152
- { key: 'time', label: 'Time (ms)', width: 10 },
153
- { key: 'allowed', label: 'Allowed', width: 10 },
154
- { key: 'remaining', label: 'Remaining', width: 11 },
155
- { key: 'retryAfter', label: 'Retry (s)', width: 10 },
156
- { key: 'resetAt', label: 'Resets At', width: 12 },
157
- ]
158
-
159
- const divider = '+' + cols.map((c) => '-'.repeat(c.width + 2)).join('+') + '+'
160
- const header = '|' + cols.map((c) => ` ${bold(c.label.padEnd(c.width))} `).join('|') + '|'
161
-
162
- console.log('\n' + divider)
163
- console.log(header)
164
- console.log(divider)
165
-
166
- for (const row of rows) {
167
- const allowed = row.allowed
168
- ? green('✅ yes'.padEnd(10))
169
- : red('❌ no'.padEnd(10))
170
-
171
- const line = '|' + [
172
- ` ${String(row.req).padEnd(cols[0].width)} `,
173
- ` ${String(row.time).padEnd(cols[1].width)} `,
174
- ` ${allowed} `,
175
- ` ${String(row.remaining).padEnd(cols[3].width)} `,
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
- console.log(divider)
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
- // ── Summary ───────────────────────────────────────────────────────
187
- function renderSummary(rows) {
188
- const allowed = rows.filter((r) => r.allowed).length
189
- const blocked = rows.filter((r) => !r.allowed).length
190
- const avgRemain = (rows.reduce((s, r) => s + r.remaining, 0) / rows.length).toFixed(1)
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
- console.log(`
193
- ${bold('Summary')}
194
- ───────────────────────────────
195
- Total requests : ${bold(String(rows.length))}
196
- Allowed : ${green(bold(String(allowed)))}
197
- Blocked : ${red(bold(String(blocked)))}
198
- Avg remaining : ${cyan(avgRemain)}
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
- // ── Sleep helper ──────────────────────────────────────────────────
203
- function sleep(ms) {
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('— Algorithm Simulator')}
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
- console.log(` ${bold('Select algorithm:')}\n`)
216
- const algoKey = await pickAlgorithm()
217
- const algo = await buildAlgorithm(algoKey)
218
- const requests = await askNumber('\nSimulate how many requests?', 15)
219
- const delayMs = await askNumber('Delay between requests (ms)?', 100)
220
-
221
- console.log(`\n ${dim('Running simulation...')}\n`)
222
-
223
- const store = new MemoryStore()
224
- const rows = []
225
- const key = 'cli:simulate'
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
- await store.destroy()
247
- renderTable(rows)
248
- renderSummary(rows)
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
- const again = await ask(' Run another simulation? (y/n) [n]: ')
251
- if (again.toLowerCase() === 'y') {
252
- await main()
253
- } else {
254
- console.log(`\n ${dim('Goodbye.')}\n`)
255
- rl.close()
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(red(`\nError: ${err.message}`))
318
+ console.error(`\nError: ${err.message}`)
261
319
  rl.close()
262
320
  process.exit(1)
263
321
  })
@@ -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.0.0",
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
+ }