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.
- package/bin/cli.js +218 -190
- 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
|
-
*
|
|
5
|
-
* Usage: npx ratelimit-engine
|
|
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
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
58
|
+
// ── Algorithm definitions ─────────────────────────────────────────
|
|
57
59
|
const ALGORITHMS = [
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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 '
|
|
94
|
-
const capacity = await askNumber('
|
|
95
|
-
const refillRate = await askNumber('
|
|
96
|
-
return
|
|
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 '
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
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 '
|
|
109
|
-
const capacity = await askNumber('
|
|
110
|
-
const leakRate = await askNumber('
|
|
111
|
-
return
|
|
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 '
|
|
114
|
-
console.log(`\n ${bold('Primary algorithm:')}`)
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
console.log(`\n ${bold('Secondary algorithm:')}`)
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const mode
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
122
|
+
return {}
|
|
129
123
|
}
|
|
130
124
|
}
|
|
131
125
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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('—
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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(
|
|
288
|
+
console.error(`\nError: ${err.message}`)
|
|
261
289
|
rl.close()
|
|
262
290
|
process.exit(1)
|
|
263
291
|
})
|