reflo 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/.github/workflows/release.yml +32 -0
- package/.releaserc.json +12 -0
- package/CHANGELOG.md +6 -0
- package/dist/index.cjs +126 -0
- package/dist/index.d.cts +32 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +87 -0
- package/jest.config.cjs +13 -0
- package/package.json +52 -0
- package/src/dump.rdb +0 -0
- package/src/index.ts +5 -0
- package/src/rateLimiter.ts +49 -0
- package/src/redisClient.ts +27 -0
- package/src/strategies/backoff.ts +12 -0
- package/src/strategies/identifier.ts +7 -0
- package/src/strategies/index.ts +3 -0
- package/src/types.ts +22 -0
- package/src/utils.ts +8 -0
- package/test/rateLimiter.spec.ts +129 -0
- package/tsconfig.json +14 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
name: Release
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches:
|
6
|
+
- master
|
7
|
+
|
8
|
+
jobs:
|
9
|
+
release:
|
10
|
+
runs-on: ubuntu-latest
|
11
|
+
|
12
|
+
permissions:
|
13
|
+
contents: write
|
14
|
+
packages: write
|
15
|
+
id-token: write
|
16
|
+
|
17
|
+
steps:
|
18
|
+
- uses: actions/checkout@v4
|
19
|
+
|
20
|
+
- uses: actions/setup-node@v4
|
21
|
+
with:
|
22
|
+
node-version: 20
|
23
|
+
registry-url: 'https://registry.npmjs.org'
|
24
|
+
|
25
|
+
- run: npm ci
|
26
|
+
|
27
|
+
- name: Run semantic-release
|
28
|
+
env:
|
29
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
30
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
31
|
+
run: npx semantic-release
|
32
|
+
|
package/.releaserc.json
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
{
|
2
|
+
"branches": ["master"],
|
3
|
+
"plugins": [
|
4
|
+
"@semantic-release/commit-analyzer",
|
5
|
+
"@semantic-release/release-notes-generator",
|
6
|
+
"@semantic-release/changelog",
|
7
|
+
"@semantic-release/npm",
|
8
|
+
"@semantic-release/github",
|
9
|
+
"@semantic-release/git"
|
10
|
+
]
|
11
|
+
}
|
12
|
+
|
package/CHANGELOG.md
ADDED
package/dist/index.cjs
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
"use strict";
|
2
|
+
var __create = Object.create;
|
3
|
+
var __defProp = Object.defineProperty;
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
8
|
+
var __export = (target, all) => {
|
9
|
+
for (var name in all)
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
11
|
+
};
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
14
|
+
for (let key of __getOwnPropNames(from))
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
17
|
+
}
|
18
|
+
return to;
|
19
|
+
};
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
26
|
+
mod
|
27
|
+
));
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
29
|
+
|
30
|
+
// src/index.ts
|
31
|
+
var index_exports = {};
|
32
|
+
__export(index_exports, {
|
33
|
+
backoffStrategies: () => backoffStrategies,
|
34
|
+
identifierStrategies: () => identifierStrategies,
|
35
|
+
rateLimiter: () => rateLimiter
|
36
|
+
});
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
38
|
+
|
39
|
+
// src/redisClient.ts
|
40
|
+
var import_ioredis = __toESM(require("ioredis"), 1);
|
41
|
+
var DEFAULT_REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
42
|
+
var redis = new import_ioredis.default(DEFAULT_REDIS_URL, {
|
43
|
+
tls: process.env.REDIS_TLS === "true" ? {} : void 0,
|
44
|
+
maxRetriesPerRequest: 0
|
45
|
+
});
|
46
|
+
redis.on("error", (err) => console.error("Redis error:", err));
|
47
|
+
redis.defineCommand("tokenBucket", {
|
48
|
+
numberOfKeys: 1,
|
49
|
+
lua: `local key=KEYS[1]
|
50
|
+
local capacity=tonumber(ARGV[1])
|
51
|
+
local refill_rate=tonumber(ARGV[2])
|
52
|
+
local now=tonumber(ARGV[3])
|
53
|
+
local tokens=tonumber(redis.call("HGET",key,"tokens") or capacity)
|
54
|
+
local last=tonumber(redis.call("HGET",key,"last") or now)
|
55
|
+
local delta=now-last
|
56
|
+
local filled=math.min(capacity, tokens + delta * refill_rate)
|
57
|
+
local allowed = 0
|
58
|
+
if filled >= 1 then allowed = 1; filled = filled - 1 end
|
59
|
+
redis.call("HMSET", key, "tokens", filled, "last", now)
|
60
|
+
redis.call("PEXPIRE", key, math.ceil(capacity/refill_rate))
|
61
|
+
local overuse = allowed == 0 and math.ceil(1 - filled) or 0
|
62
|
+
return {allowed, filled, overuse}`
|
63
|
+
});
|
64
|
+
|
65
|
+
// src/strategies/identifier.ts
|
66
|
+
var identifierStrategies = {
|
67
|
+
byIP: () => (req) => `ip:${req.ip}`,
|
68
|
+
byHeader: (name) => (req) => `hdr:${name}:${req.header(name) || "none"}`
|
69
|
+
};
|
70
|
+
|
71
|
+
// src/strategies/backoff.ts
|
72
|
+
var backoffStrategies = {
|
73
|
+
none: () => 0,
|
74
|
+
fixed: (ms) => () => ms,
|
75
|
+
linear: (base, max = 5e3) => (overuse) => Math.min(max, base * overuse),
|
76
|
+
exponential: (base, max = 5e3) => (overuse) => Math.min(max, base * Math.pow(2, overuse))
|
77
|
+
};
|
78
|
+
|
79
|
+
// src/utils.ts
|
80
|
+
function delay(ms) {
|
81
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
82
|
+
}
|
83
|
+
|
84
|
+
// src/rateLimiter.ts
|
85
|
+
function rateLimiter(config) {
|
86
|
+
const {
|
87
|
+
timeWindowSeconds,
|
88
|
+
requestCount,
|
89
|
+
exceptions = [],
|
90
|
+
allowlist = [],
|
91
|
+
blocklist = [],
|
92
|
+
getIdentifier = identifierStrategies.byIP(),
|
93
|
+
backoffStrategy = backoffStrategies.exponential(200)
|
94
|
+
} = config;
|
95
|
+
return async (req, res, next) => {
|
96
|
+
const userKey = getIdentifier(req);
|
97
|
+
if (blocklist.includes(userKey)) return res.status(403).send("Forbidden");
|
98
|
+
if (allowlist.includes(userKey)) return next();
|
99
|
+
const rule = exceptions.find((ex) => ex.route === req.path);
|
100
|
+
const capacity = rule?.count ?? requestCount;
|
101
|
+
const refillRate = capacity / (timeWindowSeconds * 1e3);
|
102
|
+
try {
|
103
|
+
const [allowed, tokensLeft, overuse] = await redis.tokenBucket(
|
104
|
+
`lim:${userKey}:${req.path}`,
|
105
|
+
capacity,
|
106
|
+
refillRate,
|
107
|
+
Date.now()
|
108
|
+
);
|
109
|
+
res.setHeader("X-RateLimit-Limit", capacity.toString());
|
110
|
+
res.setHeader("X-RateLimit-Remaining", Math.floor(tokensLeft).toString());
|
111
|
+
if (allowed) return next();
|
112
|
+
const wait = backoffStrategy(overuse);
|
113
|
+
await delay(wait);
|
114
|
+
return res.status(429).send(`Too Many Requests \u2014 delayed ${wait}ms`);
|
115
|
+
} catch (err) {
|
116
|
+
console.error("Reflo error:", err);
|
117
|
+
return res.status(503).send("Rate limit service unavailable");
|
118
|
+
}
|
119
|
+
};
|
120
|
+
}
|
121
|
+
// Annotate the CommonJS export names for ESM import in node:
|
122
|
+
0 && (module.exports = {
|
123
|
+
backoffStrategies,
|
124
|
+
identifierStrategies,
|
125
|
+
rateLimiter
|
126
|
+
});
|
package/dist/index.d.cts
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
2
|
+
|
3
|
+
interface ExceptionRule {
|
4
|
+
route: string;
|
5
|
+
count: number;
|
6
|
+
}
|
7
|
+
interface LimiterConfig {
|
8
|
+
timeWindowSeconds: number;
|
9
|
+
requestCount: number;
|
10
|
+
exceptions?: ExceptionRule[];
|
11
|
+
allowlist?: string[];
|
12
|
+
blocklist?: string[];
|
13
|
+
getIdentifier?: (req: Request) => string;
|
14
|
+
backoffStrategy?: (overuse: number) => number;
|
15
|
+
}
|
16
|
+
type ExpressHandler = (req: Request, res: Response, next: NextFunction) => void;
|
17
|
+
|
18
|
+
declare function rateLimiter(config: LimiterConfig): ExpressHandler;
|
19
|
+
|
20
|
+
declare const identifierStrategies: {
|
21
|
+
byIP: () => (req: Request) => string;
|
22
|
+
byHeader: (name: string) => (req: Request) => string;
|
23
|
+
};
|
24
|
+
|
25
|
+
declare const backoffStrategies: {
|
26
|
+
none: () => number;
|
27
|
+
fixed: (ms: number) => () => number;
|
28
|
+
linear: (base: number, max?: number) => (overuse: number) => number;
|
29
|
+
exponential: (base: number, max?: number) => (overuse: number) => number;
|
30
|
+
};
|
31
|
+
|
32
|
+
export { type ExceptionRule, type ExpressHandler, type LimiterConfig, backoffStrategies, identifierStrategies, rateLimiter };
|
package/dist/index.d.ts
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
2
|
+
|
3
|
+
interface ExceptionRule {
|
4
|
+
route: string;
|
5
|
+
count: number;
|
6
|
+
}
|
7
|
+
interface LimiterConfig {
|
8
|
+
timeWindowSeconds: number;
|
9
|
+
requestCount: number;
|
10
|
+
exceptions?: ExceptionRule[];
|
11
|
+
allowlist?: string[];
|
12
|
+
blocklist?: string[];
|
13
|
+
getIdentifier?: (req: Request) => string;
|
14
|
+
backoffStrategy?: (overuse: number) => number;
|
15
|
+
}
|
16
|
+
type ExpressHandler = (req: Request, res: Response, next: NextFunction) => void;
|
17
|
+
|
18
|
+
declare function rateLimiter(config: LimiterConfig): ExpressHandler;
|
19
|
+
|
20
|
+
declare const identifierStrategies: {
|
21
|
+
byIP: () => (req: Request) => string;
|
22
|
+
byHeader: (name: string) => (req: Request) => string;
|
23
|
+
};
|
24
|
+
|
25
|
+
declare const backoffStrategies: {
|
26
|
+
none: () => number;
|
27
|
+
fixed: (ms: number) => () => number;
|
28
|
+
linear: (base: number, max?: number) => (overuse: number) => number;
|
29
|
+
exponential: (base: number, max?: number) => (overuse: number) => number;
|
30
|
+
};
|
31
|
+
|
32
|
+
export { type ExceptionRule, type ExpressHandler, type LimiterConfig, backoffStrategies, identifierStrategies, rateLimiter };
|
package/dist/index.js
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
// src/redisClient.ts
|
2
|
+
import Redis from "ioredis";
|
3
|
+
var DEFAULT_REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
4
|
+
var redis = new Redis(DEFAULT_REDIS_URL, {
|
5
|
+
tls: process.env.REDIS_TLS === "true" ? {} : void 0,
|
6
|
+
maxRetriesPerRequest: 0
|
7
|
+
});
|
8
|
+
redis.on("error", (err) => console.error("Redis error:", err));
|
9
|
+
redis.defineCommand("tokenBucket", {
|
10
|
+
numberOfKeys: 1,
|
11
|
+
lua: `local key=KEYS[1]
|
12
|
+
local capacity=tonumber(ARGV[1])
|
13
|
+
local refill_rate=tonumber(ARGV[2])
|
14
|
+
local now=tonumber(ARGV[3])
|
15
|
+
local tokens=tonumber(redis.call("HGET",key,"tokens") or capacity)
|
16
|
+
local last=tonumber(redis.call("HGET",key,"last") or now)
|
17
|
+
local delta=now-last
|
18
|
+
local filled=math.min(capacity, tokens + delta * refill_rate)
|
19
|
+
local allowed = 0
|
20
|
+
if filled >= 1 then allowed = 1; filled = filled - 1 end
|
21
|
+
redis.call("HMSET", key, "tokens", filled, "last", now)
|
22
|
+
redis.call("PEXPIRE", key, math.ceil(capacity/refill_rate))
|
23
|
+
local overuse = allowed == 0 and math.ceil(1 - filled) or 0
|
24
|
+
return {allowed, filled, overuse}`
|
25
|
+
});
|
26
|
+
|
27
|
+
// src/strategies/identifier.ts
|
28
|
+
var identifierStrategies = {
|
29
|
+
byIP: () => (req) => `ip:${req.ip}`,
|
30
|
+
byHeader: (name) => (req) => `hdr:${name}:${req.header(name) || "none"}`
|
31
|
+
};
|
32
|
+
|
33
|
+
// src/strategies/backoff.ts
|
34
|
+
var backoffStrategies = {
|
35
|
+
none: () => 0,
|
36
|
+
fixed: (ms) => () => ms,
|
37
|
+
linear: (base, max = 5e3) => (overuse) => Math.min(max, base * overuse),
|
38
|
+
exponential: (base, max = 5e3) => (overuse) => Math.min(max, base * Math.pow(2, overuse))
|
39
|
+
};
|
40
|
+
|
41
|
+
// src/utils.ts
|
42
|
+
function delay(ms) {
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
44
|
+
}
|
45
|
+
|
46
|
+
// src/rateLimiter.ts
|
47
|
+
function rateLimiter(config) {
|
48
|
+
const {
|
49
|
+
timeWindowSeconds,
|
50
|
+
requestCount,
|
51
|
+
exceptions = [],
|
52
|
+
allowlist = [],
|
53
|
+
blocklist = [],
|
54
|
+
getIdentifier = identifierStrategies.byIP(),
|
55
|
+
backoffStrategy = backoffStrategies.exponential(200)
|
56
|
+
} = config;
|
57
|
+
return async (req, res, next) => {
|
58
|
+
const userKey = getIdentifier(req);
|
59
|
+
if (blocklist.includes(userKey)) return res.status(403).send("Forbidden");
|
60
|
+
if (allowlist.includes(userKey)) return next();
|
61
|
+
const rule = exceptions.find((ex) => ex.route === req.path);
|
62
|
+
const capacity = rule?.count ?? requestCount;
|
63
|
+
const refillRate = capacity / (timeWindowSeconds * 1e3);
|
64
|
+
try {
|
65
|
+
const [allowed, tokensLeft, overuse] = await redis.tokenBucket(
|
66
|
+
`lim:${userKey}:${req.path}`,
|
67
|
+
capacity,
|
68
|
+
refillRate,
|
69
|
+
Date.now()
|
70
|
+
);
|
71
|
+
res.setHeader("X-RateLimit-Limit", capacity.toString());
|
72
|
+
res.setHeader("X-RateLimit-Remaining", Math.floor(tokensLeft).toString());
|
73
|
+
if (allowed) return next();
|
74
|
+
const wait = backoffStrategy(overuse);
|
75
|
+
await delay(wait);
|
76
|
+
return res.status(429).send(`Too Many Requests \u2014 delayed ${wait}ms`);
|
77
|
+
} catch (err) {
|
78
|
+
console.error("Reflo error:", err);
|
79
|
+
return res.status(503).send("Rate limit service unavailable");
|
80
|
+
}
|
81
|
+
};
|
82
|
+
}
|
83
|
+
export {
|
84
|
+
backoffStrategies,
|
85
|
+
identifierStrategies,
|
86
|
+
rateLimiter
|
87
|
+
};
|
package/jest.config.cjs
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
/** @type {import('jest').Config} */
|
2
|
+
module.exports = {
|
3
|
+
preset: 'ts-jest/presets/default', // or 'ts-jest/presets/js-with-ts'
|
4
|
+
testEnvironment: 'node',
|
5
|
+
testMatch: ['**/test/**/*.spec.ts'],
|
6
|
+
transform: {
|
7
|
+
'^.+\\.ts$': ['ts-jest', {
|
8
|
+
tsconfig: 'tsconfig.json',
|
9
|
+
}],
|
10
|
+
},
|
11
|
+
};
|
12
|
+
|
13
|
+
|
package/package.json
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
{
|
2
|
+
"name": "reflo",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "A modular, Redis-backed rate limiting middleware for Express with pluggable strategies and exponential backoff.",
|
5
|
+
"main": "dist/index.cjs",
|
6
|
+
"module": "dist/index.mjs",
|
7
|
+
"types": "dist/index.d.ts",
|
8
|
+
"type": "module",
|
9
|
+
"keywords": [
|
10
|
+
"rate-limiter",
|
11
|
+
"express",
|
12
|
+
"redis",
|
13
|
+
"middleware",
|
14
|
+
"reflo"
|
15
|
+
],
|
16
|
+
"repository": {
|
17
|
+
"type": "git",
|
18
|
+
"url": "git+https://github.com/theanuragshukla/reflo.git"
|
19
|
+
},
|
20
|
+
"author": "Anurag Shukla(theanuragshukla)",
|
21
|
+
"license": "MIT",
|
22
|
+
"scripts": {
|
23
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
24
|
+
"test": "jest",
|
25
|
+
"test:coverage": "jest --coverage",
|
26
|
+
"lint": "eslint .",
|
27
|
+
"prepare": "npm run build"
|
28
|
+
},
|
29
|
+
"dependencies": {
|
30
|
+
"express": "^4.18.0",
|
31
|
+
"ioredis": "^5.0.0"
|
32
|
+
},
|
33
|
+
"devDependencies": {
|
34
|
+
"@semantic-release/changelog": "^6.0.3",
|
35
|
+
"@semantic-release/git": "^10.0.1",
|
36
|
+
"@semantic-release/github": "^11.0.3",
|
37
|
+
"@semantic-release/npm": "^12.0.2",
|
38
|
+
"@types/express": "^4.17.0",
|
39
|
+
"@types/jest": "^29.5.14",
|
40
|
+
"@types/supertest": "^6.0.3",
|
41
|
+
"jest": "^29.7.0",
|
42
|
+
"semantic-release": "^24.2.6",
|
43
|
+
"supertest": "^7.1.1",
|
44
|
+
"ts-jest": "^29.4.0",
|
45
|
+
"tsup": "^8.5.0",
|
46
|
+
"typescript": "^5.0.0"
|
47
|
+
},
|
48
|
+
"bugs": {
|
49
|
+
"url": "https://github.com/theanuragshukla/reflo/issues"
|
50
|
+
},
|
51
|
+
"homepage": "https://github.com/theanuragshukla/reflo#readme"
|
52
|
+
}
|
package/src/dump.rdb
ADDED
Binary file
|
package/src/index.ts
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
import type { ExpressHandler, LimiterConfig } from "./types";
|
2
|
+
import { redis } from "./redisClient";
|
3
|
+
import { identifierStrategies } from "./strategies/identifier";
|
4
|
+
import { backoffStrategies } from "./strategies/backoff";
|
5
|
+
import { delay } from "./utils";
|
6
|
+
|
7
|
+
export function rateLimiter(config: LimiterConfig): ExpressHandler {
|
8
|
+
const {
|
9
|
+
timeWindowSeconds,
|
10
|
+
requestCount,
|
11
|
+
exceptions = [],
|
12
|
+
allowlist = [],
|
13
|
+
blocklist = [],
|
14
|
+
getIdentifier = identifierStrategies.byIP(),
|
15
|
+
backoffStrategy = backoffStrategies.exponential(200),
|
16
|
+
} = config;
|
17
|
+
|
18
|
+
return async (req, res, next) => {
|
19
|
+
const userKey = getIdentifier(req);
|
20
|
+
if (blocklist.includes(userKey)) return res.status(403).send("Forbidden");
|
21
|
+
if (allowlist.includes(userKey)) return next();
|
22
|
+
|
23
|
+
const rule = exceptions.find((ex) => ex.route === req.path);
|
24
|
+
const capacity = rule?.count ?? requestCount;
|
25
|
+
const refillRate = capacity / (timeWindowSeconds * 1000);
|
26
|
+
|
27
|
+
try {
|
28
|
+
const [allowed, tokensLeft, overuse] = (await (redis as any).tokenBucket(
|
29
|
+
`lim:${userKey}:${req.path}`,
|
30
|
+
capacity,
|
31
|
+
refillRate,
|
32
|
+
Date.now(),
|
33
|
+
)) as [0 | 1, number, number];
|
34
|
+
|
35
|
+
res.setHeader("X-RateLimit-Limit", capacity.toString());
|
36
|
+
res.setHeader("X-RateLimit-Remaining", Math.floor(tokensLeft).toString());
|
37
|
+
|
38
|
+
if (allowed) return next();
|
39
|
+
|
40
|
+
const wait = backoffStrategy(overuse);
|
41
|
+
await delay(wait);
|
42
|
+
return res.status(429).send(`Too Many Requests — delayed ${wait}ms`);
|
43
|
+
} catch (err) {
|
44
|
+
console.error("Reflo error:", err);
|
45
|
+
return res.status(503).send("Rate limit service unavailable");
|
46
|
+
}
|
47
|
+
};
|
48
|
+
}
|
49
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
import Redis from "ioredis";
|
2
|
+
|
3
|
+
const DEFAULT_REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
4
|
+
export const redis = new Redis(DEFAULT_REDIS_URL, {
|
5
|
+
tls: process.env.REDIS_TLS === "true" ? {} : undefined,
|
6
|
+
maxRetriesPerRequest: 0,
|
7
|
+
});
|
8
|
+
|
9
|
+
redis.on("error", (err) => console.error("Redis error:", err));
|
10
|
+
|
11
|
+
redis.defineCommand("tokenBucket", {
|
12
|
+
numberOfKeys: 1,
|
13
|
+
lua: `local key=KEYS[1]
|
14
|
+
local capacity=tonumber(ARGV[1])
|
15
|
+
local refill_rate=tonumber(ARGV[2])
|
16
|
+
local now=tonumber(ARGV[3])
|
17
|
+
local tokens=tonumber(redis.call("HGET",key,"tokens") or capacity)
|
18
|
+
local last=tonumber(redis.call("HGET",key,"last") or now)
|
19
|
+
local delta=now-last
|
20
|
+
local filled=math.min(capacity, tokens + delta * refill_rate)
|
21
|
+
local allowed = 0
|
22
|
+
if filled >= 1 then allowed = 1; filled = filled - 1 end
|
23
|
+
redis.call("HMSET", key, "tokens", filled, "last", now)
|
24
|
+
redis.call("PEXPIRE", key, math.ceil(capacity/refill_rate))
|
25
|
+
local overuse = allowed == 0 and math.ceil(1 - filled) or 0
|
26
|
+
return {allowed, filled, overuse}`,
|
27
|
+
});
|
@@ -0,0 +1,12 @@
|
|
1
|
+
export const backoffStrategies = {
|
2
|
+
none: () => 0,
|
3
|
+
fixed: (ms: number) => () => ms,
|
4
|
+
linear:
|
5
|
+
(base: number, max = 5000) =>
|
6
|
+
(overuse: number) =>
|
7
|
+
Math.min(max, base * overuse),
|
8
|
+
exponential:
|
9
|
+
(base: number, max = 5000) =>
|
10
|
+
(overuse: number) =>
|
11
|
+
Math.min(max, base * Math.pow(2, overuse)),
|
12
|
+
};
|
package/src/types.ts
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
2
|
+
|
3
|
+
export interface ExceptionRule {
|
4
|
+
route: string;
|
5
|
+
count: number;
|
6
|
+
}
|
7
|
+
|
8
|
+
export interface LimiterConfig {
|
9
|
+
timeWindowSeconds: number;
|
10
|
+
requestCount: number;
|
11
|
+
exceptions?: ExceptionRule[];
|
12
|
+
allowlist?: string[];
|
13
|
+
blocklist?: string[];
|
14
|
+
getIdentifier?: (req: Request) => string;
|
15
|
+
backoffStrategy?: (overuse: number) => number;
|
16
|
+
}
|
17
|
+
|
18
|
+
export type ExpressHandler = (
|
19
|
+
req: Request,
|
20
|
+
res: Response,
|
21
|
+
next: NextFunction
|
22
|
+
) => void;
|
package/src/utils.ts
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
import express from "express";
|
2
|
+
import request from "supertest";
|
3
|
+
import { rateLimiter } from "../src/rateLimiter";
|
4
|
+
import { identifierStrategies, backoffStrategies } from "../src/strategies";
|
5
|
+
import { redis } from "../src/redisClient";
|
6
|
+
import { normalizeIP } from "../src/utils";
|
7
|
+
|
8
|
+
const createApp = (overrides = {}) => {
|
9
|
+
const app = express();
|
10
|
+
app.use(express.json());
|
11
|
+
|
12
|
+
app.use(
|
13
|
+
rateLimiter({
|
14
|
+
timeWindowSeconds: 1,
|
15
|
+
requestCount: 2,
|
16
|
+
getIdentifier: identifierStrategies.byHeader("x-test-id"),
|
17
|
+
backoffStrategy: backoffStrategies.none,
|
18
|
+
...overrides,
|
19
|
+
})
|
20
|
+
);
|
21
|
+
|
22
|
+
app.get("/", (req, res) => res.send("ok"));
|
23
|
+
return app;
|
24
|
+
};
|
25
|
+
|
26
|
+
beforeEach(async () => {
|
27
|
+
await redis.flushall();
|
28
|
+
});
|
29
|
+
|
30
|
+
afterAll(async () => {
|
31
|
+
await redis.quit();
|
32
|
+
});
|
33
|
+
|
34
|
+
describe("rateLimiter core behavior", () => {
|
35
|
+
it("allows up to limit then 429", async () => {
|
36
|
+
const app = createApp();
|
37
|
+
|
38
|
+
const req = () => request(app).get("/").set("x-test-id", "core-1");
|
39
|
+
|
40
|
+
await req().expect(200);
|
41
|
+
await req().expect(200);
|
42
|
+
await req().expect(429);
|
43
|
+
});
|
44
|
+
|
45
|
+
it("respects allowlist", async () => {
|
46
|
+
const id = "allow-1";
|
47
|
+
const app = createApp({
|
48
|
+
allowlist: [`hdr:x-test-id:${id}`],
|
49
|
+
});
|
50
|
+
|
51
|
+
const req = () => request(app).get("/").set("x-test-id", id);
|
52
|
+
await req().expect(200);
|
53
|
+
await req().expect(200);
|
54
|
+
await req().expect(200); // still allowed
|
55
|
+
});
|
56
|
+
|
57
|
+
it("respects blocklist", async () => {
|
58
|
+
const id = "block-1";
|
59
|
+
const app = createApp({
|
60
|
+
blocklist: [`hdr:x-test-id:${id}`],
|
61
|
+
});
|
62
|
+
|
63
|
+
const req = () => request(app).get("/").set("x-test-id", id);
|
64
|
+
await req().expect(403);
|
65
|
+
});
|
66
|
+
});
|
67
|
+
|
68
|
+
describe("identifierStrategies", () => {
|
69
|
+
it("identifies by header", async () => {
|
70
|
+
const app = createApp();
|
71
|
+
|
72
|
+
const req = () => request(app).get("/").set("x-test-id", "hdr-1");
|
73
|
+
|
74
|
+
await req().expect(200);
|
75
|
+
await req().expect(200);
|
76
|
+
await req().expect(429);
|
77
|
+
});
|
78
|
+
});
|
79
|
+
|
80
|
+
describe("backoffStrategies", () => {
|
81
|
+
const measureDelay = async (reqFn: () => Promise<request.Response>): Promise<number> => {
|
82
|
+
const start = Date.now();
|
83
|
+
await reqFn();
|
84
|
+
return Date.now() - start;
|
85
|
+
};
|
86
|
+
|
87
|
+
it("uses fixed backoff", async () => {
|
88
|
+
const id = "fixed-1";
|
89
|
+
const app = createApp({
|
90
|
+
backoffStrategy: backoffStrategies.fixed(100),
|
91
|
+
});
|
92
|
+
|
93
|
+
const req = () => request(app).get("/").set("x-test-id", id);
|
94
|
+
|
95
|
+
await req();
|
96
|
+
await req();
|
97
|
+
const elapsed = await measureDelay(req);
|
98
|
+
expect(elapsed).toBeGreaterThanOrEqual(90);
|
99
|
+
});
|
100
|
+
|
101
|
+
it("uses linear backoff", async () => {
|
102
|
+
const id = "linear-1";
|
103
|
+
const app = createApp({
|
104
|
+
backoffStrategy: backoffStrategies.linear(50),
|
105
|
+
});
|
106
|
+
|
107
|
+
const req = () => request(app).get("/").set("x-test-id", id);
|
108
|
+
|
109
|
+
await req();
|
110
|
+
await req();
|
111
|
+
const elapsed = await measureDelay(req);
|
112
|
+
expect(elapsed).toBeGreaterThanOrEqual(40);
|
113
|
+
});
|
114
|
+
|
115
|
+
it("uses exponential backoff", async () => {
|
116
|
+
const id = "exp-1";
|
117
|
+
const app = createApp({
|
118
|
+
backoffStrategy: backoffStrategies.exponential(60),
|
119
|
+
});
|
120
|
+
|
121
|
+
const req = () => request(app).get("/").set("x-test-id", id);
|
122
|
+
|
123
|
+
await req();
|
124
|
+
await req();
|
125
|
+
const elapsed = await measureDelay(req);
|
126
|
+
expect(elapsed).toBeGreaterThanOrEqual(60);
|
127
|
+
});
|
128
|
+
});
|
129
|
+
|
package/tsconfig.json
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"esModuleInterop": true,
|
4
|
+
"module": "ESNext",
|
5
|
+
"target": "ES2020",
|
6
|
+
"moduleResolution": "node",
|
7
|
+
"strict": true,
|
8
|
+
"resolveJsonModule": true,
|
9
|
+
"outDir": "./dist",
|
10
|
+
"baseUrl": "./src",
|
11
|
+
"types": ["node", "jest"]
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|