ratelimit-engine 1.0.0 → 1.0.1

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.
Files changed (2) hide show
  1. package/bin/cli.js +218 -190
  2. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -1,20 +1,15 @@
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
+ * Setup wizard — generates ratelimit.config.js for your project.
5
+ * Usage: npx ratelimit-engine
6
6
  */
7
7
 
8
8
  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 ─────────────────────────────────────────────────
9
+ import * as fs from 'fs'
10
+ import * as path from 'path'
11
+
12
+ // ── ANSI colours ──────────────────────────────────────────────────
18
13
  const C = {
19
14
  reset: '\x1b[0m',
20
15
  bold: '\x1b[1m',
@@ -24,19 +19,18 @@ const C = {
24
19
  yellow: '\x1b[33m',
25
20
  cyan: '\x1b[36m',
26
21
  magenta: '\x1b[35m',
27
- white: '\x1b[37m',
28
- bgDark: '\x1b[48;5;235m',
22
+ blue: '\x1b[34m',
29
23
  }
30
24
 
31
25
  const bold = (s) => `${C.bold}${s}${C.reset}`
32
26
  const dim = (s) => `${C.dim}${s}${C.reset}`
33
27
  const green = (s) => `${C.green}${s}${C.reset}`
34
- const red = (s) => `${C.red}${s}${C.reset}`
35
28
  const cyan = (s) => `${C.cyan}${s}${C.reset}`
36
29
  const yellow = (s) => `${C.yellow}${s}${C.reset}`
37
30
  const magenta = (s) => `${C.magenta}${s}${C.reset}`
31
+ const blue = (s) => `${C.blue}${s}${C.reset}`
38
32
 
39
- // ── Readline helper ───────────────────────────────────────────────
33
+ // ── Readline helpers ──────────────────────────────────────────────
40
34
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
41
35
 
42
36
  function ask(question) {
@@ -45,219 +39,253 @@ function ask(question) {
45
39
  })
46
40
  }
47
41
 
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)
42
+ async function askNumber(label, defaultVal) {
43
+ const raw = await ask(` ${label} ${dim(`[${defaultVal}]`)}: `)
44
+ const num = parseInt(raw, 10)
45
+ return isNaN(num) || num <= 0 ? defaultVal : num
46
+ }
47
+
48
+ async function askChoice(choices) {
49
+ choices.forEach((c, i) => {
50
+ console.log(` ${bold(cyan(String(i + 1)))}. ${bold(c.label.padEnd(18))} ${dim(c.desc)}`)
53
51
  })
52
+ const raw = await ask(`\n Pick [1-${choices.length}]: `)
53
+ const idx = parseInt(raw, 10) - 1
54
+ if (isNaN(idx) || idx < 0 || idx >= choices.length) return choices[0]
55
+ return choices[idx]
54
56
  }
55
57
 
56
- // ── Algorithm menu ────────────────────────────────────────────────
58
+ // ── Algorithm definitions ─────────────────────────────────────────
57
59
  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
- },
60
+ { key: 'TokenBucket', label: 'Token Bucket', desc: 'Burst-friendly — refills tokens over time' },
61
+ { key: 'FixedWindow', label: 'Fixed Window', desc: 'Simple counter per time window' },
62
+ { key: 'SlidingWindow', label: 'Sliding Window', desc: 'Smooth rolling window, no boundary spikes' },
63
+ { key: 'LeakyBucket', label: 'Leaky Bucket', desc: 'Strict output rate, drops excess' },
64
+ { key: 'Composite', label: 'Composite', desc: 'Combine two algorithms (ALL / ANY)' },
65
+ ]
66
+
67
+ const STORES = [
68
+ { key: 'MemoryStore', label: 'Memory', desc: 'In-process store — single instance' },
69
+ { key: 'RedisStore', label: 'Redis', desc: 'Distributed store — multi-instance / clustered' },
88
70
  ]
89
71
 
90
- // ── Build algorithm from user input ──────────────────────────────
91
- async function buildAlgorithm(key) {
72
+ const FRAMEWORKS = [
73
+ { key: 'express', label: 'Express', desc: 'app.use(limiter.express())' },
74
+ { key: 'fastify', label: 'Fastify', desc: 'fastify.addHook("onRequest", limiter.fastify())' },
75
+ { key: 'manual', label: 'Manual', desc: 'limiter.check(ctx) — any framework' },
76
+ ]
77
+
78
+ const KEY_STRATEGIES = [
79
+ { key: 'byIP', label: 'By IP', desc: 'One counter per client IP (default)' },
80
+ { key: 'byUser', label: 'By User', desc: 'One counter per authenticated user' },
81
+ { key: 'byRoute', label: 'By Route', desc: 'One counter per URL path' },
82
+ { key: 'compositeResolver', label: 'IP + Route', desc: 'Separate counter per IP+route combination' },
83
+ ]
84
+
85
+ // ── Config collectors ─────────────────────────────────────────────
86
+ async function collectAlgoConfig(key) {
92
87
  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 })
97
- }
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 })
88
+ case 'TokenBucket': {
89
+ const capacity = await askNumber('Token bucket capacity (burst size)', 100)
90
+ const refillRate = await askNumber('Refill rate (tokens/sec)', 10)
91
+ return { capacity, refillRate }
102
92
  }
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 })
93
+ case 'FixedWindow':
94
+ case 'SlidingWindow': {
95
+ const limit = await askNumber('Max requests per window', 100)
96
+ const windowMs = (await askNumber('Window size (seconds)', 60)) * 1000
97
+ return { limit, windowMs }
107
98
  }
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 })
99
+ case 'LeakyBucket': {
100
+ const capacity = await askNumber('Queue capacity (max queued requests)', 100)
101
+ const leakRate = await askNumber('Leak rate (requests/sec)', 10)
102
+ return { capacity, leakRate }
112
103
  }
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 })
104
+ case 'Composite': {
105
+ console.log(`\n ${bold('Primary algorithm:')}\n`)
106
+ const primary = await askChoice(ALGORITHMS.filter((a) => a.key !== 'Composite'))
107
+ const primaryConfig = await collectAlgoConfig(primary.key)
108
+
109
+ console.log(`\n ${bold('Secondary algorithm:')}\n`)
110
+ const secondary = await askChoice(ALGORITHMS.filter((a) => a.key !== 'Composite'))
111
+ const secondaryConfig = await collectAlgoConfig(secondary.key)
112
+
113
+ console.log(`\n ${bold('Composite mode:')}\n`)
114
+ const mode = await askChoice([
115
+ { key: 'ALL', label: 'ALL', desc: 'Both algorithms must allow the request' },
116
+ { key: 'ANY', label: 'ANY', desc: 'At least one algorithm must allow the request' },
117
+ ])
118
+
119
+ return { primary: { key: primary.key, config: primaryConfig }, secondary: { key: secondary.key, config: secondaryConfig }, mode: mode.key }
126
120
  }
127
121
  default:
128
- return new FixedWindow()
122
+ return {}
129
123
  }
130
124
  }
131
125
 
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'
126
+ // ── Code generators ───────────────────────────────────────────────
127
+ function algoArgs(key, config) {
128
+ switch (key) {
129
+ case 'TokenBucket':
130
+ return `{ capacity: ${config.capacity}, refillRate: ${config.refillRate} }`
131
+ case 'FixedWindow':
132
+ case 'SlidingWindow':
133
+ return `{ limit: ${config.limit}, windowMs: ${config.windowMs} }`
134
+ case 'LeakyBucket':
135
+ return `{ capacity: ${config.capacity}, leakRate: ${config.leakRate} }`
136
+ default:
137
+ return '{}'
143
138
  }
144
-
145
- return list[idx].key
146
139
  }
147
140
 
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)
141
+ function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey }) {
142
+ const isRedis = storeKey === 'RedisStore'
143
+ const isComposite = algoKey === 'Composite'
144
+
145
+ const imports = isComposite
146
+ ? [algoKey, algoConfig.primary.key, algoConfig.secondary.key]
147
+ : [algoKey]
148
+
149
+ const allImports = [...new Set([...imports, storeKey, keyStrategyKey])]
150
+
151
+ const redisImport = isRedis ? `\nimport Redis from 'ioredis'` : ''
152
+ const redisClient = isRedis ? `\nconst redis = new Redis() // configure your Redis connection\n` : ''
153
+ const storeInit = isRedis
154
+ ? `new RedisStore({ client: redis })`
155
+ : `new MemoryStore()`
156
+
157
+ let algoInit
158
+ if (isComposite) {
159
+ const { primary, secondary, mode } = algoConfig
160
+ algoInit = `new Composite({
161
+ algorithms: [
162
+ new ${primary.key}(${algoArgs(primary.key, primary.config)}),
163
+ new ${secondary.key}(${algoArgs(secondary.key, secondary.config)}),
164
+ ],
165
+ mode: COMPOSITE_MODES.${mode},
166
+ })`
167
+ } else {
168
+ algoInit = `new ${algoKey}(${algoArgs(algoKey, algoConfig)})`
181
169
  }
182
170
 
183
- console.log(divider)
184
- }
171
+ const compositeImport = isComposite ? `, COMPOSITE_MODES` : ''
172
+
173
+ let frameworkUsage
174
+ switch (frameworkKey) {
175
+ case 'express':
176
+ frameworkUsage = `// Express — apply globally
177
+ app.use(limiter.express())
178
+
179
+ // or per-route
180
+ app.get('/api/resource', limiter.express(), handler)`
181
+ break
182
+ case 'fastify':
183
+ frameworkUsage = `// Fastify — apply globally
184
+ fastify.addHook('onRequest', limiter.fastify())
185
+
186
+ // or per-route
187
+ fastify.get('/api/resource', { onRequest: limiter.fastify() }, handler)`
188
+ break
189
+ case 'manual':
190
+ frameworkUsage = `// Manual — any framework
191
+ const result = await limiter.check({
192
+ ip: req.ip,
193
+ path: req.path,
194
+ user: req.user?.id, // optional
195
+ })
185
196
 
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)
197
+ if (!result.allowed) {
198
+ return res.status(429).json({
199
+ error: 'Too Many Requests',
200
+ retryAfter: result.retryAfter,
201
+ })
202
+ }`
203
+ break
204
+ }
191
205
 
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
- }
206
+ return `import { ${allImports.join(', ')}${compositeImport} } from 'ratelimit-engine'${redisImport}
207
+ ${redisClient}
208
+ const limiter = createLimiter({
209
+ algorithm: ${algoInit},
210
+ store: ${storeInit},
211
+ keyBy: ${keyStrategyKey},
212
+ failOpen: true,
213
+ onBlocked: (ctx, result) => {
214
+ console.warn(\`[RATE LIMIT] \${ctx.ip} blocked — retry in \${result.retryAfter}s\`)
215
+ },
216
+ })
217
+
218
+ ${frameworkUsage}
201
219
 
202
- // ── Sleep helper ──────────────────────────────────────────────────
203
- function sleep(ms) {
204
- return new Promise((resolve) => setTimeout(resolve, ms))
220
+ export { limiter }
221
+ `
205
222
  }
206
223
 
207
224
  // ── Main ──────────────────────────────────────────────────────────
208
225
  async function main() {
209
226
  console.clear()
210
227
  console.log(`
211
- ${bold(cyan('⚡ ratelimit-engine'))} ${dim('— Algorithm Simulator')}
212
- ${dim('─────────────────────────────────────────')}
228
+ ${bold(cyan('⚡ ratelimit-engine'))} ${dim('— Setup Wizard')}
229
+ ${dim('─────────────────────────────────────────────')}
230
+ ${dim('Generates a ready-to-use ratelimit.config.js')}
231
+ ${dim('for your project in the current directory.')}
213
232
  `)
214
233
 
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
- }
234
+ // Step 1 — Algorithm
235
+ console.log(` ${bold('Step 1 — Choose an algorithm:')}\n`)
236
+ const algo = await askChoice(ALGORITHMS)
237
+ console.log()
238
+ const algoConfig = await collectAlgoConfig(algo.key)
239
+
240
+ // Step 2 — Store
241
+ console.log(`\n ${bold('Step 2 — Choose a store:')}\n`)
242
+ const store = await askChoice(STORES)
243
+ if (store.key === 'RedisStore') {
244
+ console.log(`\n ${yellow('Note:')} Make sure ${bold('ioredis')} is installed: ${dim('npm install ioredis')}`)
244
245
  }
245
246
 
246
- await store.destroy()
247
- renderTable(rows)
248
- renderSummary(rows)
247
+ // Step 3 — Framework
248
+ console.log(`\n ${bold('Step 3 — Choose your framework:')}\n`)
249
+ const framework = await askChoice(FRAMEWORKS)
250
+
251
+ // Step 4 — Key strategy
252
+ console.log(`\n ${bold('Step 4 — How should requests be keyed?')}\n`)
253
+ const keyStrategy = await askChoice(KEY_STRATEGIES)
254
+
255
+ // Generate
256
+ const code = generateConfig({
257
+ algoKey: algo.key,
258
+ algoConfig,
259
+ storeKey: store.key,
260
+ frameworkKey: framework.key,
261
+ keyStrategyKey: keyStrategy.key,
262
+ })
249
263
 
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()
264
+ // Write file
265
+ const outPath = path.join(process.cwd(), 'ratelimit.config.js')
266
+ fs.writeFileSync(outPath, code, 'utf8')
267
+
268
+ // Output
269
+ console.log(`\n ${dim('─────────────────────────────────────────────')}`)
270
+ console.log(` ${green(bold('✅ Done!'))} Generated ${bold('ratelimit.config.js')}\n`)
271
+ console.log(` ${dim('─────────────────────────────────────────────')}`)
272
+ console.log(`\n${code}`)
273
+ console.log(` ${dim('─────────────────────────────────────────────')}`)
274
+ console.log(`\n ${bold('Next steps:')}\n`)
275
+ console.log(` ${cyan('1.')} Import the config into your app:`)
276
+ console.log(` ${dim("import { limiter } from './ratelimit.config.js'")}`)
277
+ console.log(`\n ${cyan('2.')} Wire it up — the usage snippet is inside the generated file.`)
278
+ if (store.key === 'RedisStore') {
279
+ console.log(`\n ${cyan('3.')} Install ioredis if not already:`)
280
+ console.log(` ${dim('npm install ioredis')}`)
256
281
  }
282
+ console.log()
283
+
284
+ rl.close()
257
285
  }
258
286
 
259
287
  main().catch((err) => {
260
- console.error(red(`\nError: ${err.message}`))
288
+ console.error(`\nError: ${err.message}`)
261
289
  rl.close()
262
290
  process.exit(1)
263
291
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ratelimit-engine",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Enterprise-grade, algorithm-pluggable rate limiting engine for Node.js",
5
5
  "main": "src/index.js",
6
6
  "type": "module",