@geekmidas/rate-limit 0.1.0 → 0.3.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/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -7
- package/src/__benchmarks__/rateLimit.bench.ts +109 -0
- package/src/__tests__/rate-limit.spec.ts +314 -314
- package/src/index.ts +209 -209
- package/tsconfig.json +9 -0
|
@@ -1,321 +1,321 @@
|
|
|
1
1
|
import { InMemoryCache } from '@geekmidas/cache/memory';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
checkRateLimit,
|
|
5
|
+
defaultKeyGenerator,
|
|
6
|
+
getRateLimitHeaders,
|
|
7
|
+
type RateLimitConfig,
|
|
8
|
+
type RateLimitContext,
|
|
9
|
+
type RateLimitData,
|
|
10
|
+
TooManyRequestsError,
|
|
11
11
|
} from '../index';
|
|
12
12
|
|
|
13
13
|
describe('Rate Limiting', () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
321
|
});
|