@geekmidas/rate-limit 9.0.2 → 10.0.0-alpha.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 CHANGED
@@ -58,11 +58,11 @@ try {
58
58
  Rate limiting is built into the constructs package:
59
59
 
60
60
  ```typescript
61
- import { e } from '@geekmidas/constructs/endpoints';
61
+ import { api } from '../constructs/api';
62
62
  import { InMemoryCache } from '@geekmidas/cache/memory';
63
63
  import { z } from 'zod';
64
64
 
65
- export const sendMessage = e
65
+ export const sendMessage = api
66
66
  .post('/api/messages')
67
67
  .rateLimit({
68
68
  limit: 10, // 10 requests
@@ -347,7 +347,7 @@ try {
347
347
 
348
348
  ```typescript
349
349
  // Strict limit for auth endpoints
350
- export const login = e
350
+ export const login = api
351
351
  .post('/auth/login')
352
352
  .rateLimit({
353
353
  limit: 5,
@@ -360,7 +360,7 @@ export const login = e
360
360
  });
361
361
 
362
362
  // Generous limit for read endpoints
363
- export const getUsers = e
363
+ export const getUsers = api
364
364
  .get('/users')
365
365
  .rateLimit({
366
366
  limit: 1000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/rate-limit",
3
- "version": "9.0.2",
3
+ "version": "10.0.0-alpha.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,9 @@
15
15
  }
16
16
  }
17
17
  },
18
+ "files": [
19
+ "dist"
20
+ ],
18
21
  "repository": {
19
22
  "type": "git",
20
23
  "url": "https://github.com/geekmidas/toolbox"
@@ -25,9 +28,9 @@
25
28
  },
26
29
  "dependencies": {},
27
30
  "peerDependencies": {
28
- "@geekmidas/cache": "^9.0.2",
29
- "@geekmidas/logger": "^9.0.2",
30
- "@geekmidas/services": "^9.0.2"
31
+ "@geekmidas/cache": "^10.0.0-alpha.0",
32
+ "@geekmidas/logger": "^10.0.0-alpha.0",
33
+ "@geekmidas/services": "^10.0.0-alpha.0"
31
34
  },
32
35
  "peerDependenciesMeta": {
33
36
  "@geekmidas/cache": {
@@ -41,9 +44,9 @@
41
44
  }
42
45
  },
43
46
  "devDependencies": {
44
- "@geekmidas/cache": "^9.0.2",
45
- "@geekmidas/logger": "^9.0.2",
46
- "@geekmidas/services": "^9.0.2"
47
+ "@geekmidas/cache": "^10.0.0-alpha.0",
48
+ "@geekmidas/logger": "^10.0.0-alpha.0",
49
+ "@geekmidas/services": "^10.0.0-alpha.0"
47
50
  },
48
51
  "scripts": {
49
52
  "ts": "tsc --noEmit --skipLibCheck src/**/*.ts"
package/CHANGELOG.md DELETED
@@ -1,82 +0,0 @@
1
- # @geekmidas/rate-limit
2
-
3
- ## 9.0.2
4
-
5
- ### Patch Changes
6
-
7
- - [#12](https://github.com/geekmidas/toolbox/pull/12) [`d53863a`](https://github.com/geekmidas/toolbox/commit/d53863a84db2e4ab5420e08f79128b637043fc42) Thanks [@geekmidas](https://github.com/geekmidas)! - Align every published package on a single version and keep them in step.
8
-
9
- All packages now share one version, enforced by a changesets `fixed` group. The
10
- baseline is 9.0.1 — @geekmidas/client's published version — so nothing moves
11
- backwards; this release takes the whole set to 9.0.2 together.
12
-
13
- Independent versions made "which version of the docs applies to me"
14
- unanswerable: a reader on constructs@7 and cli@2 was on no version at all. One
15
- number per release makes versioned documentation possible, and lets 9 freeze as
16
- the current paradigm while the constructs rework is developed against it.
17
-
18
- Every release now publishes every package, and a major anywhere is a major
19
- everywhere. Peer ranges get simpler in return.
20
-
21
- - Updated dependencies [[`d53863a`](https://github.com/geekmidas/toolbox/commit/d53863a84db2e4ab5420e08f79128b637043fc42)]:
22
- - @geekmidas/cache@9.0.2
23
- - @geekmidas/logger@9.0.2
24
- - @geekmidas/services@9.0.2
25
-
26
- ## 4.0.1
27
-
28
- ### Patch Changes
29
-
30
- - 🐛 [#11](https://github.com/geekmidas/toolbox/pull/11) [`40f4dc0`](https://github.com/geekmidas/toolbox/commit/40f4dc095911b2223a255029d8f776caf7781309) Thanks [@geekmidas](https://github.com/geekmidas)! - Patch release across all packages to realign published versions with the
31
- registry. The previous release only published the four packages that had
32
- version bumps; the remaining packages failed with "cannot publish over the
33
- previously published versions" because their versions were unchanged.
34
- - Updated dependencies [[`40f4dc0`](https://github.com/geekmidas/toolbox/commit/40f4dc095911b2223a255029d8f776caf7781309)]:
35
- - @geekmidas/cache@1.1.2
36
- - @geekmidas/logger@1.0.3
37
- - @geekmidas/services@2.0.1
38
-
39
- ## 4.0.0
40
-
41
- ### Patch Changes
42
-
43
- - Updated dependencies []:
44
- - @geekmidas/services@2.0.0
45
-
46
- ## 3.0.0
47
-
48
- ### Patch Changes
49
-
50
- - Updated dependencies [[`811d740`](https://github.com/geekmidas/toolbox/commit/811d740ae3875d59ad1b0dc50261266963c8cb76)]:
51
- - @geekmidas/services@1.1.0
52
-
53
- ## 2.0.1
54
-
55
- ### Patch Changes
56
-
57
- - 🐛 [`d70c6c0`](https://github.com/geekmidas/toolbox/commit/d70c6c0aeb8a79da2473ac77dbd8255a4a2f5651) Thanks [@geekmidas](https://github.com/geekmidas)! - Fix `package.json` exports so TypeScript declarations resolve correctly under NodeNext/Bundler module resolution. Each subpath export now nests `types` inside its `import`/`require` condition, pointing at the `.d.mts` and `.d.cts` files that `tsdown` actually emits (previously the exports referenced non-existent `.d.ts` files, causing type-resolution failures for consumers). Both ESM (`.mjs`) and CJS (`.cjs`) runtime entry points are preserved. Additionally, `@geekmidas/ui` had `import` paths pointing at `.js` files that were never emitted — those are corrected to `.mjs`.
58
-
59
- - Updated dependencies [[`d70c6c0`](https://github.com/geekmidas/toolbox/commit/d70c6c0aeb8a79da2473ac77dbd8255a4a2f5651)]:
60
- - @geekmidas/cache@1.1.1
61
- - @geekmidas/logger@1.0.2
62
- - @geekmidas/services@1.0.2
63
-
64
- ## 2.0.0
65
-
66
- ### Patch Changes
67
-
68
- - Updated dependencies [[`be4f7a9`](https://github.com/geekmidas/toolbox/commit/be4f7a9bd5de7f08adbca582916d6902e0c24de2)]:
69
- - @geekmidas/cache@1.1.0
70
-
71
- ## 1.0.0
72
-
73
- ### Major Changes
74
-
75
- - [`ff7b115`](https://github.com/geekmidas/toolbox/commit/ff7b11599f60f84ac6cdc73714c853ecf786b2e8) Thanks [@geekmidas](https://github.com/geekmidas)! - Version 1 Stable release
76
-
77
- ### Patch Changes
78
-
79
- - Updated dependencies [[`ff7b115`](https://github.com/geekmidas/toolbox/commit/ff7b11599f60f84ac6cdc73714c853ecf786b2e8)]:
80
- - @geekmidas/cache@1.0.0
81
- - @geekmidas/logger@1.0.0
82
- - @geekmidas/services@1.0.0
@@ -1,109 +0,0 @@
1
- import { InMemoryCache } from '@geekmidas/cache/memory';
2
- import { bench, describe } from 'vitest';
3
- import {
4
- checkRateLimit,
5
- type RateLimitConfig,
6
- type RateLimitContext,
7
- } from '../index';
8
-
9
- // Helper to create a mock context
10
- function createContext(ip: string): RateLimitContext {
11
- return {
12
- header: (key: string) => (key === 'x-forwarded-for' ? ip : undefined),
13
- services: {},
14
- logger: {
15
- info: () => {},
16
- error: () => {},
17
- debug: () => {},
18
- warn: () => {},
19
- } as any,
20
- session: {},
21
- path: '/test',
22
- method: 'GET',
23
- };
24
- }
25
-
26
- describe('Rate Limiting', () => {
27
- const cache = new InMemoryCache();
28
- const config: RateLimitConfig = {
29
- limit: 100,
30
- windowMs: 60000,
31
- cache,
32
- };
33
-
34
- bench('checkRateLimit - single IP', async () => {
35
- const ctx = createContext('127.0.0.1');
36
- await checkRateLimit(config, ctx);
37
- });
38
-
39
- bench('checkRateLimit - varying IPs', async () => {
40
- const ip = `192.168.1.${Math.floor(Math.random() * 255)}`;
41
- const ctx = createContext(ip);
42
- await checkRateLimit(config, ctx);
43
- });
44
- });
45
-
46
- describe('Rate Limiting - High Volume', () => {
47
- bench('100 requests same IP', async () => {
48
- const cache = new InMemoryCache();
49
- const config: RateLimitConfig = {
50
- limit: 1000,
51
- windowMs: 60000,
52
- cache,
53
- };
54
- const ctx = createContext('10.0.0.1');
55
-
56
- for (let i = 0; i < 100; i++) {
57
- await checkRateLimit(config, ctx);
58
- }
59
- });
60
-
61
- bench('100 requests different IPs', async () => {
62
- const cache = new InMemoryCache();
63
- const config: RateLimitConfig = {
64
- limit: 100,
65
- windowMs: 60000,
66
- cache,
67
- };
68
-
69
- for (let i = 0; i < 100; i++) {
70
- const ctx = createContext(`10.0.0.${i}`);
71
- await checkRateLimit(config, ctx);
72
- }
73
- });
74
- });
75
-
76
- describe('Rate Limiting - Window Sizes', () => {
77
- bench('1 second window', async () => {
78
- const cache = new InMemoryCache();
79
- const config: RateLimitConfig = {
80
- limit: 10,
81
- windowMs: 1000,
82
- cache,
83
- };
84
- const ctx = createContext('127.0.0.1');
85
- await checkRateLimit(config, ctx);
86
- });
87
-
88
- bench('1 minute window', async () => {
89
- const cache = new InMemoryCache();
90
- const config: RateLimitConfig = {
91
- limit: 100,
92
- windowMs: 60000,
93
- cache,
94
- };
95
- const ctx = createContext('127.0.0.1');
96
- await checkRateLimit(config, ctx);
97
- });
98
-
99
- bench('1 hour window', async () => {
100
- const cache = new InMemoryCache();
101
- const config: RateLimitConfig = {
102
- limit: 1000,
103
- windowMs: 3600000,
104
- cache,
105
- };
106
- const ctx = createContext('127.0.0.1');
107
- await checkRateLimit(config, ctx);
108
- });
109
- });
@@ -1,321 +0,0 @@
1
- import { InMemoryCache } from '@geekmidas/cache/memory';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import {
4
- checkRateLimit,
5
- defaultKeyGenerator,
6
- getRateLimitHeaders,
7
- type RateLimitConfig,
8
- type RateLimitContext,
9
- type RateLimitData,
10
- TooManyRequestsError,
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 DELETED
@@ -1,290 +0,0 @@
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 {
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;
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,
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<RateLimitData>(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
- }
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "composite": true
7
- },
8
- "include": ["src/**/*"]
9
- }