@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 +4 -4
- package/package.json +10 -7
- package/CHANGELOG.md +0 -82
- package/src/__benchmarks__/rateLimit.bench.ts +0 -109
- package/src/__tests__/rate-limit.spec.ts +0 -321
- package/src/index.ts +0 -290
- package/tsconfig.json +0 -9
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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": "
|
|
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": "^
|
|
29
|
-
"@geekmidas/logger": "^
|
|
30
|
-
"@geekmidas/services": "^
|
|
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": "^
|
|
45
|
-
"@geekmidas/logger": "^
|
|
46
|
-
"@geekmidas/services": "^
|
|
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
|
-
}
|