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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -2
  3. package/bin/cli.js +291 -0
  4. package/package.json +56 -4
  5. package/src/adapters/BaseAdapter.js +38 -0
  6. package/src/adapters/ExpressAdapter.js +64 -0
  7. package/src/adapters/FastifyAdapter.js +67 -0
  8. package/src/algorithms/BaseAlgorithm.js +40 -0
  9. package/src/algorithms/Composite.js +97 -0
  10. package/src/algorithms/FixedWindow.js +70 -0
  11. package/src/algorithms/LeakyBucket.js +88 -0
  12. package/src/algorithms/SlidingWindow.js +99 -0
  13. package/src/algorithms/TokenBucket.js +85 -0
  14. package/src/constants/algorithms.js +18 -0
  15. package/src/constants/defaults.js +15 -0
  16. package/src/constants/errors.js +42 -0
  17. package/src/constants/headers.js +18 -0
  18. package/src/constants/store.js +20 -0
  19. package/src/engine/HeaderWriter.js +56 -0
  20. package/src/engine/KeyResolver.js +41 -0
  21. package/src/engine/RateLimiter.js +145 -0
  22. package/src/engine/createLimiter.js +37 -0
  23. package/src/errors/ConfigurationError.js +20 -0
  24. package/src/errors/ErrorFactory.js +117 -0
  25. package/src/errors/RateLimitError.js +25 -0
  26. package/src/errors/StoreError.js +20 -0
  27. package/src/index.js +52 -0
  28. package/src/logger/Logger.js +127 -0
  29. package/src/logger/LoggerFactory.js +36 -0
  30. package/src/resolvers/byAPIKey.js +11 -0
  31. package/src/resolvers/byIP.js +11 -0
  32. package/src/resolvers/byRoute.js +13 -0
  33. package/src/resolvers/byUser.js +11 -0
  34. package/src/resolvers/composite.js +18 -0
  35. package/src/stores/BaseStore.js +69 -0
  36. package/src/stores/MemoryStore.js +129 -0
  37. package/src/stores/RedisStore.js +168 -0
  38. 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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }