ratelimit-engine 1.0.1 → 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,13 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * ratelimit-engine CLI
4
- * Setup wizard — generates ratelimit.config.js for your project.
5
- * Usage: npx ratelimit-engine
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
10
  import * as fs from 'fs'
10
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
+ }
11
24
 
12
25
  // ── ANSI colours ──────────────────────────────────────────────────
13
26
  const C = {
@@ -138,7 +151,7 @@ function algoArgs(key, config) {
138
151
  }
139
152
  }
140
153
 
141
- function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey }) {
154
+ function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey, failOpen = true, customOnBlocked = null }) {
142
155
  const isRedis = storeKey === 'RedisStore'
143
156
  const isComposite = algoKey === 'Composite'
144
157
 
@@ -146,7 +159,7 @@ function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrate
146
159
  ? [algoKey, algoConfig.primary.key, algoConfig.secondary.key]
147
160
  : [algoKey]
148
161
 
149
- const allImports = [...new Set([...imports, storeKey, keyStrategyKey])]
162
+ const allImports = [...new Set(['createLimiter', ...imports, storeKey, keyStrategyKey])]
150
163
 
151
164
  const redisImport = isRedis ? `\nimport Redis from 'ioredis'` : ''
152
165
  const redisClient = isRedis ? `\nconst redis = new Redis() // configure your Redis connection\n` : ''
@@ -203,15 +216,19 @@ if (!result.allowed) {
203
216
  break
204
217
  }
205
218
 
219
+ const onBlockedMsg = customOnBlocked
220
+ ? `console.warn(\`${customOnBlocked}\`)`
221
+ : `console.warn(\`[RATE LIMIT] \${ctx.ip} blocked — retry in \${result.retryAfter}s\`)`
222
+
206
223
  return `import { ${allImports.join(', ')}${compositeImport} } from 'ratelimit-engine'${redisImport}
207
224
  ${redisClient}
208
225
  const limiter = createLimiter({
209
226
  algorithm: ${algoInit},
210
227
  store: ${storeInit},
211
228
  keyBy: ${keyStrategyKey},
212
- failOpen: true,
229
+ failOpen: ${failOpen},
213
230
  onBlocked: (ctx, result) => {
214
- console.warn(\`[RATE LIMIT] \${ctx.ip} blocked — retry in \${result.retryAfter}s\`)
231
+ ${onBlockedMsg}
215
232
  },
216
233
  })
217
234
 
@@ -252,6 +269,17 @@ async function main() {
252
269
  console.log(`\n ${bold('Step 4 — How should requests be keyed?')}\n`)
253
270
  const keyStrategy = await askChoice(KEY_STRATEGIES)
254
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
+
255
283
  // Generate
256
284
  const code = generateConfig({
257
285
  algoKey: algo.key,
@@ -259,21 +287,23 @@ async function main() {
259
287
  storeKey: store.key,
260
288
  frameworkKey: framework.key,
261
289
  keyStrategyKey: keyStrategy.key,
290
+ failOpen,
291
+ customOnBlocked: customOnBlocked || null,
262
292
  })
263
293
 
264
294
  // Write file
265
- const outPath = path.join(process.cwd(), 'ratelimit.config.js')
295
+ const outPath = path.join(process.cwd(), fileName)
266
296
  fs.writeFileSync(outPath, code, 'utf8')
267
297
 
268
298
  // Output
269
299
  console.log(`\n ${dim('─────────────────────────────────────────────')}`)
270
- console.log(` ${green(bold('✅ Done!'))} Generated ${bold('ratelimit.config.js')}\n`)
300
+ console.log(` ${green(bold('✅ Done!'))} Generated ${bold(fileName)}\n`)
271
301
  console.log(` ${dim('─────────────────────────────────────────────')}`)
272
302
  console.log(`\n${code}`)
273
303
  console.log(` ${dim('─────────────────────────────────────────────')}`)
274
304
  console.log(`\n ${bold('Next steps:')}\n`)
275
305
  console.log(` ${cyan('1.')} Import the config into your app:`)
276
- console.log(` ${dim("import { limiter } from './ratelimit.config.js'")}`)
306
+ console.log(` ${dim(`import { limiter } from './${fileName}'`)}`)
277
307
  console.log(`\n ${cyan('2.')} Wire it up — the usage snippet is inside the generated file.`)
278
308
  if (store.key === 'RedisStore') {
279
309
  console.log(`\n ${cyan('3.')} Install ioredis if not already:`)
@@ -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.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
+ }