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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -2
  3. package/bin/cli.js +263 -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
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @fileoverview Fixed Window algorithm.
3
+ * Counts requests in a fixed time window that resets at a hard boundary.
4
+ * Simple and cheap. Known issue: bursts are possible at window boundaries.
5
+ */
6
+
7
+ import { BaseAlgorithm } from './BaseAlgorithm.js'
8
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
9
+ import { ALGORITHM_NAMES } from '../constants/algorithms.js'
10
+ import { DEFAULTS } from '../constants/defaults.js'
11
+ import { createLogger } from '../logger/LoggerFactory.js'
12
+
13
+ const logger = createLogger('FixedWindow')
14
+
15
+ export class FixedWindow extends BaseAlgorithm {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {number} options.limit - Max requests per window.
19
+ * @param {number} options.windowMs - Window duration in milliseconds.
20
+ */
21
+ constructor({ limit = DEFAULTS.MAX_REQUESTS, windowMs = DEFAULTS.WINDOW_MS } = {}) {
22
+ super(ALGORITHM_NAMES.FIXED_WINDOW)
23
+
24
+ if (limit <= 0) {
25
+ throw ErrorFactory.invalidValue('limit', limit, 'must be a positive integer')
26
+ }
27
+ if (windowMs <= 0) {
28
+ throw ErrorFactory.invalidValue('windowMs', windowMs, 'must be a positive integer')
29
+ }
30
+
31
+ this._limit = limit
32
+ this._windowMs = windowMs
33
+ }
34
+
35
+ /**
36
+ * @param {string} key
37
+ * @param {import('../stores/BaseStore.js').BaseStore} store
38
+ * @returns {Promise<import('../types/index.js').RateLimitResult>}
39
+ */
40
+ async check(key, store) {
41
+ const now = Date.now()
42
+ const windowKey = `${key}:${Math.floor(now / this._windowMs)}`
43
+ const count = await store.increment(windowKey, this._windowMs)
44
+ const resetAt = new Date(Math.ceil(now / this._windowMs) * this._windowMs)
45
+ const remaining = Math.max(0, this._limit - count)
46
+ const allowed = count <= this._limit
47
+
48
+ logger.debug('Fixed window check', { key, count, limit: this._limit, allowed })
49
+
50
+ return {
51
+ allowed,
52
+ limit: this._limit,
53
+ remaining,
54
+ resetAt,
55
+ retryAfter: allowed ? undefined : Math.ceil((resetAt - now) / 1000),
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} key
61
+ * @param {import('../stores/BaseStore.js').BaseStore} store
62
+ * @returns {Promise<void>}
63
+ */
64
+ async reset(key, store) {
65
+ const now = Date.now()
66
+ const windowKey = `${key}:${Math.floor(now / this._windowMs)}`
67
+ await store.delete(windowKey)
68
+ logger.debug('Fixed window reset', { key })
69
+ }
70
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @fileoverview Leaky Bucket algorithm.
3
+ * Requests are processed at a fixed leak rate regardless of burst size.
4
+ * Excess requests are dropped. Ideal for strict output rate control.
5
+ */
6
+
7
+ import { BaseAlgorithm } from './BaseAlgorithm.js'
8
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
9
+ import { ALGORITHM_NAMES } from '../constants/algorithms.js'
10
+ import { DEFAULTS } from '../constants/defaults.js'
11
+ import { createLogger } from '../logger/LoggerFactory.js'
12
+
13
+ const logger = createLogger('LeakyBucket')
14
+
15
+ export class LeakyBucket extends BaseAlgorithm {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {number} options.capacity - Max bucket size (queue depth).
19
+ * @param {number} options.leakRate - Requests allowed per second.
20
+ */
21
+ constructor({
22
+ capacity = DEFAULTS.LEAKY_BUCKET_CAPACITY,
23
+ leakRate = DEFAULTS.LEAKY_BUCKET_LEAK_RATE,
24
+ } = {}) {
25
+ super(ALGORITHM_NAMES.LEAKY_BUCKET)
26
+
27
+ if (capacity <= 0) {
28
+ throw ErrorFactory.invalidValue('capacity', capacity, 'must be a positive integer')
29
+ }
30
+ if (leakRate <= 0) {
31
+ throw ErrorFactory.invalidValue('leakRate', leakRate, 'must be a positive number')
32
+ }
33
+
34
+ this._capacity = capacity
35
+ this._leakRate = leakRate
36
+ }
37
+
38
+ /**
39
+ * @param {string} key
40
+ * @param {import('../stores/BaseStore.js').BaseStore} store
41
+ * @returns {Promise<import('../types/index.js').RateLimitResult>}
42
+ */
43
+ async check(key, store) {
44
+ const now = Date.now()
45
+ const ttlMs = Math.ceil((this._capacity / this._leakRate) * 1000)
46
+ const entry = await store.get(key)
47
+ const lastMeta = entry?.meta ?? { queue: 0, lastLeak: now }
48
+
49
+ const elapsed = (now - lastMeta.lastLeak) / 1000
50
+ const leaked = elapsed * this._leakRate
51
+ const queue = Math.max(0, lastMeta.queue - leaked)
52
+ const allowed = queue < this._capacity
53
+ const newQueue = allowed ? queue + 1 : queue
54
+ const remaining = Math.max(0, this._capacity - Math.ceil(newQueue))
55
+
56
+ const msUntilSlot = allowed
57
+ ? 0
58
+ : Math.ceil((queue - this._capacity + 1) / this._leakRate * 1000)
59
+ const resetAt = new Date(now + msUntilSlot)
60
+
61
+ await store.set(key, {
62
+ count: Math.ceil(newQueue),
63
+ windowStart: now,
64
+ expiresAt: now + ttlMs,
65
+ meta: { queue: newQueue, lastLeak: now },
66
+ }, ttlMs)
67
+
68
+ logger.debug('Leaky bucket check', { key, queue, newQueue, allowed })
69
+
70
+ return {
71
+ allowed,
72
+ limit: this._capacity,
73
+ remaining,
74
+ resetAt,
75
+ retryAfter: allowed ? undefined : Math.ceil(msUntilSlot / 1000),
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {string} key
81
+ * @param {import('../stores/BaseStore.js').BaseStore} store
82
+ * @returns {Promise<void>}
83
+ */
84
+ async reset(key, store) {
85
+ await store.delete(key)
86
+ logger.debug('Leaky bucket reset', { key })
87
+ }
88
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @fileoverview Sliding Window Log algorithm.
3
+ * Counts requests within a rolling window from "now - windowMs" to "now".
4
+ * Eliminates the boundary spike problem of Fixed Window.
5
+ */
6
+
7
+ import { BaseAlgorithm } from './BaseAlgorithm.js'
8
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
9
+ import { ALGORITHM_NAMES } from '../constants/algorithms.js'
10
+ import { DEFAULTS } from '../constants/defaults.js'
11
+ import { createLogger } from '../logger/LoggerFactory.js'
12
+
13
+ const logger = createLogger('SlidingWindow')
14
+
15
+ export class SlidingWindow extends BaseAlgorithm {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {number} options.limit - Max requests per window.
19
+ * @param {number} options.windowMs - Window duration in milliseconds.
20
+ */
21
+ constructor({ limit = DEFAULTS.MAX_REQUESTS, windowMs = DEFAULTS.WINDOW_MS } = {}) {
22
+ super(ALGORITHM_NAMES.SLIDING_WINDOW)
23
+
24
+ if (limit <= 0) {
25
+ throw ErrorFactory.invalidValue('limit', limit, 'must be a positive integer')
26
+ }
27
+ if (windowMs <= 0) {
28
+ throw ErrorFactory.invalidValue('windowMs', windowMs, 'must be a positive integer')
29
+ }
30
+
31
+ this._limit = limit
32
+ this._windowMs = windowMs
33
+ }
34
+
35
+ /**
36
+ * Uses a weighted count across the current and previous windows
37
+ * to approximate a true sliding window without storing per-request timestamps.
38
+ *
39
+ * @param {string} key
40
+ * @param {import('../stores/BaseStore.js').BaseStore} store
41
+ * @returns {Promise<import('../types/index.js').RateLimitResult>}
42
+ */
43
+ async check(key, store) {
44
+ const now = Date.now()
45
+ const currentSlot = Math.floor(now / this._windowMs)
46
+ const prevSlot = currentSlot - 1
47
+ const currentKey = `${key}:${currentSlot}`
48
+ const prevKey = `${key}:${prevSlot}`
49
+
50
+ const [currentEntry, prevEntry] = await Promise.all([
51
+ store.get(currentKey),
52
+ store.get(prevKey),
53
+ ])
54
+
55
+ const currentCount = currentEntry?.count ?? 0
56
+ const prevCount = prevEntry?.count ?? 0
57
+
58
+ // Weight the previous window count by how far into the current window we are
59
+ const windowProgress = (now % this._windowMs) / this._windowMs
60
+ const weightedCount = Math.floor(prevCount * (1 - windowProgress)) + currentCount
61
+
62
+ const newCount = await store.increment(currentKey, this._windowMs * 2)
63
+ const totalCount = Math.floor(prevCount * (1 - windowProgress)) + newCount
64
+ const remaining = Math.max(0, this._limit - totalCount)
65
+ const allowed = totalCount <= this._limit
66
+ const resetAt = new Date((currentSlot + 1) * this._windowMs)
67
+
68
+ logger.debug('Sliding window check', {
69
+ key,
70
+ weightedCount,
71
+ totalCount,
72
+ limit: this._limit,
73
+ allowed,
74
+ })
75
+
76
+ return {
77
+ allowed,
78
+ limit: this._limit,
79
+ remaining,
80
+ resetAt,
81
+ retryAfter: allowed ? undefined : Math.ceil((resetAt - now) / 1000),
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {string} key
87
+ * @param {import('../stores/BaseStore.js').BaseStore} store
88
+ * @returns {Promise<void>}
89
+ */
90
+ async reset(key, store) {
91
+ const now = Date.now()
92
+ const currentSlot = Math.floor(now / this._windowMs)
93
+ await Promise.all([
94
+ store.delete(`${key}:${currentSlot}`),
95
+ store.delete(`${key}:${currentSlot - 1}`),
96
+ ])
97
+ logger.debug('Sliding window reset', { key })
98
+ }
99
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @fileoverview Token Bucket algorithm.
3
+ * Tokens refill at a fixed rate up to a maximum capacity.
4
+ * Allows controlled bursting — ideal for API gateways.
5
+ */
6
+
7
+ import { BaseAlgorithm } from './BaseAlgorithm.js'
8
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
9
+ import { ALGORITHM_NAMES } from '../constants/algorithms.js'
10
+ import { DEFAULTS } from '../constants/defaults.js'
11
+ import { createLogger } from '../logger/LoggerFactory.js'
12
+
13
+ const logger = createLogger('TokenBucket')
14
+
15
+ export class TokenBucket extends BaseAlgorithm {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {number} options.capacity - Maximum tokens the bucket can hold.
19
+ * @param {number} options.refillRate - Tokens added per second.
20
+ */
21
+ constructor({
22
+ capacity = DEFAULTS.TOKEN_BUCKET_CAPACITY,
23
+ refillRate = DEFAULTS.TOKEN_BUCKET_REFILL_RATE,
24
+ } = {}) {
25
+ super(ALGORITHM_NAMES.TOKEN_BUCKET)
26
+
27
+ if (capacity <= 0) {
28
+ throw ErrorFactory.invalidValue('capacity', capacity, 'must be a positive integer')
29
+ }
30
+ if (refillRate <= 0) {
31
+ throw ErrorFactory.invalidValue('refillRate', refillRate, 'must be a positive number')
32
+ }
33
+
34
+ this._capacity = capacity
35
+ this._refillRate = refillRate
36
+ }
37
+
38
+ /**
39
+ * @param {string} key
40
+ * @param {import('../stores/BaseStore.js').BaseStore} store
41
+ * @returns {Promise<import('../types/index.js').RateLimitResult>}
42
+ */
43
+ async check(key, store) {
44
+ const now = Date.now()
45
+ const ttlMs = Math.ceil((this._capacity / this._refillRate) * 1000)
46
+ const entry = await store.get(key)
47
+ const lastEntry = entry?.meta ?? { tokens: this._capacity, lastRefill: now }
48
+
49
+ const elapsed = (now - lastEntry.lastRefill) / 1000
50
+ const refilled = elapsed * this._refillRate
51
+ const tokensNow = Math.min(this._capacity, lastEntry.tokens + refilled)
52
+ const allowed = tokensNow >= 1
53
+ const tokensAfter = allowed ? tokensNow - 1 : tokensNow
54
+ const remaining = Math.floor(tokensAfter)
55
+ const msUntilToken = allowed ? 0 : Math.ceil((1 - tokensNow) / this._refillRate * 1000)
56
+ const resetAt = new Date(now + msUntilToken)
57
+
58
+ await store.set(key, {
59
+ count: this._capacity - remaining,
60
+ windowStart: now,
61
+ expiresAt: now + ttlMs,
62
+ meta: { tokens: tokensAfter, lastRefill: now },
63
+ }, ttlMs)
64
+
65
+ logger.debug('Token bucket check', { key, tokensNow, tokensAfter, allowed })
66
+
67
+ return {
68
+ allowed,
69
+ limit: this._capacity,
70
+ remaining,
71
+ resetAt,
72
+ retryAfter: allowed ? undefined : Math.ceil(msUntilToken / 1000),
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {string} key
78
+ * @param {import('../stores/BaseStore.js').BaseStore} store
79
+ * @returns {Promise<void>}
80
+ */
81
+ async reset(key, store) {
82
+ await store.delete(key)
83
+ logger.debug('Token bucket reset', { key })
84
+ }
85
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Supported built-in algorithm identifiers.
3
+ */
4
+ export const ALGORITHM_NAMES = Object.freeze({
5
+ FIXED_WINDOW: 'fixed-window',
6
+ SLIDING_WINDOW: 'sliding-window',
7
+ TOKEN_BUCKET: 'token-bucket',
8
+ LEAKY_BUCKET: 'leaky-bucket',
9
+ COMPOSITE: 'composite',
10
+ })
11
+
12
+ /**
13
+ * Composite algorithm evaluation modes.
14
+ */
15
+ export const COMPOSITE_MODES = Object.freeze({
16
+ ALL: 'ALL',
17
+ ANY: 'ANY',
18
+ })
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Default configuration values used across the engine.
3
+ * All time values are in milliseconds unless noted.
4
+ */
5
+ export const DEFAULTS = Object.freeze({
6
+ WINDOW_MS: 60_000,
7
+ MAX_REQUESTS: 100,
8
+ FAIL_OPEN: true,
9
+ TOKEN_BUCKET_CAPACITY: 100,
10
+ TOKEN_BUCKET_REFILL_RATE: 10,
11
+ LEAKY_BUCKET_CAPACITY: 100,
12
+ LEAKY_BUCKET_LEAK_RATE: 10,
13
+ STORE_TTL_BUFFER_MS: 5_000,
14
+ KEY_PREFIX: 'rle:',
15
+ })
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Error codes used across the engine.
3
+ * Each code maps to a unique, machine-readable string.
4
+ */
5
+ export const ERROR_CODES = Object.freeze({
6
+ CONFIGURATION_MISSING_FIELD: 'CONFIGURATION_MISSING_FIELD',
7
+ CONFIGURATION_INVALID_VALUE: 'CONFIGURATION_INVALID_VALUE',
8
+ CONFIGURATION_INVALID_ALGO: 'CONFIGURATION_INVALID_ALGO',
9
+ CONFIGURATION_INVALID_STORE: 'CONFIGURATION_INVALID_STORE',
10
+
11
+ STORE_UNAVAILABLE: 'STORE_UNAVAILABLE',
12
+ STORE_READ_FAILED: 'STORE_READ_FAILED',
13
+ STORE_WRITE_FAILED: 'STORE_WRITE_FAILED',
14
+
15
+ RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
16
+
17
+ ALGORITHM_CHECK_FAILED: 'ALGORITHM_CHECK_FAILED',
18
+ ALGORITHM_RESET_FAILED: 'ALGORITHM_RESET_FAILED',
19
+
20
+ ADAPTER_INVALID_REQUEST: 'ADAPTER_INVALID_REQUEST',
21
+ })
22
+
23
+ /**
24
+ * Human-readable error messages keyed by error code.
25
+ */
26
+ export const ERROR_MESSAGES = Object.freeze({
27
+ [ERROR_CODES.CONFIGURATION_MISSING_FIELD]: 'Required configuration field is missing',
28
+ [ERROR_CODES.CONFIGURATION_INVALID_VALUE]: 'Configuration field has an invalid value',
29
+ [ERROR_CODES.CONFIGURATION_INVALID_ALGO]: 'Provided algorithm is not a valid BaseAlgorithm instance',
30
+ [ERROR_CODES.CONFIGURATION_INVALID_STORE]: 'Provided store is not a valid BaseStore instance',
31
+
32
+ [ERROR_CODES.STORE_UNAVAILABLE]: 'Store is currently unavailable',
33
+ [ERROR_CODES.STORE_READ_FAILED]: 'Store read operation failed',
34
+ [ERROR_CODES.STORE_WRITE_FAILED]: 'Store write operation failed',
35
+
36
+ [ERROR_CODES.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded',
37
+
38
+ [ERROR_CODES.ALGORITHM_CHECK_FAILED]: 'Algorithm check operation failed',
39
+ [ERROR_CODES.ALGORITHM_RESET_FAILED]: 'Algorithm reset operation failed',
40
+
41
+ [ERROR_CODES.ADAPTER_INVALID_REQUEST]: 'Adapter received an invalid request object',
42
+ })
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Standard HTTP response header names used by the engine.
3
+ */
4
+ export const HEADERS = Object.freeze({
5
+ LIMIT: 'X-RateLimit-Limit',
6
+ REMAINING: 'X-RateLimit-Remaining',
7
+ RESET: 'X-RateLimit-Reset',
8
+ RETRY_AFTER: 'Retry-After',
9
+ POLICY: 'X-RateLimit-Policy',
10
+ })
11
+
12
+ /**
13
+ * HTTP status codes used by rate limit responses.
14
+ */
15
+ export const HTTP_STATUS = Object.freeze({
16
+ TOO_MANY_REQUESTS: 429,
17
+ OK: 200,
18
+ })
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Store-related constants shared across MemoryStore and RedisStore.
3
+ */
4
+ export const STORE_TYPES = Object.freeze({
5
+ MEMORY: 'memory',
6
+ REDIS: 'redis',
7
+ })
8
+
9
+ /**
10
+ * Redis Lua script keys used for atomic operations.
11
+ */
12
+ export const REDIS_SCRIPTS = Object.freeze({
13
+ INCREMENT_WITH_TTL: 'increment_with_ttl',
14
+ GET_AND_SET: 'get_and_set',
15
+ })
16
+
17
+ /**
18
+ * Cleanup interval for MemoryStore expired-entry eviction (ms).
19
+ */
20
+ export const MEMORY_STORE_CLEANUP_INTERVAL_MS = 30_000
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @fileoverview Writes standard rate limit headers onto HTTP responses.
3
+ */
4
+
5
+ import { HEADERS, HTTP_STATUS } from '../constants/headers.js'
6
+ import { createLogger } from '../logger/LoggerFactory.js'
7
+
8
+ const logger = createLogger('HeaderWriter')
9
+
10
+ export class HeaderWriter {
11
+ /**
12
+ * Writes X-RateLimit-* headers and optionally Retry-After onto a response object.
13
+ * Works with any response that exposes a setHeader(name, value) method.
14
+ *
15
+ * @param {Object} res - Framework response object.
16
+ * @param {import('../types/index.js').RateLimitResult} result
17
+ */
18
+ write(res, result) {
19
+ try {
20
+ res.setHeader(HEADERS.LIMIT, String(result.limit))
21
+ res.setHeader(HEADERS.REMAINING, String(result.remaining))
22
+ res.setHeader(HEADERS.RESET, String(Math.floor(result.resetAt.getTime() / 1000)))
23
+
24
+ if (!result.allowed && result.retryAfter !== undefined) {
25
+ res.setHeader(HEADERS.RETRY_AFTER, String(result.retryAfter))
26
+ }
27
+ } catch (err) {
28
+ logger.warn('Failed to write rate limit headers', { error: err.message })
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Sends a 429 Too Many Requests response with a JSON body.
34
+ * Works with Express and Fastify response objects.
35
+ *
36
+ * @param {Object} res
37
+ * @param {import('../types/index.js').RateLimitResult} result
38
+ */
39
+ sendBlocked(res, result) {
40
+ this.write(res, result)
41
+
42
+ const body = JSON.stringify({
43
+ error: 'Too Many Requests',
44
+ retryAfter: result.retryAfter,
45
+ resetAt: result.resetAt.toISOString(),
46
+ })
47
+
48
+ if (typeof res.status === 'function') {
49
+ res.status(HTTP_STATUS.TOO_MANY_REQUESTS).send(body)
50
+ } else {
51
+ res.statusCode = HTTP_STATUS.TOO_MANY_REQUESTS
52
+ res.setHeader('Content-Type', 'application/json')
53
+ res.end(body)
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @fileoverview Resolves a rate limit key from a request context.
3
+ * Validates the resolved key before returning it.
4
+ */
5
+
6
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
7
+ import { DEFAULTS } from '../constants/defaults.js'
8
+ import { createLogger } from '../logger/LoggerFactory.js'
9
+
10
+ const logger = createLogger('KeyResolver')
11
+
12
+ export class KeyResolver {
13
+ /**
14
+ * @param {import('../types/index.js').KeyResolverFn} resolverFn
15
+ * @param {string} [prefix]
16
+ */
17
+ constructor(resolverFn, prefix = DEFAULTS.KEY_PREFIX) {
18
+ if (typeof resolverFn !== 'function') {
19
+ throw ErrorFactory.missingField('keyBy', { reason: 'keyBy must be a function' })
20
+ }
21
+ this._resolverFn = resolverFn
22
+ this._prefix = prefix
23
+ }
24
+
25
+ /**
26
+ * Resolves and prefixes the key for a given request context.
27
+ *
28
+ * @param {import('../types/index.js').RequestContext} context
29
+ * @returns {Promise<string>}
30
+ */
31
+ async resolve(context) {
32
+ const raw = await this._resolverFn(context)
33
+
34
+ if (!raw || typeof raw !== 'string' || raw.trim().length === 0) {
35
+ logger.warn('Key resolver returned empty key, falling back to IP', { context })
36
+ return `${this._prefix}${context.ip ?? 'unknown'}`
37
+ }
38
+
39
+ return `${this._prefix}${raw}`
40
+ }
41
+ }