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
|
@@ -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
|
+
}
|