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.
@@ -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
+
@@ -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
@@ -0,0 +1,6 @@
1
+ # 1.0.0 (2025-07-06)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * updated keywords ([88aca71](https://github.com/theanuragshukla/reflo/commit/88aca7177da6d3aa627e2be07f30cfbfca3ca544))
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
+ });
@@ -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 };
@@ -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
+ };
@@ -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,5 @@
1
+ export * from "./rateLimiter";
2
+ export * from "./strategies/identifier";
3
+ export * from "./strategies/backoff";
4
+ export * from "./types";
5
+
@@ -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
+ };
@@ -0,0 +1,7 @@
1
+ import type { Request } from "express";
2
+
3
+ export const identifierStrategies = {
4
+ byIP: () => (req: Request) => `ip:${req.ip}`,
5
+ byHeader: (name: string) => (req: Request) =>
6
+ `hdr:${name}:${req.header(name) || "none"}`,
7
+ };
@@ -0,0 +1,3 @@
1
+ export * from "./identifier";
2
+ export * from "./backoff";
3
+
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,8 @@
1
+ export function normalizeIP(ip: string): string {
2
+ return ip.replace("::ffff:", "").replace("::1", "127.0.0.1");
3
+ }
4
+
5
+ export function delay(ms: number): Promise<void> {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+
@@ -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
+