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,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
+ */