@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/index.d.ts +14 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +793 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +780 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/rate-limit/api/decorators/rate-limit.decorator.d.ts +123 -0
  10. package/dist/rate-limit/api/decorators/rate-limit.decorator.d.ts.map +1 -0
  11. package/dist/rate-limit/api/filters/rate-limit-exception.filter.d.ts +13 -0
  12. package/dist/rate-limit/api/filters/rate-limit-exception.filter.d.ts.map +1 -0
  13. package/dist/rate-limit/api/guards/rate-limit.guard.d.ts +72 -0
  14. package/dist/rate-limit/api/guards/rate-limit.guard.d.ts.map +1 -0
  15. package/dist/rate-limit/application/ports/rate-limit-service.port.d.ts +74 -0
  16. package/dist/rate-limit/application/ports/rate-limit-service.port.d.ts.map +1 -0
  17. package/dist/rate-limit/application/ports/rate-limit-store.port.d.ts +55 -0
  18. package/dist/rate-limit/application/ports/rate-limit-store.port.d.ts.map +1 -0
  19. package/dist/rate-limit/application/services/rate-limit.service.d.ts +56 -0
  20. package/dist/rate-limit/application/services/rate-limit.service.d.ts.map +1 -0
  21. package/dist/rate-limit/domain/strategies/fixed-window.strategy.d.ts +18 -0
  22. package/dist/rate-limit/domain/strategies/fixed-window.strategy.d.ts.map +1 -0
  23. package/dist/rate-limit/domain/strategies/rate-limit-strategy.interface.d.ts +49 -0
  24. package/dist/rate-limit/domain/strategies/rate-limit-strategy.interface.d.ts.map +1 -0
  25. package/dist/rate-limit/domain/strategies/sliding-window.strategy.d.ts +18 -0
  26. package/dist/rate-limit/domain/strategies/sliding-window.strategy.d.ts.map +1 -0
  27. package/dist/rate-limit/domain/strategies/token-bucket.strategy.d.ts +18 -0
  28. package/dist/rate-limit/domain/strategies/token-bucket.strategy.d.ts.map +1 -0
  29. package/dist/rate-limit/infrastructure/adapters/redis-rate-limit-store.adapter.d.ts +61 -0
  30. package/dist/rate-limit/infrastructure/adapters/redis-rate-limit-store.adapter.d.ts.map +1 -0
  31. package/dist/rate-limit/infrastructure/scripts/lua-scripts.d.ts +42 -0
  32. package/dist/rate-limit/infrastructure/scripts/lua-scripts.d.ts.map +1 -0
  33. package/dist/rate-limit.plugin.d.ts +45 -0
  34. package/dist/rate-limit.plugin.d.ts.map +1 -0
  35. package/dist/shared/constants/index.d.ts +16 -0
  36. package/dist/shared/constants/index.d.ts.map +1 -0
  37. package/dist/shared/errors/index.d.ts +26 -0
  38. package/dist/shared/errors/index.d.ts.map +1 -0
  39. package/dist/shared/types/index.d.ts +153 -0
  40. package/dist/shared/types/index.d.ts.map +1 -0
  41. 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