ratelimit-engine 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +437 -2
- package/bin/cli.js +291 -0
- package/package.json +56 -4
- package/src/adapters/BaseAdapter.js +38 -0
- package/src/adapters/ExpressAdapter.js +64 -0
- package/src/adapters/FastifyAdapter.js +67 -0
- package/src/algorithms/BaseAlgorithm.js +40 -0
- package/src/algorithms/Composite.js +97 -0
- package/src/algorithms/FixedWindow.js +70 -0
- package/src/algorithms/LeakyBucket.js +88 -0
- package/src/algorithms/SlidingWindow.js +99 -0
- package/src/algorithms/TokenBucket.js +85 -0
- package/src/constants/algorithms.js +18 -0
- package/src/constants/defaults.js +15 -0
- package/src/constants/errors.js +42 -0
- package/src/constants/headers.js +18 -0
- package/src/constants/store.js +20 -0
- package/src/engine/HeaderWriter.js +56 -0
- package/src/engine/KeyResolver.js +41 -0
- package/src/engine/RateLimiter.js +145 -0
- package/src/engine/createLimiter.js +37 -0
- package/src/errors/ConfigurationError.js +20 -0
- package/src/errors/ErrorFactory.js +117 -0
- package/src/errors/RateLimitError.js +25 -0
- package/src/errors/StoreError.js +20 -0
- package/src/index.js +52 -0
- package/src/logger/Logger.js +127 -0
- package/src/logger/LoggerFactory.js +36 -0
- package/src/resolvers/byAPIKey.js +11 -0
- package/src/resolvers/byIP.js +11 -0
- package/src/resolvers/byRoute.js +13 -0
- package/src/resolvers/byUser.js +11 -0
- package/src/resolvers/composite.js +18 -0
- package/src/stores/BaseStore.js +69 -0
- package/src/stores/MemoryStore.js +129 -0
- package/src/stores/RedisStore.js +168 -0
- package/src/types/index.js +79 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ratelimit-engine CLI
|
|
4
|
+
* Setup wizard — generates ratelimit.config.js for your project.
|
|
5
|
+
* Usage: npx ratelimit-engine
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as readline from 'readline'
|
|
9
|
+
import * as fs from 'fs'
|
|
10
|
+
import * as path from 'path'
|
|
11
|
+
|
|
12
|
+
// ── ANSI colours ──────────────────────────────────────────────────
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
const bold = (s) => `${C.bold}${s}${C.reset}`
|
|
26
|
+
const dim = (s) => `${C.dim}${s}${C.reset}`
|
|
27
|
+
const green = (s) => `${C.green}${s}${C.reset}`
|
|
28
|
+
const cyan = (s) => `${C.cyan}${s}${C.reset}`
|
|
29
|
+
const yellow = (s) => `${C.yellow}${s}${C.reset}`
|
|
30
|
+
const magenta = (s) => `${C.magenta}${s}${C.reset}`
|
|
31
|
+
const blue = (s) => `${C.blue}${s}${C.reset}`
|
|
32
|
+
|
|
33
|
+
// ── Readline helpers ──────────────────────────────────────────────
|
|
34
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
35
|
+
|
|
36
|
+
function ask(question) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
rl.question(question, (answer) => resolve(answer.trim()))
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
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)}`)
|
|
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]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Algorithm definitions ─────────────────────────────────────────
|
|
59
|
+
const ALGORITHMS = [
|
|
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' },
|
|
70
|
+
]
|
|
71
|
+
|
|
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) {
|
|
87
|
+
switch (key) {
|
|
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 }
|
|
92
|
+
}
|
|
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 }
|
|
98
|
+
}
|
|
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 }
|
|
103
|
+
}
|
|
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 }
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
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 '{}'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
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)})`
|
|
169
|
+
}
|
|
170
|
+
|
|
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
|
+
})
|
|
196
|
+
|
|
197
|
+
if (!result.allowed) {
|
|
198
|
+
return res.status(429).json({
|
|
199
|
+
error: 'Too Many Requests',
|
|
200
|
+
retryAfter: result.retryAfter,
|
|
201
|
+
})
|
|
202
|
+
}`
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
|
|
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}
|
|
219
|
+
|
|
220
|
+
export { limiter }
|
|
221
|
+
`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Main ──────────────────────────────────────────────────────────
|
|
225
|
+
async function main() {
|
|
226
|
+
console.clear()
|
|
227
|
+
console.log(`
|
|
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.')}
|
|
232
|
+
`)
|
|
233
|
+
|
|
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')}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
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
|
+
})
|
|
263
|
+
|
|
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')}`)
|
|
281
|
+
}
|
|
282
|
+
console.log()
|
|
283
|
+
|
|
284
|
+
rl.close()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
main().catch((err) => {
|
|
288
|
+
console.error(`\nError: ${err.message}`)
|
|
289
|
+
rl.close()
|
|
290
|
+
process.exit(1)
|
|
291
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ratelimit-engine",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Enterprise-grade, algorithm-pluggable rate limiting engine for Node.js",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"test:coverage": "vitest run --coverage",
|
|
14
|
+
"lint": "eslint src tests",
|
|
15
|
+
"lint:fix": "eslint src tests --fix"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"rate-limit",
|
|
19
|
+
"rate-limiter",
|
|
20
|
+
"throttle",
|
|
21
|
+
"token-bucket",
|
|
22
|
+
"sliding-window",
|
|
23
|
+
"fixed-window",
|
|
24
|
+
"leaky-bucket",
|
|
25
|
+
"redis",
|
|
26
|
+
"express",
|
|
27
|
+
"fastify",
|
|
28
|
+
"enterprise",
|
|
29
|
+
"middleware"
|
|
30
|
+
],
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"bin",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"bin": {
|
|
38
|
+
"ratelimit-engine": "./bin/cli.js"
|
|
39
|
+
},
|
|
40
|
+
"author": "Abhinav710 <kumarabhinav5693@gmail.com>",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@vitest/coverage-v8": "^1.0.0",
|
|
47
|
+
"eslint": "^8.57.0",
|
|
48
|
+
"vitest": "^1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"ioredis": ">=5.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"ioredis": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Abstract base class for framework adapters.
|
|
3
|
+
* Adapters extract a RequestContext from the framework's request object
|
|
4
|
+
* and delegate to the RateLimiter engine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
8
|
+
|
|
9
|
+
export class BaseAdapter {
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../engine/RateLimiter.js').RateLimiter} limiter
|
|
12
|
+
*/
|
|
13
|
+
constructor(limiter) {
|
|
14
|
+
if (new.target === BaseAdapter) {
|
|
15
|
+
throw ErrorFactory.invalidAlgorithm({ reason: 'BaseAdapter cannot be instantiated directly' })
|
|
16
|
+
}
|
|
17
|
+
this._limiter = limiter
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts a RequestContext from the framework's native request object.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} req - Framework request object.
|
|
24
|
+
* @returns {import('../types/index.js').RequestContext}
|
|
25
|
+
*/
|
|
26
|
+
extractContext(_req) {
|
|
27
|
+
throw new Error('extractContext() must be implemented by subclass')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns the framework-specific middleware function.
|
|
32
|
+
*
|
|
33
|
+
* @returns {Function}
|
|
34
|
+
*/
|
|
35
|
+
middleware() {
|
|
36
|
+
throw new Error('middleware() must be implemented by subclass')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Express middleware adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { BaseAdapter } from './BaseAdapter.js'
|
|
6
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('ExpressAdapter')
|
|
9
|
+
|
|
10
|
+
export class ExpressAdapter extends BaseAdapter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('../engine/RateLimiter.js').RateLimiter} limiter
|
|
13
|
+
*/
|
|
14
|
+
constructor(limiter) {
|
|
15
|
+
super(limiter)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} req - Express request.
|
|
20
|
+
* @returns {import('../types/index.js').RequestContext}
|
|
21
|
+
*/
|
|
22
|
+
extractContext(req) {
|
|
23
|
+
return {
|
|
24
|
+
ip: req.ip ?? req.socket?.remoteAddress ?? 'unknown',
|
|
25
|
+
userId: req.user?.id ?? req.user?.sub ?? undefined,
|
|
26
|
+
apiKey: req.headers?.['x-api-key'] ?? undefined,
|
|
27
|
+
method: req.method,
|
|
28
|
+
path: req.path ?? req.url,
|
|
29
|
+
headers: req.headers ?? {},
|
|
30
|
+
rawRequest: req,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns an Express-compatible middleware function.
|
|
36
|
+
* Writes rate limit headers and sends 429 when blocked.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Function} (req, res, next) => void
|
|
39
|
+
*/
|
|
40
|
+
middleware() {
|
|
41
|
+
const limiter = this._limiter
|
|
42
|
+
const headerWriter = this._limiter.getHeaderWriter()
|
|
43
|
+
|
|
44
|
+
return async (req, res, next) => {
|
|
45
|
+
try {
|
|
46
|
+
const context = this.extractContext(req)
|
|
47
|
+
const result = await limiter.check(context)
|
|
48
|
+
|
|
49
|
+
headerWriter.write(res, result)
|
|
50
|
+
|
|
51
|
+
if (!result.allowed) {
|
|
52
|
+
logger.info('Blocking request', { ip: context.ip, path: context.path })
|
|
53
|
+
headerWriter.sendBlocked(res, result)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
next()
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.error('Unexpected error in Express middleware', { error: err.message })
|
|
60
|
+
next(err)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fastify hook adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { BaseAdapter } from './BaseAdapter.js'
|
|
6
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
7
|
+
import { HTTP_STATUS } from '../constants/headers.js'
|
|
8
|
+
|
|
9
|
+
const logger = createLogger('FastifyAdapter')
|
|
10
|
+
|
|
11
|
+
export class FastifyAdapter extends BaseAdapter {
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('../engine/RateLimiter.js').RateLimiter} limiter
|
|
14
|
+
*/
|
|
15
|
+
constructor(limiter) {
|
|
16
|
+
super(limiter)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} req - Fastify request.
|
|
21
|
+
* @returns {import('../types/index.js').RequestContext}
|
|
22
|
+
*/
|
|
23
|
+
extractContext(req) {
|
|
24
|
+
return {
|
|
25
|
+
ip: req.ip ?? 'unknown',
|
|
26
|
+
userId: req.user?.id ?? req.user?.sub ?? undefined,
|
|
27
|
+
apiKey: req.headers?.['x-api-key'] ?? undefined,
|
|
28
|
+
method: req.method,
|
|
29
|
+
path: req.url,
|
|
30
|
+
headers: req.headers ?? {},
|
|
31
|
+
rawRequest: req,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns a Fastify onRequest hook function.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Function} async (request, reply) => void
|
|
39
|
+
*/
|
|
40
|
+
middleware() {
|
|
41
|
+
const limiter = this._limiter
|
|
42
|
+
const headerWriter = this._limiter.getHeaderWriter()
|
|
43
|
+
|
|
44
|
+
return async (request, reply) => {
|
|
45
|
+
try {
|
|
46
|
+
const context = this.extractContext(request)
|
|
47
|
+
const result = await limiter.check(context)
|
|
48
|
+
|
|
49
|
+
headerWriter.write(reply.raw, result)
|
|
50
|
+
|
|
51
|
+
if (!result.allowed) {
|
|
52
|
+
logger.info('Blocking request', { ip: context.ip, path: context.path })
|
|
53
|
+
reply
|
|
54
|
+
.code(HTTP_STATUS.TOO_MANY_REQUESTS)
|
|
55
|
+
.send({
|
|
56
|
+
error: 'Too Many Requests',
|
|
57
|
+
retryAfter: result.retryAfter,
|
|
58
|
+
resetAt: result.resetAt.toISOString(),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.error('Unexpected error in Fastify hook', { error: err.message })
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Abstract base class for all rate limit algorithms.
|
|
3
|
+
* Every algorithm must extend this and implement check() and reset().
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
7
|
+
|
|
8
|
+
export class BaseAlgorithm {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} name - Algorithm identifier from ALGORITHM_NAMES.
|
|
11
|
+
*/
|
|
12
|
+
constructor(name) {
|
|
13
|
+
if (new.target === BaseAlgorithm) {
|
|
14
|
+
throw ErrorFactory.invalidAlgorithm({ reason: 'BaseAlgorithm cannot be instantiated directly' })
|
|
15
|
+
}
|
|
16
|
+
this.name = name
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checks whether the given key is within its rate limit.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} key
|
|
23
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
24
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
25
|
+
*/
|
|
26
|
+
async check(_key, _store) {
|
|
27
|
+
throw new Error('check() must be implemented by subclass')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resets the rate limit state for the given key.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} key
|
|
34
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async reset(_key, _store) {
|
|
38
|
+
throw new Error('reset() must be implemented by subclass')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Composite algorithm.
|
|
3
|
+
* Combines multiple algorithms with ALL (every must pass) or ANY (one must pass) mode.
|
|
4
|
+
* Enables multi-policy enforcement — e.g. burst cap AND daily hard limit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAlgorithm } from './BaseAlgorithm.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { ALGORITHM_NAMES, COMPOSITE_MODES } from '../constants/algorithms.js'
|
|
10
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('Composite')
|
|
13
|
+
|
|
14
|
+
export class Composite extends BaseAlgorithm {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {BaseAlgorithm[]} options.algorithms - Array of algorithm instances to evaluate.
|
|
18
|
+
* @param {string} [options.mode] - 'ALL' (default) or 'ANY'.
|
|
19
|
+
*/
|
|
20
|
+
constructor({ algorithms, mode = COMPOSITE_MODES.ALL } = {}) {
|
|
21
|
+
super(ALGORITHM_NAMES.COMPOSITE)
|
|
22
|
+
|
|
23
|
+
if (!Array.isArray(algorithms) || algorithms.length === 0) {
|
|
24
|
+
throw ErrorFactory.invalidValue('algorithms', algorithms, 'must be a non-empty array of BaseAlgorithm instances')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const allValid = algorithms.every((a) => a instanceof BaseAlgorithm)
|
|
28
|
+
if (!allValid) {
|
|
29
|
+
throw ErrorFactory.invalidAlgorithm({ reason: 'All composite algorithms must extend BaseAlgorithm' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (mode !== COMPOSITE_MODES.ALL && mode !== COMPOSITE_MODES.ANY) {
|
|
33
|
+
throw ErrorFactory.invalidValue('mode', mode, `must be '${COMPOSITE_MODES.ALL}' or '${COMPOSITE_MODES.ANY}'`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this._algorithms = algorithms
|
|
37
|
+
this._mode = mode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs all child algorithms in parallel and combines their results
|
|
42
|
+
* according to the configured mode.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} key
|
|
45
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
46
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
47
|
+
*/
|
|
48
|
+
async check(key, store) {
|
|
49
|
+
const results = await Promise.all(
|
|
50
|
+
this._algorithms.map((algo) => algo.check(key, store)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const allowed = this._mode === COMPOSITE_MODES.ALL
|
|
54
|
+
? results.every((r) => r.allowed)
|
|
55
|
+
: results.some((r) => r.allowed)
|
|
56
|
+
|
|
57
|
+
// Use the most restrictive result when blocked
|
|
58
|
+
const blocking = results.filter((r) => !r.allowed)
|
|
59
|
+
const strictest = blocking.length > 0
|
|
60
|
+
? blocking.reduce((a, b) => (a.retryAfter ?? 0) > (b.retryAfter ?? 0) ? a : b)
|
|
61
|
+
: results[0]
|
|
62
|
+
|
|
63
|
+
const remaining = Math.min(...results.map((r) => r.remaining))
|
|
64
|
+
const limit = Math.min(...results.map((r) => r.limit))
|
|
65
|
+
const resetAt = blocking.length > 0
|
|
66
|
+
? new Date(Math.max(...blocking.map((r) => r.resetAt.getTime())))
|
|
67
|
+
: results[0].resetAt
|
|
68
|
+
|
|
69
|
+
logger.debug('Composite check', {
|
|
70
|
+
key,
|
|
71
|
+
mode: this._mode,
|
|
72
|
+
allowed,
|
|
73
|
+
algorithms: results.map((r, i) => ({
|
|
74
|
+
name: this._algorithms[i].name,
|
|
75
|
+
allowed: r.allowed,
|
|
76
|
+
})),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
allowed,
|
|
81
|
+
limit,
|
|
82
|
+
remaining,
|
|
83
|
+
resetAt,
|
|
84
|
+
retryAfter: allowed ? undefined : strictest.retryAfter,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} key
|
|
90
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
91
|
+
* @returns {Promise<void>}
|
|
92
|
+
*/
|
|
93
|
+
async reset(key, store) {
|
|
94
|
+
await Promise.all(this._algorithms.map((algo) => algo.reset(key, store)))
|
|
95
|
+
logger.debug('Composite reset', { key })
|
|
96
|
+
}
|
|
97
|
+
}
|