@roneysilva25/test 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 +2 -0
- package/package.json +34 -0
- package/src/algorithms/Algorithm.interface.ts +17 -0
- package/src/algorithms/FixedWindow.interfaces.ts +8 -0
- package/src/algorithms/FixedWindow.ts +50 -0
- package/src/controllers/RateLimiterController.interfaces.ts +34 -0
- package/src/controllers/RateLimiterController.ts +20 -0
- package/src/db/Storage.interfaces.ts +8 -0
- package/src/db/Storage.ts +26 -0
- package/src/factories/AlgorithmFactory.interfaces.ts +17 -0
- package/src/factories/AlgorithmFactory.ts +14 -0
- package/src/index.ts +61 -0
- package/src/interfaces.ts +12 -0
- package/src/test/FixedWindow.test.ts +121 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roneysilva25/test",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Implementation of the most common rate limiting algorithms: Fixed Window, Sliding Window, Leaky Bucket, Token Bucket. To be used as middleware for express applications.",
|
|
5
|
+
"homepage": "https://github.com/roneysilva25/rate-limiter#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/roneysilva25/rate-limiter/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/roneysilva25/rate-limiter.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"author": "Roney Silva <roney.diego000@gmail.com> (https://roneysilva25.github.io/portfolio)",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./index.js",
|
|
17
|
+
"files": ["dist", "src"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "npx jest --watch",
|
|
20
|
+
"clean": "rm -rf ./dist",
|
|
21
|
+
"prebuild": "npm run clean",
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"preversion": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@swc/core": "^1.15.11",
|
|
27
|
+
"@swc/jest": "^0.2.39",
|
|
28
|
+
"@types/express": "^5.0.6",
|
|
29
|
+
"@types/jest": "^30.0.0",
|
|
30
|
+
"jest": "^30.2.0",
|
|
31
|
+
"ts-node-dev": "^2.0.0",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Storage } from "../db/Storage.js";
|
|
2
|
+
import type { PacketPayload } from "./FixedWindow.interfaces.js";
|
|
3
|
+
|
|
4
|
+
export interface HandleArgs {
|
|
5
|
+
packetKey: string;
|
|
6
|
+
forwardCb: (packetInfo: PacketPayload) => void;
|
|
7
|
+
dropCb:(packetInfo: PacketPayload) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Algorithm {
|
|
11
|
+
readonly storage: typeof Storage,
|
|
12
|
+
capacity: number;
|
|
13
|
+
timeWindowInMs: number;
|
|
14
|
+
handle: (args: HandleArgs) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AlgorithmConstructorArgs = Omit<Algorithm, "handle">;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Storage } from "../db/Storage.js";
|
|
2
|
+
import type { PacketPayload } from "./FixedWindow.interfaces.js";
|
|
3
|
+
import type { HandleArgs, Algorithm, AlgorithmConstructorArgs } from "./Algorithm.interface.js";
|
|
4
|
+
|
|
5
|
+
export class FixedWindow implements Algorithm {
|
|
6
|
+
capacity: number;
|
|
7
|
+
timeWindowInMs: number;
|
|
8
|
+
storage: typeof Storage;
|
|
9
|
+
|
|
10
|
+
constructor({
|
|
11
|
+
capacity,
|
|
12
|
+
timeWindowInMs,
|
|
13
|
+
storage,
|
|
14
|
+
}: AlgorithmConstructorArgs) {
|
|
15
|
+
this.capacity = capacity;
|
|
16
|
+
this.timeWindowInMs = timeWindowInMs;
|
|
17
|
+
this.storage = storage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
handle({ packetKey, forwardCb, dropCb }: HandleArgs) {
|
|
21
|
+
const existingPacket = this.storage.retrieve<PacketPayload>(packetKey);
|
|
22
|
+
const currentTime = new Date().getTime();
|
|
23
|
+
|
|
24
|
+
if (!existingPacket || currentTime - existingPacket.createdAt >= this.timeWindowInMs) {
|
|
25
|
+
const createdPacket = this.storage.store<PacketPayload>({
|
|
26
|
+
key: packetKey,
|
|
27
|
+
payload: {
|
|
28
|
+
createdAt: currentTime,
|
|
29
|
+
allowance: this.capacity - 1,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return forwardCb(createdPacket);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (existingPacket.allowance - 1 >= 0) {
|
|
37
|
+
const updatedPacket = this.storage.store<PacketPayload>({
|
|
38
|
+
key: packetKey,
|
|
39
|
+
payload: {
|
|
40
|
+
createdAt: existingPacket.createdAt,
|
|
41
|
+
allowance: existingPacket.allowance - 1,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return forwardCb(updatedPacket);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return dropCb(existingPacket);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PacketPayload } from "../algorithms/FixedWindow.interfaces.js";
|
|
2
|
+
import type { NextFunction, Response, Request, } from "express";
|
|
3
|
+
|
|
4
|
+
interface LimiterInfo {
|
|
5
|
+
capacity: number;
|
|
6
|
+
timeWindowInMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ConfigResponseHeaders {
|
|
10
|
+
res: Response;
|
|
11
|
+
packetInfo: PacketPayload;
|
|
12
|
+
limiterInfo: LimiterInfo;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DropArgs {
|
|
16
|
+
req: Request;
|
|
17
|
+
res: Response;
|
|
18
|
+
packetInfo: PacketPayload;
|
|
19
|
+
limiterInfo: LimiterInfo;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ForwardArgs {
|
|
23
|
+
req: Request;
|
|
24
|
+
res: Response;
|
|
25
|
+
next: NextFunction;
|
|
26
|
+
packetInfo: PacketPayload;
|
|
27
|
+
limiterInfo: LimiterInfo;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type {
|
|
31
|
+
ConfigResponseHeaders,
|
|
32
|
+
DropArgs,
|
|
33
|
+
ForwardArgs,
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ConfigResponseHeaders, DropArgs, ForwardArgs } from "./RateLimiterController.interfaces.js";
|
|
2
|
+
|
|
3
|
+
export class RateLimiterController {
|
|
4
|
+
private configResponseHeaders({ limiterInfo, packetInfo, res }: ConfigResponseHeaders) {
|
|
5
|
+
const resetsIn = limiterInfo.timeWindowInMs + packetInfo.createdAt - new Date().getTime();
|
|
6
|
+
res.setHeader("X-RateLimit-Limit", limiterInfo.capacity);
|
|
7
|
+
res.setHeader("X-RateLimit-Remaining", packetInfo.allowance);
|
|
8
|
+
res.setHeader("X-RateLimit-Reset", resetsIn);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public drop({ res, req, packetInfo, limiterInfo }: DropArgs) {
|
|
12
|
+
this.configResponseHeaders({ res, limiterInfo, packetInfo });
|
|
13
|
+
return res.status(429).send();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public forward({ req, res, next, packetInfo, limiterInfo }: ForwardArgs) {
|
|
17
|
+
this.configResponseHeaders({ res, limiterInfo, packetInfo });
|
|
18
|
+
next();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StoreArgs } from "./Storage.interfaces.js";
|
|
2
|
+
|
|
3
|
+
export class Storage {
|
|
4
|
+
private static instance: Storage;
|
|
5
|
+
private storage: Map<string, any>;
|
|
6
|
+
|
|
7
|
+
private constructor() {
|
|
8
|
+
this.storage = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private static getInstance(): Storage {
|
|
12
|
+
if (!this.instance) {
|
|
13
|
+
this.instance = new Storage();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return this.instance;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public static store<Payload>({ key, payload }: StoreArgs<Payload>): Payload {
|
|
20
|
+
return this.getInstance().storage.set(key, payload).get(key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static retrieve<Payload>(key: string): Payload | undefined {
|
|
24
|
+
return this.getInstance().storage.get(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Algorithm, AlgorithmConstructorArgs } from "../algorithms/Algorithm.interface.js";
|
|
2
|
+
|
|
3
|
+
type Algorithms = "fixed_window";
|
|
4
|
+
|
|
5
|
+
interface GetArgs {
|
|
6
|
+
algorithm: Algorithms;
|
|
7
|
+
config: AlgorithmConstructorArgs;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IAlgorithmFactory {
|
|
11
|
+
get(args: GetArgs): Algorithm;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
type IAlgorithmFactory,
|
|
16
|
+
type GetArgs,
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { FixedWindow } from "../algorithms/FixedWindow.js";
|
|
2
|
+
import type { Algorithm } from "../algorithms/Algorithm.interface.js";
|
|
3
|
+
import type { GetArgs, IAlgorithmFactory } from "./AlgorithmFactory.interfaces.js";
|
|
4
|
+
|
|
5
|
+
export class AlgorithmFactory implements IAlgorithmFactory {
|
|
6
|
+
public get({ algorithm, config }: GetArgs): Algorithm {
|
|
7
|
+
switch (algorithm) {
|
|
8
|
+
case "fixed_window":
|
|
9
|
+
return new FixedWindow(config);
|
|
10
|
+
default:
|
|
11
|
+
throw new Error(`Unknown algorithm: ${algorithm}.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { Storage } from "./db/Storage.js";
|
|
3
|
+
import { RateLimiterController } from "./controllers/RateLimiterController.js";
|
|
4
|
+
import { AlgorithmFactory } from "./factories/AlgorithmFactory.js";
|
|
5
|
+
import type { RateLimiterArgs } from "./interfaces.js";
|
|
6
|
+
|
|
7
|
+
function validateIP(req: Request, res: Response) {
|
|
8
|
+
if (!req.ip) {
|
|
9
|
+
return res.status(400).send("Invalid IP Address.");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rateLimiter({
|
|
14
|
+
algorithm,
|
|
15
|
+
capacity,
|
|
16
|
+
timeWindowInMs,
|
|
17
|
+
storage = Storage
|
|
18
|
+
}: RateLimiterArgs) {
|
|
19
|
+
const controller = new RateLimiterController();
|
|
20
|
+
const algFactory = new AlgorithmFactory().get({
|
|
21
|
+
algorithm,
|
|
22
|
+
config: {
|
|
23
|
+
timeWindowInMs,
|
|
24
|
+
storage,
|
|
25
|
+
capacity,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
limit(req: Request, res: Response, next: NextFunction) {
|
|
31
|
+
validateIP(req, res);
|
|
32
|
+
|
|
33
|
+
return algFactory.handle({
|
|
34
|
+
packetKey: req.ip as string,
|
|
35
|
+
dropCb: (packetInfo) => controller.drop({
|
|
36
|
+
req,
|
|
37
|
+
res,
|
|
38
|
+
packetInfo,
|
|
39
|
+
limiterInfo: {
|
|
40
|
+
capacity,
|
|
41
|
+
timeWindowInMs,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
forwardCb: (packetInfo) => controller.forward({
|
|
45
|
+
req,
|
|
46
|
+
res,
|
|
47
|
+
next,
|
|
48
|
+
packetInfo,
|
|
49
|
+
limiterInfo: {
|
|
50
|
+
capacity,
|
|
51
|
+
timeWindowInMs,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
rateLimiter,
|
|
61
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { FixedWindow } from "../algorithms/FixedWindow.js";
|
|
2
|
+
|
|
3
|
+
describe("FixedWindow", () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
jest.clearAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const mockGetTime = jest.fn();
|
|
9
|
+
const mockStore = jest.fn();
|
|
10
|
+
const mockRetrieve = jest.fn();
|
|
11
|
+
const mockStorage = {
|
|
12
|
+
store: mockStore,
|
|
13
|
+
retrieve: mockRetrieve,
|
|
14
|
+
}
|
|
15
|
+
const mockDropCb = jest.fn();
|
|
16
|
+
const mockForwardCb = jest.fn();
|
|
17
|
+
|
|
18
|
+
const fixedWindowArgs = {
|
|
19
|
+
capacity: 200,
|
|
20
|
+
timeWindowInMs: 1000*60*2,
|
|
21
|
+
storage: mockStorage,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//@ts-ignore
|
|
25
|
+
const fixedWindow = new FixedWindow(fixedWindowArgs);
|
|
26
|
+
|
|
27
|
+
jest.spyOn(Date.prototype, "getTime").mockImplementation(mockGetTime);
|
|
28
|
+
|
|
29
|
+
it("should create a packet with maximum allowance, the current timestamp and call forwardCb, all if the packet does not exist yet ", () => {
|
|
30
|
+
const createdPacket = {
|
|
31
|
+
key: "packetKey",
|
|
32
|
+
payload: {
|
|
33
|
+
createdAt: 1,
|
|
34
|
+
allowance: fixedWindowArgs.capacity - 1,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
mockGetTime.mockReturnValueOnce(1);
|
|
39
|
+
mockRetrieve.mockReturnValueOnce(undefined);
|
|
40
|
+
mockStore.mockReturnValueOnce(createdPacket);
|
|
41
|
+
|
|
42
|
+
fixedWindow.handle({
|
|
43
|
+
packetKey: "packetKey",
|
|
44
|
+
dropCb: mockDropCb,
|
|
45
|
+
forwardCb: mockForwardCb,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(mockStore).toHaveBeenCalledWith(createdPacket);
|
|
49
|
+
expect(mockForwardCb).toHaveBeenCalledWith(createdPacket);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should update an existing packet with reset allowance and timestamp, and call forwardCb if the time window has passed", () => {
|
|
53
|
+
const resetAllowance = fixedWindowArgs.capacity - 1
|
|
54
|
+
const updatedPacket = {
|
|
55
|
+
createdAt: fixedWindowArgs.timeWindowInMs,
|
|
56
|
+
allowance: resetAllowance,
|
|
57
|
+
};
|
|
58
|
+
const currentTimestamp = fixedWindowArgs.timeWindowInMs;
|
|
59
|
+
|
|
60
|
+
mockGetTime.mockReturnValueOnce(currentTimestamp);
|
|
61
|
+
mockRetrieve.mockReturnValueOnce({
|
|
62
|
+
createdAt: 0,
|
|
63
|
+
allowance: 10,
|
|
64
|
+
});
|
|
65
|
+
mockStore.mockReturnValueOnce(updatedPacket);
|
|
66
|
+
|
|
67
|
+
fixedWindow.handle({
|
|
68
|
+
packetKey: "packetKey",
|
|
69
|
+
dropCb: mockDropCb,
|
|
70
|
+
forwardCb: mockForwardCb,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(mockForwardCb).toHaveBeenCalledWith(updatedPacket);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should update an existing packet, decreasing its allowance by 1 and keeping the same createdAt timestamp, when the allowance has not reached 0 (zero)", () => {
|
|
77
|
+
const foundPacket = {
|
|
78
|
+
createdAt: 10,
|
|
79
|
+
allowance: 4,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const updatedPacket = {
|
|
83
|
+
createdAt: foundPacket.createdAt,
|
|
84
|
+
allowance: 3,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
mockGetTime.mockReturnValueOnce(foundPacket.createdAt + 1);
|
|
88
|
+
mockRetrieve.mockReturnValueOnce(foundPacket);
|
|
89
|
+
mockStore.mockReturnValueOnce(updatedPacket);
|
|
90
|
+
|
|
91
|
+
fixedWindow.handle({
|
|
92
|
+
packetKey: "packetKey",
|
|
93
|
+
dropCb: mockDropCb,
|
|
94
|
+
forwardCb: mockForwardCb,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(mockStore).toHaveBeenCalledWith({
|
|
98
|
+
key: "packetKey",
|
|
99
|
+
payload: updatedPacket,
|
|
100
|
+
});
|
|
101
|
+
expect(mockForwardCb).toHaveBeenCalledWith(updatedPacket);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should call the dropCb when the allowance has already reached zero.", () => {
|
|
105
|
+
const foundPacket = {
|
|
106
|
+
createdAt: 10,
|
|
107
|
+
allowance: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
mockGetTime.mockReturnValueOnce(foundPacket.createdAt + 10);
|
|
111
|
+
mockRetrieve.mockReturnValueOnce(foundPacket);
|
|
112
|
+
|
|
113
|
+
fixedWindow.handle({
|
|
114
|
+
packetKey: "packetKey",
|
|
115
|
+
dropCb: mockDropCb,
|
|
116
|
+
forwardCb: mockForwardCb,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(mockDropCb).toHaveBeenCalledWith(foundPacket);
|
|
120
|
+
});
|
|
121
|
+
});
|