@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.
@@ -0,0 +1,321 @@
1
+ import { InMemoryCache } from '@geekmidas/cache/memory';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ type RateLimitConfig,
5
+ type RateLimitContext,
6
+ type RateLimitData,
7
+ TooManyRequestsError,
8
+ checkRateLimit,
9
+ defaultKeyGenerator,
10
+ getRateLimitHeaders,
11
+ } from '../index';
12
+
13
+ describe('Rate Limiting', () => {
14
+ const createContext = (
15
+ overrides?: Partial<RateLimitContext>,
16
+ ): RateLimitContext => ({
17
+ header: vi.fn((key: string) => {
18
+ if (key === 'x-forwarded-for') return '192.168.1.1';
19
+ return undefined;
20
+ }),
21
+ services: [],
22
+ logger: {
23
+ debug: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ child: vi.fn().mockReturnThis(),
28
+ } as any,
29
+ session: {},
30
+ path: '/api/test',
31
+ method: 'GET',
32
+ ...overrides,
33
+ });
34
+
35
+ describe('defaultKeyGenerator', () => {
36
+ it('should generate key with IP from x-forwarded-for', () => {
37
+ const ctx = createContext();
38
+ const key = defaultKeyGenerator(ctx);
39
+ expect(key).toBe('rate-limit:GET:/api/test:192.168.1.1');
40
+ });
41
+
42
+ it('should use x-real-ip when x-forwarded-for is not available', () => {
43
+ const ctx = createContext({
44
+ header: vi.fn((key: string) => {
45
+ if (key === 'x-real-ip') return '10.0.0.1';
46
+ return undefined;
47
+ }),
48
+ });
49
+ const key = defaultKeyGenerator(ctx);
50
+ expect(key).toBe('rate-limit:GET:/api/test:10.0.0.1');
51
+ });
52
+
53
+ it('should use unknown when no IP header is available', () => {
54
+ const ctx = createContext({
55
+ header: vi.fn(() => undefined),
56
+ });
57
+ const key = defaultKeyGenerator(ctx);
58
+ expect(key).toBe('rate-limit:GET:/api/test:unknown');
59
+ });
60
+
61
+ it('should handle comma-separated IPs in x-forwarded-for', () => {
62
+ const ctx = createContext({
63
+ header: vi.fn((key: string) => {
64
+ if (key === 'x-forwarded-for')
65
+ return '192.168.1.1, 10.0.0.1, 172.16.0.1';
66
+ return undefined;
67
+ }),
68
+ });
69
+ const key = defaultKeyGenerator(ctx);
70
+ expect(key).toBe('rate-limit:GET:/api/test:192.168.1.1');
71
+ });
72
+ });
73
+
74
+ describe('checkRateLimit', () => {
75
+ it('should allow requests within the limit', async () => {
76
+ const cache = new InMemoryCache<RateLimitData>();
77
+ const config: RateLimitConfig<RateLimitData> = {
78
+ limit: 5,
79
+ windowMs: 60000, // 1 minute
80
+ cache,
81
+ };
82
+ const ctx = createContext();
83
+
84
+ const info = await checkRateLimit(config, ctx);
85
+ expect(info.count).toBe(1);
86
+ expect(info.remaining).toBe(4);
87
+ expect(info.limit).toBe(5);
88
+ });
89
+
90
+ it('should increment count on subsequent requests', async () => {
91
+ const cache = new InMemoryCache<RateLimitData>();
92
+ const config: RateLimitConfig<RateLimitData> = {
93
+ limit: 5,
94
+ windowMs: 60000,
95
+ cache,
96
+ };
97
+ const ctx = createContext();
98
+
99
+ // First request
100
+ await checkRateLimit(config, ctx);
101
+
102
+ // Second request
103
+ const info = await checkRateLimit(config, ctx);
104
+ expect(info.count).toBe(2);
105
+ expect(info.remaining).toBe(3);
106
+ });
107
+
108
+ it('should throw TooManyRequestsError when limit is exceeded', async () => {
109
+ const cache = new InMemoryCache<RateLimitData>();
110
+ const config: RateLimitConfig<RateLimitData> = {
111
+ limit: 2,
112
+ windowMs: 60000,
113
+ cache,
114
+ };
115
+ const ctx = createContext();
116
+
117
+ // Use up the limit
118
+ await checkRateLimit(config, ctx);
119
+ await checkRateLimit(config, ctx);
120
+
121
+ // Exceed the limit
122
+ await expect(checkRateLimit(config, ctx)).rejects.toThrow(
123
+ TooManyRequestsError,
124
+ );
125
+ });
126
+
127
+ it('should use custom message when rate limit is exceeded', async () => {
128
+ const cache = new InMemoryCache<RateLimitData>();
129
+ const config: RateLimitConfig<RateLimitData> = {
130
+ limit: 1,
131
+ windowMs: 60000,
132
+ cache,
133
+ message: 'Custom rate limit message',
134
+ };
135
+ const ctx = createContext();
136
+
137
+ await checkRateLimit(config, ctx);
138
+
139
+ try {
140
+ await checkRateLimit(config, ctx);
141
+ } catch (error) {
142
+ expect(error).toBeInstanceOf(TooManyRequestsError);
143
+ expect((error as TooManyRequestsError).message).toBe(
144
+ 'Custom rate limit message',
145
+ );
146
+ }
147
+ });
148
+
149
+ it('should skip rate limiting when skip function returns true', async () => {
150
+ const cache = new InMemoryCache<RateLimitData>();
151
+ const config: RateLimitConfig<RateLimitData> = {
152
+ limit: 1,
153
+ windowMs: 60000,
154
+ cache,
155
+ skip: vi.fn().mockResolvedValue(true),
156
+ };
157
+ const ctx = createContext();
158
+
159
+ // Should not throw even though limit would be exceeded
160
+ await checkRateLimit(config, ctx);
161
+ const info = await checkRateLimit(config, ctx);
162
+
163
+ expect(info.count).toBe(0);
164
+ expect(info.remaining).toBe(1);
165
+ expect(config.skip).toHaveBeenCalledWith(ctx);
166
+ });
167
+
168
+ it('should call custom handler when rate limit is exceeded', async () => {
169
+ const cache = new InMemoryCache<RateLimitData>();
170
+ const handler = vi.fn();
171
+ const config: RateLimitConfig<RateLimitData> = {
172
+ limit: 1,
173
+ windowMs: 60000,
174
+ cache,
175
+ handler,
176
+ };
177
+ const ctx = createContext();
178
+
179
+ await checkRateLimit(config, ctx);
180
+
181
+ try {
182
+ await checkRateLimit(config, ctx);
183
+ } catch {
184
+ // Expected to throw
185
+ }
186
+
187
+ expect(handler).toHaveBeenCalledWith(
188
+ ctx,
189
+ expect.objectContaining({
190
+ count: 2,
191
+ limit: 1,
192
+ remaining: 0,
193
+ }),
194
+ );
195
+ });
196
+
197
+ it('should use custom key generator', async () => {
198
+ const cache = new InMemoryCache<RateLimitData>();
199
+ const keyGenerator = vi.fn().mockReturnValue('custom-key');
200
+ const config: RateLimitConfig<RateLimitData> = {
201
+ limit: 5,
202
+ windowMs: 60000,
203
+ cache,
204
+ keyGenerator,
205
+ };
206
+ const ctx = createContext();
207
+
208
+ await checkRateLimit(config, ctx);
209
+
210
+ expect(keyGenerator).toHaveBeenCalledWith(ctx);
211
+ });
212
+
213
+ it('should reset count after window expires', async () => {
214
+ const cache = new InMemoryCache<RateLimitData>();
215
+ const config: RateLimitConfig<RateLimitData> = {
216
+ limit: 2,
217
+ windowMs: 100, // 100ms window
218
+ cache,
219
+ };
220
+ const ctx = createContext();
221
+
222
+ // Use up the limit
223
+ await checkRateLimit(config, ctx);
224
+ await checkRateLimit(config, ctx);
225
+
226
+ // Wait for window to expire
227
+ await new Promise((resolve) => setTimeout(resolve, 150));
228
+
229
+ // Should be allowed again
230
+ const info = await checkRateLimit(config, ctx);
231
+ expect(info.count).toBe(1);
232
+ expect(info.remaining).toBe(1);
233
+ });
234
+ });
235
+
236
+ describe('getRateLimitHeaders', () => {
237
+ it('should generate standard headers by default', () => {
238
+ const info = {
239
+ count: 3,
240
+ limit: 10,
241
+ remaining: 7,
242
+ resetTime: Date.now() + 60000,
243
+ retryAfter: 60000,
244
+ };
245
+ const config: RateLimitConfig = {
246
+ limit: 10,
247
+ windowMs: 60000,
248
+ cache: new InMemoryCache(),
249
+ };
250
+
251
+ const headers = getRateLimitHeaders(info, config);
252
+
253
+ expect(headers['X-RateLimit-Limit']).toBe('10');
254
+ expect(headers['X-RateLimit-Remaining']).toBe('7');
255
+ expect(headers['X-RateLimit-Reset']).toMatch(/^\d{4}-\d{2}-\d{2}T/);
256
+ expect(headers['Retry-After']).toBeUndefined();
257
+ });
258
+
259
+ it('should include Retry-After when limit is exceeded', () => {
260
+ const info = {
261
+ count: 11,
262
+ limit: 10,
263
+ remaining: 0,
264
+ resetTime: Date.now() + 60000,
265
+ retryAfter: 60000,
266
+ };
267
+ const config: RateLimitConfig = {
268
+ limit: 10,
269
+ windowMs: 60000,
270
+ cache: new InMemoryCache(),
271
+ };
272
+
273
+ const headers = getRateLimitHeaders(info, config);
274
+
275
+ expect(headers['Retry-After']).toBe('60');
276
+ });
277
+
278
+ it('should not include standard headers when disabled', () => {
279
+ const info = {
280
+ count: 3,
281
+ limit: 10,
282
+ remaining: 7,
283
+ resetTime: Date.now() + 60000,
284
+ retryAfter: 60000,
285
+ };
286
+ const config: RateLimitConfig = {
287
+ limit: 10,
288
+ windowMs: 60000,
289
+ cache: new InMemoryCache(),
290
+ standardHeaders: false,
291
+ };
292
+
293
+ const headers = getRateLimitHeaders(info, config);
294
+
295
+ expect(headers['X-RateLimit-Limit']).toBeUndefined();
296
+ expect(headers['X-RateLimit-Remaining']).toBeUndefined();
297
+ expect(headers['X-RateLimit-Reset']).toBeUndefined();
298
+ });
299
+
300
+ it('should include legacy headers when enabled', () => {
301
+ const info = {
302
+ count: 3,
303
+ limit: 10,
304
+ remaining: 7,
305
+ resetTime: Date.now() + 60000,
306
+ retryAfter: 60000,
307
+ };
308
+ const config: RateLimitConfig = {
309
+ limit: 10,
310
+ windowMs: 60000,
311
+ cache: new InMemoryCache(),
312
+ legacyHeaders: true,
313
+ };
314
+
315
+ const headers = getRateLimitHeaders(info, config);
316
+
317
+ expect(headers['X-RateLimit-Retry-After']).toBe('60000');
318
+ expect(headers['X-RateLimit-Reset-After']).toBe('60');
319
+ });
320
+ });
321
+ });
package/src/index.ts ADDED
@@ -0,0 +1,290 @@
1
+ import type { Cache } from '@geekmidas/cache';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import type { Service, ServiceRecord } from '@geekmidas/services';
4
+
5
+ /**
6
+ * Error thrown when rate limit is exceeded
7
+ */
8
+ export class TooManyRequestsError extends Error {
9
+ public readonly statusCode = 429;
10
+ public readonly retryAfter?: number;
11
+
12
+ constructor(message?: string, retryAfter?: number) {
13
+ super(message || 'Too many requests, please try again later.');
14
+ this.name = 'TooManyRequestsError';
15
+ this.retryAfter = retryAfter;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Rate limit configuration for an endpoint
21
+ */
22
+ export interface RateLimitConfig<T = RateLimitData> {
23
+ /**
24
+ * Maximum number of requests allowed in the window
25
+ */
26
+ limit: number;
27
+
28
+ /**
29
+ * Time window in milliseconds
30
+ */
31
+ windowMs: number;
32
+
33
+ /**
34
+ * Cache instance to store rate limit data
35
+ */
36
+ cache: Cache<T>;
37
+
38
+ /**
39
+ * Key generator function to identify clients
40
+ * Defaults to using IP address
41
+ */
42
+ keyGenerator?: RateLimitKeyGenerator;
43
+
44
+ /**
45
+ * Skip rate limiting for certain requests
46
+ */
47
+ skip?: RateLimitSkipFn;
48
+
49
+ /**
50
+ * Optional message to return when rate limit is exceeded
51
+ */
52
+ message?: string;
53
+
54
+ /**
55
+ * Optional custom handler when rate limit is exceeded
56
+ */
57
+ handler?: RateLimitExceededHandler;
58
+
59
+ /**
60
+ * Whether to include rate limit headers in response
61
+ * @default true
62
+ */
63
+ standardHeaders?: boolean;
64
+
65
+ /**
66
+ * Whether to include legacy rate limit headers
67
+ * @default false
68
+ */
69
+ legacyHeaders?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Context for rate limiting decisions
74
+ */
75
+ export interface RateLimitContext<
76
+ TServices extends Service[] = [],
77
+ TLogger extends Logger = Logger,
78
+ TSession = unknown,
79
+ > {
80
+ header: (key: string) => string | undefined;
81
+ services: ServiceRecord<TServices>;
82
+ logger: TLogger;
83
+ session: TSession;
84
+ path: string;
85
+ method: string;
86
+ }
87
+
88
+ /**
89
+ * Function to generate a unique key for rate limiting
90
+ */
91
+ export type RateLimitKeyGenerator<
92
+ TServices extends Service[] = [],
93
+ TLogger extends Logger = Logger,
94
+ TSession = unknown,
95
+ > = (
96
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
97
+ ) => string | Promise<string>;
98
+
99
+ /**
100
+ * Function to determine if rate limiting should be skipped
101
+ */
102
+ export type RateLimitSkipFn<
103
+ TServices extends Service[] = [],
104
+ TLogger extends Logger = Logger,
105
+ TSession = unknown,
106
+ > = (
107
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
108
+ ) => boolean | Promise<boolean>;
109
+
110
+ /**
111
+ * Handler for when rate limit is exceeded
112
+ */
113
+ export type RateLimitExceededHandler<
114
+ TServices extends Service[] = [],
115
+ TLogger extends Logger = Logger,
116
+ TSession = unknown,
117
+ > = (
118
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
119
+ info: RateLimitInfo,
120
+ ) => void | Promise<void>;
121
+
122
+ /**
123
+ * Information about current rate limit status
124
+ */
125
+ export interface RateLimitInfo {
126
+ /**
127
+ * Current request count in the window
128
+ */
129
+ count: number;
130
+
131
+ /**
132
+ * Maximum allowed requests
133
+ */
134
+ limit: number;
135
+
136
+ /**
137
+ * Remaining requests allowed
138
+ */
139
+ remaining: number;
140
+
141
+ /**
142
+ * Time when the window resets (Unix timestamp)
143
+ */
144
+ resetTime: number;
145
+
146
+ /**
147
+ * Time until reset in milliseconds
148
+ */
149
+ retryAfter: number;
150
+ }
151
+
152
+ /**
153
+ * Headers to be set on responses
154
+ */
155
+ export interface RateLimitHeaders {
156
+ 'X-RateLimit-Limit'?: string;
157
+ 'X-RateLimit-Remaining'?: string;
158
+ 'X-RateLimit-Reset'?: string;
159
+ 'Retry-After'?: string;
160
+ 'X-RateLimit-Retry-After'?: string;
161
+ 'X-RateLimit-Reset-After'?: string;
162
+ }
163
+
164
+ /**
165
+ * Data stored in cache for rate limiting
166
+ */
167
+ export interface RateLimitData {
168
+ count: number;
169
+ resetTime: number;
170
+ }
171
+
172
+ /**
173
+ * Default key generator using IP address
174
+ */
175
+ export const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {
176
+ // Try various headers for IP address
177
+ const ip =
178
+ ctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||
179
+ ctx.header('x-real-ip') ||
180
+ ctx.header('x-client-ip') ||
181
+ ctx.header('cf-connecting-ip') ||
182
+ 'unknown';
183
+
184
+ return `rate-limit:${ctx.method}:${ctx.path}:${ip}`;
185
+ };
186
+
187
+ /**
188
+ * Check rate limit and throw error if exceeded
189
+ */
190
+ export async function checkRateLimit<
191
+ TServices extends Service[] = [],
192
+ TLogger extends Logger = Logger,
193
+ TSession = unknown,
194
+ >(
195
+ config: RateLimitConfig<RateLimitData>,
196
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
197
+ ): Promise<RateLimitInfo> {
198
+ // Check if we should skip rate limiting
199
+ if (config.skip && (await config.skip(ctx))) {
200
+ return {
201
+ count: 0,
202
+ limit: config.limit,
203
+ remaining: config.limit,
204
+ resetTime: Date.now() + config.windowMs,
205
+ retryAfter: config.windowMs,
206
+ };
207
+ }
208
+
209
+ // Generate key for this request
210
+ const keyGenerator = config.keyGenerator || defaultKeyGenerator;
211
+ const key = await keyGenerator(ctx);
212
+
213
+ // Get current data from cache
214
+ const now = Date.now();
215
+ let data = await config.cache.get(key);
216
+
217
+ // If no data or window expired, create new entry
218
+ if (!data || data.resetTime <= now) {
219
+ const resetTime = now + config.windowMs;
220
+ data = { count: 1, resetTime };
221
+
222
+ // Store with TTL matching the window
223
+ const ttlSeconds = Math.ceil(config.windowMs / 1000);
224
+ await config.cache.set(key, data, ttlSeconds);
225
+ } else {
226
+ // Increment count
227
+ data.count++;
228
+
229
+ // Calculate remaining TTL
230
+ const remainingMs = data.resetTime - now;
231
+ const ttlSeconds = Math.ceil(remainingMs / 1000);
232
+ await config.cache.set(key, data, ttlSeconds);
233
+ }
234
+
235
+ // Calculate rate limit info
236
+ const info: RateLimitInfo = {
237
+ count: data.count,
238
+ limit: config.limit,
239
+ remaining: Math.max(0, config.limit - data.count),
240
+ resetTime: data.resetTime,
241
+ retryAfter: data.resetTime - now,
242
+ };
243
+
244
+ // Check if limit exceeded
245
+ if (data.count > config.limit) {
246
+ // Call custom handler if provided
247
+ if (config.handler) {
248
+ await config.handler(ctx, info);
249
+ }
250
+
251
+ // Throw rate limit error
252
+ const retryAfterSeconds = Math.ceil(info.retryAfter / 1000);
253
+ throw new TooManyRequestsError(
254
+ config.message || 'Too many requests, please try again later.',
255
+ retryAfterSeconds,
256
+ );
257
+ }
258
+
259
+ return info;
260
+ }
261
+
262
+ /**
263
+ * Generate rate limit headers
264
+ */
265
+ export function getRateLimitHeaders(
266
+ info: RateLimitInfo,
267
+ config: RateLimitConfig,
268
+ ): RateLimitHeaders {
269
+ const headers: RateLimitHeaders = {};
270
+
271
+ if (config.standardHeaders !== false) {
272
+ headers['X-RateLimit-Limit'] = info.limit.toString();
273
+ headers['X-RateLimit-Remaining'] = info.remaining.toString();
274
+ headers['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();
275
+ }
276
+
277
+ if (config.legacyHeaders) {
278
+ headers['X-RateLimit-Retry-After'] = info.retryAfter.toString();
279
+ headers['X-RateLimit-Reset-After'] = Math.ceil(
280
+ info.retryAfter / 1000,
281
+ ).toString();
282
+ }
283
+
284
+ // Always set Retry-After when limit is exceeded
285
+ if (info.remaining === 0) {
286
+ headers['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();
287
+ }
288
+
289
+ return headers;
290
+ }