limitforge 0.0.0-stage → 8.0.1
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/LICENSE +21 -0
- package/README.md +461 -3
- package/index.d.ts +192 -0
- package/package.json +81 -4
- package/src/benchmark/benchmarkRunner.js +235 -0
- package/src/index.js +30 -0
- package/src/limiter/rateLimiter.js +670 -0
- package/src/resilience/circuitBreaker.js +216 -0
- package/src/resilience/errors.js +29 -0
- package/src/resilience/timeoutGuard.js +24 -0
- package/src/scripts/fixedWindow.lua +10 -0
- package/src/scripts/slidingWindow.lua +26 -0
- package/src/scripts/tokenBucket.lua +66 -0
- package/src/stores/memoryStore.js +238 -0
- package/src/stores/redisStore.js +213 -0
- package/src/stores/resilientStore.js +121 -0
- package/src/telemetry/metricsCollector.js +212 -0
- package/src/telemetry/openTelemetryBridge.js +133 -0
- package/src/telemetry/prometheusExporter.js +131 -0
- package/src/telemetry/telemetryEvents.js +58 -0
- package/src/utils/keyBuilder.js +41 -0
- package/src/utils/routeNormalizer.js +49 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
2
|
+
|
|
3
|
+
export type RateLimiterAlgorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
4
|
+
export type FailStrategy = 'fail-open' | 'fail-closed';
|
|
5
|
+
|
|
6
|
+
export interface StoreConsumeParams {
|
|
7
|
+
key: string;
|
|
8
|
+
limit?: number;
|
|
9
|
+
windowMs?: number;
|
|
10
|
+
algorithm?: RateLimiterAlgorithm;
|
|
11
|
+
capacity?: number;
|
|
12
|
+
refillRate?: number;
|
|
13
|
+
refillIntervalMs?: number;
|
|
14
|
+
cost?: number;
|
|
15
|
+
now?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StoreConsumeResult {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
count?: number;
|
|
21
|
+
remaining?: number;
|
|
22
|
+
reset?: number;
|
|
23
|
+
retryAfter?: number;
|
|
24
|
+
degraded?: boolean;
|
|
25
|
+
circuitState?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IRateLimitStore {
|
|
29
|
+
consume(params: StoreConsumeParams): Promise<StoreConsumeResult> | StoreConsumeResult;
|
|
30
|
+
resetKey?(key: string): Promise<void> | void;
|
|
31
|
+
destroy?(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RateLimiterOptions {
|
|
35
|
+
limit?: number | ((req: Request) => number | Promise<number>);
|
|
36
|
+
windowMs?: number | ((req: Request) => number | Promise<number>);
|
|
37
|
+
store?: IRateLimitStore;
|
|
38
|
+
fallbackStore?: boolean | IRateLimitStore;
|
|
39
|
+
algorithm?: RateLimiterAlgorithm;
|
|
40
|
+
capacity?: number | ((req: Request) => number | Promise<number>);
|
|
41
|
+
refillRate?: number | ((req: Request) => number | Promise<number>);
|
|
42
|
+
refillIntervalMs?: number;
|
|
43
|
+
cost?: number | ((req: Request) => number | Promise<number>);
|
|
44
|
+
keyGenerator?: (req: Request) => string | Promise<string>;
|
|
45
|
+
hashClientIdentifier?: boolean | ((identifier: string) => string);
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
failureThreshold?: number;
|
|
48
|
+
resetTimeoutMs?: number;
|
|
49
|
+
failStrategy?: FailStrategy;
|
|
50
|
+
metrics?: boolean | MetricsCollector;
|
|
51
|
+
openTelemetry?: boolean | { tracer?: any; getSpan?: (req: Request) => any };
|
|
52
|
+
onStoreError?: (err: Error, req: Request, res: Response, next: NextFunction) => void;
|
|
53
|
+
statusCode?: number;
|
|
54
|
+
message?: string | object | ((req: Request, res: Response) => any);
|
|
55
|
+
skip?: (req: Request) => boolean | Promise<boolean>;
|
|
56
|
+
skipFailedRequests?: boolean;
|
|
57
|
+
skipSuccessfulRequests?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export declare function rateLimiter(options?: RateLimiterOptions): RequestHandler;
|
|
61
|
+
export default rateLimiter;
|
|
62
|
+
|
|
63
|
+
export declare class MemoryStore implements IRateLimitStore {
|
|
64
|
+
constructor(options?: { cleanupIntervalMs?: number });
|
|
65
|
+
consume(params: StoreConsumeParams): StoreConsumeResult;
|
|
66
|
+
resetKey(key: string): void;
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RedisClientLike {
|
|
71
|
+
eval(script: string, options: { keys: string[]; arguments: (string | number)[] }): Promise<any>;
|
|
72
|
+
sendCommand?(args: string[]): Promise<any>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export declare class RedisStore implements IRateLimitStore {
|
|
76
|
+
constructor(options: { client: RedisClientLike; prefix?: string });
|
|
77
|
+
consume(params: StoreConsumeParams): Promise<StoreConsumeResult>;
|
|
78
|
+
resetKey(key: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CircuitBreakerOptions {
|
|
82
|
+
failureThreshold?: number;
|
|
83
|
+
resetTimeoutMs?: number;
|
|
84
|
+
name?: string;
|
|
85
|
+
breakerId?: string;
|
|
86
|
+
onStateChange?: (oldState: string, newState: string) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export declare class CircuitBreaker {
|
|
90
|
+
constructor(options?: CircuitBreakerOptions);
|
|
91
|
+
execute<T>(action: () => Promise<T>): Promise<T>;
|
|
92
|
+
getState(): 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
93
|
+
trip(): void;
|
|
94
|
+
reset(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export declare class ResilientStore implements IRateLimitStore {
|
|
98
|
+
primaryStore: IRateLimitStore;
|
|
99
|
+
fallbackStore: IRateLimitStore;
|
|
100
|
+
breaker: CircuitBreaker;
|
|
101
|
+
constructor(options: {
|
|
102
|
+
primaryStore: IRateLimitStore;
|
|
103
|
+
fallbackStore?: IRateLimitStore;
|
|
104
|
+
breaker?: CircuitBreaker;
|
|
105
|
+
timeoutMs?: number;
|
|
106
|
+
failureThreshold?: number;
|
|
107
|
+
resetTimeoutMs?: number;
|
|
108
|
+
breakerId?: string;
|
|
109
|
+
});
|
|
110
|
+
consume(params: StoreConsumeParams): Promise<StoreConsumeResult>;
|
|
111
|
+
resetKey(key: string): Promise<void>;
|
|
112
|
+
getStats(): {
|
|
113
|
+
circuitBreaker: { state: string; failureCount: number };
|
|
114
|
+
primaryStore: string;
|
|
115
|
+
fallbackStore: string;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export declare class MetricsCollector {
|
|
120
|
+
constructor(options?: { maxSeries?: number; metricMap?: Map<string, any> });
|
|
121
|
+
recordRequest(params: {
|
|
122
|
+
outcome: 'allowed' | 'blocked';
|
|
123
|
+
normalized_route?: string;
|
|
124
|
+
method?: string;
|
|
125
|
+
status_code?: number;
|
|
126
|
+
store?: string;
|
|
127
|
+
degraded?: boolean;
|
|
128
|
+
}): void;
|
|
129
|
+
recordStoreDuration(params: {
|
|
130
|
+
store: string;
|
|
131
|
+
operation: string;
|
|
132
|
+
outcome: 'success' | 'failure' | 'timeout';
|
|
133
|
+
duration_ms: number;
|
|
134
|
+
}): void;
|
|
135
|
+
recordCircuitBreakerState(params: {
|
|
136
|
+
state: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
137
|
+
breaker?: string;
|
|
138
|
+
}): void;
|
|
139
|
+
getSnapshot(): any;
|
|
140
|
+
reset(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export declare const defaultMetricsCollector: MetricsCollector;
|
|
144
|
+
|
|
145
|
+
export declare class OpenTelemetryBridge {
|
|
146
|
+
constructor(options?: { tracer?: any; getSpan?: (req: Request) => any });
|
|
147
|
+
startSpan(name?: string, options?: any): any;
|
|
148
|
+
resolveSpan(req: Request): any;
|
|
149
|
+
recordEvaluation(params: {
|
|
150
|
+
req: Request;
|
|
151
|
+
result: StoreConsumeResult;
|
|
152
|
+
algorithm?: string;
|
|
153
|
+
normalizedRoute?: string;
|
|
154
|
+
store?: string;
|
|
155
|
+
}): void;
|
|
156
|
+
recordStoreError(params: { req: Request; storeError: Error; store?: string }): void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export declare class StoreTimeoutError extends Error {
|
|
160
|
+
code: string;
|
|
161
|
+
timeoutMs: number;
|
|
162
|
+
constructor(timeoutMs: number, operation?: string);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export declare class CircuitBreakerOpenError extends Error {
|
|
166
|
+
code: string;
|
|
167
|
+
retryAfter: number;
|
|
168
|
+
constructor(message?: string, retryAfter?: number);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export declare const MAX_IDENTIFIER_LENGTH: number;
|
|
172
|
+
|
|
173
|
+
export declare function buildRateLimitKey(params: {
|
|
174
|
+
method?: string;
|
|
175
|
+
route?: string;
|
|
176
|
+
clientIdentifier?: string;
|
|
177
|
+
algorithm?: string;
|
|
178
|
+
prefix?: string;
|
|
179
|
+
hashClientIdentifier?: boolean | ((id: string) => string);
|
|
180
|
+
}): string;
|
|
181
|
+
|
|
182
|
+
export declare function normalizeRoute(path: string): string;
|
|
183
|
+
export declare function sanitizePath(path: string): string;
|
|
184
|
+
export declare function createPrometheusExporter(collector?: MetricsCollector): RequestHandler;
|
|
185
|
+
export declare function formatPrometheusMetrics(snapshot: any): string;
|
|
186
|
+
export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation?: string): Promise<T>;
|
|
187
|
+
export declare function runBenchmark(options?: any): Promise<any>;
|
|
188
|
+
export declare function calculatePercentiles(latencies: bigint[]): any;
|
|
189
|
+
|
|
190
|
+
export declare const METRIC_NAMES: Record<string, string>;
|
|
191
|
+
export declare const METRIC_TYPE: Record<string, string>;
|
|
192
|
+
export declare const CIRCUIT_STATE: Record<string, string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limitforge",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "8.0.1",
|
|
4
|
+
"description": "Production-ready distributed rate limiting and resilience middleware for Express.js supporting Redis Lua, Token Bucket, Sliding Window, Circuit Breakers, and Prometheus metrics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.0.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./index.d.ts",
|
|
14
|
+
"default": "./src/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"index.d.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "node --watch examples/express-demo/server.js",
|
|
25
|
+
"start": "node examples/express-demo/server.js",
|
|
26
|
+
"demo:multi": "node examples/express-demo/multi-instance.js",
|
|
27
|
+
"demo:sliding": "node examples/express-demo/sliding-demo.js",
|
|
28
|
+
"demo:saas": "node examples/express-demo/saas-demo.js",
|
|
29
|
+
"demo:burst": "node examples/express-demo/burst-demo.js",
|
|
30
|
+
"demo:resilience": "node examples/express-demo/resilience-demo.js",
|
|
31
|
+
"demo:observability": "node examples/express-demo/observability-demo.js",
|
|
32
|
+
"demo:stack": "node examples/observability-stack/server.js",
|
|
33
|
+
"demo:simulate": "node examples/observability-stack/simulate-traffic.js",
|
|
34
|
+
"benchmark": "node scripts/benchmark.js",
|
|
35
|
+
"test": "node --test"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"rate-limiter",
|
|
39
|
+
"express",
|
|
40
|
+
"middleware",
|
|
41
|
+
"fixed-window",
|
|
42
|
+
"sliding-window",
|
|
43
|
+
"rolling-window",
|
|
44
|
+
"token-bucket",
|
|
45
|
+
"multi-tenant",
|
|
46
|
+
"dynamic-policy",
|
|
47
|
+
"circuit-breaker",
|
|
48
|
+
"resilience",
|
|
49
|
+
"prometheus",
|
|
50
|
+
"opentelemetry",
|
|
51
|
+
"in-memory",
|
|
52
|
+
"redis",
|
|
53
|
+
"distributed",
|
|
54
|
+
"lua",
|
|
55
|
+
"zset",
|
|
56
|
+
"limitforge"
|
|
57
|
+
],
|
|
58
|
+
"author": "Anivesh Sikarwar <aniveshsikarwar@gmail.com>",
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/Anivesh91/LimitForge.git"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/Anivesh91/LimitForge/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/Anivesh91/LimitForge#readme",
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"express": "^4.21.2 || ^5.0.0",
|
|
70
|
+
"redis": ">=4.0.0"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"redis": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"dotenv": "^16.4.7",
|
|
79
|
+
"express": "^4.21.2",
|
|
80
|
+
"redis": "^6.2.1",
|
|
81
|
+
"supertest": "^7.0.0"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { rateLimiter } from '../limiter/rateLimiter.js';
|
|
2
|
+
import { MemoryStore } from '../stores/memoryStore.js';
|
|
3
|
+
import { MetricsCollector } from '../telemetry/metricsCollector.js';
|
|
4
|
+
import { OpenTelemetryBridge } from '../telemetry/openTelemetryBridge.js';
|
|
5
|
+
|
|
6
|
+
export function calculatePercentiles(samplesNs) {
|
|
7
|
+
if (!Array.isArray(samplesNs) || samplesNs.length === 0) {
|
|
8
|
+
return {
|
|
9
|
+
count: 0,
|
|
10
|
+
minUs: 0,
|
|
11
|
+
maxUs: 0,
|
|
12
|
+
meanUs: 0,
|
|
13
|
+
p50Us: 0,
|
|
14
|
+
p90Us: 0,
|
|
15
|
+
p95Us: 0,
|
|
16
|
+
p99Us: 0,
|
|
17
|
+
minMs: 0,
|
|
18
|
+
maxMs: 0,
|
|
19
|
+
meanMs: 0,
|
|
20
|
+
p50Ms: 0,
|
|
21
|
+
p90Ms: 0,
|
|
22
|
+
p95Ms: 0,
|
|
23
|
+
p99Ms: 0
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const samplesUs = samplesNs
|
|
28
|
+
.map((ns) => (typeof ns === 'bigint' ? Number(ns) / 1000 : Number(ns) / 1000))
|
|
29
|
+
.sort((a, b) => a - b);
|
|
30
|
+
|
|
31
|
+
const count = samplesUs.length;
|
|
32
|
+
const sumUs = samplesUs.reduce((acc, val) => acc + val, 0);
|
|
33
|
+
const meanUs = sumUs / count;
|
|
34
|
+
|
|
35
|
+
const getPercentile = (p) => {
|
|
36
|
+
const rank = (p / 100) * (count - 1);
|
|
37
|
+
const lowerIndex = Math.floor(rank);
|
|
38
|
+
const upperIndex = Math.ceil(rank);
|
|
39
|
+
const fraction = rank - lowerIndex;
|
|
40
|
+
return samplesUs[lowerIndex] + (samplesUs[upperIndex] - samplesUs[lowerIndex]) * fraction;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const minUs = samplesUs[0];
|
|
44
|
+
const maxUs = samplesUs[count - 1];
|
|
45
|
+
const p50Us = getPercentile(50);
|
|
46
|
+
const p90Us = getPercentile(90);
|
|
47
|
+
const p95Us = getPercentile(95);
|
|
48
|
+
const p99Us = getPercentile(99);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
count,
|
|
52
|
+
minUs: Number(minUs.toFixed(2)),
|
|
53
|
+
maxUs: Number(maxUs.toFixed(2)),
|
|
54
|
+
meanUs: Number(meanUs.toFixed(2)),
|
|
55
|
+
p50Us: Number(p50Us.toFixed(2)),
|
|
56
|
+
p90Us: Number(p90Us.toFixed(2)),
|
|
57
|
+
p95Us: Number(p95Us.toFixed(2)),
|
|
58
|
+
p99Us: Number(p99Us.toFixed(2)),
|
|
59
|
+
minMs: Number((minUs / 1000).toFixed(4)),
|
|
60
|
+
maxMs: Number((maxUs / 1000).toFixed(4)),
|
|
61
|
+
meanMs: Number((meanUs / 1000).toFixed(4)),
|
|
62
|
+
p50Ms: Number((p50Us / 1000).toFixed(4)),
|
|
63
|
+
p90Ms: Number((p90Us / 1000).toFixed(4)),
|
|
64
|
+
p95Ms: Number((p95Us / 1000).toFixed(4)),
|
|
65
|
+
p99Ms: Number((p99Us / 1000).toFixed(4))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMockHttpPair(ip = '192.168.1.1', path = '/api/resource', method = 'GET') {
|
|
70
|
+
const req = {
|
|
71
|
+
method,
|
|
72
|
+
originalUrl: path,
|
|
73
|
+
path,
|
|
74
|
+
ip,
|
|
75
|
+
headers: { host: 'localhost:3000' },
|
|
76
|
+
socket: { remoteAddress: ip }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const headers = {};
|
|
80
|
+
const res = {
|
|
81
|
+
statusCode: 200,
|
|
82
|
+
headersSent: false,
|
|
83
|
+
setHeader(name, value) {
|
|
84
|
+
headers[name.toLowerCase()] = String(value);
|
|
85
|
+
},
|
|
86
|
+
getHeader(name) {
|
|
87
|
+
return headers[name.toLowerCase()];
|
|
88
|
+
},
|
|
89
|
+
status(code) {
|
|
90
|
+
this.statusCode = code;
|
|
91
|
+
return this;
|
|
92
|
+
},
|
|
93
|
+
json(body) {
|
|
94
|
+
this.body = body;
|
|
95
|
+
return this;
|
|
96
|
+
},
|
|
97
|
+
end() {
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { req, res };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function runBenchmark(options = {}) {
|
|
106
|
+
const iterations = options.iterations || 10000;
|
|
107
|
+
const warmup = options.warmup ?? 1000;
|
|
108
|
+
const nextStub = () => {};
|
|
109
|
+
|
|
110
|
+
const scenarios = [
|
|
111
|
+
{
|
|
112
|
+
name: '1. No-op Middleware (next() only)',
|
|
113
|
+
middleware: (req, res, next) => next()
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: '2. Fixed Window (Memory Store)',
|
|
117
|
+
middleware: rateLimiter({
|
|
118
|
+
limit: 1000000,
|
|
119
|
+
windowMs: 60000,
|
|
120
|
+
algorithm: 'fixed-window',
|
|
121
|
+
store: new MemoryStore()
|
|
122
|
+
})
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: '3. Sliding Window (Memory Store)',
|
|
126
|
+
middleware: rateLimiter({
|
|
127
|
+
limit: 1000000,
|
|
128
|
+
windowMs: 60000,
|
|
129
|
+
algorithm: 'sliding-window',
|
|
130
|
+
store: new MemoryStore()
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: '4. Token Bucket (Memory Store)',
|
|
135
|
+
middleware: rateLimiter({
|
|
136
|
+
capacity: 1000000,
|
|
137
|
+
refillRate: 100000,
|
|
138
|
+
algorithm: 'token-bucket',
|
|
139
|
+
store: new MemoryStore()
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: '5. Sliding Window + Metrics (Telemetry Enabled)',
|
|
144
|
+
middleware: rateLimiter({
|
|
145
|
+
limit: 1000000,
|
|
146
|
+
windowMs: 60000,
|
|
147
|
+
algorithm: 'sliding-window',
|
|
148
|
+
store: new MemoryStore(),
|
|
149
|
+
metricsCollector: new MetricsCollector()
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: '6. Sliding Window + Metrics + OpenTelemetry Bridge',
|
|
154
|
+
middleware: rateLimiter({
|
|
155
|
+
limit: 1000000,
|
|
156
|
+
windowMs: 60000,
|
|
157
|
+
algorithm: 'sliding-window',
|
|
158
|
+
store: new MemoryStore(),
|
|
159
|
+
metricsCollector: new MetricsCollector(),
|
|
160
|
+
openTelemetry: new OpenTelemetryBridge({
|
|
161
|
+
getSpan: (req) => req.span
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const results = [];
|
|
168
|
+
|
|
169
|
+
for (const scenario of scenarios) {
|
|
170
|
+
const mockSpan = {
|
|
171
|
+
attributes: {},
|
|
172
|
+
events: [],
|
|
173
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
174
|
+
addEvent(name, attrs) { this.events.push({ name, attrs }); }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Warmup phase
|
|
178
|
+
for (let i = 0; i < warmup; i++) {
|
|
179
|
+
const { req, res } = createMockHttpPair(`10.0.0.${(i % 50) + 1}`, '/api/benchmark');
|
|
180
|
+
req.span = mockSpan;
|
|
181
|
+
await scenario.middleware(req, res, nextStub);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Measurement phase
|
|
185
|
+
const samplesNs = [];
|
|
186
|
+
const totalStart = process.hrtime.bigint();
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < iterations; i++) {
|
|
189
|
+
const { req, res } = createMockHttpPair(`10.0.0.${(i % 100) + 1}`, '/api/benchmark');
|
|
190
|
+
req.span = mockSpan;
|
|
191
|
+
|
|
192
|
+
const start = process.hrtime.bigint();
|
|
193
|
+
await scenario.middleware(req, res, nextStub);
|
|
194
|
+
const durationNs = process.hrtime.bigint() - start;
|
|
195
|
+
|
|
196
|
+
samplesNs.push(durationNs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const totalElapsedNs = process.hrtime.bigint() - totalStart;
|
|
200
|
+
const totalElapsedSec = Number(totalElapsedNs) / 1e9;
|
|
201
|
+
const opsPerSec = Math.round(iterations / totalElapsedSec);
|
|
202
|
+
const stats = calculatePercentiles(samplesNs);
|
|
203
|
+
|
|
204
|
+
results.push({
|
|
205
|
+
scenario: scenario.name,
|
|
206
|
+
iterations,
|
|
207
|
+
opsPerSec,
|
|
208
|
+
stats
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const baselineStats = results[0].stats;
|
|
213
|
+
const enrichedResults = results.map((r, idx) => {
|
|
214
|
+
if (idx === 0) {
|
|
215
|
+
return { ...r, overheadP50Us: 0, overheadP99Us: 0 };
|
|
216
|
+
}
|
|
217
|
+
const overheadP50Us = Number((r.stats.p50Us - baselineStats.p50Us).toFixed(2));
|
|
218
|
+
const overheadP99Us = Number((r.stats.p99Us - baselineStats.p99Us).toFixed(2));
|
|
219
|
+
return {
|
|
220
|
+
...r,
|
|
221
|
+
overheadP50Us: Math.max(0, overheadP50Us),
|
|
222
|
+
overheadP99Us: Math.max(0, overheadP99Us)
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
timestamp: new Date().toISOString(),
|
|
228
|
+
nodeVersion: process.version,
|
|
229
|
+
platform: process.platform,
|
|
230
|
+
arch: process.arch,
|
|
231
|
+
iterations,
|
|
232
|
+
warmup,
|
|
233
|
+
scenarios: enrichedResults
|
|
234
|
+
};
|
|
235
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { default, rateLimiter } from './limiter/rateLimiter.js';
|
|
2
|
+
export { MemoryStore } from './stores/memoryStore.js';
|
|
3
|
+
export { RedisStore } from './stores/redisStore.js';
|
|
4
|
+
export { ResilientStore } from './stores/resilientStore.js';
|
|
5
|
+
|
|
6
|
+
export { buildRateLimitKey, MAX_IDENTIFIER_LENGTH } from './utils/keyBuilder.js';
|
|
7
|
+
export { normalizeRoute, sanitizePath } from './utils/routeNormalizer.js';
|
|
8
|
+
|
|
9
|
+
export { withTimeout } from './resilience/timeoutGuard.js';
|
|
10
|
+
export { CircuitBreaker, CIRCUIT_STATE } from './resilience/circuitBreaker.js';
|
|
11
|
+
export { StoreTimeoutError, CircuitBreakerOpenError } from './resilience/errors.js';
|
|
12
|
+
|
|
13
|
+
export { MetricsCollector, defaultMetricsCollector } from './telemetry/metricsCollector.js';
|
|
14
|
+
export { OpenTelemetryBridge } from './telemetry/openTelemetryBridge.js';
|
|
15
|
+
export {
|
|
16
|
+
createPrometheusExporter,
|
|
17
|
+
formatPrometheusMetrics,
|
|
18
|
+
escapeLabelValue,
|
|
19
|
+
escapeHelpString,
|
|
20
|
+
formatLabels
|
|
21
|
+
} from './telemetry/prometheusExporter.js';
|
|
22
|
+
export {
|
|
23
|
+
METRIC_NAMES,
|
|
24
|
+
METRIC_TYPE,
|
|
25
|
+
METRIC_METADATA,
|
|
26
|
+
CIRCUIT_STATE_GAUGE_VALUES,
|
|
27
|
+
DEFAULT_STORE_DURATION_BUCKETS
|
|
28
|
+
} from './telemetry/telemetryEvents.js';
|
|
29
|
+
|
|
30
|
+
export { runBenchmark, calculatePercentiles } from './benchmark/benchmarkRunner.js';
|