redis-distributed-rate-limiter 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,135 @@
1
+ # redis-distributed-rate-limiter
2
+
3
+ A high-performance, ultra-low-latency distributed rate-limiting middleware for Express.js applications. Built with atomic Redis Lua scripting to prevent race conditions across horizontally scaled infrastructure, featuring a decoupled asynchronous telemetry hook for real-time traffic observability.
4
+
5
+ [![TypeScript Supported](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
6
+
7
+ ## 🏗️ Core Architecture
8
+
9
+ This library isolates the critical path of API request evaluation from the secondary collection of analytical telemetry data. By executing a Rolling Sliding Window algorithm atomically within a single Redis thread, the middleware maintains a sub-3ms latency overhead while providing rich hooks to stream analytics to downstream systems like Apache Kafka, RabbitMQ, or managed logging databases.
10
+
11
+ * **Distributed Synchronization:** State is centralized in Redis, allowing multiple stateless API gateway instances to scale out horizontally while maintaining shared rate-limit quotas.
12
+ * **Atomic Sliding Window:** Uses custom Lua scripting (`ZREMRANGEBYSCORE` + `ZADD` + `ZCARD`) to guarantee thread safety and prevent window-boundary traffic exploitation.
13
+ * **Non-Blocking Telemetry Pipelines:** Exposes a clean event-driven interface that offloads log recording out of the main request-response lifecycle loop using `process.nextTick()`.
14
+ * **Resilient Fail-Open Design:** Automatically logs internal exceptions and fails open, preventing cache cluster outages from crashing customer-facing API nodes.
15
+
16
+ ---
17
+
18
+ ## ⚙️ Installation
19
+
20
+ Install the package via the npm registry:
21
+
22
+ ```bash
23
+ npm install redis-distributed-rate-limiter
24
+
25
+ ```
26
+
27
+ ### Peer Dependencies
28
+
29
+ Ensure you have the official Redis client installed and running in your root application environment:
30
+
31
+ ```bash
32
+ npm install redis
33
+
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 🚀 Quick Start (TypeScript / JavaScript)
39
+
40
+ Initialize the middleware by passing your pre-configured Redis client wrapper and setting up your threshold definitions:
41
+
42
+ ```typescript
43
+ import express from 'express';
44
+ import { createClient } from 'redis';
45
+ import { distributedRateLimiter, TelemetryLog } from 'redis-distributed-rate-limiter';
46
+
47
+ const app = express();
48
+
49
+ // 1. Initialize your centralized Redis client connection
50
+ const redisClient = createClient({ url: 'redis://localhost:6379' });
51
+ redisClient.connect().then(() => console.log('Redis connected successfully'));
52
+
53
+ // 2. Configure the Distributed Rate Limiter Middleware
54
+ const limiter = distributedRateLimiter({
55
+ redisClient: redisClient,
56
+ windowInMs: 60000, // 1 Minute moving sliding window
57
+ maxRequests: 10, // Allow up to 10 requests per window
58
+
59
+ // Custom non-blocking callback hook for async logging infrastructure
60
+ onLog: (logData: TelemetryLog) => {
61
+ // Example: Stream log payloads asynchronously to an Apache Kafka Producer,
62
+ // a microservice logger, or push to an analytics queue.
63
+ console.log(`[Telemetry Ingress] IP: ${logData.ip} | Status: ${logData.status} | Hits: ${logData.currentCount}`);
64
+ }
65
+ });
66
+
67
+ // 3. Apply the middleware cluster globally or to explicit routes
68
+ app.use(limiter);
69
+
70
+ app.get('/api/v1/resource', (req, res) => {
71
+ res.json({ success: true, message: "Welcome to the secure gateway." });
72
+ });
73
+
74
+ // Note: If running behind reverse proxies (AWS ALB, Nginx, Cloudflare, Vercel)
75
+ app.set('trust proxy', true);
76
+
77
+ app.listen(3000, () => console.log('API Gateway active on port 3000'));
78
+
79
+ ```
80
+
81
+ ---
82
+
83
+ ## 🎛️ Configuration Reference
84
+
85
+ The `distributedRateLimiter` initialization constructor accepts the following structured parameters:
86
+
87
+ | Property | Type | Required | Description |
88
+ | --- | --- | --- | --- |
89
+ | `redisClient` | `any` | **Yes** | An open, active v4 instance connection to your Redis deployment server. |
90
+ | `windowInMs` | `number` | **Yes** | Moving time framework window tracked in milliseconds (e.g., `60000` for 1 minute). |
91
+ | `maxRequests` | `number` | **Yes** | Total allowed operations inside the specific time frame constraint bounds. |
92
+ | `keyGenerator` | `(req) => string` | No | Overrides default key tracking logic. Allows tracking limits using authorization tokens, API keys, or User IDs instead of plain IP routing. |
93
+ | `onLog` | `(log: TelemetryLog) => void` | No | Callback hook firing asynchronously upon execution completion. Passes complete log payload packages. |
94
+
95
+ ---
96
+
97
+ ## 📊 Telemetry Log Payload Schema
98
+
99
+ The `onLog` event emitter passes an immutable `TelemetryLog` object containing downstream performance values:
100
+
101
+ ```typescript
102
+ interface TelemetryLog {
103
+ ip: string; // Remote user origin IP or mapped proxy client
104
+ path: string; // Visited API node endpoint URL string
105
+ method: string; // Executed HTTP request method verb (GET, POST, etc.)
106
+ status: 'ALLOWED' | 'BLOCKED'; // Resolution status string output
107
+ timestamp: number; // POSIX timestamp tracking exact request ingress time
108
+ currentCount: number; // Active tracking window hit state returned from Redis
109
+ }
110
+
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🛡️ Response Headers
116
+
117
+ Successful operations return standard rate limit metadata embedded securely inside response headers:
118
+
119
+ ```http
120
+ X-RateLimit-Limit: 10
121
+ X-RateLimit-Remaining: 9
122
+ X-RateLimit-Reset: 2026-07-05T11:04:48.201Z
123
+
124
+ ```
125
+
126
+ When thresholds are broken, the middleware automatically rejects traffic with an explicit `429 Too Many Requests` status payload:
127
+
128
+ ```json
129
+ {
130
+ "status": 429,
131
+ "error": "Too Many Requests",
132
+ "message": "Rate limit exceeded. Please try again in 60 seconds."
133
+ }
134
+
135
+ ```
@@ -0,0 +1,4 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { LimiterConfig } from './types';
3
+ export declare function distributedRateLimiter(config: LimiterConfig): (req: Request, res: Response, next: NextFunction) => Promise<void>;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAExC,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,aAAa,IAO5C,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,OAAO,CAAC,IAAI,CAAC,CA6D9E"}
package/dist/index.js ADDED
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.distributedRateLimiter = distributedRateLimiter;
4
+ const luaScript_1 = require("./luaScript");
5
+ function distributedRateLimiter(config) {
6
+ const { redisClient, windowInMs, maxRequests, onLog } = config;
7
+ // Default key generator uses the client's IP address
8
+ const defaultKeyGen = (req) => `ratelimit:${req.ip || req.socket.remoteAddress}`;
9
+ const getKey = config.keyGenerator || defaultKeyGen;
10
+ return async (req, res, next) => {
11
+ // cast to any to avoid strict Request type incompatibilities across express versions
12
+ const key = getKey(req);
13
+ const now = Date.now();
14
+ try {
15
+ // Execute the atomic Lua script in Redis
16
+ // eval(script, numKeys, keys, args)
17
+ const result = await redisClient.eval(luaScript_1.SLIDING_WINDOW_LUA, {
18
+ keys: [key],
19
+ arguments: [now.toString(), windowInMs.toString(), maxRequests.toString()]
20
+ });
21
+ const [isAllowed, currentCount] = result;
22
+ const remaining = Math.max(0, maxRequests - currentCount);
23
+ // Set standard rate-limiting headers
24
+ res.setHeader('X-RateLimit-Limit', maxRequests);
25
+ res.setHeader('X-RateLimit-Remaining', remaining);
26
+ res.setHeader('X-RateLimit-Reset', new Date(now + windowInMs).toISOString());
27
+ if (isAllowed === 1) {
28
+ // Trigger telemetry asynchronously without pausing the application cycle
29
+ if (onLog) {
30
+ process.nextTick(() => onLog({
31
+ ip: req.ip || 'unknown',
32
+ path: req.path,
33
+ method: req.method,
34
+ status: 'ALLOWED',
35
+ timestamp: now,
36
+ currentCount
37
+ }));
38
+ }
39
+ return next();
40
+ }
41
+ else {
42
+ // Block the request if the threshold is breached
43
+ if (onLog) {
44
+ process.nextTick(() => onLog({
45
+ ip: req.ip || 'unknown',
46
+ path: req.path,
47
+ method: req.method,
48
+ status: 'BLOCKED',
49
+ timestamp: now,
50
+ currentCount
51
+ }));
52
+ }
53
+ res.status(429).json({
54
+ status: 429,
55
+ error: 'Too Many Requests',
56
+ message: `Rate limit exceeded. Please try again in ${Math.ceil(windowInMs / 1000)} seconds.`
57
+ });
58
+ return;
59
+ }
60
+ }
61
+ catch (error) {
62
+ // Fail-open strategy: If Redis fails, log the error and allow the traffic to pass
63
+ // so your security tool doesn't accidentally cause a total app outage.
64
+ console.error('Rate Limiter Internal Error:', error);
65
+ return next();
66
+ }
67
+ };
68
+ }
69
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare const SLIDING_WINDOW_LUA = "\n local key = KEYS[1]\n local now = tonumber(ARGV[1])\n local window = tonumber(ARGV[2])\n local limit = tonumber(ARGV[3])\n \n local clearBefore = now - window\n \n -- 1. Remove timestamps older than the current sliding window boundary\n redis.call('ZREMRANGEBYSCORE', key, '-inf', clearBefore)\n \n -- 2. Count how many requests are left in the current window\n local currentRequests = redis.call('ZCARD', key)\n \n -- 3. If below the limit, allow the request and log the current timestamp\n if currentRequests < limit then\n redis.call('ZADD', key, now, now)\n -- Set an expiry on the key so it cleans itself up if the user stops sending requests\n redis.call('EXPIRE', key, math.ceil(window / 1000))\n return {1, currentRequests + 1} -- [Allowed = true, New Count]\n else\n return {0, currentRequests} -- [Allowed = false, Current Count]\n end\n";
2
+ //# sourceMappingURL=luaScript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"luaScript.d.ts","sourceRoot":"","sources":["../src/luaScript.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,23BAuB9B,CAAC"}
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SLIDING_WINDOW_LUA = void 0;
4
+ exports.SLIDING_WINDOW_LUA = `
5
+ local key = KEYS[1]
6
+ local now = tonumber(ARGV[1])
7
+ local window = tonumber(ARGV[2])
8
+ local limit = tonumber(ARGV[3])
9
+
10
+ local clearBefore = now - window
11
+
12
+ -- 1. Remove timestamps older than the current sliding window boundary
13
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', clearBefore)
14
+
15
+ -- 2. Count how many requests are left in the current window
16
+ local currentRequests = redis.call('ZCARD', key)
17
+
18
+ -- 3. If below the limit, allow the request and log the current timestamp
19
+ if currentRequests < limit then
20
+ redis.call('ZADD', key, now, now)
21
+ -- Set an expiry on the key so it cleans itself up if the user stops sending requests
22
+ redis.call('EXPIRE', key, math.ceil(window / 1000))
23
+ return {1, currentRequests + 1} -- [Allowed = true, New Count]
24
+ else
25
+ return {0, currentRequests} -- [Allowed = false, Current Count]
26
+ end
27
+ `;
28
+ //# sourceMappingURL=luaScript.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"luaScript.js","sourceRoot":"","sources":["../src/luaScript.ts"],"names":[],"mappings":";;;AAAa,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;CAuBjC,CAAC"}
@@ -0,0 +1,16 @@
1
+ export interface TelemetryLog {
2
+ ip: string;
3
+ path: string;
4
+ method: string;
5
+ status: 'ALLOWED' | 'BLOCKED';
6
+ timestamp: number;
7
+ currentCount: number;
8
+ }
9
+ export interface LimiterConfig {
10
+ redisClient: any;
11
+ windowInMs: number;
12
+ maxRequests: number;
13
+ keyGenerator?: (req: Request) => string;
14
+ onLog?: (log: TelemetryLog) => void;
15
+ }
16
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AACA,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,GAAG,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;IACxC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;CACrC"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "redis-distributed-rate-limiter",
3
+ "version": "1.0.0",
4
+ "description": "High-performance distributed rate-limiting middleware for Express using atomic Redis Lua scripting and non-blocking telemetry hooks.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "keywords": [
15
+ "redis",
16
+ "rate-limiter",
17
+ "distributed",
18
+ "express",
19
+ "middleware",
20
+ "lua",
21
+ "sliding-window",
22
+ "telemetry",
23
+ "api-security"
24
+ ],
25
+ "author": "Rohit Verma",
26
+ "license": "MIT",
27
+ "devDependencies": {
28
+ "@types/express": "^5.0.0",
29
+ "@types/node": "^20.0.0",
30
+ "typescript": "^5.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "redis": "^4.0.0"
34
+ }
35
+ }