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.
- package/LICENSE +21 -0
- package/README.md +437 -2
- package/bin/cli.js +291 -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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Abstract base class for all store implementations.
|
|
3
|
+
* Any custom store must extend this class and implement all methods.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
7
|
+
|
|
8
|
+
export class BaseStore {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} type - Store type identifier (from STORE_TYPES).
|
|
11
|
+
*/
|
|
12
|
+
constructor(type) {
|
|
13
|
+
if (new.target === BaseStore) {
|
|
14
|
+
throw ErrorFactory.invalidStore({ reason: 'BaseStore cannot be instantiated directly' })
|
|
15
|
+
}
|
|
16
|
+
this.type = type
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Retrieves the entry for a key. Returns null if not found or expired.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} key
|
|
23
|
+
* @returns {Promise<import('../types/index.js').StoreEntry|null>}
|
|
24
|
+
*/
|
|
25
|
+
async get(_key) {
|
|
26
|
+
throw new Error('get() must be implemented by subclass')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Persists an entry for a key with a TTL.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @param {import('../types/index.js').StoreEntry} entry
|
|
34
|
+
* @param {number} ttlMs
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async set(_key, _entry, _ttlMs) {
|
|
38
|
+
throw new Error('set() must be implemented by subclass')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Atomically increments the count for a key.
|
|
43
|
+
* Creates the entry with ttlMs if it does not exist.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} key
|
|
46
|
+
* @param {number} ttlMs
|
|
47
|
+
* @returns {Promise<number>} New count after increment.
|
|
48
|
+
*/
|
|
49
|
+
async increment(_key, _ttlMs) {
|
|
50
|
+
throw new Error('increment() must be implemented by subclass')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Deletes the entry for a key.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} key
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
async delete(_key) {
|
|
60
|
+
throw new Error('delete() must be implemented by subclass')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Releases resources held by the store (connections, timers).
|
|
65
|
+
*
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async destroy() {}
|
|
69
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview In-memory store backed by a Map.
|
|
3
|
+
* Suitable for development, testing, and single-instance deployments.
|
|
4
|
+
* Not suitable for multi-pod / distributed deployments.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseStore } from './BaseStore.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { STORE_TYPES, MEMORY_STORE_CLEANUP_INTERVAL_MS } from '../constants/store.js'
|
|
10
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('MemoryStore')
|
|
13
|
+
|
|
14
|
+
export class MemoryStore extends BaseStore {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(STORE_TYPES.MEMORY)
|
|
17
|
+
/** @type {Map<string, import('../types/index.js').StoreEntry>} */
|
|
18
|
+
this._store = new Map()
|
|
19
|
+
this._cleanupTimer = setInterval(
|
|
20
|
+
() => this._evictExpired(),
|
|
21
|
+
MEMORY_STORE_CLEANUP_INTERVAL_MS,
|
|
22
|
+
)
|
|
23
|
+
// Allow Node.js to exit even if this timer is active
|
|
24
|
+
this._cleanupTimer.unref?.()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @returns {Promise<import('../types/index.js').StoreEntry|null>}
|
|
30
|
+
*/
|
|
31
|
+
async get(key) {
|
|
32
|
+
try {
|
|
33
|
+
const entry = this._store.get(key)
|
|
34
|
+
if (!entry) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
if (Date.now() > entry.expiresAt) {
|
|
38
|
+
this._store.delete(key)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
return entry
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
throw ErrorFactory.storeOperationFailed('read', cause, { key })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} key
|
|
49
|
+
* @param {import('../types/index.js').StoreEntry} entry
|
|
50
|
+
* @param {number} ttlMs
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async set(key, entry, ttlMs) {
|
|
54
|
+
try {
|
|
55
|
+
this._store.set(key, {
|
|
56
|
+
expiresAt: Date.now() + ttlMs,
|
|
57
|
+
...entry,
|
|
58
|
+
})
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Atomically increments the count for a key.
|
|
66
|
+
* If no entry exists, initialises count to 1 with the given TTL.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} key
|
|
69
|
+
* @param {number} ttlMs
|
|
70
|
+
* @returns {Promise<number>}
|
|
71
|
+
*/
|
|
72
|
+
async increment(key, ttlMs) {
|
|
73
|
+
try {
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
const entry = this._store.get(key)
|
|
76
|
+
|
|
77
|
+
if (!entry || now > entry.expiresAt) {
|
|
78
|
+
const fresh = { count: 1, windowStart: now, expiresAt: now + ttlMs, meta: {} }
|
|
79
|
+
this._store.set(key, fresh)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
entry.count += 1
|
|
84
|
+
return entry.count
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} key
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
async delete(key) {
|
|
95
|
+
try {
|
|
96
|
+
this._store.delete(key)
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @returns {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
async destroy() {
|
|
106
|
+
clearInterval(this._cleanupTimer)
|
|
107
|
+
this._store.clear()
|
|
108
|
+
logger.info('MemoryStore destroyed')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Removes entries whose TTL has elapsed.
|
|
113
|
+
*/
|
|
114
|
+
_evictExpired() {
|
|
115
|
+
const now = Date.now()
|
|
116
|
+
let evicted = 0
|
|
117
|
+
|
|
118
|
+
for (const [key, entry] of this._store) {
|
|
119
|
+
if (now > entry.expiresAt) {
|
|
120
|
+
this._store.delete(key)
|
|
121
|
+
evicted++
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (evicted > 0) {
|
|
126
|
+
logger.debug('Evicted expired entries', { evicted })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Redis-backed store using atomic Lua scripts.
|
|
3
|
+
* Requires ioredis (peer dependency).
|
|
4
|
+
* Safe for distributed / multi-pod deployments.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseStore } from './BaseStore.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { STORE_TYPES } from '../constants/store.js'
|
|
10
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('RedisStore')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Atomically increments a counter key, setting TTL on first write.
|
|
16
|
+
* Returns: [newCount, ttlMs]
|
|
17
|
+
*/
|
|
18
|
+
const LUA_INCREMENT_WITH_TTL = `
|
|
19
|
+
local current = redis.call('GET', KEYS[1])
|
|
20
|
+
if current == false then
|
|
21
|
+
redis.call('SET', KEYS[1], 1, 'PX', ARGV[1])
|
|
22
|
+
return {1, tonumber(ARGV[1])}
|
|
23
|
+
end
|
|
24
|
+
local newVal = redis.call('INCR', KEYS[1])
|
|
25
|
+
local pttl = redis.call('PTTL', KEYS[1])
|
|
26
|
+
return {newVal, pttl}
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Atomically gets a JSON-encoded entry. Returns nil if absent/expired.
|
|
31
|
+
*/
|
|
32
|
+
const LUA_GET_ENTRY = `
|
|
33
|
+
local val = redis.call('GET', KEYS[1])
|
|
34
|
+
if val == false then return nil end
|
|
35
|
+
return val
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Atomically sets a JSON-encoded entry with a TTL in milliseconds.
|
|
40
|
+
*/
|
|
41
|
+
const LUA_SET_ENTRY = `
|
|
42
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
43
|
+
return 1
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
export class RedisStore extends BaseStore {
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} options
|
|
49
|
+
* @param {Object} options.client - ioredis client instance.
|
|
50
|
+
* @param {string} [options.prefix] - Key prefix. Default: 'rle:'.
|
|
51
|
+
*/
|
|
52
|
+
constructor({ client, prefix = 'rle:' }) {
|
|
53
|
+
super(STORE_TYPES.REDIS)
|
|
54
|
+
|
|
55
|
+
if (!client) {
|
|
56
|
+
throw ErrorFactory.missingField('client', { store: 'RedisStore' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this._client = client
|
|
60
|
+
this._prefix = prefix
|
|
61
|
+
this._defineScripts()
|
|
62
|
+
logger.info('RedisStore initialised', { prefix })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} key
|
|
67
|
+
* @returns {Promise<import('../types/index.js').StoreEntry|null>}
|
|
68
|
+
*/
|
|
69
|
+
async get(key) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await this._client.get(this._prefixed(key))
|
|
72
|
+
if (!raw) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const entry = JSON.parse(raw)
|
|
76
|
+
if (Date.now() > entry.expiresAt) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
return entry
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
throw ErrorFactory.storeOperationFailed('read', cause, { key })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} key
|
|
87
|
+
* @param {import('../types/index.js').StoreEntry} entry
|
|
88
|
+
* @param {number} ttlMs
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async set(key, entry, ttlMs) {
|
|
92
|
+
try {
|
|
93
|
+
await this._client.call(
|
|
94
|
+
'EVALSHA', this._scripts.setEntry,
|
|
95
|
+
1,
|
|
96
|
+
this._prefixed(key),
|
|
97
|
+
JSON.stringify({ ...entry, expiresAt: Date.now() + ttlMs }),
|
|
98
|
+
String(ttlMs),
|
|
99
|
+
)
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} key
|
|
107
|
+
* @param {number} ttlMs
|
|
108
|
+
* @returns {Promise<number>}
|
|
109
|
+
*/
|
|
110
|
+
async increment(key, ttlMs) {
|
|
111
|
+
try {
|
|
112
|
+
const result = await this._client.call(
|
|
113
|
+
'EVALSHA', this._scripts.incrementWithTtl,
|
|
114
|
+
1,
|
|
115
|
+
this._prefixed(key),
|
|
116
|
+
String(ttlMs),
|
|
117
|
+
)
|
|
118
|
+
return result[0]
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} key
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
async delete(key) {
|
|
129
|
+
try {
|
|
130
|
+
await this._client.del(this._prefixed(key))
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
throw ErrorFactory.storeOperationFailed('write', cause, { key })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async destroy() {
|
|
140
|
+
await this._client.quit()
|
|
141
|
+
logger.info('RedisStore connection closed')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Loads Lua scripts into Redis and caches their SHAs.
|
|
146
|
+
*/
|
|
147
|
+
_defineScripts() {
|
|
148
|
+
const load = (script) => this._client.script('LOAD', script)
|
|
149
|
+
|
|
150
|
+
this._scripts = {}
|
|
151
|
+
|
|
152
|
+
Promise.all([
|
|
153
|
+
load(LUA_INCREMENT_WITH_TTL).then((sha) => { this._scripts.incrementWithTtl = sha }),
|
|
154
|
+
load(LUA_GET_ENTRY).then((sha) => { this._scripts.getEntry = sha }),
|
|
155
|
+
load(LUA_SET_ENTRY).then((sha) => { this._scripts.setEntry = sha }),
|
|
156
|
+
]).catch((cause) => {
|
|
157
|
+
logger.error('Failed to load Lua scripts', { error: cause.message })
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} key
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
_prefixed(key) {
|
|
166
|
+
return `${this._prefix}${key}`
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Central JSDoc type contracts for ratelimit-engine.
|
|
3
|
+
* All layers depend on these types — never on concrete implementations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Result returned by every algorithm check.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {Object} RateLimitResult
|
|
10
|
+
* @property {boolean} allowed - Whether the request is permitted.
|
|
11
|
+
* @property {number} limit - Maximum requests allowed in the window.
|
|
12
|
+
* @property {number} remaining - Requests remaining in the current window.
|
|
13
|
+
* @property {Date} resetAt - When the current window resets.
|
|
14
|
+
* @property {number} [retryAfter] - Seconds until the client may retry (only when allowed=false).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Raw entry persisted in a store for a given key.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} StoreEntry
|
|
21
|
+
* @property {number} count - Request count in the current window.
|
|
22
|
+
* @property {number} windowStart - Window start timestamp (ms since epoch).
|
|
23
|
+
* @property {number} expiresAt - Absolute expiry timestamp (ms since epoch).
|
|
24
|
+
* @property {Object} [meta] - Algorithm-specific extra fields (e.g. tokens, lastLeak).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Request context passed through the engine and resolvers.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {Object} RequestContext
|
|
31
|
+
* @property {string} ip - Client IP address.
|
|
32
|
+
* @property {string} [userId] - Authenticated user ID, if available.
|
|
33
|
+
* @property {string} [apiKey] - API key from request headers, if present.
|
|
34
|
+
* @property {string} method - HTTP method (GET, POST, …).
|
|
35
|
+
* @property {string} path - Request path.
|
|
36
|
+
* @property {Object} headers - Raw request headers.
|
|
37
|
+
* @property {Object} [rawRequest] - Original framework request object.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Core engine configuration passed to createLimiter().
|
|
42
|
+
*
|
|
43
|
+
* @typedef {Object} LimiterConfig
|
|
44
|
+
* @property {import('../algorithms/BaseAlgorithm.js').BaseAlgorithm} algorithm - Algorithm instance.
|
|
45
|
+
* @property {import('../stores/BaseStore.js').BaseStore} store - Store instance.
|
|
46
|
+
* @property {KeyResolverFn} keyBy - Function that derives the rate limit key from context.
|
|
47
|
+
* @property {boolean} [failOpen] - Allow requests when store is unavailable. Default: true.
|
|
48
|
+
* @property {OnBlockedHook} [onBlocked] - Called when a request is blocked.
|
|
49
|
+
* @property {OnAllowedHook} [onAllowed] - Called when a request is allowed.
|
|
50
|
+
* @property {import('../logger/Logger.js').Logger} [logger] - Custom logger instance.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Function that derives a rate limit key string from a request context.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {function(RequestContext): (string|Promise<string>)} KeyResolverFn
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hook called when a request is blocked by the rate limiter.
|
|
61
|
+
*
|
|
62
|
+
* @typedef {function(RequestContext, RateLimitResult): void} OnBlockedHook
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hook called when a request is allowed through.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {function(RequestContext, RateLimitResult): void} OnAllowedHook
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Logger interface — implement this to plug in Winston, Pino, etc.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {Object} LoggerInterface
|
|
75
|
+
* @property {function(string, Object=): void} debug
|
|
76
|
+
* @property {function(string, Object=): void} info
|
|
77
|
+
* @property {function(string, Object=): void} warn
|
|
78
|
+
* @property {function(string, Object=): void} error
|
|
79
|
+
*/
|