redis-lua-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 +78 -0
- package/dist/core/lua.d.ts +2 -0
- package/dist/core/lua.js +15 -0
- package/dist/core/lua.js.map +1 -0
- package/dist/core/redis.d.ts +2 -0
- package/dist/core/redis.js +11 -0
- package/dist/core/redis.js.map +1 -0
- package/dist/core/tokenBucket.d.ts +2 -0
- package/dist/core/tokenBucket.js +9 -0
- package/dist/core/tokenBucket.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/express.d.ts +2 -0
- package/dist/middleware/express.js +14 -0
- package/dist/middleware/express.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/lua/token_bucket.lua +30 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# redis-lua-rate-limiter
|
|
2
|
+
|
|
3
|
+
Distributed, atomic, token-bucket rate limiter for Node.js powered by Redis Lua scripts.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Most Express rate limiters break in distributed systems.
|
|
8
|
+
This one keeps the logic inside Redis using Lua for atomic safety.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i redis-lua-rate-limiter
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import express from "express";
|
|
20
|
+
import { createRateLimiter } from "redis-lua-rate-limiter";
|
|
21
|
+
|
|
22
|
+
const limiter = await createRateLimiter({
|
|
23
|
+
redisUrl: "redis://localhost:6379",
|
|
24
|
+
default: { capacity: 10, refillRate: 5 },
|
|
25
|
+
headers: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.use(limiter.middleware());
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- Token bucket algorithm
|
|
34
|
+
- Redis Lua atomic execution
|
|
35
|
+
- Per API key / IP limiting
|
|
36
|
+
- Per route limits
|
|
37
|
+
- Rate limit headers
|
|
38
|
+
- Works across multiple Node servers
|
|
39
|
+
|
|
40
|
+
## Benchmark
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
$ npx autocannon -c 1000 -d 15 http://localhost:3000
|
|
44
|
+
Running 15s test @ http://localhost:3000
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tested with 1000 connections using autocannon
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
|
|
51
|
+
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev
|
|
52
|
+
│ Max │
|
|
53
|
+
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
|
|
54
|
+
│ Latency │ 203 ms │ 214 ms │ 253 ms │ 481 ms │ 220.74 ms │ 41.38 ms │ 759 ms │
|
|
55
|
+
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
|
|
56
|
+
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬────────┬────────┐
|
|
57
|
+
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
|
|
58
|
+
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
|
|
59
|
+
│ Req/Sec │ 2,739 │ 2,739 │ 4,595 │ 5,003 │ 4,516.4 │ 558.22 │ 2,739 │
|
|
60
|
+
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
|
|
61
|
+
│ Bytes/Sec │ 898 kB │ 898 kB │ 1.51 MB │ 1.64 MB │ 1.48 MB │ 183 kB │ 897 kB │
|
|
62
|
+
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴────────┴────────┘
|
|
63
|
+
```
|
|
64
|
+
Req/Bytes counts sampled once per second.
|
|
65
|
+
# of samples: 15
|
|
66
|
+
```
|
|
67
|
+
160 2xx responses, 67579 non 2xx responses
|
|
68
|
+
69k requests in 15.22s, 22.2 MB read
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Shows strict enforcement without race conditions.
|
|
73
|
+
|
|
74
|
+
## Docker
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker compose up
|
|
78
|
+
```
|
package/dist/core/lua.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadLua = loadLua;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
async function loadLua(redis) {
|
|
10
|
+
const luaPath = path_1.default.join(__dirname, "..", "..", "lua", "token_bucket.lua");
|
|
11
|
+
const script = fs_1.default.readFileSync(luaPath, "utf8");
|
|
12
|
+
const sha = await redis.script("LOAD", script);
|
|
13
|
+
return sha;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=lua.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lua.js","sourceRoot":"","sources":["../../src/core/lua.ts"],"names":[],"mappings":";;;;;AAIA,0BAKC;AATD,4CAAoB;AACpB,gDAAwB;AAGjB,KAAK,UAAU,OAAO,CAAC,KAAY;IACxC,MAAM,OAAO,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,kBAAkB,CAAC,CAAC;IAC5E,MAAM,MAAM,GAAG,YAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAChD,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createRedisClient = createRedisClient;
|
|
7
|
+
const ioredis_1 = __importDefault(require("ioredis"));
|
|
8
|
+
function createRedisClient(redisUrl) {
|
|
9
|
+
return new ioredis_1.default(redisUrl);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=redis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis.js","sourceRoot":"","sources":["../../src/core/redis.ts"],"names":[],"mappings":";;;;;AAEA,8CAEC;AAJD,sDAA4B;AAE5B,SAAgB,iBAAiB,CAAC,QAAgB;IAChD,OAAO,IAAI,iBAAK,CAAC,QAAQ,CAAC,CAAC;AAC7B,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeTokenBucket = executeTokenBucket;
|
|
4
|
+
async function executeTokenBucket(redis, sha, key, capacity, refillRate) {
|
|
5
|
+
const now = Math.floor(Date.now() / 1000);
|
|
6
|
+
const allowed = await redis.evalsha(sha, 1, key, capacity, refillRate, now, 1);
|
|
7
|
+
return allowed === 1;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=tokenBucket.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenBucket.js","sourceRoot":"","sources":["../../src/core/tokenBucket.ts"],"names":[],"mappings":";;AAEA,gDAoBC;AApBM,KAAK,UAAU,kBAAkB,CACtC,KAAY,EACZ,GAAW,EACX,GAAW,EACX,QAAgB,EAChB,UAAkB;IAElB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAE1C,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,OAAO,CACjC,GAAG,EACH,CAAC,EACD,GAAG,EACH,QAAQ,EACR,UAAU,EACV,GAAG,EACH,CAAC,CACF,CAAC;IAEF,OAAO,OAAO,KAAK,CAAC,CAAC;AACvB,CAAC"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRateLimiter = createRateLimiter;
|
|
4
|
+
const redis_1 = require("./core/redis");
|
|
5
|
+
const lua_1 = require("./core/lua");
|
|
6
|
+
async function createRateLimiter(options) {
|
|
7
|
+
const redis = (0, redis_1.createRedisClient)(options.redisUrl);
|
|
8
|
+
const sha = await (0, lua_1.loadLua)(redis);
|
|
9
|
+
const keyGen = options.keyGenerator || ((req) => `rate:${req.ip}`);
|
|
10
|
+
const routes = new Map(Object.entries(options.routes || {}));
|
|
11
|
+
async function rateLimit(key, config) {
|
|
12
|
+
const now = Math.floor(Date.now() / 1000);
|
|
13
|
+
const [allowed, remaining] = (await redis.evalsha(sha, 1, key, config.capacity, config.refillRate, now, 1));
|
|
14
|
+
return { allowed: allowed === 1, remaining };
|
|
15
|
+
}
|
|
16
|
+
function middleware() {
|
|
17
|
+
return async (req, res, next) => {
|
|
18
|
+
const routeConfig = routes.get(req.path) || options.default;
|
|
19
|
+
const key = keyGen(req);
|
|
20
|
+
const { allowed, remaining } = await rateLimit(key, routeConfig);
|
|
21
|
+
if (options.headers) {
|
|
22
|
+
res.setHeader("X-RateLimit-Limit", routeConfig.capacity);
|
|
23
|
+
res.setHeader("X-RateLimit-Remaining", Math.floor(remaining));
|
|
24
|
+
}
|
|
25
|
+
if (!allowed) {
|
|
26
|
+
return res.status(429).json({
|
|
27
|
+
error: "Too Many Requests",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
next();
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { middleware };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAKA,8CA0DC;AA/DD,wCAAiD;AACjD,oCAAqC;AAI9B,KAAK,UAAU,iBAAiB,CAAC,OAA2B;IACjE,MAAM,KAAK,GAAG,IAAA,yBAAiB,EAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClD,MAAM,GAAG,GAAG,MAAM,IAAA,aAAO,EAAC,KAAK,CAAC,CAAC;IAEjC,MAAM,MAAM,GACV,OAAO,CAAC,YAAY,IAAI,CAAC,CAAC,GAAY,EAAE,EAAE,CAAC,QAAQ,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;IAE/D,MAAM,MAAM,GAAG,IAAI,GAAG,CACpB,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC,CACrC,CAAC;IAEF,KAAK,UAAU,SAAS,CACtB,GAAW,EACX,MAAoB;QAEpB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QAE1C,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC,OAAO,CAC/C,GAAa,EACb,CAAC,EACD,GAAG,EACH,MAAM,CAAC,QAAQ,EACf,MAAM,CAAC,UAAU,EACjB,GAAG,EACH,CAAC,CACF,CAAqB,CAAC;QAEvB,OAAO,EAAE,OAAO,EAAE,OAAO,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IAC/C,CAAC;IAED,SAAS,UAAU;QACjB,OAAO,KAAK,EAAE,GAAY,EAAE,GAAQ,EAAE,IAAS,EAAE,EAAE;YACjD,MAAM,WAAW,GACf,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC;YAE1C,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YAExB,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,MAAM,SAAS,CAC5C,GAAG,EACH,WAAW,CACZ,CAAC;YAEF,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;gBACpB,GAAG,CAAC,SAAS,CAAC,mBAAmB,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;gBACzD,GAAG,CAAC,SAAS,CAAC,uBAAuB,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;YAChE,CAAC;YAED,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBAC1B,KAAK,EAAE,mBAAmB;iBAC3B,CAAC,CAAC;YACL,CAAC;YAED,IAAI,EAAE,CAAC;QACT,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,CAAC;AACxB,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createExpressMiddleware = createExpressMiddleware;
|
|
4
|
+
function createExpressMiddleware(rateLimitFn) {
|
|
5
|
+
return async (req, res, next) => {
|
|
6
|
+
const key = `rate:${req.ip}`;
|
|
7
|
+
const allowed = await rateLimitFn(key);
|
|
8
|
+
if (!allowed) {
|
|
9
|
+
return res.status(429).json({ error: "Too Many Requests" });
|
|
10
|
+
}
|
|
11
|
+
next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.js","sourceRoot":"","sources":["../../src/middleware/express.ts"],"names":[],"mappings":";;AAEA,0DAYC;AAZD,SAAgB,uBAAuB,CAAC,WAA8C;IACpF,OAAO,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QAC/D,MAAM,GAAG,GAAG,QAAQ,GAAG,CAAC,EAAE,EAAE,CAAC;QAE7B,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,GAAG,CAAC,CAAC;QAEvC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAC;QAC9D,CAAC;QAED,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Request } from "express";
|
|
2
|
+
export type BucketConfig = {
|
|
3
|
+
capacity: number;
|
|
4
|
+
refillRate: number;
|
|
5
|
+
};
|
|
6
|
+
export type RateLimiterOptions = {
|
|
7
|
+
redisUrl: string;
|
|
8
|
+
default: BucketConfig;
|
|
9
|
+
routes?: Record<string, BucketConfig>;
|
|
10
|
+
keyGenerator?: (req: Request) => string;
|
|
11
|
+
headers?: boolean;
|
|
12
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
|
|
3
|
+
local capacity = tonumber(ARGV[1])
|
|
4
|
+
local refill_rate = tonumber(ARGV[2])
|
|
5
|
+
local now = tonumber(ARGV[3])
|
|
6
|
+
local requested = tonumber(ARGV[4])
|
|
7
|
+
|
|
8
|
+
local data = redis.call("HMGET", key, "tokens", "timestamp")
|
|
9
|
+
local tokens = tonumber(data[1])
|
|
10
|
+
local timestamp = tonumber(data[2])
|
|
11
|
+
|
|
12
|
+
if tokens == nil then
|
|
13
|
+
tokens = capacity
|
|
14
|
+
timestamp = now
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local delta = math.max(0, now - timestamp)
|
|
18
|
+
local refill = delta * refill_rate
|
|
19
|
+
tokens = math.min(capacity, tokens + refill)
|
|
20
|
+
|
|
21
|
+
local allowed = tokens >= requested
|
|
22
|
+
|
|
23
|
+
if allowed then
|
|
24
|
+
tokens = tokens - requested
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
redis.call("HMSET", key, "tokens", tokens, "timestamp", now)
|
|
28
|
+
redis.call("EXPIRE", key, 3600)
|
|
29
|
+
|
|
30
|
+
return { allowed and 1 or 0, tokens }
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "redis-lua-rate-limiter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Distributed token-bucket rate limiter using Redis Lua scripts for Node.js/Express",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": ["dist", "lua"],
|
|
8
|
+
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "ts-node-dev example/express-app.ts"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"rate-limiter",
|
|
15
|
+
"redis",
|
|
16
|
+
"lua",
|
|
17
|
+
"token-bucket",
|
|
18
|
+
"express",
|
|
19
|
+
"distributed"
|
|
20
|
+
],
|
|
21
|
+
"author": "shubhankar kumar singh",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
|
|
25
|
+
"ioredis": "^5.9.2"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"express": "^4 || ^5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/express": "^5.0.6",
|
|
32
|
+
"@types/node": "^25.2.1",
|
|
33
|
+
"ts-node-dev": "^2.0.0",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
}
|
|
36
|
+
}
|