@nestjs-redisx/rate-limit 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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +793 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +780 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rate-limit/api/decorators/rate-limit.decorator.d.ts +123 -0
- package/dist/rate-limit/api/decorators/rate-limit.decorator.d.ts.map +1 -0
- package/dist/rate-limit/api/filters/rate-limit-exception.filter.d.ts +13 -0
- package/dist/rate-limit/api/filters/rate-limit-exception.filter.d.ts.map +1 -0
- package/dist/rate-limit/api/guards/rate-limit.guard.d.ts +72 -0
- package/dist/rate-limit/api/guards/rate-limit.guard.d.ts.map +1 -0
- package/dist/rate-limit/application/ports/rate-limit-service.port.d.ts +74 -0
- package/dist/rate-limit/application/ports/rate-limit-service.port.d.ts.map +1 -0
- package/dist/rate-limit/application/ports/rate-limit-store.port.d.ts +55 -0
- package/dist/rate-limit/application/ports/rate-limit-store.port.d.ts.map +1 -0
- package/dist/rate-limit/application/services/rate-limit.service.d.ts +56 -0
- package/dist/rate-limit/application/services/rate-limit.service.d.ts.map +1 -0
- package/dist/rate-limit/domain/strategies/fixed-window.strategy.d.ts +18 -0
- package/dist/rate-limit/domain/strategies/fixed-window.strategy.d.ts.map +1 -0
- package/dist/rate-limit/domain/strategies/rate-limit-strategy.interface.d.ts +49 -0
- package/dist/rate-limit/domain/strategies/rate-limit-strategy.interface.d.ts.map +1 -0
- package/dist/rate-limit/domain/strategies/sliding-window.strategy.d.ts +18 -0
- package/dist/rate-limit/domain/strategies/sliding-window.strategy.d.ts.map +1 -0
- package/dist/rate-limit/domain/strategies/token-bucket.strategy.d.ts +18 -0
- package/dist/rate-limit/domain/strategies/token-bucket.strategy.d.ts.map +1 -0
- package/dist/rate-limit/infrastructure/adapters/redis-rate-limit-store.adapter.d.ts +61 -0
- package/dist/rate-limit/infrastructure/adapters/redis-rate-limit-store.adapter.d.ts.map +1 -0
- package/dist/rate-limit/infrastructure/scripts/lua-scripts.d.ts +42 -0
- package/dist/rate-limit/infrastructure/scripts/lua-scripts.d.ts.map +1 -0
- package/dist/rate-limit.plugin.d.ts +45 -0
- package/dist/rate-limit.plugin.d.ts.map +1 -0
- package/dist/shared/constants/index.d.ts +16 -0
- package/dist/shared/constants/index.d.ts.map +1 -0
- package/dist/shared/errors/index.d.ts +26 -0
- package/dist/shared/errors/index.d.ts.map +1 -0
- package/dist/shared/types/index.d.ts +153 -0
- package/dist/shared/types/index.d.ts.map +1 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@nestjs/core');
|
|
4
|
+
var common = require('@nestjs/common');
|
|
5
|
+
var core$1 = require('@nestjs-redisx/core');
|
|
6
|
+
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
9
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
10
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
11
|
+
if (decorator = decorators[i])
|
|
12
|
+
result = (decorator(result)) || result;
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
16
|
+
var RateLimitError = class extends core$1.RedisXError {
|
|
17
|
+
constructor(message, code, result, cause) {
|
|
18
|
+
super(message, code, cause, { result });
|
|
19
|
+
this.result = result;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var RateLimitExceededError = class extends RateLimitError {
|
|
23
|
+
constructor(message, result) {
|
|
24
|
+
super(message, core$1.ErrorCode.RATE_LIMIT_EXCEEDED, result);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Seconds until retry is allowed.
|
|
28
|
+
*/
|
|
29
|
+
get retryAfter() {
|
|
30
|
+
return this.result?.retryAfter ?? 0;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var RateLimitScriptError = class extends RateLimitError {
|
|
34
|
+
constructor(message, cause) {
|
|
35
|
+
super(message, core$1.ErrorCode.RATE_LIMIT_SCRIPT_ERROR, void 0, cause);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/rate-limit/api/filters/rate-limit-exception.filter.ts
|
|
40
|
+
exports.RateLimitExceptionFilter = class RateLimitExceptionFilter {
|
|
41
|
+
/**
|
|
42
|
+
* Catch rate limit exceeded error and format response.
|
|
43
|
+
*/
|
|
44
|
+
catch(exception, host) {
|
|
45
|
+
const ctx = host.switchToHttp();
|
|
46
|
+
const response = ctx.getResponse();
|
|
47
|
+
const result = exception.result;
|
|
48
|
+
response.status(common.HttpStatus.TOO_MANY_REQUESTS).header("Retry-After", exception.retryAfter.toString()).json({
|
|
49
|
+
statusCode: common.HttpStatus.TOO_MANY_REQUESTS,
|
|
50
|
+
message: exception.message,
|
|
51
|
+
error: "Too Many Requests",
|
|
52
|
+
retryAfter: exception.retryAfter,
|
|
53
|
+
limit: result?.limit,
|
|
54
|
+
remaining: result?.remaining,
|
|
55
|
+
reset: result?.reset
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
exports.RateLimitExceptionFilter = __decorateClass([
|
|
60
|
+
common.Catch(RateLimitExceededError)
|
|
61
|
+
], exports.RateLimitExceptionFilter);
|
|
62
|
+
|
|
63
|
+
// src/shared/constants/index.ts
|
|
64
|
+
var RATE_LIMIT_PLUGIN_OPTIONS = /* @__PURE__ */ Symbol.for("RATE_LIMIT_PLUGIN_OPTIONS");
|
|
65
|
+
var RATE_LIMIT_SERVICE = /* @__PURE__ */ Symbol.for("RATE_LIMIT_SERVICE");
|
|
66
|
+
var RATE_LIMIT_STORE = /* @__PURE__ */ Symbol.for("RATE_LIMIT_STORE");
|
|
67
|
+
var RATE_LIMIT_OPTIONS = /* @__PURE__ */ Symbol.for("RATE_LIMIT_OPTIONS");
|
|
68
|
+
function RateLimit(options = {}) {
|
|
69
|
+
return common.applyDecorators(common.SetMetadata(RATE_LIMIT_OPTIONS, options), common.UseGuards(exports.RateLimitGuard));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/rate-limit/api/guards/rate-limit.guard.ts
|
|
73
|
+
var METRICS_SERVICE = /* @__PURE__ */ Symbol.for("METRICS_SERVICE");
|
|
74
|
+
var TRACING_SERVICE = /* @__PURE__ */ Symbol.for("TRACING_SERVICE");
|
|
75
|
+
exports.RateLimitGuard = class RateLimitGuard {
|
|
76
|
+
constructor(rateLimitService, config, reflector, metrics, tracing) {
|
|
77
|
+
this.rateLimitService = rateLimitService;
|
|
78
|
+
this.config = config;
|
|
79
|
+
this.reflector = reflector;
|
|
80
|
+
this.metrics = metrics;
|
|
81
|
+
this.tracing = tracing;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Guard activation logic.
|
|
85
|
+
* Checks rate limit and sets response headers.
|
|
86
|
+
*/
|
|
87
|
+
async canActivate(context) {
|
|
88
|
+
const options = this.getOptions(context);
|
|
89
|
+
if (await this.shouldSkip(context, options)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const key = await this.extractKey(context, options);
|
|
93
|
+
const span = this.tracing?.startSpan("ratelimit.check", {
|
|
94
|
+
kind: "INTERNAL",
|
|
95
|
+
attributes: { "ratelimit.key": key }
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
const result = await this.rateLimitService.check(key, options);
|
|
99
|
+
this.setHeaders(context, result);
|
|
100
|
+
span?.setAttribute("ratelimit.allowed", result.allowed);
|
|
101
|
+
span?.setAttribute("ratelimit.remaining", result.remaining);
|
|
102
|
+
span?.setAttribute("ratelimit.limit", result.limit);
|
|
103
|
+
if (!result.allowed) {
|
|
104
|
+
this.metrics?.incrementCounter("redisx_ratelimit_requests_total", { status: "rejected" });
|
|
105
|
+
span?.setStatus("OK");
|
|
106
|
+
throw this.createError(result, options);
|
|
107
|
+
}
|
|
108
|
+
this.metrics?.incrementCounter("redisx_ratelimit_requests_total", { status: "allowed" });
|
|
109
|
+
span?.setStatus("OK");
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!(error instanceof RateLimitExceededError)) {
|
|
113
|
+
span?.recordException(error);
|
|
114
|
+
span?.setStatus("ERROR");
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
} finally {
|
|
118
|
+
span?.end();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get rate limit options from decorator metadata.
|
|
123
|
+
* Merges class-level and method-level options.
|
|
124
|
+
*/
|
|
125
|
+
getOptions(context) {
|
|
126
|
+
const handlerOptions = this.reflector.get(RATE_LIMIT_OPTIONS, context.getHandler());
|
|
127
|
+
const classOptions = this.reflector.get(RATE_LIMIT_OPTIONS, context.getClass());
|
|
128
|
+
return { ...classOptions, ...handlerOptions };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extract rate limit key from context.
|
|
132
|
+
*/
|
|
133
|
+
async extractKey(context, options) {
|
|
134
|
+
const extractor = options.key ?? this.config.defaultKeyExtractor ?? "ip";
|
|
135
|
+
if (typeof extractor === "function") {
|
|
136
|
+
return await extractor(context);
|
|
137
|
+
}
|
|
138
|
+
if (typeof extractor === "string" && !["ip", "user", "apiKey"].includes(extractor)) {
|
|
139
|
+
return extractor;
|
|
140
|
+
}
|
|
141
|
+
const request = context.switchToHttp().getRequest();
|
|
142
|
+
switch (extractor) {
|
|
143
|
+
case "ip":
|
|
144
|
+
return this.getClientIp(request);
|
|
145
|
+
case "user":
|
|
146
|
+
return this.getUserId(request);
|
|
147
|
+
case "apiKey":
|
|
148
|
+
return this.getApiKey(request);
|
|
149
|
+
default:
|
|
150
|
+
return this.getClientIp(request);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Get client IP address.
|
|
155
|
+
*/
|
|
156
|
+
getClientIp(request) {
|
|
157
|
+
const forwardedFor = request.headers["x-forwarded-for"];
|
|
158
|
+
if (forwardedFor) {
|
|
159
|
+
const ips = forwardedFor.split(",").map((ip) => ip.trim());
|
|
160
|
+
return ips[0] || "unknown";
|
|
161
|
+
}
|
|
162
|
+
const realIp = request.headers["x-real-ip"];
|
|
163
|
+
if (realIp) {
|
|
164
|
+
return realIp;
|
|
165
|
+
}
|
|
166
|
+
return request.ip || "unknown";
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get user ID from request.
|
|
170
|
+
*/
|
|
171
|
+
getUserId(request) {
|
|
172
|
+
const userId = request.user?.id;
|
|
173
|
+
if (!userId) {
|
|
174
|
+
throw new Error("User ID not found. Ensure authentication guard runs before rate limit guard.");
|
|
175
|
+
}
|
|
176
|
+
return `user:${userId}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get API key from request.
|
|
180
|
+
*/
|
|
181
|
+
getApiKey(request) {
|
|
182
|
+
const apiKey = request.headers["x-api-key"] || request.headers["authorization"];
|
|
183
|
+
if (!apiKey) {
|
|
184
|
+
throw new Error("API key not found. Ensure request includes X-API-Key or Authorization header.");
|
|
185
|
+
}
|
|
186
|
+
return `apikey:${apiKey}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Set response headers.
|
|
190
|
+
*/
|
|
191
|
+
setHeaders(context, result) {
|
|
192
|
+
if (this.config.includeHeaders === false) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const response = context.switchToHttp().getResponse();
|
|
196
|
+
const headers = this.config.headers ?? {};
|
|
197
|
+
const limitHeader = headers.limit ?? "X-RateLimit-Limit";
|
|
198
|
+
const remainingHeader = headers.remaining ?? "X-RateLimit-Remaining";
|
|
199
|
+
const resetHeader = headers.reset ?? "X-RateLimit-Reset";
|
|
200
|
+
const retryAfterHeader = headers.retryAfter ?? "Retry-After";
|
|
201
|
+
response.header(limitHeader, result.limit.toString());
|
|
202
|
+
response.header(remainingHeader, result.remaining.toString());
|
|
203
|
+
response.header(resetHeader, result.reset.toString());
|
|
204
|
+
if (!result.allowed && result.retryAfter) {
|
|
205
|
+
response.header(retryAfterHeader, result.retryAfter.toString());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Create error when rate limit exceeded.
|
|
210
|
+
*/
|
|
211
|
+
createError(result, options) {
|
|
212
|
+
if (options.errorFactory) {
|
|
213
|
+
return options.errorFactory(result);
|
|
214
|
+
}
|
|
215
|
+
if (this.config.errorFactory) {
|
|
216
|
+
return this.config.errorFactory(result);
|
|
217
|
+
}
|
|
218
|
+
const message = options.message ?? `Rate limit exceeded. Try again in ${result.retryAfter || 0} seconds.`;
|
|
219
|
+
return new RateLimitExceededError(message, result);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Check if rate limiting should be skipped.
|
|
223
|
+
*/
|
|
224
|
+
async shouldSkip(context, options) {
|
|
225
|
+
if (options.skip) {
|
|
226
|
+
return await options.skip(context);
|
|
227
|
+
}
|
|
228
|
+
if (this.config.skip) {
|
|
229
|
+
return await this.config.skip(context);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
exports.RateLimitGuard = __decorateClass([
|
|
235
|
+
common.Injectable(),
|
|
236
|
+
__decorateParam(0, common.Inject(RATE_LIMIT_SERVICE)),
|
|
237
|
+
__decorateParam(1, common.Inject(RATE_LIMIT_PLUGIN_OPTIONS)),
|
|
238
|
+
__decorateParam(2, common.Inject(core.Reflector)),
|
|
239
|
+
__decorateParam(3, common.Optional()),
|
|
240
|
+
__decorateParam(3, common.Inject(METRICS_SERVICE)),
|
|
241
|
+
__decorateParam(4, common.Optional()),
|
|
242
|
+
__decorateParam(4, common.Inject(TRACING_SERVICE))
|
|
243
|
+
], exports.RateLimitGuard);
|
|
244
|
+
exports.RateLimitService = class RateLimitService {
|
|
245
|
+
constructor(config, store) {
|
|
246
|
+
this.config = config;
|
|
247
|
+
this.store = store;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Check and consume rate limit.
|
|
251
|
+
*/
|
|
252
|
+
async check(key, config = {}) {
|
|
253
|
+
const algorithm = config.algorithm ?? this.config.defaultAlgorithm ?? "sliding-window";
|
|
254
|
+
const fullKey = this.buildKey(key, algorithm);
|
|
255
|
+
try {
|
|
256
|
+
switch (algorithm) {
|
|
257
|
+
case "fixed-window":
|
|
258
|
+
return await this.checkFixedWindow(fullKey, config);
|
|
259
|
+
case "sliding-window":
|
|
260
|
+
return await this.checkSlidingWindow(fullKey, config);
|
|
261
|
+
case "token-bucket":
|
|
262
|
+
return await this.checkTokenBucket(fullKey, config);
|
|
263
|
+
default:
|
|
264
|
+
throw new Error(`Unknown algorithm: ${algorithm}`);
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return this.handleError(error, config);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check without consuming.
|
|
272
|
+
*/
|
|
273
|
+
async peek(key, config = {}) {
|
|
274
|
+
const algorithm = config.algorithm ?? this.config.defaultAlgorithm ?? "sliding-window";
|
|
275
|
+
const fullKey = this.buildKey(key, algorithm);
|
|
276
|
+
try {
|
|
277
|
+
const storeConfig = this.buildStoreConfig(algorithm, config);
|
|
278
|
+
return await this.store.peek(fullKey, algorithm, storeConfig);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return this.handleError(error, config);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Reset rate limit for key.
|
|
285
|
+
* Resets all algorithm variants (fixed-window, sliding-window, token-bucket).
|
|
286
|
+
*/
|
|
287
|
+
async reset(key) {
|
|
288
|
+
const algorithms = ["fixed-window", "sliding-window", "token-bucket"];
|
|
289
|
+
await Promise.all(algorithms.map((algo) => this.store.reset(this.buildKey(key, algo))));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Get current state.
|
|
293
|
+
*/
|
|
294
|
+
async getState(key, config = {}) {
|
|
295
|
+
const result = await this.peek(key, config);
|
|
296
|
+
return {
|
|
297
|
+
current: result.current,
|
|
298
|
+
limit: result.limit,
|
|
299
|
+
remaining: result.remaining,
|
|
300
|
+
resetAt: new Date(result.reset * 1e3)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Check fixed window rate limit.
|
|
305
|
+
*/
|
|
306
|
+
async checkFixedWindow(key, config) {
|
|
307
|
+
const points = config.points ?? this.config.defaultPoints ?? 100;
|
|
308
|
+
const duration = config.duration ?? this.config.defaultDuration ?? 60;
|
|
309
|
+
return await this.store.fixedWindow(key, points, duration);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Check sliding window rate limit.
|
|
313
|
+
*/
|
|
314
|
+
async checkSlidingWindow(key, config) {
|
|
315
|
+
const points = config.points ?? this.config.defaultPoints ?? 100;
|
|
316
|
+
const duration = config.duration ?? this.config.defaultDuration ?? 60;
|
|
317
|
+
return await this.store.slidingWindow(key, points, duration);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Check token bucket rate limit.
|
|
321
|
+
*/
|
|
322
|
+
async checkTokenBucket(key, config) {
|
|
323
|
+
const capacity = config.capacity ?? config.points ?? this.config.defaultPoints ?? 100;
|
|
324
|
+
const refillRate = config.refillRate ?? (config.duration ? capacity / config.duration : 10);
|
|
325
|
+
return await this.store.tokenBucket(key, capacity, refillRate, 1);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Build store configuration.
|
|
329
|
+
*/
|
|
330
|
+
buildStoreConfig(algorithm, config) {
|
|
331
|
+
const baseConfig = {
|
|
332
|
+
points: config.points ?? this.config.defaultPoints ?? 100,
|
|
333
|
+
duration: config.duration ?? this.config.defaultDuration ?? 60
|
|
334
|
+
};
|
|
335
|
+
if (algorithm === "token-bucket") {
|
|
336
|
+
const capacity = config.capacity ?? baseConfig.points;
|
|
337
|
+
const refillRate = config.refillRate ?? capacity / baseConfig.duration;
|
|
338
|
+
return { capacity, refillRate };
|
|
339
|
+
}
|
|
340
|
+
return baseConfig;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Handle error based on error policy.
|
|
344
|
+
*/
|
|
345
|
+
handleError(error, config) {
|
|
346
|
+
const errorPolicy = this.config.errorPolicy ?? "fail-closed";
|
|
347
|
+
if (errorPolicy === "fail-open") {
|
|
348
|
+
const points = config.points ?? this.config.defaultPoints ?? 100;
|
|
349
|
+
const duration = config.duration ?? this.config.defaultDuration ?? 60;
|
|
350
|
+
return {
|
|
351
|
+
allowed: true,
|
|
352
|
+
limit: points,
|
|
353
|
+
remaining: points,
|
|
354
|
+
reset: Math.floor(Date.now() / 1e3) + duration,
|
|
355
|
+
current: 0
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
throw new RateLimitScriptError(`Rate limit check failed: ${error.message}`, error);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Build full key with prefix and algorithm.
|
|
362
|
+
* Including algorithm prevents WRONGTYPE errors when different algorithms
|
|
363
|
+
* use different Redis data types for the same logical key.
|
|
364
|
+
*/
|
|
365
|
+
buildKey(key, algorithm) {
|
|
366
|
+
const prefix = this.config.keyPrefix ?? "rl:";
|
|
367
|
+
const algoPrefix = algorithm ? `${algorithm}:` : "";
|
|
368
|
+
return `${prefix}${algoPrefix}${key}`;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
exports.RateLimitService = __decorateClass([
|
|
372
|
+
common.Injectable(),
|
|
373
|
+
__decorateParam(0, common.Inject(RATE_LIMIT_PLUGIN_OPTIONS)),
|
|
374
|
+
__decorateParam(1, common.Inject(RATE_LIMIT_STORE))
|
|
375
|
+
], exports.RateLimitService);
|
|
376
|
+
|
|
377
|
+
// src/rate-limit/infrastructure/scripts/lua-scripts.ts
|
|
378
|
+
var FIXED_WINDOW_SCRIPT = `
|
|
379
|
+
local key = KEYS[1]
|
|
380
|
+
local max_points = tonumber(ARGV[1])
|
|
381
|
+
local duration = tonumber(ARGV[2])
|
|
382
|
+
local now = tonumber(ARGV[3])
|
|
383
|
+
|
|
384
|
+
local window = math.floor(now / duration) * duration
|
|
385
|
+
local window_key = '{' .. key .. '}:' .. window
|
|
386
|
+
|
|
387
|
+
local current = redis.call('INCR', window_key)
|
|
388
|
+
|
|
389
|
+
if current == 1 then
|
|
390
|
+
redis.call('EXPIRE', window_key, duration)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
local allowed = current <= max_points
|
|
394
|
+
local remaining = math.max(0, max_points - current)
|
|
395
|
+
local reset = window + duration
|
|
396
|
+
|
|
397
|
+
return {allowed and 1 or 0, remaining, reset, current}
|
|
398
|
+
`.trim();
|
|
399
|
+
var SLIDING_WINDOW_SCRIPT = `
|
|
400
|
+
local key = KEYS[1]
|
|
401
|
+
local max_points = tonumber(ARGV[1])
|
|
402
|
+
local duration = tonumber(ARGV[2]) * 1000 -- Convert to ms
|
|
403
|
+
local now = tonumber(ARGV[3])
|
|
404
|
+
local request_id = ARGV[4]
|
|
405
|
+
|
|
406
|
+
local window_start = now - duration
|
|
407
|
+
|
|
408
|
+
-- Remove expired entries
|
|
409
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
|
|
410
|
+
|
|
411
|
+
-- Count current requests
|
|
412
|
+
local current = redis.call('ZCARD', key)
|
|
413
|
+
|
|
414
|
+
if current < max_points then
|
|
415
|
+
-- Add new request
|
|
416
|
+
redis.call('ZADD', key, now, request_id)
|
|
417
|
+
redis.call('PEXPIRE', key, duration)
|
|
418
|
+
|
|
419
|
+
return {1, max_points - current - 1, math.ceil((now + duration) / 1000), current + 1}
|
|
420
|
+
else
|
|
421
|
+
-- Get oldest entry to calculate retry time
|
|
422
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
423
|
+
local retry_after = 0
|
|
424
|
+
if #oldest > 0 then
|
|
425
|
+
retry_after = math.ceil((tonumber(oldest[2]) + duration - now) / 1000)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
return {0, 0, math.ceil((now + duration) / 1000), current, retry_after}
|
|
429
|
+
end
|
|
430
|
+
`.trim();
|
|
431
|
+
var TOKEN_BUCKET_SCRIPT = `
|
|
432
|
+
local key = KEYS[1]
|
|
433
|
+
local capacity = tonumber(ARGV[1])
|
|
434
|
+
local refill_rate = tonumber(ARGV[2])
|
|
435
|
+
local now = tonumber(ARGV[3])
|
|
436
|
+
local consume = tonumber(ARGV[4]) or 1
|
|
437
|
+
|
|
438
|
+
-- Get current state
|
|
439
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
|
|
440
|
+
local tokens = tonumber(bucket[1]) or capacity
|
|
441
|
+
local last_refill = tonumber(bucket[2]) or now
|
|
442
|
+
|
|
443
|
+
-- Calculate refill
|
|
444
|
+
local elapsed = (now - last_refill) / 1000 -- Convert to seconds
|
|
445
|
+
local refill = elapsed * refill_rate
|
|
446
|
+
tokens = math.min(capacity, tokens + refill)
|
|
447
|
+
|
|
448
|
+
-- Try to consume
|
|
449
|
+
local allowed = tokens >= consume
|
|
450
|
+
local new_tokens = tokens
|
|
451
|
+
|
|
452
|
+
if allowed then
|
|
453
|
+
new_tokens = tokens - consume
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
-- Save state
|
|
457
|
+
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
|
|
458
|
+
redis.call('PEXPIRE', key, math.ceil(capacity / refill_rate * 1000) + 1000)
|
|
459
|
+
|
|
460
|
+
local retry_after = 0
|
|
461
|
+
if not allowed then
|
|
462
|
+
retry_after = math.ceil((consume - new_tokens) / refill_rate)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
-- Calculate reset time (when bucket will be full again)
|
|
466
|
+
local time_to_full = (capacity - new_tokens) / refill_rate
|
|
467
|
+
local reset = math.ceil(now / 1000 + time_to_full)
|
|
468
|
+
|
|
469
|
+
return {allowed and 1 or 0, math.floor(new_tokens), reset, math.floor(tokens), retry_after}
|
|
470
|
+
`.trim();
|
|
471
|
+
|
|
472
|
+
// src/rate-limit/infrastructure/adapters/redis-rate-limit-store.adapter.ts
|
|
473
|
+
var RedisRateLimitStoreAdapter = class {
|
|
474
|
+
constructor(driver) {
|
|
475
|
+
this.driver = driver;
|
|
476
|
+
}
|
|
477
|
+
fixedWindowSha = null;
|
|
478
|
+
slidingWindowSha = null;
|
|
479
|
+
tokenBucketSha = null;
|
|
480
|
+
/**
|
|
481
|
+
* Pre-load Lua scripts on module initialization.
|
|
482
|
+
*/
|
|
483
|
+
async onModuleInit() {
|
|
484
|
+
try {
|
|
485
|
+
this.fixedWindowSha = await this.driver.scriptLoad(FIXED_WINDOW_SCRIPT);
|
|
486
|
+
this.slidingWindowSha = await this.driver.scriptLoad(SLIDING_WINDOW_SCRIPT);
|
|
487
|
+
this.tokenBucketSha = await this.driver.scriptLoad(TOKEN_BUCKET_SCRIPT);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
throw new RateLimitScriptError(`Failed to load Lua scripts: ${error.message}`, error);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Fixed window rate limiting.
|
|
494
|
+
*/
|
|
495
|
+
async fixedWindow(key, points, duration) {
|
|
496
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
497
|
+
try {
|
|
498
|
+
const result = await this.driver.evalsha(this.fixedWindowSha, [key], [points, duration, now]);
|
|
499
|
+
return this.parseFixedWindowResult(result, points);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (this.isNoScriptError(error)) {
|
|
502
|
+
const result = await this.driver.eval(FIXED_WINDOW_SCRIPT, [key], [points, duration, now]);
|
|
503
|
+
return this.parseFixedWindowResult(result, points);
|
|
504
|
+
}
|
|
505
|
+
throw new RateLimitScriptError(`Fixed window check failed: ${error.message}`, error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Sliding window rate limiting.
|
|
510
|
+
*/
|
|
511
|
+
async slidingWindow(key, points, duration) {
|
|
512
|
+
const now = Date.now();
|
|
513
|
+
const requestId = `${now}-${Math.random().toString(36).substring(7)}`;
|
|
514
|
+
try {
|
|
515
|
+
const result = await this.driver.evalsha(this.slidingWindowSha, [key], [points, duration, now, requestId]);
|
|
516
|
+
return this.parseSlidingWindowResult(result, points);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (this.isNoScriptError(error)) {
|
|
519
|
+
const result = await this.driver.eval(SLIDING_WINDOW_SCRIPT, [key], [points, duration, now, requestId]);
|
|
520
|
+
return this.parseSlidingWindowResult(result, points);
|
|
521
|
+
}
|
|
522
|
+
throw new RateLimitScriptError(`Sliding window check failed: ${error.message}`, error);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Token bucket rate limiting.
|
|
527
|
+
*/
|
|
528
|
+
async tokenBucket(key, capacity, refillRate, consume = 1) {
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
try {
|
|
531
|
+
const result = await this.driver.evalsha(this.tokenBucketSha, [key], [capacity, refillRate, now, consume]);
|
|
532
|
+
return this.parseTokenBucketResult(result, capacity);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (this.isNoScriptError(error)) {
|
|
535
|
+
const result = await this.driver.eval(TOKEN_BUCKET_SCRIPT, [key], [capacity, refillRate, now, consume]);
|
|
536
|
+
return this.parseTokenBucketResult(result, capacity);
|
|
537
|
+
}
|
|
538
|
+
throw new RateLimitScriptError(`Token bucket check failed: ${error.message}`, error);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Peek current state without consuming.
|
|
543
|
+
* Note: This is a simplified implementation.
|
|
544
|
+
* For accurate peek, we would need separate Lua scripts.
|
|
545
|
+
*/
|
|
546
|
+
async peek(key, algorithm, config) {
|
|
547
|
+
try {
|
|
548
|
+
if (algorithm === "fixed-window") {
|
|
549
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
550
|
+
const duration = config.duration || 60;
|
|
551
|
+
const window = Math.floor(now / duration) * duration;
|
|
552
|
+
const windowKey = `${key}:${window}`;
|
|
553
|
+
const currentStr = await this.driver.get(windowKey);
|
|
554
|
+
const current = currentStr ? parseInt(currentStr, 10) : 0;
|
|
555
|
+
const points2 = config.points || 100;
|
|
556
|
+
return {
|
|
557
|
+
allowed: current < points2,
|
|
558
|
+
limit: points2,
|
|
559
|
+
remaining: Math.max(0, points2 - current),
|
|
560
|
+
reset: window + duration,
|
|
561
|
+
current
|
|
562
|
+
};
|
|
563
|
+
} else if (algorithm === "sliding-window") {
|
|
564
|
+
const count = await this.driver.zcard(key);
|
|
565
|
+
const points2 = config.points || 100;
|
|
566
|
+
const duration = config.duration || 60;
|
|
567
|
+
return {
|
|
568
|
+
allowed: count < points2,
|
|
569
|
+
limit: points2,
|
|
570
|
+
remaining: Math.max(0, points2 - count),
|
|
571
|
+
reset: Math.floor(Date.now() / 1e3) + duration,
|
|
572
|
+
current: count
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const points = config.capacity || 100;
|
|
576
|
+
return {
|
|
577
|
+
allowed: true,
|
|
578
|
+
limit: points,
|
|
579
|
+
remaining: points,
|
|
580
|
+
reset: Math.floor(Date.now() / 1e3) + 60,
|
|
581
|
+
current: 0
|
|
582
|
+
};
|
|
583
|
+
} catch (error) {
|
|
584
|
+
throw new RateLimitScriptError(`Peek failed: ${error.message}`, error);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Reset rate limit key.
|
|
589
|
+
*/
|
|
590
|
+
async reset(key) {
|
|
591
|
+
try {
|
|
592
|
+
await this.driver.del(key);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw new RateLimitScriptError(`Reset failed: ${error.message}`, error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Parse fixed window script result.
|
|
599
|
+
* Returns: {allowed, remaining, reset, current}
|
|
600
|
+
*/
|
|
601
|
+
parseFixedWindowResult(result, limit) {
|
|
602
|
+
const allowed = result[0] ?? 0;
|
|
603
|
+
const remaining = result[1] ?? 0;
|
|
604
|
+
const reset = result[2] ?? 0;
|
|
605
|
+
const current = result[3] ?? 0;
|
|
606
|
+
return {
|
|
607
|
+
allowed: allowed === 1,
|
|
608
|
+
limit,
|
|
609
|
+
remaining,
|
|
610
|
+
reset,
|
|
611
|
+
current,
|
|
612
|
+
retryAfter: allowed === 0 ? Math.ceil(reset - Date.now() / 1e3) : void 0
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Parse sliding window script result.
|
|
617
|
+
* Returns: {allowed, remaining, reset, current, retryAfter?}
|
|
618
|
+
*/
|
|
619
|
+
parseSlidingWindowResult(result, limit) {
|
|
620
|
+
const allowed = result[0] ?? 0;
|
|
621
|
+
const remaining = result[1] ?? 0;
|
|
622
|
+
const reset = result[2] ?? 0;
|
|
623
|
+
const current = result[3] ?? 0;
|
|
624
|
+
const retryAfter = result[4];
|
|
625
|
+
return {
|
|
626
|
+
allowed: allowed === 1,
|
|
627
|
+
limit,
|
|
628
|
+
remaining,
|
|
629
|
+
reset,
|
|
630
|
+
current,
|
|
631
|
+
retryAfter: retryAfter ? Math.max(0, retryAfter) : void 0
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Parse token bucket script result.
|
|
636
|
+
* Returns: {allowed, remaining, reset, current, retryAfter?}
|
|
637
|
+
*/
|
|
638
|
+
parseTokenBucketResult(result, capacity) {
|
|
639
|
+
const allowed = result[0] ?? 0;
|
|
640
|
+
const remaining = result[1] ?? 0;
|
|
641
|
+
const reset = result[2] ?? 0;
|
|
642
|
+
const current = result[3] ?? 0;
|
|
643
|
+
const retryAfter = result[4];
|
|
644
|
+
return {
|
|
645
|
+
allowed: allowed === 1,
|
|
646
|
+
limit: capacity,
|
|
647
|
+
remaining,
|
|
648
|
+
reset,
|
|
649
|
+
// Unix timestamp when bucket will be full again
|
|
650
|
+
current,
|
|
651
|
+
retryAfter: retryAfter ? Math.max(0, retryAfter) : void 0
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Check if error is NOSCRIPT error.
|
|
656
|
+
*/
|
|
657
|
+
isNoScriptError(error) {
|
|
658
|
+
const message = error.message;
|
|
659
|
+
return message.includes("NOSCRIPT") || message.includes("No matching script");
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
RedisRateLimitStoreAdapter = __decorateClass([
|
|
663
|
+
common.Injectable(),
|
|
664
|
+
__decorateParam(0, common.Inject(core$1.REDIS_DRIVER))
|
|
665
|
+
], RedisRateLimitStoreAdapter);
|
|
666
|
+
|
|
667
|
+
// src/rate-limit.plugin.ts
|
|
668
|
+
var DEFAULT_RATE_LIMIT_CONFIG = {
|
|
669
|
+
defaultAlgorithm: "sliding-window",
|
|
670
|
+
defaultPoints: 100,
|
|
671
|
+
defaultDuration: 60,
|
|
672
|
+
keyPrefix: "rl:",
|
|
673
|
+
defaultKeyExtractor: "ip",
|
|
674
|
+
includeHeaders: true,
|
|
675
|
+
headers: {
|
|
676
|
+
limit: "X-RateLimit-Limit",
|
|
677
|
+
remaining: "X-RateLimit-Remaining",
|
|
678
|
+
reset: "X-RateLimit-Reset",
|
|
679
|
+
retryAfter: "Retry-After"
|
|
680
|
+
},
|
|
681
|
+
errorPolicy: "fail-closed"
|
|
682
|
+
};
|
|
683
|
+
var RateLimitPlugin = class {
|
|
684
|
+
constructor(options = {}) {
|
|
685
|
+
this.options = options;
|
|
686
|
+
}
|
|
687
|
+
name = "rate-limit";
|
|
688
|
+
version = "0.1.0";
|
|
689
|
+
description = "Rate limiting with fixed-window, sliding-window, and token-bucket algorithms";
|
|
690
|
+
getProviders() {
|
|
691
|
+
const config = {
|
|
692
|
+
defaultAlgorithm: this.options.defaultAlgorithm ?? DEFAULT_RATE_LIMIT_CONFIG.defaultAlgorithm,
|
|
693
|
+
defaultPoints: this.options.defaultPoints ?? DEFAULT_RATE_LIMIT_CONFIG.defaultPoints,
|
|
694
|
+
defaultDuration: this.options.defaultDuration ?? DEFAULT_RATE_LIMIT_CONFIG.defaultDuration,
|
|
695
|
+
keyPrefix: this.options.keyPrefix ?? DEFAULT_RATE_LIMIT_CONFIG.keyPrefix,
|
|
696
|
+
defaultKeyExtractor: this.options.defaultKeyExtractor ?? DEFAULT_RATE_LIMIT_CONFIG.defaultKeyExtractor,
|
|
697
|
+
includeHeaders: this.options.includeHeaders ?? DEFAULT_RATE_LIMIT_CONFIG.includeHeaders,
|
|
698
|
+
headers: {
|
|
699
|
+
...DEFAULT_RATE_LIMIT_CONFIG.headers,
|
|
700
|
+
...this.options.headers
|
|
701
|
+
},
|
|
702
|
+
errorPolicy: this.options.errorPolicy ?? DEFAULT_RATE_LIMIT_CONFIG.errorPolicy,
|
|
703
|
+
skip: this.options.skip,
|
|
704
|
+
errorFactory: this.options.errorFactory
|
|
705
|
+
};
|
|
706
|
+
return [
|
|
707
|
+
{ provide: RATE_LIMIT_PLUGIN_OPTIONS, useValue: config },
|
|
708
|
+
{ provide: RATE_LIMIT_STORE, useClass: RedisRateLimitStoreAdapter },
|
|
709
|
+
{ provide: RATE_LIMIT_SERVICE, useClass: exports.RateLimitService },
|
|
710
|
+
// Reflector is needed for @RateLimit decorator metadata
|
|
711
|
+
core.Reflector,
|
|
712
|
+
// Guard must be in providers for proper DI
|
|
713
|
+
exports.RateLimitGuard,
|
|
714
|
+
// Global exception filter to return 429 instead of 500
|
|
715
|
+
{ provide: core.APP_FILTER, useClass: exports.RateLimitExceptionFilter }
|
|
716
|
+
];
|
|
717
|
+
}
|
|
718
|
+
getExports() {
|
|
719
|
+
return [RATE_LIMIT_PLUGIN_OPTIONS, RATE_LIMIT_SERVICE, exports.RateLimitGuard];
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
// src/rate-limit/domain/strategies/fixed-window.strategy.ts
|
|
724
|
+
var FixedWindowStrategy = class {
|
|
725
|
+
constructor(store) {
|
|
726
|
+
this.store = store;
|
|
727
|
+
}
|
|
728
|
+
name = "fixed-window";
|
|
729
|
+
getScript() {
|
|
730
|
+
return FIXED_WINDOW_SCRIPT;
|
|
731
|
+
}
|
|
732
|
+
async check(key, config) {
|
|
733
|
+
if (!this.store) {
|
|
734
|
+
throw new Error("FixedWindowStrategy requires an IRateLimitStore. Pass it via constructor or use RateLimitService instead.");
|
|
735
|
+
}
|
|
736
|
+
const points = config.points ?? 100;
|
|
737
|
+
const duration = config.duration ?? 60;
|
|
738
|
+
return this.store.fixedWindow(key, points, duration);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// src/rate-limit/domain/strategies/sliding-window.strategy.ts
|
|
743
|
+
var SlidingWindowStrategy = class {
|
|
744
|
+
constructor(store) {
|
|
745
|
+
this.store = store;
|
|
746
|
+
}
|
|
747
|
+
name = "sliding-window";
|
|
748
|
+
getScript() {
|
|
749
|
+
return SLIDING_WINDOW_SCRIPT;
|
|
750
|
+
}
|
|
751
|
+
async check(key, config) {
|
|
752
|
+
if (!this.store) {
|
|
753
|
+
throw new Error("SlidingWindowStrategy requires an IRateLimitStore. Pass it via constructor or use RateLimitService instead.");
|
|
754
|
+
}
|
|
755
|
+
const points = config.points ?? 100;
|
|
756
|
+
const duration = config.duration ?? 60;
|
|
757
|
+
return this.store.slidingWindow(key, points, duration);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// src/rate-limit/domain/strategies/token-bucket.strategy.ts
|
|
762
|
+
var TokenBucketStrategy = class {
|
|
763
|
+
constructor(store) {
|
|
764
|
+
this.store = store;
|
|
765
|
+
}
|
|
766
|
+
name = "token-bucket";
|
|
767
|
+
getScript() {
|
|
768
|
+
return TOKEN_BUCKET_SCRIPT;
|
|
769
|
+
}
|
|
770
|
+
async check(key, config) {
|
|
771
|
+
if (!this.store) {
|
|
772
|
+
throw new Error("TokenBucketStrategy requires an IRateLimitStore. Pass it via constructor or use RateLimitService instead.");
|
|
773
|
+
}
|
|
774
|
+
const capacity = config.capacity ?? config.points ?? 100;
|
|
775
|
+
const refillRate = config.refillRate ?? (config.duration ? capacity / config.duration : 10);
|
|
776
|
+
return this.store.tokenBucket(key, capacity, refillRate, 1);
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
exports.FixedWindowStrategy = FixedWindowStrategy;
|
|
781
|
+
exports.RATE_LIMIT_OPTIONS = RATE_LIMIT_OPTIONS;
|
|
782
|
+
exports.RATE_LIMIT_PLUGIN_OPTIONS = RATE_LIMIT_PLUGIN_OPTIONS;
|
|
783
|
+
exports.RATE_LIMIT_SERVICE = RATE_LIMIT_SERVICE;
|
|
784
|
+
exports.RATE_LIMIT_STORE = RATE_LIMIT_STORE;
|
|
785
|
+
exports.RateLimit = RateLimit;
|
|
786
|
+
exports.RateLimitError = RateLimitError;
|
|
787
|
+
exports.RateLimitExceededError = RateLimitExceededError;
|
|
788
|
+
exports.RateLimitPlugin = RateLimitPlugin;
|
|
789
|
+
exports.RateLimitScriptError = RateLimitScriptError;
|
|
790
|
+
exports.SlidingWindowStrategy = SlidingWindowStrategy;
|
|
791
|
+
exports.TokenBucketStrategy = TokenBucketStrategy;
|
|
792
|
+
//# sourceMappingURL=index.js.map
|
|
793
|
+
//# sourceMappingURL=index.js.map
|