@geekmidas/rate-limit 0.1.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 +504 -0
- package/dist/index.cjs +88 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +139 -0
- package/dist/index.d.mts +139 -0
- package/dist/index.mjs +84 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/__tests__/rate-limit.spec.ts +321 -0
- package/src/index.ts +290 -0
package/README.md
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# @geekmidas/rate-limit
|
|
2
|
+
|
|
3
|
+
Flexible rate limiting library with support for multiple cache backends, customizable key generation, and full TypeScript support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multiple Cache Backends**: Works with any cache implementation (Redis, In-Memory, etc.)
|
|
8
|
+
- **Flexible Key Generation**: Customize how clients are identified
|
|
9
|
+
- **Skip Logic**: Conditionally skip rate limiting for certain requests
|
|
10
|
+
- **Standard Headers**: Automatic rate limit header generation
|
|
11
|
+
- **Type-Safe**: Full TypeScript support with generic types
|
|
12
|
+
- **Customizable**: Configure limits, windows, messages, and handlers
|
|
13
|
+
- **IP Detection**: Automatic client IP detection from various headers
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @geekmidas/rate-limit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### Basic Usage
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { checkRateLimit } from '@geekmidas/rate-limit';
|
|
27
|
+
import { InMemoryCache } from '@geekmidas/cache/memory';
|
|
28
|
+
|
|
29
|
+
const config = {
|
|
30
|
+
limit: 10, // 10 requests
|
|
31
|
+
windowMs: 60000, // per 1 minute
|
|
32
|
+
cache: new InMemoryCache(), // storage backend
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// In your handler
|
|
36
|
+
try {
|
|
37
|
+
const info = await checkRateLimit(config, {
|
|
38
|
+
header: (key) => request.headers.get(key),
|
|
39
|
+
services: {},
|
|
40
|
+
logger,
|
|
41
|
+
session: null,
|
|
42
|
+
path: '/api/users',
|
|
43
|
+
method: 'GET'
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Request allowed
|
|
47
|
+
console.log(`${info.remaining} requests remaining`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof TooManyRequestsError) {
|
|
50
|
+
// Rate limit exceeded
|
|
51
|
+
console.log(`Try again in ${error.retryAfter} seconds`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### With Endpoints
|
|
57
|
+
|
|
58
|
+
Rate limiting is built into the constructs package:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { e } from '@geekmidas/constructs/endpoints';
|
|
62
|
+
import { InMemoryCache } from '@geekmidas/cache/memory';
|
|
63
|
+
import { z } from 'zod';
|
|
64
|
+
|
|
65
|
+
export const sendMessage = e
|
|
66
|
+
.post('/api/messages')
|
|
67
|
+
.rateLimit({
|
|
68
|
+
limit: 10, // 10 requests
|
|
69
|
+
windowMs: 60000, // per minute
|
|
70
|
+
cache: new InMemoryCache(),
|
|
71
|
+
message: 'Too many messages sent. Please try again later.'
|
|
72
|
+
})
|
|
73
|
+
.body(z.object({
|
|
74
|
+
content: z.string()
|
|
75
|
+
}))
|
|
76
|
+
.handle(async ({ body }) => {
|
|
77
|
+
// Rate limited to 10 requests per minute
|
|
78
|
+
return { success: true };
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
### RateLimitConfig
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
interface RateLimitConfig {
|
|
88
|
+
limit: number; // Max requests in window
|
|
89
|
+
windowMs: number; // Time window in milliseconds
|
|
90
|
+
cache: Cache; // Cache backend
|
|
91
|
+
keyGenerator?: RateLimitKeyGenerator; // Custom key generation
|
|
92
|
+
skip?: RateLimitSkipFn; // Skip certain requests
|
|
93
|
+
message?: string; // Custom error message
|
|
94
|
+
handler?: RateLimitExceededHandler; // Custom exceeded handler
|
|
95
|
+
standardHeaders?: boolean; // Include standard headers (default: true)
|
|
96
|
+
legacyHeaders?: boolean; // Include legacy headers (default: false)
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Cache Backends
|
|
101
|
+
|
|
102
|
+
### In-Memory Cache
|
|
103
|
+
|
|
104
|
+
Best for development and single-instance deployments:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { InMemoryCache } from '@geekmidas/cache/memory';
|
|
108
|
+
|
|
109
|
+
const config = {
|
|
110
|
+
limit: 100,
|
|
111
|
+
windowMs: 60000,
|
|
112
|
+
cache: new InMemoryCache()
|
|
113
|
+
};
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Redis (Upstash)
|
|
117
|
+
|
|
118
|
+
Best for production and distributed systems:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { UpstashCache } from '@geekmidas/cache/upstash';
|
|
122
|
+
|
|
123
|
+
const config = {
|
|
124
|
+
limit: 100,
|
|
125
|
+
windowMs: 60000,
|
|
126
|
+
cache: new UpstashCache({
|
|
127
|
+
url: process.env.UPSTASH_REDIS_URL,
|
|
128
|
+
token: process.env.UPSTASH_REDIS_TOKEN
|
|
129
|
+
})
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Custom Key Generation
|
|
134
|
+
|
|
135
|
+
### By User ID
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
const config = {
|
|
139
|
+
limit: 100,
|
|
140
|
+
windowMs: 60000,
|
|
141
|
+
cache: new InMemoryCache(),
|
|
142
|
+
keyGenerator: (ctx) => {
|
|
143
|
+
const userId = ctx.session?.userId || 'anonymous';
|
|
144
|
+
return `rate-limit:${ctx.method}:${ctx.path}:${userId}`;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### By API Key
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const config = {
|
|
153
|
+
limit: 1000,
|
|
154
|
+
windowMs: 3600000, // 1 hour
|
|
155
|
+
cache: new InMemoryCache(),
|
|
156
|
+
keyGenerator: (ctx) => {
|
|
157
|
+
const apiKey = ctx.header('x-api-key') || 'unknown';
|
|
158
|
+
return `rate-limit:${ctx.path}:${apiKey}`;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### By IP and User Agent
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const config = {
|
|
167
|
+
limit: 50,
|
|
168
|
+
windowMs: 60000,
|
|
169
|
+
cache: new InMemoryCache(),
|
|
170
|
+
keyGenerator: (ctx) => {
|
|
171
|
+
const ip = ctx.header('x-forwarded-for')?.split(',')[0] || 'unknown';
|
|
172
|
+
const userAgent = ctx.header('user-agent') || 'unknown';
|
|
173
|
+
return `rate-limit:${ip}:${userAgent}`;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Skip Logic
|
|
179
|
+
|
|
180
|
+
### Skip Authenticated Users
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
const config = {
|
|
184
|
+
limit: 10,
|
|
185
|
+
windowMs: 60000,
|
|
186
|
+
cache: new InMemoryCache(),
|
|
187
|
+
skip: (ctx) => {
|
|
188
|
+
// Skip rate limiting for authenticated users
|
|
189
|
+
return !!ctx.session?.userId;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Skip Admin Routes
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const config = {
|
|
198
|
+
limit: 100,
|
|
199
|
+
windowMs: 60000,
|
|
200
|
+
cache: new InMemoryCache(),
|
|
201
|
+
skip: (ctx) => {
|
|
202
|
+
// Skip rate limiting for admin routes
|
|
203
|
+
return ctx.path.startsWith('/admin/');
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Skip Internal IPs
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
const config = {
|
|
212
|
+
limit: 10,
|
|
213
|
+
windowMs: 60000,
|
|
214
|
+
cache: new InMemoryCache(),
|
|
215
|
+
skip: (ctx) => {
|
|
216
|
+
const ip = ctx.header('x-forwarded-for')?.split(',')[0];
|
|
217
|
+
// Skip rate limiting for internal IPs
|
|
218
|
+
return ip?.startsWith('10.') || ip?.startsWith('192.168.');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Custom Handlers
|
|
224
|
+
|
|
225
|
+
### Log Rate Limit Violations
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
const config = {
|
|
229
|
+
limit: 100,
|
|
230
|
+
windowMs: 60000,
|
|
231
|
+
cache: new InMemoryCache(),
|
|
232
|
+
handler: async (ctx, info) => {
|
|
233
|
+
ctx.logger.warn({
|
|
234
|
+
path: ctx.path,
|
|
235
|
+
method: ctx.method,
|
|
236
|
+
count: info.count,
|
|
237
|
+
limit: info.limit,
|
|
238
|
+
ip: ctx.header('x-forwarded-for')
|
|
239
|
+
}, 'Rate limit exceeded');
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Alert on Abuse
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
const config = {
|
|
248
|
+
limit: 100,
|
|
249
|
+
windowMs: 60000,
|
|
250
|
+
cache: new InMemoryCache(),
|
|
251
|
+
handler: async (ctx, info) => {
|
|
252
|
+
// Alert if significantly over limit
|
|
253
|
+
if (info.count > info.limit * 2) {
|
|
254
|
+
await alertService.send({
|
|
255
|
+
type: 'rate_limit_abuse',
|
|
256
|
+
ip: ctx.header('x-forwarded-for'),
|
|
257
|
+
count: info.count,
|
|
258
|
+
path: ctx.path
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Response Headers
|
|
266
|
+
|
|
267
|
+
The library automatically sets standard rate limit headers:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
X-RateLimit-Limit: 100
|
|
271
|
+
X-RateLimit-Remaining: 95
|
|
272
|
+
X-RateLimit-Reset: 2024-01-15T12:34:56.789Z
|
|
273
|
+
Retry-After: 45
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Disable Headers
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const config = {
|
|
280
|
+
limit: 100,
|
|
281
|
+
windowMs: 60000,
|
|
282
|
+
cache: new InMemoryCache(),
|
|
283
|
+
standardHeaders: false // Disable standard headers
|
|
284
|
+
};
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Enable Legacy Headers
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const config = {
|
|
291
|
+
limit: 100,
|
|
292
|
+
windowMs: 60000,
|
|
293
|
+
cache: new InMemoryCache(),
|
|
294
|
+
legacyHeaders: true // Enable legacy headers
|
|
295
|
+
};
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Rate Limit Info
|
|
299
|
+
|
|
300
|
+
The `checkRateLimit` function returns information about the current rate limit status:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
interface RateLimitInfo {
|
|
304
|
+
count: number; // Current request count
|
|
305
|
+
limit: number; // Max allowed requests
|
|
306
|
+
remaining: number; // Remaining requests
|
|
307
|
+
resetTime: number; // Unix timestamp when window resets
|
|
308
|
+
retryAfter: number; // Milliseconds until reset
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const info = await checkRateLimit(config, ctx);
|
|
312
|
+
console.log(`You have ${info.remaining} requests remaining`);
|
|
313
|
+
console.log(`Rate limit resets at ${new Date(info.resetTime)}`);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Error Handling
|
|
317
|
+
|
|
318
|
+
### TooManyRequestsError
|
|
319
|
+
|
|
320
|
+
Thrown when rate limit is exceeded:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { TooManyRequestsError } from '@geekmidas/rate-limit';
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await checkRateLimit(config, ctx);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error instanceof TooManyRequestsError) {
|
|
329
|
+
console.log(`Status: ${error.statusCode}`); // 429
|
|
330
|
+
console.log(`Message: ${error.message}`);
|
|
331
|
+
console.log(`Retry after: ${error.retryAfter} seconds`);
|
|
332
|
+
|
|
333
|
+
// Send appropriate response
|
|
334
|
+
return new Response(error.message, {
|
|
335
|
+
status: error.statusCode,
|
|
336
|
+
headers: {
|
|
337
|
+
'Retry-After': error.retryAfter?.toString() || '60'
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Common Patterns
|
|
345
|
+
|
|
346
|
+
### Different Limits for Different Endpoints
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
// Strict limit for auth endpoints
|
|
350
|
+
export const login = e
|
|
351
|
+
.post('/auth/login')
|
|
352
|
+
.rateLimit({
|
|
353
|
+
limit: 5,
|
|
354
|
+
windowMs: 300000, // 5 minutes
|
|
355
|
+
cache: new InMemoryCache(),
|
|
356
|
+
message: 'Too many login attempts. Please try again later.'
|
|
357
|
+
})
|
|
358
|
+
.handle(async () => {
|
|
359
|
+
// Login logic
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Generous limit for read endpoints
|
|
363
|
+
export const getUsers = e
|
|
364
|
+
.get('/users')
|
|
365
|
+
.rateLimit({
|
|
366
|
+
limit: 1000,
|
|
367
|
+
windowMs: 60000, // 1 minute
|
|
368
|
+
cache: new InMemoryCache()
|
|
369
|
+
})
|
|
370
|
+
.handle(async () => {
|
|
371
|
+
// Get users logic
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Tiered Rate Limits
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
const config = {
|
|
379
|
+
limit: 100,
|
|
380
|
+
windowMs: 60000,
|
|
381
|
+
cache: new InMemoryCache(),
|
|
382
|
+
keyGenerator: (ctx) => {
|
|
383
|
+
// Different limits based on user tier
|
|
384
|
+
const tier = ctx.session?.tier || 'free';
|
|
385
|
+
const limit = tier === 'premium' ? 1000 : 100;
|
|
386
|
+
|
|
387
|
+
return `rate-limit:${tier}:${ctx.path}:${ctx.session?.userId}`;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Per-API-Key Limits
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const config = {
|
|
396
|
+
limit: 10000,
|
|
397
|
+
windowMs: 3600000, // 1 hour
|
|
398
|
+
cache: new UpstashCache({ /* config */ }),
|
|
399
|
+
keyGenerator: (ctx) => {
|
|
400
|
+
const apiKey = ctx.header('x-api-key');
|
|
401
|
+
if (!apiKey) {
|
|
402
|
+
throw new Error('API key required');
|
|
403
|
+
}
|
|
404
|
+
return `rate-limit:api-key:${apiKey}`;
|
|
405
|
+
},
|
|
406
|
+
message: 'API rate limit exceeded. Upgrade your plan for higher limits.'
|
|
407
|
+
};
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## Testing
|
|
411
|
+
|
|
412
|
+
Mock the cache for testing:
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
import { checkRateLimit, type RateLimitData } from '@geekmidas/rate-limit';
|
|
416
|
+
import { vi } from 'vitest';
|
|
417
|
+
|
|
418
|
+
const mockCache = {
|
|
419
|
+
get: vi.fn(),
|
|
420
|
+
set: vi.fn(),
|
|
421
|
+
delete: vi.fn()
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const config = {
|
|
425
|
+
limit: 10,
|
|
426
|
+
windowMs: 60000,
|
|
427
|
+
cache: mockCache as any
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Test rate limit not exceeded
|
|
431
|
+
mockCache.get.mockResolvedValue({ count: 5, resetTime: Date.now() + 30000 });
|
|
432
|
+
|
|
433
|
+
const info = await checkRateLimit(config, ctx);
|
|
434
|
+
expect(info.remaining).toBe(5);
|
|
435
|
+
|
|
436
|
+
// Test rate limit exceeded
|
|
437
|
+
mockCache.get.mockResolvedValue({ count: 11, resetTime: Date.now() + 30000 });
|
|
438
|
+
|
|
439
|
+
await expect(checkRateLimit(config, ctx)).rejects.toThrow(TooManyRequestsError);
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## Best Practices
|
|
443
|
+
|
|
444
|
+
### 1. Use Redis in Production
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
// ✅ Use Redis for distributed systems
|
|
448
|
+
import { UpstashCache } from '@geekmidas/cache/upstash';
|
|
449
|
+
|
|
450
|
+
const cache = new UpstashCache({ /* config */ });
|
|
451
|
+
|
|
452
|
+
// ❌ Don't use in-memory cache in production
|
|
453
|
+
const cache = new InMemoryCache(); // Only for dev/testing
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### 2. Set Appropriate Limits
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
// ✅ Different limits for different operations
|
|
460
|
+
const authConfig = { limit: 5, windowMs: 300000 }; // 5 per 5 min
|
|
461
|
+
const readConfig = { limit: 1000, windowMs: 60000 }; // 1000 per min
|
|
462
|
+
|
|
463
|
+
// ❌ Don't use same limit for everything
|
|
464
|
+
const config = { limit: 100, windowMs: 60000 }; // Too generic
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### 3. Include Helpful Error Messages
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
// ✅ Clear, actionable messages
|
|
471
|
+
message: 'Rate limit exceeded. You can make 100 requests per minute. Please try again in 45 seconds.'
|
|
472
|
+
|
|
473
|
+
// ❌ Generic messages
|
|
474
|
+
message: 'Too many requests'
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 4. Log Rate Limit Events
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
const config = {
|
|
481
|
+
limit: 100,
|
|
482
|
+
windowMs: 60000,
|
|
483
|
+
cache,
|
|
484
|
+
handler: async (ctx, info) => {
|
|
485
|
+
ctx.logger.warn({
|
|
486
|
+
event: 'rate_limit_exceeded',
|
|
487
|
+
path: ctx.path,
|
|
488
|
+
count: info.count,
|
|
489
|
+
limit: info.limit
|
|
490
|
+
}, 'Rate limit exceeded');
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Related Packages
|
|
496
|
+
|
|
497
|
+
- [@geekmidas/cache](../cache) - Cache implementations for rate limiting
|
|
498
|
+
- [@geekmidas/constructs](../constructs) - Built-in rate limiting for endpoints
|
|
499
|
+
- [@geekmidas/errors](../errors) - HTTP error classes
|
|
500
|
+
- [@geekmidas/logger](../logger) - Structured logging
|
|
501
|
+
|
|
502
|
+
## License
|
|
503
|
+
|
|
504
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when rate limit is exceeded
|
|
5
|
+
*/
|
|
6
|
+
var TooManyRequestsError = class extends Error {
|
|
7
|
+
statusCode = 429;
|
|
8
|
+
retryAfter;
|
|
9
|
+
constructor(message, retryAfter) {
|
|
10
|
+
super(message || "Too many requests, please try again later.");
|
|
11
|
+
this.name = "TooManyRequestsError";
|
|
12
|
+
this.retryAfter = retryAfter;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Default key generator using IP address
|
|
17
|
+
*/
|
|
18
|
+
const defaultKeyGenerator = (ctx) => {
|
|
19
|
+
const ip = ctx.header("x-forwarded-for")?.split(",")[0]?.trim() || ctx.header("x-real-ip") || ctx.header("x-client-ip") || ctx.header("cf-connecting-ip") || "unknown";
|
|
20
|
+
return `rate-limit:${ctx.method}:${ctx.path}:${ip}`;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Check rate limit and throw error if exceeded
|
|
24
|
+
*/
|
|
25
|
+
async function checkRateLimit(config, ctx) {
|
|
26
|
+
if (config.skip && await config.skip(ctx)) return {
|
|
27
|
+
count: 0,
|
|
28
|
+
limit: config.limit,
|
|
29
|
+
remaining: config.limit,
|
|
30
|
+
resetTime: Date.now() + config.windowMs,
|
|
31
|
+
retryAfter: config.windowMs
|
|
32
|
+
};
|
|
33
|
+
const keyGenerator = config.keyGenerator || defaultKeyGenerator;
|
|
34
|
+
const key = await keyGenerator(ctx);
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
let data = await config.cache.get(key);
|
|
37
|
+
if (!data || data.resetTime <= now) {
|
|
38
|
+
const resetTime = now + config.windowMs;
|
|
39
|
+
data = {
|
|
40
|
+
count: 1,
|
|
41
|
+
resetTime
|
|
42
|
+
};
|
|
43
|
+
const ttlSeconds = Math.ceil(config.windowMs / 1e3);
|
|
44
|
+
await config.cache.set(key, data, ttlSeconds);
|
|
45
|
+
} else {
|
|
46
|
+
data.count++;
|
|
47
|
+
const remainingMs = data.resetTime - now;
|
|
48
|
+
const ttlSeconds = Math.ceil(remainingMs / 1e3);
|
|
49
|
+
await config.cache.set(key, data, ttlSeconds);
|
|
50
|
+
}
|
|
51
|
+
const info = {
|
|
52
|
+
count: data.count,
|
|
53
|
+
limit: config.limit,
|
|
54
|
+
remaining: Math.max(0, config.limit - data.count),
|
|
55
|
+
resetTime: data.resetTime,
|
|
56
|
+
retryAfter: data.resetTime - now
|
|
57
|
+
};
|
|
58
|
+
if (data.count > config.limit) {
|
|
59
|
+
if (config.handler) await config.handler(ctx, info);
|
|
60
|
+
const retryAfterSeconds = Math.ceil(info.retryAfter / 1e3);
|
|
61
|
+
throw new TooManyRequestsError(config.message || "Too many requests, please try again later.", retryAfterSeconds);
|
|
62
|
+
}
|
|
63
|
+
return info;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate rate limit headers
|
|
67
|
+
*/
|
|
68
|
+
function getRateLimitHeaders(info, config) {
|
|
69
|
+
const headers = {};
|
|
70
|
+
if (config.standardHeaders !== false) {
|
|
71
|
+
headers["X-RateLimit-Limit"] = info.limit.toString();
|
|
72
|
+
headers["X-RateLimit-Remaining"] = info.remaining.toString();
|
|
73
|
+
headers["X-RateLimit-Reset"] = new Date(info.resetTime).toISOString();
|
|
74
|
+
}
|
|
75
|
+
if (config.legacyHeaders) {
|
|
76
|
+
headers["X-RateLimit-Retry-After"] = info.retryAfter.toString();
|
|
77
|
+
headers["X-RateLimit-Reset-After"] = Math.ceil(info.retryAfter / 1e3).toString();
|
|
78
|
+
}
|
|
79
|
+
if (info.remaining === 0) headers["Retry-After"] = Math.ceil(info.retryAfter / 1e3).toString();
|
|
80
|
+
return headers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
exports.TooManyRequestsError = TooManyRequestsError;
|
|
85
|
+
exports.checkRateLimit = checkRateLimit;
|
|
86
|
+
exports.defaultKeyGenerator = defaultKeyGenerator;
|
|
87
|
+
exports.getRateLimitHeaders = getRateLimitHeaders;
|
|
88
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["message?: string","retryAfter?: number","defaultKeyGenerator: RateLimitKeyGenerator","config: RateLimitConfig<RateLimitData>","ctx: RateLimitContext<TServices, TLogger, TSession>","info: RateLimitInfo","config: RateLimitConfig","headers: RateLimitHeaders"],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class TooManyRequestsError extends Error {\n public readonly statusCode = 429;\n public readonly retryAfter?: number;\n\n constructor(message?: string, retryAfter?: number) {\n super(message || 'Too many requests, please try again later.');\n this.name = 'TooManyRequestsError';\n this.retryAfter = retryAfter;\n }\n}\n\n/**\n * Rate limit configuration for an endpoint\n */\nexport interface RateLimitConfig<T = RateLimitData> {\n /**\n * Maximum number of requests allowed in the window\n */\n limit: number;\n\n /**\n * Time window in milliseconds\n */\n windowMs: number;\n\n /**\n * Cache instance to store rate limit data\n */\n cache: Cache<T>;\n\n /**\n * Key generator function to identify clients\n * Defaults to using IP address\n */\n keyGenerator?: RateLimitKeyGenerator;\n\n /**\n * Skip rate limiting for certain requests\n */\n skip?: RateLimitSkipFn;\n\n /**\n * Optional message to return when rate limit is exceeded\n */\n message?: string;\n\n /**\n * Optional custom handler when rate limit is exceeded\n */\n handler?: RateLimitExceededHandler;\n\n /**\n * Whether to include rate limit headers in response\n * @default true\n */\n standardHeaders?: boolean;\n\n /**\n * Whether to include legacy rate limit headers\n * @default false\n */\n legacyHeaders?: boolean;\n}\n\n/**\n * Context for rate limiting decisions\n */\nexport interface RateLimitContext<\n TServices extends Service[] = [],\n TLogger extends Logger = Logger,\n TSession = unknown,\n> {\n header: (key: string) => string | undefined;\n services: ServiceRecord<TServices>;\n logger: TLogger;\n session: TSession;\n path: string;\n method: string;\n}\n\n/**\n * Function to generate a unique key for rate limiting\n */\nexport type RateLimitKeyGenerator<\n TServices extends Service[] = [],\n TLogger extends Logger = Logger,\n TSession = unknown,\n> = (\n ctx: RateLimitContext<TServices, TLogger, TSession>,\n) => string | Promise<string>;\n\n/**\n * Function to determine if rate limiting should be skipped\n */\nexport type RateLimitSkipFn<\n TServices extends Service[] = [],\n TLogger extends Logger = Logger,\n TSession = unknown,\n> = (\n ctx: RateLimitContext<TServices, TLogger, TSession>,\n) => boolean | Promise<boolean>;\n\n/**\n * Handler for when rate limit is exceeded\n */\nexport type RateLimitExceededHandler<\n TServices extends Service[] = [],\n TLogger extends Logger = Logger,\n TSession = unknown,\n> = (\n ctx: RateLimitContext<TServices, TLogger, TSession>,\n info: RateLimitInfo,\n) => void | Promise<void>;\n\n/**\n * Information about current rate limit status\n */\nexport interface RateLimitInfo {\n /**\n * Current request count in the window\n */\n count: number;\n\n /**\n * Maximum allowed requests\n */\n limit: number;\n\n /**\n * Remaining requests allowed\n */\n remaining: number;\n\n /**\n * Time when the window resets (Unix timestamp)\n */\n resetTime: number;\n\n /**\n * Time until reset in milliseconds\n */\n retryAfter: number;\n}\n\n/**\n * Headers to be set on responses\n */\nexport interface RateLimitHeaders {\n 'X-RateLimit-Limit'?: string;\n 'X-RateLimit-Remaining'?: string;\n 'X-RateLimit-Reset'?: string;\n 'Retry-After'?: string;\n 'X-RateLimit-Retry-After'?: string;\n 'X-RateLimit-Reset-After'?: string;\n}\n\n/**\n * Data stored in cache for rate limiting\n */\nexport interface RateLimitData {\n count: number;\n resetTime: number;\n}\n\n/**\n * Default key generator using IP address\n */\nexport const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {\n // Try various headers for IP address\n const ip =\n ctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||\n ctx.header('x-real-ip') ||\n ctx.header('x-client-ip') ||\n ctx.header('cf-connecting-ip') ||\n 'unknown';\n\n return `rate-limit:${ctx.method}:${ctx.path}:${ip}`;\n};\n\n/**\n * Check rate limit and throw error if exceeded\n */\nexport async function checkRateLimit<\n TServices extends Service[] = [],\n TLogger extends Logger = Logger,\n TSession = unknown,\n>(\n config: RateLimitConfig<RateLimitData>,\n ctx: RateLimitContext<TServices, TLogger, TSession>,\n): Promise<RateLimitInfo> {\n // Check if we should skip rate limiting\n if (config.skip && (await config.skip(ctx))) {\n return {\n count: 0,\n limit: config.limit,\n remaining: config.limit,\n resetTime: Date.now() + config.windowMs,\n retryAfter: config.windowMs,\n };\n }\n\n // Generate key for this request\n const keyGenerator = config.keyGenerator || defaultKeyGenerator;\n const key = await keyGenerator(ctx);\n\n // Get current data from cache\n const now = Date.now();\n let data = await config.cache.get(key);\n\n // If no data or window expired, create new entry\n if (!data || data.resetTime <= now) {\n const resetTime = now + config.windowMs;\n data = { count: 1, resetTime };\n\n // Store with TTL matching the window\n const ttlSeconds = Math.ceil(config.windowMs / 1000);\n await config.cache.set(key, data, ttlSeconds);\n } else {\n // Increment count\n data.count++;\n\n // Calculate remaining TTL\n const remainingMs = data.resetTime - now;\n const ttlSeconds = Math.ceil(remainingMs / 1000);\n await config.cache.set(key, data, ttlSeconds);\n }\n\n // Calculate rate limit info\n const info: RateLimitInfo = {\n count: data.count,\n limit: config.limit,\n remaining: Math.max(0, config.limit - data.count),\n resetTime: data.resetTime,\n retryAfter: data.resetTime - now,\n };\n\n // Check if limit exceeded\n if (data.count > config.limit) {\n // Call custom handler if provided\n if (config.handler) {\n await config.handler(ctx, info);\n }\n\n // Throw rate limit error\n const retryAfterSeconds = Math.ceil(info.retryAfter / 1000);\n throw new TooManyRequestsError(\n config.message || 'Too many requests, please try again later.',\n retryAfterSeconds,\n );\n }\n\n return info;\n}\n\n/**\n * Generate rate limit headers\n */\nexport function getRateLimitHeaders(\n info: RateLimitInfo,\n config: RateLimitConfig,\n): RateLimitHeaders {\n const headers: RateLimitHeaders = {};\n\n if (config.standardHeaders !== false) {\n headers['X-RateLimit-Limit'] = info.limit.toString();\n headers['X-RateLimit-Remaining'] = info.remaining.toString();\n headers['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();\n }\n\n if (config.legacyHeaders) {\n headers['X-RateLimit-Retry-After'] = info.retryAfter.toString();\n headers['X-RateLimit-Reset-After'] = Math.ceil(\n info.retryAfter / 1000,\n ).toString();\n }\n\n // Always set Retry-After when limit is exceeded\n if (info.remaining === 0) {\n headers['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();\n }\n\n return headers;\n}\n"],"mappings":";;;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;CAC9C,AAAgB,aAAa;CAC7B,AAAgB;CAEhB,YAAYA,SAAkBC,YAAqB;AACjD,QAAM,WAAW,6CAA6C;AAC9D,OAAK,OAAO;AACZ,OAAK,aAAa;CACnB;AACF;;;;AA8JD,MAAaC,sBAA6C,CAAC,QAAQ;CAEjE,MAAM,KACJ,IAAI,OAAO,kBAAkB,EAAE,MAAM,IAAI,CAAC,IAAI,MAAM,IACpD,IAAI,OAAO,YAAY,IACvB,IAAI,OAAO,cAAc,IACzB,IAAI,OAAO,mBAAmB,IAC9B;AAEF,SAAQ,aAAa,IAAI,OAAO,GAAG,IAAI,KAAK,GAAG,GAAG;AACnD;;;;AAKD,eAAsB,eAKpBC,QACAC,KACwB;AAExB,KAAI,OAAO,QAAS,MAAM,OAAO,KAAK,IAAI,CACxC,QAAO;EACL,OAAO;EACP,OAAO,OAAO;EACd,WAAW,OAAO;EAClB,WAAW,KAAK,KAAK,GAAG,OAAO;EAC/B,YAAY,OAAO;CACpB;CAIH,MAAM,eAAe,OAAO,gBAAgB;CAC5C,MAAM,MAAM,MAAM,aAAa,IAAI;CAGnC,MAAM,MAAM,KAAK,KAAK;CACtB,IAAI,OAAO,MAAM,OAAO,MAAM,IAAI,IAAI;AAGtC,MAAK,QAAQ,KAAK,aAAa,KAAK;EAClC,MAAM,YAAY,MAAM,OAAO;AAC/B,SAAO;GAAE,OAAO;GAAG;EAAW;EAG9B,MAAM,aAAa,KAAK,KAAK,OAAO,WAAW,IAAK;AACpD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC9C,OAAM;AAEL,OAAK;EAGL,MAAM,cAAc,KAAK,YAAY;EACrC,MAAM,aAAa,KAAK,KAAK,cAAc,IAAK;AAChD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC9C;CAGD,MAAMC,OAAsB;EAC1B,OAAO,KAAK;EACZ,OAAO,OAAO;EACd,WAAW,KAAK,IAAI,GAAG,OAAO,QAAQ,KAAK,MAAM;EACjD,WAAW,KAAK;EAChB,YAAY,KAAK,YAAY;CAC9B;AAGD,KAAI,KAAK,QAAQ,OAAO,OAAO;AAE7B,MAAI,OAAO,QACT,OAAM,OAAO,QAAQ,KAAK,KAAK;EAIjC,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,IAAK;AAC3D,QAAM,IAAI,qBACR,OAAO,WAAW,8CAClB;CAEH;AAED,QAAO;AACR;;;;AAKD,SAAgB,oBACdA,MACAC,QACkB;CAClB,MAAMC,UAA4B,CAAE;AAEpC,KAAI,OAAO,oBAAoB,OAAO;AACpC,UAAQ,uBAAuB,KAAK,MAAM,UAAU;AACpD,UAAQ,2BAA2B,KAAK,UAAU,UAAU;AAC5D,UAAQ,uBAAuB,IAAI,KAAK,KAAK,WAAW,aAAa;CACtE;AAED,KAAI,OAAO,eAAe;AACxB,UAAQ,6BAA6B,KAAK,WAAW,UAAU;AAC/D,UAAQ,6BAA6B,KAAK,KACxC,KAAK,aAAa,IACnB,CAAC,UAAU;CACb;AAGD,KAAI,KAAK,cAAc,EACrB,SAAQ,iBAAiB,KAAK,KAAK,KAAK,aAAa,IAAK,CAAC,UAAU;AAGvE,QAAO;AACR"}
|