ratelimit-engine 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +437 -2
- package/bin/cli.js +263 -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,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ratelimit-engine CLI
|
|
4
|
+
* Interactive wizard to simulate any built-in algorithm.
|
|
5
|
+
* Usage: npx ratelimit-engine simulate
|
|
6
|
+
*/
|
|
7
|
+
|
|
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 ─────────────────────────────────────────────────
|
|
18
|
+
const C = {
|
|
19
|
+
reset: '\x1b[0m',
|
|
20
|
+
bold: '\x1b[1m',
|
|
21
|
+
dim: '\x1b[2m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
red: '\x1b[31m',
|
|
24
|
+
yellow: '\x1b[33m',
|
|
25
|
+
cyan: '\x1b[36m',
|
|
26
|
+
magenta: '\x1b[35m',
|
|
27
|
+
white: '\x1b[37m',
|
|
28
|
+
bgDark: '\x1b[48;5;235m',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bold = (s) => `${C.bold}${s}${C.reset}`
|
|
32
|
+
const dim = (s) => `${C.dim}${s}${C.reset}`
|
|
33
|
+
const green = (s) => `${C.green}${s}${C.reset}`
|
|
34
|
+
const red = (s) => `${C.red}${s}${C.reset}`
|
|
35
|
+
const cyan = (s) => `${C.cyan}${s}${C.reset}`
|
|
36
|
+
const yellow = (s) => `${C.yellow}${s}${C.reset}`
|
|
37
|
+
const magenta = (s) => `${C.magenta}${s}${C.reset}`
|
|
38
|
+
|
|
39
|
+
// ── Readline helper ───────────────────────────────────────────────
|
|
40
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
41
|
+
|
|
42
|
+
function ask(question) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
rl.question(question, (answer) => resolve(answer.trim()))
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
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)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Algorithm menu ────────────────────────────────────────────────
|
|
57
|
+
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
|
+
},
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
// ── Build algorithm from user input ──────────────────────────────
|
|
91
|
+
async function buildAlgorithm(key) {
|
|
92
|
+
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 })
|
|
102
|
+
}
|
|
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 })
|
|
107
|
+
}
|
|
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 })
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
return new FixedWindow()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
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'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return list[idx].key
|
|
146
|
+
}
|
|
147
|
+
|
|
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)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(divider)
|
|
184
|
+
}
|
|
185
|
+
|
|
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)
|
|
191
|
+
|
|
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
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Sleep helper ──────────────────────────────────────────────────
|
|
203
|
+
function sleep(ms) {
|
|
204
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Main ──────────────────────────────────────────────────────────
|
|
208
|
+
async function main() {
|
|
209
|
+
console.clear()
|
|
210
|
+
console.log(`
|
|
211
|
+
${bold(cyan('⚡ ratelimit-engine'))} ${dim('— Algorithm Simulator')}
|
|
212
|
+
${dim('─────────────────────────────────────────')}
|
|
213
|
+
`)
|
|
214
|
+
|
|
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
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await store.destroy()
|
|
247
|
+
renderTable(rows)
|
|
248
|
+
renderSummary(rows)
|
|
249
|
+
|
|
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()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
main().catch((err) => {
|
|
260
|
+
console.error(red(`\nError: ${err.message}`))
|
|
261
|
+
rl.close()
|
|
262
|
+
process.exit(1)
|
|
263
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ratelimit-engine",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
}
|