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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @fileoverview Core rate limiter engine.
3
+ * Orchestrates algorithm, store, key resolution, header writing, and hooks.
4
+ * This class has no knowledge of HTTP frameworks — adapters handle that.
5
+ */
6
+
7
+ import { BaseAlgorithm } from '../algorithms/BaseAlgorithm.js'
8
+ import { BaseStore } from '../stores/BaseStore.js'
9
+ import { KeyResolver } from './KeyResolver.js'
10
+ import { HeaderWriter } from './HeaderWriter.js'
11
+ import { ErrorFactory } from '../errors/ErrorFactory.js'
12
+ import { DEFAULTS } from '../constants/defaults.js'
13
+ import { createLogger, wrapExternalLogger } from '../logger/LoggerFactory.js'
14
+
15
+ export class RateLimiter {
16
+ /**
17
+ * @param {import('../types/index.js').LimiterConfig} config
18
+ */
19
+ constructor(config) {
20
+ this._validateConfig(config)
21
+
22
+ this._algorithm = config.algorithm
23
+ this._store = config.store
24
+ this._keyResolver = new KeyResolver(config.keyBy)
25
+ this._headerWriter = new HeaderWriter()
26
+ this._failOpen = config.failOpen ?? DEFAULTS.FAIL_OPEN
27
+ this._onBlocked = config.onBlocked ?? null
28
+ this._onAllowed = config.onAllowed ?? null
29
+ this._logger = config.logger
30
+ ? wrapExternalLogger(config.logger)
31
+ : createLogger('RateLimiter')
32
+ }
33
+
34
+ /**
35
+ * Core check — resolves key, runs algorithm, fires hooks.
36
+ * Call this directly for framework-agnostic usage.
37
+ *
38
+ * @param {import('../types/index.js').RequestContext} context
39
+ * @returns {Promise<import('../types/index.js').RateLimitResult>}
40
+ */
41
+ async check(context) {
42
+ let key
43
+
44
+ try {
45
+ key = await this._keyResolver.resolve(context)
46
+ } catch (err) {
47
+ this._logger.error('Key resolution failed', { error: err.message })
48
+ return this._failOpenResult()
49
+ }
50
+
51
+ let result
52
+
53
+ try {
54
+ result = await this._algorithm.check(key, this._store)
55
+ } catch (err) {
56
+ this._logger.error('Algorithm check failed', { key, error: err.message })
57
+
58
+ if (this._failOpen) {
59
+ return this._failOpenResult()
60
+ }
61
+
62
+ throw ErrorFactory.storeUnavailable(err, { key })
63
+ }
64
+
65
+ if (result.allowed) {
66
+ this._onAllowed?.(context, result)
67
+ this._logger.debug('Request allowed', { key, remaining: result.remaining })
68
+ } else {
69
+ this._onBlocked?.(context, result)
70
+ this._logger.info('Request blocked', {
71
+ key,
72
+ retryAfter: result.retryAfter,
73
+ resetAt: result.resetAt.toISOString(),
74
+ })
75
+ }
76
+
77
+ return result
78
+ }
79
+
80
+ /**
81
+ * Resets the rate limit state for the given context.
82
+ *
83
+ * @param {import('../types/index.js').RequestContext} context
84
+ * @returns {Promise<void>}
85
+ */
86
+ async reset(context) {
87
+ const key = await this._keyResolver.resolve(context)
88
+ await this._algorithm.reset(key, this._store)
89
+ this._logger.info('Rate limit reset', { key })
90
+ }
91
+
92
+ /**
93
+ * Returns the HeaderWriter so adapters can write headers.
94
+ *
95
+ * @returns {HeaderWriter}
96
+ */
97
+ getHeaderWriter() {
98
+ return this._headerWriter
99
+ }
100
+
101
+ /**
102
+ * Releases store resources.
103
+ *
104
+ * @returns {Promise<void>}
105
+ */
106
+ async destroy() {
107
+ await this._store.destroy()
108
+ this._logger.info('RateLimiter destroyed')
109
+ }
110
+
111
+ /**
112
+ * Validates required config fields and their types.
113
+ *
114
+ * @param {import('../types/index.js').LimiterConfig} config
115
+ */
116
+ _validateConfig(config) {
117
+ if (!config) {
118
+ throw ErrorFactory.missingField('config')
119
+ }
120
+ if (!(config.algorithm instanceof BaseAlgorithm)) {
121
+ throw ErrorFactory.invalidAlgorithm()
122
+ }
123
+ if (!(config.store instanceof BaseStore)) {
124
+ throw ErrorFactory.invalidStore()
125
+ }
126
+ if (typeof config.keyBy !== 'function') {
127
+ throw ErrorFactory.missingField('keyBy')
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Fail-open result — allows the request when the store/algorithm is unavailable.
133
+ *
134
+ * @returns {import('../types/index.js').RateLimitResult}
135
+ */
136
+ _failOpenResult() {
137
+ this._logger.warn('Fail-open: allowing request due to internal error')
138
+ return {
139
+ allowed: true,
140
+ limit: 0,
141
+ remaining: 0,
142
+ resetAt: new Date(),
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @fileoverview Convenience factory — wraps RateLimiter construction
3
+ * and wires Express/Fastify adapters so consumers get a single entry point.
4
+ */
5
+
6
+ import { RateLimiter } from './RateLimiter.js'
7
+ import { ExpressAdapter } from '../adapters/ExpressAdapter.js'
8
+ import { FastifyAdapter } from '../adapters/FastifyAdapter.js'
9
+
10
+ /**
11
+ * Creates a configured RateLimiter and returns it with
12
+ * pre-built adapter helpers for Express and Fastify.
13
+ *
14
+ * @param {import('../types/index.js').LimiterConfig} config
15
+ * @returns {{
16
+ * limiter: RateLimiter,
17
+ * express: () => Function,
18
+ * fastify: () => Function,
19
+ * check: (context: import('../types/index.js').RequestContext) => Promise<import('../types/index.js').RateLimitResult>,
20
+ * reset: (context: import('../types/index.js').RequestContext) => Promise<void>,
21
+ * destroy: () => Promise<void>,
22
+ * }}
23
+ */
24
+ export function createLimiter(config) {
25
+ const limiter = new RateLimiter(config)
26
+ const expressAdapter = new ExpressAdapter(limiter)
27
+ const fastifyAdapter = new FastifyAdapter(limiter)
28
+
29
+ return {
30
+ limiter,
31
+ express: () => expressAdapter.middleware(),
32
+ fastify: () => fastifyAdapter.middleware(),
33
+ check: (context) => limiter.check(context),
34
+ reset: (context) => limiter.reset(context),
35
+ destroy: () => limiter.destroy(),
36
+ }
37
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fileoverview Error class for configuration validation failures.
3
+ */
4
+
5
+ import { RateLimitError } from './RateLimitError.js'
6
+
7
+ /**
8
+ * Thrown when createLimiter() or an algorithm/store receives invalid config.
9
+ */
10
+ export class ConfigurationError extends RateLimitError {
11
+ /**
12
+ * @param {string} message
13
+ * @param {string} code
14
+ * @param {Object} [meta]
15
+ */
16
+ constructor(message, code, meta = {}) {
17
+ super(message, code, meta)
18
+ this.name = 'ConfigurationError'
19
+ }
20
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @fileoverview Central factory for creating all engine errors.
3
+ * No other file should call `new XyzError(...)` directly —
4
+ * always go through ErrorFactory so error shape stays consistent.
5
+ */
6
+
7
+ import { ConfigurationError } from './ConfigurationError.js'
8
+ import { StoreError } from './StoreError.js'
9
+ import { RateLimitError } from './RateLimitError.js'
10
+ import { ERROR_CODES, ERROR_MESSAGES } from '../constants/errors.js'
11
+
12
+ /**
13
+ * @param {string} field - Name of the missing config field.
14
+ * @param {Object} [meta]
15
+ * @returns {ConfigurationError}
16
+ */
17
+ function missingField(field, meta = {}) {
18
+ return new ConfigurationError(
19
+ `${ERROR_MESSAGES[ERROR_CODES.CONFIGURATION_MISSING_FIELD]}: '${field}'`,
20
+ ERROR_CODES.CONFIGURATION_MISSING_FIELD,
21
+ { field, ...meta },
22
+ )
23
+ }
24
+
25
+ /**
26
+ * @param {string} field - Name of the invalid config field.
27
+ * @param {*} value - The value that was rejected.
28
+ * @param {string} reason - Why it is invalid.
29
+ * @param {Object} [meta]
30
+ * @returns {ConfigurationError}
31
+ */
32
+ function invalidValue(field, value, reason, meta = {}) {
33
+ return new ConfigurationError(
34
+ `${ERROR_MESSAGES[ERROR_CODES.CONFIGURATION_INVALID_VALUE]}: '${field}' — ${reason}`,
35
+ ERROR_CODES.CONFIGURATION_INVALID_VALUE,
36
+ { field, value, reason, ...meta },
37
+ )
38
+ }
39
+
40
+ /**
41
+ * @param {Object} [meta]
42
+ * @returns {ConfigurationError}
43
+ */
44
+ function invalidAlgorithm(meta = {}) {
45
+ return new ConfigurationError(
46
+ ERROR_MESSAGES[ERROR_CODES.CONFIGURATION_INVALID_ALGO],
47
+ ERROR_CODES.CONFIGURATION_INVALID_ALGO,
48
+ meta,
49
+ )
50
+ }
51
+
52
+ /**
53
+ * @param {Object} [meta]
54
+ * @returns {ConfigurationError}
55
+ */
56
+ function invalidStore(meta = {}) {
57
+ return new ConfigurationError(
58
+ ERROR_MESSAGES[ERROR_CODES.CONFIGURATION_INVALID_STORE],
59
+ ERROR_CODES.CONFIGURATION_INVALID_STORE,
60
+ meta,
61
+ )
62
+ }
63
+
64
+ /**
65
+ * @param {string} operation - 'read' | 'write'.
66
+ * @param {Error} cause - Underlying error from the store.
67
+ * @param {Object} [meta]
68
+ * @returns {StoreError}
69
+ */
70
+ function storeOperationFailed(operation, cause, meta = {}) {
71
+ const code = operation === 'read'
72
+ ? ERROR_CODES.STORE_READ_FAILED
73
+ : ERROR_CODES.STORE_WRITE_FAILED
74
+
75
+ return new StoreError(
76
+ `${ERROR_MESSAGES[code]}: ${cause.message}`,
77
+ code,
78
+ { operation, cause: cause.message, ...meta },
79
+ )
80
+ }
81
+
82
+ /**
83
+ * @param {Error} cause
84
+ * @param {Object} [meta]
85
+ * @returns {StoreError}
86
+ */
87
+ function storeUnavailable(cause, meta = {}) {
88
+ return new StoreError(
89
+ `${ERROR_MESSAGES[ERROR_CODES.STORE_UNAVAILABLE]}: ${cause.message}`,
90
+ ERROR_CODES.STORE_UNAVAILABLE,
91
+ { cause: cause.message, ...meta },
92
+ )
93
+ }
94
+
95
+ /**
96
+ * @param {string} key
97
+ * @param {import('../types/index.js').RateLimitResult} result
98
+ * @param {Object} [meta]
99
+ * @returns {RateLimitError}
100
+ */
101
+ function rateLimitExceeded(key, result, meta = {}) {
102
+ return new RateLimitError(
103
+ ERROR_MESSAGES[ERROR_CODES.RATE_LIMIT_EXCEEDED],
104
+ ERROR_CODES.RATE_LIMIT_EXCEEDED,
105
+ { key, remaining: result.remaining, resetAt: result.resetAt, ...meta },
106
+ )
107
+ }
108
+
109
+ export const ErrorFactory = Object.freeze({
110
+ missingField,
111
+ invalidValue,
112
+ invalidAlgorithm,
113
+ invalidStore,
114
+ storeOperationFailed,
115
+ storeUnavailable,
116
+ rateLimitExceeded,
117
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @fileoverview Base error class for all ratelimit-engine errors.
3
+ */
4
+
5
+ /**
6
+ * Base error class. All engine errors extend this.
7
+ * Carries a machine-readable code alongside the human message.
8
+ */
9
+ export class RateLimitError extends Error {
10
+ /**
11
+ * @param {string} message - Human-readable description.
12
+ * @param {string} code - Machine-readable error code from ERROR_CODES.
13
+ * @param {Object} [meta] - Optional extra context (key, config field, etc.).
14
+ */
15
+ constructor(message, code, meta = {}) {
16
+ super(message)
17
+ this.name = 'RateLimitError'
18
+ this.code = code
19
+ this.meta = meta
20
+
21
+ if (Error.captureStackTrace) {
22
+ Error.captureStackTrace(this, this.constructor)
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fileoverview Error class for store-layer failures.
3
+ */
4
+
5
+ import { RateLimitError } from './RateLimitError.js'
6
+
7
+ /**
8
+ * Thrown when a store operation (read, write, connect) fails.
9
+ */
10
+ export class StoreError extends RateLimitError {
11
+ /**
12
+ * @param {string} message
13
+ * @param {string} code
14
+ * @param {Object} [meta]
15
+ */
16
+ constructor(message, code, meta = {}) {
17
+ super(message, code, meta)
18
+ this.name = 'StoreError'
19
+ }
20
+ }
package/src/index.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview Public API surface for ratelimit-engine.
3
+ * Everything a consumer needs is exported from here.
4
+ * Internal implementation details are not re-exported.
5
+ */
6
+
7
+ // Engine
8
+ export { RateLimiter } from './engine/RateLimiter.js'
9
+
10
+ // Factory (convenience wrapper)
11
+ export { createLimiter } from './engine/createLimiter.js'
12
+
13
+ // Algorithms
14
+ export { BaseAlgorithm } from './algorithms/BaseAlgorithm.js'
15
+ export { FixedWindow } from './algorithms/FixedWindow.js'
16
+ export { SlidingWindow } from './algorithms/SlidingWindow.js'
17
+ export { TokenBucket } from './algorithms/TokenBucket.js'
18
+ export { LeakyBucket } from './algorithms/LeakyBucket.js'
19
+ export { Composite } from './algorithms/Composite.js'
20
+
21
+ // Stores
22
+ export { BaseStore } from './stores/BaseStore.js'
23
+ export { MemoryStore } from './stores/MemoryStore.js'
24
+ export { RedisStore } from './stores/RedisStore.js'
25
+
26
+ // Adapters
27
+ export { ExpressAdapter } from './adapters/ExpressAdapter.js'
28
+ export { FastifyAdapter } from './adapters/FastifyAdapter.js'
29
+
30
+ // Resolvers
31
+ export { byIP } from './resolvers/byIP.js'
32
+ export { byUser } from './resolvers/byUser.js'
33
+ export { byAPIKey } from './resolvers/byAPIKey.js'
34
+ export { byRoute } from './resolvers/byRoute.js'
35
+ export { compositeResolver } from './resolvers/composite.js'
36
+
37
+ // Logger
38
+ export { Logger } from './logger/Logger.js'
39
+ export { createLogger, wrapExternalLogger } from './logger/LoggerFactory.js'
40
+
41
+ // Errors
42
+ export { RateLimitError } from './errors/RateLimitError.js'
43
+ export { StoreError } from './errors/StoreError.js'
44
+ export { ConfigurationError } from './errors/ConfigurationError.js'
45
+ export { ErrorFactory } from './errors/ErrorFactory.js'
46
+
47
+ // Constants (consumers may need these for custom implementations)
48
+ export { ALGORITHM_NAMES, COMPOSITE_MODES } from './constants/algorithms.js'
49
+ export { DEFAULTS } from './constants/defaults.js'
50
+ export { ERROR_CODES, ERROR_MESSAGES } from './constants/errors.js'
51
+ export { HEADERS, HTTP_STATUS } from './constants/headers.js'
52
+ export { STORE_TYPES } from './constants/store.js'
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @fileoverview Structured logger with pluggable transport.
3
+ * Enterprise teams can inject Winston, Pino, or any compatible logger.
4
+ */
5
+
6
+ /**
7
+ * Log level hierarchy — higher index = more severe.
8
+ */
9
+ const LOG_LEVELS = Object.freeze({
10
+ debug: 0,
11
+ info: 1,
12
+ warn: 2,
13
+ error: 3,
14
+ })
15
+
16
+ /**
17
+ * Default console transport — used when no custom logger is injected.
18
+ *
19
+ * @param {string} level
20
+ * @param {string} message
21
+ * @param {Object} context
22
+ */
23
+ function defaultTransport(level, message, context) {
24
+ const timestamp = new Date().toISOString()
25
+ const base = { timestamp, level, message, ...context }
26
+ const output = JSON.stringify(base)
27
+
28
+ if (level === 'error' || level === 'warn') {
29
+ process.stderr.write(output + '\n')
30
+ } else {
31
+ process.stdout.write(output + '\n')
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Structured logger. Each instance carries a fixed context object
37
+ * that is merged into every log entry it emits.
38
+ */
39
+ export class Logger {
40
+ /**
41
+ * @param {Object} options
42
+ * @param {string} options.name - Logger name / component label.
43
+ * @param {string} [options.level] - Minimum log level. Default: 'info'.
44
+ * @param {Object} [options.context] - Fixed context merged into every entry.
45
+ * @param {Function} [options.transport] - Custom transport fn(level, message, context).
46
+ */
47
+ constructor({ name, level = 'info', context = {}, transport = defaultTransport }) {
48
+ this._name = name
49
+ this._minLevel = LOG_LEVELS[level] ?? LOG_LEVELS.info
50
+ this._context = context
51
+ this._transport = transport
52
+ }
53
+
54
+ /**
55
+ * Creates a child logger that inherits this logger's context
56
+ * and merges additional fixed context fields.
57
+ *
58
+ * @param {Object} additionalContext
59
+ * @returns {Logger}
60
+ */
61
+ child(additionalContext) {
62
+ return new Logger({
63
+ name: this._name,
64
+ level: this._levelName(),
65
+ context: { ...this._context, ...additionalContext },
66
+ transport: this._transport,
67
+ })
68
+ }
69
+
70
+ /**
71
+ * @param {string} message
72
+ * @param {Object} [context]
73
+ */
74
+ debug(message, context = {}) {
75
+ this._log('debug', message, context)
76
+ }
77
+
78
+ /**
79
+ * @param {string} message
80
+ * @param {Object} [context]
81
+ */
82
+ info(message, context = {}) {
83
+ this._log('info', message, context)
84
+ }
85
+
86
+ /**
87
+ * @param {string} message
88
+ * @param {Object} [context]
89
+ */
90
+ warn(message, context = {}) {
91
+ this._log('warn', message, context)
92
+ }
93
+
94
+ /**
95
+ * @param {string} message
96
+ * @param {Object} [context]
97
+ */
98
+ error(message, context = {}) {
99
+ this._log('error', message, context)
100
+ }
101
+
102
+ /**
103
+ * @param {string} level
104
+ * @param {string} message
105
+ * @param {Object} context
106
+ */
107
+ _log(level, message, context) {
108
+ if (LOG_LEVELS[level] < this._minLevel) {
109
+ return
110
+ }
111
+
112
+ this._transport(level, message, {
113
+ logger: this._name,
114
+ ...this._context,
115
+ ...context,
116
+ })
117
+ }
118
+
119
+ /**
120
+ * @returns {string}
121
+ */
122
+ _levelName() {
123
+ return Object.keys(LOG_LEVELS).find(
124
+ (k) => LOG_LEVELS[k] === this._minLevel
125
+ ) ?? 'info'
126
+ }
127
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @fileoverview Factory that creates named Logger instances.
3
+ * Supports injecting an enterprise logger (Winston, Pino) as the transport.
4
+ */
5
+
6
+ import { Logger } from './Logger.js'
7
+
8
+ /**
9
+ * Creates a Logger bound to a component name and optional shared context.
10
+ *
11
+ * @param {string} name - Component name (e.g. 'MemoryStore', 'TokenBucket').
12
+ * @param {Object} [options]
13
+ * @param {string} [options.level] - Minimum log level. Default: 'info'.
14
+ * @param {Object} [options.context] - Fixed context fields merged into every entry.
15
+ * @param {Function} [options.transport]- Custom transport fn(level, message, context).
16
+ * @returns {Logger}
17
+ */
18
+ export function createLogger(name, options = {}) {
19
+ return new Logger({ name, ...options })
20
+ }
21
+
22
+ /**
23
+ * Wraps an external logger (Winston, Pino, etc.) so it satisfies
24
+ * the Logger interface expected by the engine.
25
+ * The external logger must expose debug/info/warn/error methods.
26
+ *
27
+ * @param {Object} externalLogger
28
+ * @returns {Logger}
29
+ */
30
+ export function wrapExternalLogger(externalLogger) {
31
+ const transport = (level, message, context) => {
32
+ externalLogger[level]?.(message, context)
33
+ }
34
+
35
+ return new Logger({ name: 'external', transport })
36
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Resolves the rate limit key from an API key header.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../types/index.js').RequestContext} context
7
+ * @returns {string}
8
+ */
9
+ export function byAPIKey(context) {
10
+ return `apikey:${context.apiKey ?? context.ip ?? 'unknown'}`
11
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Resolves the rate limit key from the client's IP address.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../types/index.js').RequestContext} context
7
+ * @returns {string}
8
+ */
9
+ export function byIP(context) {
10
+ return `ip:${context.ip ?? 'unknown'}`
11
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @fileoverview Resolves the rate limit key from the HTTP method and path.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../types/index.js').RequestContext} context
7
+ * @returns {string}
8
+ */
9
+ export function byRoute(context) {
10
+ const method = context.method ?? 'UNKNOWN'
11
+ const path = context.path ?? '/'
12
+ return `route:${method}:${path}`
13
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Resolves the rate limit key from an authenticated user ID.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../types/index.js').RequestContext} context
7
+ * @returns {string}
8
+ */
9
+ export function byUser(context) {
10
+ return `user:${context.userId ?? context.ip ?? 'unknown'}`
11
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @fileoverview Composite resolver — combines multiple resolvers into one key.
3
+ * Useful for scoping limits by both user and route simultaneously.
4
+ */
5
+
6
+ /**
7
+ * Creates a resolver that joins the output of multiple resolvers with a separator.
8
+ *
9
+ * @param {import('../types/index.js').KeyResolverFn[]} resolvers
10
+ * @param {string} [separator]
11
+ * @returns {import('../types/index.js').KeyResolverFn}
12
+ */
13
+ export function compositeResolver(resolvers, separator = '|') {
14
+ return async (context) => {
15
+ const parts = await Promise.all(resolvers.map((fn) => fn(context)))
16
+ return parts.join(separator)
17
+ }
18
+ }