web-gatekeeper-js 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/README.md ADDED
@@ -0,0 +1,486 @@
1
+ # gatekeeper-js
2
+
3
+ A high-performance, Redis-powered rate limiter and throttler for Node.js applications.
4
+ Built for distributed systems, horizontally scalable architectures, and high concurrency environments.
5
+
6
+ Supports:
7
+
8
+ - Sliding Window Counter rate limiting
9
+ - Token Bucket throttling
10
+ - Atomic operations using Lua scripts
11
+ - Distributed environments using Redis
12
+ - Zero race conditions
13
+ - High concurrency handling
14
+ - Express, Fastify, NestJS, Koa, and custom Node.js servers
15
+
16
+ ---
17
+
18
+ # Features
19
+
20
+ - โšก Extremely fast Redis-based implementation
21
+ - ๐Ÿ”’ Atomic operations using Redis Lua scripts
22
+ - ๐ŸŒ Works across multiple servers/instances
23
+ - ๐Ÿ“ˆ Horizontally scalable
24
+ - ๐Ÿง  Supports burst traffic handling
25
+ - ๐Ÿšซ Prevents race conditions in concurrent environments
26
+ - ๐Ÿชถ Lightweight and dependency minimal
27
+ - ๐Ÿ”ง Fully configurable
28
+ - ๐Ÿงต Safe under heavy parallel requests
29
+ - โ™ป๏ธ Reusable Redis connections
30
+ - ๐Ÿ“ฆ TypeScript support
31
+
32
+ ---
33
+
34
+ # Why Redis?
35
+
36
+ Traditional in-memory rate limiters work only on a single server instance.
37
+
38
+ That becomes a problem when your application is deployed across:
39
+
40
+ - Multiple containers
41
+ - Multiple Node.js processes
42
+ - Kubernetes clusters
43
+ - Load balanced servers
44
+ - Microservices
45
+
46
+ Redis acts as a centralized shared datastore, allowing every instance of your application to enforce the same limits consistently.
47
+
48
+ This package uses Redis for:
49
+
50
+ - Shared state management
51
+ - Atomic counters
52
+ - Distributed synchronization
53
+ - Fast in-memory performance
54
+
55
+ ---
56
+
57
+ # Why Lua Scripts?
58
+
59
+ Redis commands executed separately can lead to race conditions.
60
+
61
+ Example problem:
62
+
63
+ Two requests arrive simultaneously.
64
+
65
+ Both requests:
66
+
67
+ 1. Read current token count
68
+ 2. Both think tokens are available
69
+ 3. Both consume tokens
70
+ 4. Limit gets bypassed
71
+
72
+ This package avoids that entirely using Redis Lua scripts.
73
+
74
+ Lua scripts run atomically inside Redis, meaning:
75
+
76
+ - No other Redis command can interrupt execution
77
+ - Read + update operations happen together
78
+ - Concurrent requests remain safe
79
+ - No locks are required
80
+
81
+ This guarantees correctness even under massive traffic spikes.
82
+
83
+ ---
84
+
85
+ # Algorithms Used
86
+
87
+ ## Sliding Window Counter (Rate Limiter)
88
+
89
+ Used by `RateLimiter`.
90
+
91
+ Tracks requests within a moving time window.
92
+
93
+ Example:
94
+
95
+ - Limit: `100 requests`
96
+ - Window: `60 seconds`
97
+
98
+ The limiter continuously tracks requests within the last 60 seconds instead of resetting abruptly like fixed window algorithms.
99
+
100
+ ### Benefits
101
+
102
+ - Smoother limiting
103
+ - More accurate than fixed windows
104
+ - Prevents sudden request bursts at window boundaries
105
+ - Better user experience
106
+
107
+ ---
108
+
109
+ ## Token Bucket (Throttler)
110
+
111
+ Used by `Throttler`.
112
+
113
+ Tokens refill gradually over time.
114
+
115
+ Example:
116
+
117
+ - Bucket size: `10`
118
+ - Refill rate: `2 tokens/sec`
119
+
120
+ If the bucket has tokens available, requests are allowed immediately.
121
+
122
+ If not:
123
+
124
+ - Requests are delayed/rejected
125
+ - Burst traffic is controlled gracefully
126
+
127
+ ### Benefits
128
+
129
+ - Handles traffic spikes efficiently
130
+ - Allows short bursts
131
+ - Smooth request flow
132
+ - Ideal for APIs and real-time systems
133
+
134
+ ---
135
+
136
+ # Installation
137
+
138
+ ```bash
139
+ npm install gatekeeper-js ioredis
140
+ ```
141
+
142
+ ---
143
+
144
+ # Quick Start
145
+
146
+ ## Rate Limiter Example
147
+
148
+ ```js
149
+ import { RateLimiter } from 'gatekeeper-js'
150
+ import Redis from 'ioredis'
151
+
152
+ const redis = new Redis()
153
+
154
+ const limiter = new RateLimiter({
155
+ redisClient : redis,
156
+ windowSize : 60000,
157
+ limit : 100,
158
+ maxToken : 10,
159
+ refillRate : 2
160
+ })
161
+
162
+ app.use(async (req, res, next) => {
163
+ const result = await limiter.consume(req.ip)
164
+
165
+ if (!result.allowed) {
166
+ return res.status(429).json({
167
+ error: result.reason
168
+ })
169
+ }
170
+
171
+ next()
172
+ })
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Throttler Example
178
+
179
+ ```js
180
+ import { Throttler } from 'gatekeeper-js'
181
+ import Redis from 'ioredis'
182
+
183
+ const redis = new Redis()
184
+
185
+ const throttler = new Throttler({
186
+ redisClient : redis,
187
+ refillRate : 2,
188
+ maxWait : 5000
189
+ })
190
+
191
+ app.use(async (req, res, next) => {
192
+ const result = await throttler.consume(req.ip)
193
+
194
+ if (!result.allowed) {
195
+ return res.status(429).json({
196
+ error: 'Too Many Requests'
197
+ })
198
+ }
199
+
200
+ next()
201
+ })
202
+ ```
203
+
204
+ ---
205
+
206
+ # API
207
+
208
+ # RateLimiter
209
+
210
+ ## Constructor
211
+
212
+ ```js
213
+ new RateLimiter(options)
214
+ ```
215
+
216
+ ## Options
217
+
218
+ | Option | Type | Required | Description |
219
+ |---|---|---|---|
220
+ | redisClient | Redis | Yes | ioredis client instance |
221
+ | windowSize | number | Yes | Time window in milliseconds |
222
+ | limit | number | Yes | Maximum requests allowed per window |
223
+ | maxToken | number | No | Maximum burst capacity |
224
+ | refillRate | number | No | Token refill rate per second |
225
+
226
+ ---
227
+
228
+ ## consume(key)
229
+
230
+ Consumes one request for a given identifier.
231
+
232
+ ```js
233
+ const result = await limiter.consume('user-id')
234
+ ```
235
+
236
+ ## Response
237
+
238
+ ```js
239
+ {
240
+ allowed: true,
241
+ remaining: 42,
242
+ resetTime: 1716200000000
243
+ }
244
+ ```
245
+
246
+ ## Response Fields
247
+
248
+ | Field | Description |
249
+ |---|---|
250
+ | allowed | Whether request is allowed |
251
+ | remaining | Remaining requests/tokens |
252
+ | resetTime | Unix timestamp when limit resets |
253
+ | reason | Rejection reason if blocked |
254
+
255
+ ---
256
+
257
+ # Throttler
258
+
259
+ ## Constructor
260
+
261
+ ```js
262
+ new Throttler(options)
263
+ ```
264
+
265
+ ## Options
266
+
267
+ | Option | Type | Required | Description |
268
+ |---|---|---|---|
269
+ | redisClient | Redis | Yes | ioredis client instance |
270
+ | refillRate | number | Yes | Tokens added per second |
271
+ | maxWait | number | No | Maximum wait time before rejection |
272
+
273
+ ---
274
+
275
+ ## consume(key)
276
+
277
+ ```js
278
+ const result = await throttler.consume('user-id')
279
+ ```
280
+
281
+ ## Response
282
+
283
+ ```js
284
+ {
285
+ allowed: true,
286
+ retryAfter: 0
287
+ }
288
+ ```
289
+
290
+ ---
291
+
292
+ # Scalability
293
+
294
+ This package is designed for distributed systems.
295
+
296
+ Because Redis stores the state centrally:
297
+
298
+ - Multiple Node.js instances share the same limits
299
+ - Limits remain consistent across deployments
300
+ - Works seamlessly behind load balancers
301
+ - Suitable for microservices and Kubernetes
302
+
303
+ Example architecture:
304
+
305
+ ```text
306
+ Client Requests
307
+ โ†“
308
+ Load Balancer
309
+ โ†“
310
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
311
+ โ”‚ Node App 1 โ”‚
312
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
313
+ โ”‚ Node App 2 โ”‚
314
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
315
+ โ”‚ Node App 3 โ”‚
316
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
317
+ โ†“
318
+ Redis
319
+ ```
320
+
321
+ All application instances communicate with the same Redis server.
322
+
323
+ ---
324
+
325
+ # Concurrency Safety
326
+
327
+ This package is safe under heavy concurrent traffic.
328
+
329
+ Redis Lua scripts ensure:
330
+
331
+ - Atomic execution
332
+ - No partial updates
333
+ - No inconsistent counters
334
+ - No race conditions
335
+
336
+ Even if thousands of requests arrive simultaneously, limits remain accurate.
337
+
338
+ ---
339
+
340
+ # Performance
341
+
342
+ Redis operations are extremely fast because Redis stores data in memory.
343
+
344
+ The package is optimized to:
345
+
346
+ - Minimize Redis calls
347
+ - Use atomic Lua execution
348
+ - Reduce network overhead
349
+ - Handle high throughput efficiently
350
+
351
+ Suitable for:
352
+
353
+ - Public APIs
354
+ - Authentication systems
355
+ - Login protection
356
+ - Payment APIs
357
+ - WebSocket gateways
358
+ - Real-time applications
359
+
360
+ ---
361
+
362
+ # Redis Compatibility
363
+
364
+ Compatible with:
365
+
366
+ - Redis 6+
367
+ - Redis 7+
368
+ - Redis Cluster
369
+ - Redis Cloud providers
370
+
371
+ ---
372
+
373
+ # Framework Support
374
+
375
+ Works with any Node.js framework:
376
+
377
+ - Express
378
+ - Fastify
379
+ - NestJS
380
+ - Koa
381
+ - Hono
382
+ - Native HTTP servers
383
+
384
+ ---
385
+
386
+ # Error Handling
387
+
388
+ Example:
389
+
390
+ ```js
391
+ try {
392
+ const result = await limiter.consume(req.ip)
393
+
394
+ if (!result.allowed) {
395
+ return res.status(429).json({
396
+ error: result.reason
397
+ })
398
+ }
399
+
400
+ next()
401
+ } catch (error) {
402
+ console.error(error)
403
+
404
+ return res.status(500).json({
405
+ error: 'Internal Server Error'
406
+ })
407
+ }
408
+ ```
409
+
410
+ ---
411
+
412
+ # Best Practices
413
+
414
+ ## Use Stable Keys
415
+
416
+ Good examples:
417
+
418
+ ```js
419
+ req.ip
420
+ user.id
421
+ apiKey
422
+ ```
423
+
424
+ Avoid random or frequently changing keys.
425
+
426
+ ---
427
+
428
+ ## Reuse Redis Connections
429
+
430
+ Create one shared Redis client instance and reuse it across the application.
431
+
432
+ ```js
433
+ const redis = new Redis()
434
+ ```
435
+
436
+ ---
437
+
438
+ ## Configure Reasonable Limits
439
+
440
+ | Use Case | Suggested Limit |
441
+ |---|---|
442
+ | Login API | 5 requests/min |
443
+ | Public API | 100 requests/min |
444
+ | OTP Verification | 3 requests/5 min |
445
+
446
+ ---
447
+
448
+ # License
449
+
450
+ ISC License
451
+
452
+ Permission to use, modify, and distribute this software for any purpose with or without fee is hereby granted.
453
+
454
+ See the `LICENSE` file for full details.
455
+
456
+ ---
457
+
458
+ # Contributing
459
+
460
+ Contributions are welcome.
461
+
462
+ Feel free to:
463
+
464
+ - Open issues
465
+ - Suggest improvements
466
+ - Submit pull requests
467
+
468
+ ---
469
+
470
+ # Roadmap
471
+
472
+ Planned features:
473
+
474
+ - Redis Cluster optimizations
475
+ - Sliding log algorithm
476
+ - Fixed window limiter
477
+ - Rate limit headers
478
+ - Automatic retries
479
+ - Memory fallback store
480
+ - Metrics integration
481
+
482
+ ---
483
+
484
+ # Author
485
+
486
+ Built for modern distributed Node.js applications using Redis and Lua for correctness, scalability, and performance.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "web-gatekeeper-js",
3
+ "version": "1.0.0",
4
+ "description": "Redis based rate limiter and throttler using sliding window and token bucket algorithms",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "keywords": [
8
+ "rate-limiter",
9
+ "throttler",
10
+ "redis",
11
+ "sliding-window",
12
+ "token-bucket",
13
+ "express",
14
+ "nodejs"
15
+ ],
16
+ "author": "Naveen Kushwaha",
17
+ "license": "ISC",
18
+ "peerDependencies": {
19
+ "ioredis": "^5.10.1"
20
+ },
21
+ "devDependencies": {
22
+ "ioredis": "^5.10.1"
23
+ }
24
+ }
@@ -0,0 +1,94 @@
1
+ import { slidingWindowScript } from "./scripts/slidingWindow.lua";
2
+ import { tokenBucket } from "./scripts/tokenBucket.lua";
3
+ import { RedisStore } from "./store/RedisStore";
4
+
5
+ export class RateLimiter {
6
+ #store;
7
+ #windowSize;
8
+ #limit;
9
+ #maxToken;
10
+ #refillRate;
11
+
12
+ constructor({ redisClient, windowSize, limit, maxToken, refillRate }) {
13
+ if (!redisClient) throw new Error("redisClient is required");
14
+ if (!windowSize) throw new Error("windowSize is required");
15
+ if (!limit) throw new Error("limit is required");
16
+ if (!maxToken) throw new Error("maxToken is required");
17
+ if (!refillRate) throw new Error("refillRate is required");
18
+
19
+ this.#store = new RedisStore(redisClient);
20
+ this.#windowSize = windowSize;
21
+ this.#limit = limit;
22
+ this.#maxToken = maxToken;
23
+ this.#refillRate = refillRate;
24
+ }
25
+
26
+ async consume(identifier){
27
+ const now = Date.now()
28
+
29
+ const slidingResult = await this.#runSlidingWindow(identifier, now)
30
+ if(!slidingResult.allowed){
31
+ return {
32
+ allowed: false,
33
+ reason: 'rate_limit_exceeded',
34
+ resetAfter: slidingResult.resetAfter
35
+ }
36
+ }
37
+
38
+ const bucketResult = await this.#runTokenBucket(identifier, now)
39
+ if(!bucketResult.allowed){
40
+ return {
41
+ allowed: false,
42
+ reason: 'burst_limit_exceeded',
43
+ retryAfter: bucketResult.retryAfter
44
+ }
45
+ }
46
+
47
+ return {
48
+ allowed : true,
49
+ remaining : slidingResult.remaining,
50
+ tokensRemaining : bucketResult.tokensLeft
51
+ }
52
+ }
53
+
54
+ async #runSlidingWindow(identifier,now){
55
+ const key = `rl:sliding:${identifier}`
56
+ const ttl = Math.ceil((this.#windowSize / 1000) * 2)
57
+
58
+ const result = await this.#store.evalScript(
59
+ slidingWindowScript,
60
+ key,
61
+ this.#windowSize,
62
+ this.#limit,
63
+ now,
64
+ ttl
65
+ )
66
+
67
+ return {
68
+ allowed : result[0] === 1,
69
+ current : result[1],
70
+ remaining : result[2],
71
+ resetAfter: result[3]
72
+ }
73
+ }
74
+
75
+ async #runTokenBucket(identifier, now) {
76
+ const key = `rl:bucket:${identifier}`
77
+ const ttl = Math.max(Math.ceil(this.#maxToken / this.#refillRate) * 2, 60)
78
+
79
+ const result = await this.#store.evalScript(
80
+ tokenBucketScript,
81
+ key,
82
+ now,
83
+ this.#maxToken,
84
+ this.#refillRate,
85
+ ttl
86
+ )
87
+
88
+ return {
89
+ allowed : result[0] === 1,
90
+ tokensLeft : result[1],
91
+ retryAfter : result[2]
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,55 @@
1
+ // src/Throttler.js
2
+
3
+ import { throttlerScript } from './scripts/throttler.lua.js'
4
+ import { RedisStore } from './store/RedisStore.js'
5
+
6
+ export class Throttler {
7
+ #store
8
+ #refillRate
9
+ #maxWait
10
+
11
+ constructor({ redisClient, refillRate, maxWait }) {
12
+ if (!redisClient) throw new Error('redisClient is required')
13
+ if (!refillRate) throw new Error('refillRate is required')
14
+ if (!maxWait) throw new Error('maxWait is required')
15
+
16
+ this.#store = new RedisStore(redisClient)
17
+ this.#refillRate = refillRate
18
+ this.#maxWait = maxWait
19
+ }
20
+
21
+ async consume(identifier) {
22
+ const now = Date.now()
23
+ const key = `rl:throttler:${identifier}`
24
+ const ttl = 60
25
+
26
+ const result = await this.#store.evalScript(
27
+ throttlerScript,
28
+ key,
29
+ now,
30
+ this.#refillRate,
31
+ this.#maxWait,
32
+ ttl
33
+ )
34
+
35
+ const allowed = result[0] === 1
36
+ const waitTime = result[1]
37
+
38
+ if (!allowed) {
39
+ return {
40
+ allowed : false,
41
+ retryAfter : (waitTime / 1000).toFixed(2) + 's'
42
+ }
43
+ }
44
+
45
+ if (waitTime > 0) {
46
+ await this.#delay(waitTime)
47
+ }
48
+
49
+ return { allowed: true, waitTime }
50
+ }
51
+
52
+ #delay(ms) {
53
+ return new Promise(resolve => setTimeout(resolve, ms))
54
+ }
55
+ }
@@ -0,0 +1,29 @@
1
+ export const slidingWindow = async (req, res, next) => {
2
+ const userId = req.ip
3
+ const windowSize = 60000
4
+ const limit = 5
5
+ const now = Date.now()
6
+ const ttl = (windowSize / 1000) * 2
7
+
8
+ const result = await redisClient.eval(
9
+ slidingWindowScript,
10
+ 1,
11
+ `rl:Sliding:user:${userId}`,
12
+ windowSize,
13
+ limit,
14
+ now,
15
+ ttl
16
+ )
17
+
18
+ console.log({
19
+ allowed : result[0] === 1,
20
+ currentCount : result[1],
21
+ remaining : result[2]
22
+ })
23
+
24
+ if (result[0] === 0) {
25
+ return res.status(429).json({ message: "Too Many Requests" })
26
+ }
27
+
28
+ return next()
29
+ }
@@ -0,0 +1,32 @@
1
+ export const tokenBucket = async (req, res, next) => {
2
+ const userId = req.ip
3
+ const maxToken = 5
4
+ const refillRate = 0.5
5
+ const now = Date.now()
6
+ const ttl = Math.max(Math.ceil(maxToken / refillRate) * 2, 60)
7
+
8
+ const result = await redisClient.eval(
9
+ tokenBucketScript,
10
+ 1,
11
+ `rl:tokenBucket:user:${userId}`,
12
+ now,
13
+ maxToken,
14
+ refillRate,
15
+ ttl
16
+ )
17
+
18
+ console.log({
19
+ allowed : result[0] === 1,
20
+ tokensLeft : result[1],
21
+ retryAfter : result[2]
22
+ })
23
+
24
+ if (result[0] === 0) {
25
+ return res.status(429).json({
26
+ message : "Too Many Requests",
27
+ retryAfter : `${result[2]}s`
28
+ })
29
+ }
30
+
31
+ return next()
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { RateLimiter } from './RateLimiter.js'
2
+ export { Throttler } from './Throttler.js'
@@ -0,0 +1,62 @@
1
+ export const slidingWindowScript = `
2
+ local key = KEYS[1]
3
+ local windowSize = tonumber(ARGV[1])
4
+ local limit = tonumber(ARGV[2])
5
+ local now = tonumber(ARGV[3])
6
+ local ttl = tonumber(ARGV[4])
7
+
8
+ local windowStart = math.floor(now / windowSize) * windowSize
9
+
10
+ local storedWindowStart = tonumber(redis.call('HGET', key, 'windowStart') or 0)
11
+ local currentCount = tonumber(redis.call('HGET', key, 'currentCount') or 0)
12
+ local previousCount = tonumber(redis.call('HGET', key, 'previousCount') or 0)
13
+
14
+ -- resetAfter calculated once, used in all returns
15
+ local resetAfter = math.ceil((windowSize - (now - windowStart)) / 1000)
16
+
17
+ -- first time user
18
+ if storedWindowStart == 0 then
19
+ redis.call('HSET', key,
20
+ 'windowStart', windowStart,
21
+ 'currentCount', 1,
22
+ 'previousCount', 0
23
+ )
24
+ redis.call('EXPIRE', key, ttl)
25
+ return { 1, 1, limit - 1, resetAfter } -- โ† 4 values
26
+ end
27
+
28
+ local windowsPassed = math.floor((windowStart - storedWindowStart) / windowSize)
29
+
30
+ local newPreviousCount = previousCount
31
+ local newCurrentCount = currentCount
32
+
33
+ if windowsPassed > 1 then
34
+ newPreviousCount = 0
35
+ newCurrentCount = 0
36
+ elseif windowsPassed == 1 then
37
+ newPreviousCount = currentCount
38
+ newCurrentCount = 0
39
+ end
40
+
41
+ local elapsedTime = (now - windowStart) / 1000
42
+ local overlap = 1 - (elapsedTime / (windowSize / 1000))
43
+ local effectiveCount = (overlap * newPreviousCount) + newCurrentCount
44
+
45
+ -- blocked
46
+ if effectiveCount >= limit then
47
+ return { 0, newCurrentCount, 0, resetAfter } -- โ† 4 values
48
+ end
49
+
50
+ -- allowed
51
+ newCurrentCount = newCurrentCount + 1
52
+
53
+ redis.call('HSET', key,
54
+ 'windowStart', windowStart,
55
+ 'currentCount', newCurrentCount,
56
+ 'previousCount', newPreviousCount
57
+ )
58
+ redis.call('EXPIRE', key, ttl)
59
+
60
+ local remaining = math.floor(limit - effectiveCount - 1)
61
+ return { 1, newCurrentCount, remaining, resetAfter } -- โ† 4 values
62
+ `
@@ -0,0 +1,34 @@
1
+ export const throttler = `
2
+ local key = KEYS[1]
3
+ local now = tonumber(ARGV[1])
4
+ local refillRate = tonumber(ARGV[2])
5
+ local maxWait = tonumber(ARGV[3])
6
+ local ttl = tonumber(ARGV[4])
7
+
8
+ -- safely read nextAllowedTime
9
+ local raw = redis.call('HGET', key, 'nextAllowedTime')
10
+ local nextAllowedTime = raw and tonumber(raw) or now
11
+
12
+ -- calculate wait time
13
+ local waitTime = nextAllowedTime - now
14
+
15
+ -- reject if queue too full
16
+ if waitTime > maxWait then
17
+ return { 0, waitTime }
18
+ end
19
+
20
+ -- calculate new nextAllowedTime
21
+ local newNextAllowedTime
22
+ if waitTime <= 0 then
23
+ newNextAllowedTime = now + (1000 / refillRate)
24
+ else
25
+ newNextAllowedTime = nextAllowedTime + (1000 / refillRate)
26
+ end
27
+
28
+ -- save and set TTL
29
+ redis.call('HSET', key, 'nextAllowedTime', newNextAllowedTime)
30
+ redis.call('EXPIRE', key, ttl)
31
+
32
+ -- return allowed + waitTime so Node.js knows how long to delay
33
+ return { 1, math.max(waitTime, 0) }
34
+ `
@@ -0,0 +1,46 @@
1
+ const tokenBucketScript = `
2
+ local key = KEYS[1]
3
+ local now = tonumber(ARGV[1])
4
+ local maxToken = tonumber(ARGV[2])
5
+ local refillRate = tonumber(ARGV[3])
6
+ local ttl = tonumber(ARGV[4])
7
+
8
+ -- read stored state (same as your hGetAll)
9
+ local lastReqTime = tonumber(redis.call('HGET', key, 'time') or now)
10
+ local tokenLeft = tonumber(redis.call('HGET', key, 'tokenLeft') or maxToken)
11
+
12
+ -- first time user (same as your empty check)
13
+ if lastReqTime == nil or tokenLeft == nil then
14
+ redis.call('HSET', key,
15
+ 'time', now,
16
+ 'tokenLeft', maxToken - 1
17
+ )
18
+ redis.call('EXPIRE', key, ttl)
19
+ return { 1, maxToken - 1 }
20
+ end
21
+
22
+ -- calculate refill (same as your timeElapsed and updatedToken)
23
+ local timeElapsed = (now - lastReqTime) / 1000
24
+ local updatedToken = math.min(tokenLeft + (timeElapsed * refillRate), maxToken)
25
+
26
+ -- block if not enough tokens (same as your updatedToken < 1)
27
+ if updatedToken < 1 then
28
+ redis.call('HSET', key,
29
+ 'time', now,
30
+ 'tokenLeft', updatedToken
31
+ )
32
+ redis.call('EXPIRE', key, ttl)
33
+
34
+ local retryAfter = math.ceil((1 - updatedToken) / refillRate)
35
+ return { 0, 0, retryAfter }
36
+ end
37
+
38
+ -- allow โ€” consume 1 token and save (same as your hSet)
39
+ redis.call('HSET', key,
40
+ 'time', now,
41
+ 'tokenLeft', updatedToken - 1
42
+ )
43
+ redis.call('EXPIRE', key, ttl)
44
+
45
+ return { 1, updatedToken - 1, 0 }
46
+ `
@@ -0,0 +1,16 @@
1
+ export class RedisStore {
2
+ #redis
3
+
4
+ constructor(redisClient) {
5
+ this.#redis = redisClient
6
+ }
7
+
8
+ async evalScript(script, key, ...args) {
9
+ return await this.#redis.eval(
10
+ script,
11
+ 1,
12
+ key,
13
+ ...args
14
+ )
15
+ }
16
+ }