@mpen/routekit 0.1.0 → 0.1.2

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.
Files changed (133) hide show
  1. package/dist/bin.d.mts +4 -0
  2. package/dist/client/react.d.mts +178 -0
  3. package/dist/client/react.mjs +142 -0
  4. package/dist/client.d.mts +433 -0
  5. package/dist/client.mjs +264 -0
  6. package/dist/content-BuDOmhH_.mjs +102 -0
  7. package/dist/core-CzUCxvGk.d.mts +140 -0
  8. package/dist/core-DbmQauwS.mjs +81 -0
  9. package/dist/handlers.d.mts +72 -0
  10. package/dist/handlers.mjs +153 -0
  11. package/dist/index.d.mts +3 -0
  12. package/dist/index.mjs +1152 -0
  13. package/dist/middleware.d.mts +388 -0
  14. package/dist/middleware.mjs +1222 -0
  15. package/dist/request-Dn0zc-xm.mjs +1025 -0
  16. package/dist/response/content.d.mts +79 -0
  17. package/dist/response/content.mjs +2 -0
  18. package/dist/response/json-rpc.d.mts +1 -0
  19. package/dist/response/json-rpc.mjs +1 -0
  20. package/dist/response/problem/valibot.d.mts +230 -0
  21. package/dist/response/problem/valibot.mjs +258 -0
  22. package/dist/response/problem.d.mts +415 -0
  23. package/dist/response/problem.mjs +183 -0
  24. package/dist/response/status.d.mts +45 -0
  25. package/dist/response/status.mjs +2 -0
  26. package/dist/responses-B379Ep9Y.d.mts +296 -0
  27. package/dist/responses-BpVrgeYi.mjs +101 -0
  28. package/dist/router-Cwb7ak0J.d.mts +1819 -0
  29. package/dist/routes.d.mts +282 -0
  30. package/dist/routes.mjs +311 -0
  31. package/dist/status-C-8mw-FB.mjs +59 -0
  32. package/dist/valibot-D7liFYyB.d.mts +290 -0
  33. package/dist/valibot-Du97X-TS.mjs +326 -0
  34. package/package.json +8 -2
  35. package/src/bin/gen-api-client.test.ts +0 -70
  36. package/src/bin/gen-api-client.ts +0 -986
  37. package/src/client/headers.ts +0 -31
  38. package/src/client/index.ts +0 -8
  39. package/src/client/promise.ts +0 -11
  40. package/src/client/react/index.test.tsx +0 -266
  41. package/src/client/react/index.ts +0 -431
  42. package/src/client/responses.test.ts +0 -151
  43. package/src/client/responses.ts +0 -278
  44. package/src/client/transport.ts +0 -74
  45. package/src/client/transports/body-codec.ts +0 -61
  46. package/src/client/transports/fetch.ts +0 -113
  47. package/src/client/tsconfig.json +0 -9
  48. package/src/client/types.ts +0 -15
  49. package/src/client/url.ts +0 -31
  50. package/src/index.ts +0 -63
  51. package/src/router/fetch-types.ts +0 -13
  52. package/src/router/handlers/index.ts +0 -2
  53. package/src/router/handlers/openapi/index.ts +0 -2
  54. package/src/router/handlers/openapi/openapi.ts +0 -293
  55. package/src/router/integration/zod-openapi.test.ts +0 -74
  56. package/src/router/lib/charset.test.ts +0 -22
  57. package/src/router/lib/charset.ts +0 -133
  58. package/src/router/lib/collections.ts +0 -3
  59. package/src/router/lib/format.test.ts +0 -67
  60. package/src/router/lib/format.ts +0 -35
  61. package/src/router/lib/host.ts +0 -4
  62. package/src/router/lib/json-schema.ts +0 -6
  63. package/src/router/lib/media-type.test.ts +0 -122
  64. package/src/router/lib/media-type.ts +0 -289
  65. package/src/router/lib/pathname.test.ts +0 -18
  66. package/src/router/lib/pathname.ts +0 -19
  67. package/src/router/lib/route-names.ts +0 -70
  68. package/src/router/lib/route-normalize.test.ts +0 -36
  69. package/src/router/lib/route-normalize.ts +0 -67
  70. package/src/router/lib/schema-merge.ts +0 -56
  71. package/src/router/middleware/accept-ctx.test.ts +0 -33
  72. package/src/router/middleware/accept-ctx.ts +0 -12
  73. package/src/router/middleware/body-limit.test.ts +0 -112
  74. package/src/router/middleware/body-limit.ts +0 -121
  75. package/src/router/middleware/content-type-context.ts +0 -0
  76. package/src/router/middleware/cors.test.ts +0 -269
  77. package/src/router/middleware/cors.ts +0 -490
  78. package/src/router/middleware/csrf.test.ts +0 -106
  79. package/src/router/middleware/csrf.ts +0 -192
  80. package/src/router/middleware/define.ts +0 -249
  81. package/src/router/middleware/index.ts +0 -34
  82. package/src/router/middleware/jsxhtml-response.ts +0 -0
  83. package/src/router/middleware/oas-swagger.ts +0 -0
  84. package/src/router/middleware/rate-limit.test.ts +0 -886
  85. package/src/router/middleware/rate-limit.ts +0 -920
  86. package/src/router/middleware/request-id-ctx.test.ts +0 -183
  87. package/src/router/middleware/request-id-ctx.ts +0 -135
  88. package/src/router/middleware/request-logger-format.test.ts +0 -16
  89. package/src/router/middleware/request-logger-format.ts +0 -269
  90. package/src/router/middleware/request-logger.test.ts +0 -267
  91. package/src/router/middleware/request-logger.ts +0 -131
  92. package/src/router/middleware/start-time-ctx.ts +0 -5
  93. package/src/router/request.ts +0 -611
  94. package/src/router/response/core.ts +0 -181
  95. package/src/router/response/directives.ts +0 -233
  96. package/src/router/response/formats/content/bodyless.ts +0 -54
  97. package/src/router/response/formats/content/content.ts +0 -79
  98. package/src/router/response/formats/content/index.ts +0 -2
  99. package/src/router/response/formats/json-rpc/index.ts +0 -2
  100. package/src/router/response/formats/problem/badRequest.ts +0 -90
  101. package/src/router/response/formats/problem/conflict.ts +0 -90
  102. package/src/router/response/formats/problem/created.ts +0 -40
  103. package/src/router/response/formats/problem/index.ts +0 -27
  104. package/src/router/response/formats/problem/notFound.ts +0 -90
  105. package/src/router/response/formats/problem/permissionDenied.ts +0 -90
  106. package/src/router/response/formats/problem/problem.test.ts +0 -888
  107. package/src/router/response/formats/problem/rateLimited.ts +0 -90
  108. package/src/router/response/formats/problem/responses.ts +0 -219
  109. package/src/router/response/formats/problem/root-errors.ts +0 -48
  110. package/src/router/response/formats/problem/sessionExpired.ts +0 -90
  111. package/src/router/response/formats/problem/types.ts +0 -170
  112. package/src/router/response/formats/problem/unauthenticated.ts +0 -90
  113. package/src/router/response/formats/problem/valibot.ts +0 -410
  114. package/src/router/response/formats/status/index.ts +0 -1
  115. package/src/router/response/formats/status/responses.ts +0 -59
  116. package/src/router/response/formats/status/status.test.ts +0 -21
  117. package/src/router/response/framers.ts +0 -85
  118. package/src/router/response/index.ts +0 -28
  119. package/src/router/response/openapi.test.ts +0 -96
  120. package/src/router/response/openapi.ts +0 -1
  121. package/src/router/response/serializers.ts +0 -66
  122. package/src/router/response/stream.ts +0 -35
  123. package/src/router/router.test.ts +0 -1571
  124. package/src/router/router.ts +0 -1965
  125. package/src/router/routes/index.ts +0 -46
  126. package/src/router/routes/valibot/index.ts +0 -18
  127. package/src/router/routes/valibot/valibot.ts +0 -1393
  128. package/src/router/routes/valibot.test.ts +0 -286
  129. package/src/router/routes/zod/index.ts +0 -18
  130. package/src/router/routes/zod/zod.ts +0 -1318
  131. package/src/router/routes/zod.test.ts +0 -280
  132. package/src/router/server-interface.ts +0 -31
  133. package/src/router/types.ts +0 -657
@@ -1,886 +0,0 @@
1
- #!/usr/bin/env -S bun test
2
- import { describe, expect, it } from 'bun:test'
3
- import { HttpMethod } from '@mpen/http'
4
- import { existsSync } from 'node:fs'
5
- import { fileURLToPath } from 'node:url'
6
- import { Router } from '../router'
7
- import { rateLimit } from './rate-limit'
8
-
9
- const asnDbPath = fileURLToPath(new URL('../testing/GeoLite2-ASN.mmdb', import.meta.url))
10
- const countryDbPath = fileURLToPath(new URL('../testing/GeoLite2-Country.mmdb', import.meta.url))
11
- const hasMaxmindDbs = existsSync(asnDbPath) && existsSync(countryDbPath)
12
-
13
- class CaptureStorage {
14
- keys: string[] = []
15
-
16
- async readCounter(_ctx: unknown, _key: string) {
17
- return null
18
- }
19
-
20
- async writeCounter(_ctx: unknown, key: string, _counter: unknown, _ttlMs: number) {
21
- this.keys.push(key)
22
- }
23
- }
24
-
25
- const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
26
-
27
- describe(rateLimit.name, () => {
28
- it('declares its throttled response on affected routes', () => {
29
- const router = new Router()
30
- router.use(
31
- rateLimit({
32
- getUserId: async () => 'user-1',
33
- getGlobalPeakConcurrentUsers: async () => 1,
34
- baseWindowMs: 1000,
35
- baseMaxRequestsPerBaseWindow: 2,
36
- anonymousIpMultiplier: 1,
37
- addRetryAfterHeader: false,
38
- buckets: [{ windowMs: 1000, scale: 1 }],
39
- endpointLimits: [],
40
- includeQueryInEndpointKey: false,
41
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
42
- }),
43
- )
44
- router.add({ method: HttpMethod.GET, path: '/', handler: () => new Response() })
45
-
46
- expect(router.getRoutes()[0]?.schema?.response?.body).toEqual({
47
- 429: { type: 'string' },
48
- })
49
- })
50
-
51
- it('enforces per-user identity limits', async () => {
52
- const router = new Router()
53
- router.use(
54
- rateLimit({
55
- getUserId: async () => 'user-1',
56
- getGlobalPeakConcurrentUsers: async () => 1,
57
- baseWindowMs: 1000,
58
- baseMaxRequestsPerBaseWindow: 2,
59
- anonymousIpMultiplier: 1,
60
- addRetryAfterHeader: false,
61
- buckets: [{ windowMs: 1000, scale: 1 }],
62
- endpointLimits: [],
63
- includeQueryInEndpointKey: false,
64
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
65
- }),
66
- )
67
- router.add({
68
- method: HttpMethod.GET,
69
- path: '/',
70
- handler: () => new Response('ok'),
71
- })
72
-
73
- const makeRequest = () =>
74
- new Request('https://example.com/', {
75
- headers: { 'x-forwarded-for': '203.0.113.5' },
76
- })
77
-
78
- const response1 = await router.fetch(makeRequest())
79
- const response2 = await router.fetch(makeRequest())
80
- const response3 = await router.fetch(makeRequest())
81
-
82
- expect(response1.status).toBe(200)
83
- expect(response2.status).toBe(200)
84
- expect(response3.status).toBe(429)
85
- })
86
-
87
- it('applies endpoint limits to identity and subnet', async () => {
88
- const router = new Router()
89
- router.use(
90
- rateLimit({
91
- getUserId: async () => null,
92
- getGlobalPeakConcurrentUsers: async () => 1,
93
- baseWindowMs: 1000,
94
- baseMaxRequestsPerBaseWindow: 100,
95
- anonymousIpMultiplier: 1,
96
- addRetryAfterHeader: true,
97
- buckets: [{ windowMs: 1000, scale: 1 }],
98
- endpointLimits: [{ pattern: '/limited', limit: { GET: 1 } }],
99
- includeQueryInEndpointKey: false,
100
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
101
- }),
102
- )
103
- router.add({
104
- method: HttpMethod.GET,
105
- path: '/limited',
106
- handler: () => new Response('ok'),
107
- })
108
-
109
- const makeRequest = () =>
110
- new Request('https://example.com/limited', {
111
- headers: { 'x-forwarded-for': '203.0.113.7' },
112
- })
113
-
114
- const response1 = await router.fetch(makeRequest())
115
- const response2 = await router.fetch(makeRequest())
116
-
117
- expect(response1.status).toBe(200)
118
- expect(response2.status).toBe(429)
119
- expect(response2.headers.get('retry-after')).not.toBeNull()
120
- })
121
-
122
- it('avoids duplicate identity prefixes in endpoint keys', async () => {
123
- const storage = new CaptureStorage()
124
- const router = new Router()
125
- router.use(
126
- rateLimit({
127
- getUserId: async () => 'user-1',
128
- getGlobalPeakConcurrentUsers: async () => 1,
129
- baseWindowMs: 1000,
130
- baseMaxRequestsPerBaseWindow: 10,
131
- anonymousIpMultiplier: 1,
132
- addRetryAfterHeader: false,
133
- buckets: [{ windowMs: 1000, scale: 1 }],
134
- endpointLimits: [{ pattern: '/endpoint', limit: { GET: 1 } }],
135
- includeQueryInEndpointKey: false,
136
- storage,
137
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
138
- }),
139
- )
140
- router.add({
141
- method: HttpMethod.GET,
142
- path: '/endpoint',
143
- handler: () => new Response('ok'),
144
- })
145
-
146
- await router.fetch(
147
- new Request('https://example.com/endpoint', {
148
- headers: { 'x-forwarded-for': '203.0.113.11' },
149
- }),
150
- )
151
-
152
- expect(storage.keys.some((key) => key.includes('identity:identity:'))).toBe(false)
153
- })
154
-
155
- it('normalizes query params by default', async () => {
156
- const router = new Router()
157
- router.use(
158
- rateLimit({
159
- getUserId: async () => null,
160
- getGlobalPeakConcurrentUsers: async () => 1,
161
- baseWindowMs: 1000,
162
- baseMaxRequestsPerBaseWindow: 10,
163
- anonymousIpMultiplier: 1,
164
- addRetryAfterHeader: false,
165
- buckets: [{ windowMs: 1000, scale: 1 }],
166
- endpointLimits: [{ pattern: '/search', limit: { GET: 1 } }],
167
- includeQueryInEndpointKey: true,
168
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
169
- }),
170
- )
171
- router.add({
172
- method: HttpMethod.GET,
173
- path: '/search',
174
- handler: () => new Response('ok'),
175
- })
176
-
177
- const request1 = new Request('https://example.com/search?a=1&b=2', {
178
- headers: { 'x-forwarded-for': '203.0.113.70' },
179
- })
180
- const request2 = new Request('https://example.com/search?b=2&a=1', {
181
- headers: { 'x-forwarded-for': '203.0.113.70' },
182
- })
183
-
184
- const response1 = await router.fetch(request1)
185
- const response2 = await router.fetch(request2)
186
-
187
- expect(response1.status).toBe(200)
188
- expect(response2.status).toBe(429)
189
- })
190
-
191
- it('enforces ipv4 subnet limits', async () => {
192
- const router = new Router()
193
- router.use(
194
- rateLimit({
195
- getUserId: async () => 'user-1',
196
- getGlobalPeakConcurrentUsers: async () => 1,
197
- baseWindowMs: 1000,
198
- baseMaxRequestsPerBaseWindow: 5,
199
- anonymousIpMultiplier: 1,
200
- addRetryAfterHeader: false,
201
- buckets: [{ windowMs: 1000, scale: 1 }],
202
- endpointLimits: [],
203
- includeQueryInEndpointKey: false,
204
- scales: { subnet: { ipv4: 0.2, ipv6: 10 } },
205
- }),
206
- )
207
- router.add({
208
- method: HttpMethod.GET,
209
- path: '/ipv4',
210
- handler: () => new Response('ok'),
211
- })
212
-
213
- const request = () =>
214
- new Request('https://example.com/ipv4', {
215
- headers: { 'x-forwarded-for': '203.0.113.10' },
216
- })
217
-
218
- const response1 = await router.fetch(request())
219
- const response2 = await router.fetch(request())
220
-
221
- expect(response1.status).toBe(200)
222
- expect(response2.status).toBe(429)
223
- })
224
-
225
- it('enforces ipv6 subnet limits', async () => {
226
- const router = new Router()
227
- router.use(
228
- rateLimit({
229
- getUserId: async () => 'user-1',
230
- getGlobalPeakConcurrentUsers: async () => 1,
231
- baseWindowMs: 1000,
232
- baseMaxRequestsPerBaseWindow: 5,
233
- anonymousIpMultiplier: 1,
234
- addRetryAfterHeader: false,
235
- buckets: [{ windowMs: 1000, scale: 1 }],
236
- endpointLimits: [],
237
- includeQueryInEndpointKey: false,
238
- scales: { subnet: { ipv4: 10, ipv6: 0.2 } },
239
- }),
240
- )
241
- router.add({
242
- method: HttpMethod.GET,
243
- path: '/ipv6',
244
- handler: () => new Response('ok'),
245
- })
246
-
247
- const request = () =>
248
- new Request('https://example.com/ipv6', {
249
- headers: { 'x-forwarded-for': '2001:db8:abcd:12::1' },
250
- })
251
-
252
- const response1 = await router.fetch(request())
253
- const response2 = await router.fetch(request())
254
-
255
- expect(response1.status).toBe(200)
256
- expect(response2.status).toBe(429)
257
- })
258
-
259
- it('applies a conservative subnet scale for unknown IPs', async () => {
260
- const router = new Router()
261
- router.use(
262
- rateLimit({
263
- getUserId: async () => 'user-1',
264
- getIpAddress: async () => 'not-an-ip',
265
- getGlobalPeakConcurrentUsers: async () => 1,
266
- baseWindowMs: 1000,
267
- baseMaxRequestsPerBaseWindow: 5,
268
- anonymousIpMultiplier: 1,
269
- addRetryAfterHeader: false,
270
- buckets: [{ windowMs: 1000, scale: 1 }],
271
- endpointLimits: [],
272
- includeQueryInEndpointKey: false,
273
- scales: { subnet: { ipv4: 0.4, ipv6: 0.2 } },
274
- }),
275
- )
276
- router.add({
277
- method: HttpMethod.GET,
278
- path: '/unknown-ip',
279
- handler: () => new Response('ok'),
280
- })
281
-
282
- const request = () => new Request('https://example.com/unknown-ip')
283
-
284
- const response1 = await router.fetch(request())
285
- const response2 = await router.fetch(request())
286
-
287
- expect(response1.status).toBe(200)
288
- expect(response2.status).toBe(429)
289
- })
290
-
291
- it('uses ipv4-mapped IPv6 addresses as ipv4', async () => {
292
- const router = new Router()
293
- router.use(
294
- rateLimit({
295
- getUserId: async () => 'user-1',
296
- getGlobalPeakConcurrentUsers: async () => 1,
297
- baseWindowMs: 1000,
298
- baseMaxRequestsPerBaseWindow: 5,
299
- anonymousIpMultiplier: 1,
300
- addRetryAfterHeader: false,
301
- buckets: [{ windowMs: 1000, scale: 1 }],
302
- endpointLimits: [],
303
- includeQueryInEndpointKey: false,
304
- scales: { subnet: { ipv4: 0.2, ipv6: 10 } },
305
- }),
306
- )
307
- router.add({
308
- method: HttpMethod.GET,
309
- path: '/ipv4-mapped',
310
- handler: () => new Response('ok'),
311
- })
312
-
313
- const request = () =>
314
- new Request('https://example.com/ipv4-mapped', {
315
- headers: { 'x-forwarded-for': '::ffff:203.0.113.80' },
316
- })
317
-
318
- const response1 = await router.fetch(request())
319
- const response2 = await router.fetch(request())
320
-
321
- expect(response1.status).toBe(200)
322
- expect(response2.status).toBe(429)
323
- })
324
-
325
- it('uses ipv4Prefix:32 so subnet keys are per-IP (no grouping)', async () => {
326
- const router = new Router()
327
- router.use(
328
- rateLimit({
329
- getUserId: async () => null, // force identity=ip so subnet layer is what we’re testing
330
- getGlobalPeakConcurrentUsers: async () => 1,
331
- baseWindowMs: 1000,
332
- baseMaxRequestsPerBaseWindow: 5,
333
- anonymousIpMultiplier: 1,
334
- addRetryAfterHeader: false,
335
- buckets: [{ windowMs: 1000, scale: 1 }],
336
- endpointLimits: [],
337
- includeQueryInEndpointKey: false,
338
- // subnet max = bucketMax * ipv4 scale = 5 * 0.2 = 1 request per /prefix per second
339
- scales: { subnet: { ipv4: 0.2, ipv6: 10, ipv4Prefix: 32 } },
340
- }),
341
- )
342
- router.add({
343
- method: HttpMethod.GET,
344
- path: '/ipv4-prefix',
345
- handler: () => new Response('ok'),
346
- })
347
-
348
- const request = (ip: string) =>
349
- new Request('https://example.com/ipv4-prefix', {
350
- headers: { 'x-forwarded-for': ip },
351
- })
352
-
353
- // Different IPs => different /32 subnet keys => both allowed (each gets 1)
354
- const r1 = await router.fetch(request('203.0.113.10'))
355
- const r2 = await router.fetch(request('203.0.113.11'))
356
-
357
- // Second request from same IP => same /32 subnet key => blocked
358
- const r3 = await router.fetch(request('203.0.113.10'))
359
- const r4 = await router.fetch(request('203.0.113.11'))
360
-
361
- expect(r1.status).toBe(200)
362
- expect(r2.status).toBe(200)
363
- expect(r3.status).toBe(429)
364
- expect(r4.status).toBe(429)
365
- })
366
-
367
- it('uses ipv4Prefix:16 so all IPs in the same /16 share a subnet bucket', async () => {
368
- const router = new Router()
369
- router.use(
370
- rateLimit({
371
- getUserId: async () => null, // force identity=ip; subnet layer is under test
372
- getGlobalPeakConcurrentUsers: async () => 1,
373
- baseWindowMs: 1000,
374
- baseMaxRequestsPerBaseWindow: 5,
375
- anonymousIpMultiplier: 1,
376
- addRetryAfterHeader: false,
377
- buckets: [{ windowMs: 1000, scale: 1 }],
378
- endpointLimits: [],
379
- includeQueryInEndpointKey: false,
380
- // subnet max = 5 * 0.2 = 1 request per /16 per second
381
- scales: { subnet: { ipv4: 0.2, ipv6: 10, ipv4Prefix: 16 } },
382
- }),
383
- )
384
- router.add({
385
- method: HttpMethod.GET,
386
- path: '/ipv4-prefix',
387
- handler: () => new Response('ok'),
388
- })
389
-
390
- const request = (ip: string) =>
391
- new Request('https://example.com/ipv4-prefix', {
392
- headers: { 'x-forwarded-for': ip },
393
- })
394
-
395
- // Same /16: 203.0.0.0/16
396
- const r1 = await router.fetch(request('203.0.113.10'))
397
- const r2 = await router.fetch(request('203.0.200.42'))
398
-
399
- // Different /16: 198.51.0.0/16
400
- const r3 = await router.fetch(request('198.51.100.5'))
401
-
402
- expect(r1.status).toBe(200)
403
- // Second request from same /16 exceeds subnet bucket
404
- expect(r2.status).toBe(429)
405
-
406
- // Different /16 should not be affected
407
- expect(r3.status).toBe(200)
408
- })
409
-
410
- it('uses custom ipv6Prefix for subnet grouping', async () => {
411
- const router = new Router()
412
- router.use(
413
- rateLimit({
414
- getUserId: async () => 'user-1',
415
- getGlobalPeakConcurrentUsers: async () => 1,
416
- baseWindowMs: 1000,
417
- baseMaxRequestsPerBaseWindow: 5,
418
- anonymousIpMultiplier: 1,
419
- addRetryAfterHeader: false,
420
- buckets: [{ windowMs: 1000, scale: 1 }],
421
- endpointLimits: [],
422
- includeQueryInEndpointKey: false,
423
- scales: { subnet: { ipv4: 10, ipv6: 0.2, ipv6Prefix: 128 } },
424
- }),
425
- )
426
- router.add({
427
- method: HttpMethod.GET,
428
- path: '/ipv6-prefix',
429
- handler: () => new Response('ok'),
430
- })
431
-
432
- const request = (ip: string) =>
433
- new Request('https://example.com/ipv6-prefix', {
434
- headers: { 'x-forwarded-for': ip },
435
- })
436
-
437
- const response1 = await router.fetch(request('2001:db8:abcd:12::1'))
438
- const response2 = await router.fetch(request('2001:db8:abcd:12::2'))
439
- const response3 = await router.fetch(request('2001:db8:abcd:12::1'))
440
-
441
- expect(response1.status).toBe(200)
442
- expect(response2.status).toBe(200)
443
- expect(response3.status).toBe(429)
444
- })
445
-
446
- it('prefixes subnet keys to avoid collisions', async () => {
447
- const storage = new CaptureStorage()
448
- const router = new Router()
449
- router.use(
450
- rateLimit({
451
- getUserId: async () => 'user-1',
452
- getGlobalPeakConcurrentUsers: async () => 1,
453
- baseWindowMs: 1000,
454
- baseMaxRequestsPerBaseWindow: 1,
455
- anonymousIpMultiplier: 1,
456
- addRetryAfterHeader: false,
457
- buckets: [{ windowMs: 1000, scale: 1 }],
458
- endpointLimits: [],
459
- includeQueryInEndpointKey: false,
460
- storage,
461
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
462
- }),
463
- )
464
- router.add({
465
- method: HttpMethod.GET,
466
- path: '/subnet-prefix',
467
- handler: () => new Response('ok'),
468
- })
469
-
470
- await router.fetch(
471
- new Request('https://example.com/subnet-prefix', {
472
- headers: { 'x-forwarded-for': '203.0.113.90' },
473
- }),
474
- )
475
-
476
- expect(storage.keys.some((key) => key.startsWith('subnet:'))).toBe(true)
477
- })
478
-
479
- it('enforces country limits', async () => {
480
- const router = new Router()
481
- router.use(
482
- rateLimit({
483
- getUserId: async () => 'user-1',
484
- getGlobalPeakConcurrentUsers: async () => 1,
485
- baseWindowMs: 1000,
486
- baseMaxRequestsPerBaseWindow: 5,
487
- anonymousIpMultiplier: 1,
488
- addRetryAfterHeader: false,
489
- buckets: [{ windowMs: 1000, scale: 1 }],
490
- endpointLimits: [],
491
- includeQueryInEndpointKey: false,
492
- getCountryCode: async () => 'US',
493
- scales: {
494
- subnet: { ipv4: 10, ipv6: 10 },
495
- country: { US: 0.2, other: 1, unknown: 1 },
496
- },
497
- }),
498
- )
499
- router.add({
500
- method: HttpMethod.GET,
501
- path: '/country',
502
- handler: () => new Response('ok'),
503
- })
504
-
505
- const request = () =>
506
- new Request('https://example.com/country', {
507
- headers: { 'x-forwarded-for': '203.0.113.20' },
508
- })
509
-
510
- const response1 = await router.fetch(request())
511
- const response2 = await router.fetch(request())
512
-
513
- expect(response1.status).toBe(200)
514
- expect(response2.status).toBe(429)
515
- })
516
-
517
- it('calls getGlobalPeakConcurrentUsers per request', async () => {
518
- let calls = 0
519
- const router = new Router()
520
- router.use(
521
- rateLimit({
522
- getUserId: async () => 'user-1',
523
- getGlobalPeakConcurrentUsers: async () => {
524
- calls += 1
525
- return 1
526
- },
527
- baseWindowMs: 1000,
528
- baseMaxRequestsPerBaseWindow: 5,
529
- anonymousIpMultiplier: 1,
530
- addRetryAfterHeader: false,
531
- buckets: [{ windowMs: 1000, scale: 1 }],
532
- endpointLimits: [],
533
- includeQueryInEndpointKey: false,
534
- getCountryCode: async () => 'US',
535
- scales: {
536
- subnet: { ipv4: 10, ipv6: 10 },
537
- country: { US: 1, other: 1, unknown: 1 },
538
- },
539
- }),
540
- )
541
- router.add({
542
- method: HttpMethod.GET,
543
- path: '/peak',
544
- handler: () => new Response('ok'),
545
- })
546
-
547
- const request = () =>
548
- new Request('https://example.com/peak', {
549
- headers: { 'x-forwarded-for': '203.0.113.21' },
550
- })
551
-
552
- const response1 = await router.fetch(request())
553
- const response2 = await router.fetch(request())
554
-
555
- expect(response1.status).toBe(200)
556
- expect(response2.status).toBe(200)
557
- expect(calls).toBe(2)
558
- })
559
-
560
- it('enforces ASN limits', async () => {
561
- const router = new Router()
562
- router.use(
563
- rateLimit({
564
- getUserId: async () => 'user-1',
565
- getGlobalPeakConcurrentUsers: async () => 1,
566
- baseWindowMs: 1000,
567
- baseMaxRequestsPerBaseWindow: 5,
568
- anonymousIpMultiplier: 1,
569
- addRetryAfterHeader: false,
570
- buckets: [{ windowMs: 1000, scale: 1 }],
571
- endpointLimits: [],
572
- includeQueryInEndpointKey: false,
573
- getAsn: async () => ({ asn: 15169, organization: 'Google' }),
574
- scales: {
575
- subnet: { ipv4: 10, ipv6: 10 },
576
- asnClass: { cloud: 0.2, unknown: 1 },
577
- },
578
- }),
579
- )
580
- router.add({
581
- method: HttpMethod.GET,
582
- path: '/asn',
583
- handler: () => new Response('ok'),
584
- })
585
-
586
- const request = () =>
587
- new Request('https://example.com/asn', {
588
- headers: { 'x-forwarded-for': '203.0.113.30' },
589
- })
590
-
591
- const response1 = await router.fetch(request())
592
- const response2 = await router.fetch(request())
593
-
594
- expect(response1.status).toBe(200)
595
- expect(response2.status).toBe(429)
596
- })
597
-
598
- it.skipIf(!hasMaxmindDbs)('enforces ASN limits via MaxMind database', async () => {
599
- const router = new Router()
600
- router.use(
601
- rateLimit({
602
- getUserId: async () => 'user-1',
603
- getGlobalPeakConcurrentUsers: async () => 1,
604
- baseWindowMs: 10_000,
605
- baseMaxRequestsPerBaseWindow: 5,
606
- anonymousIpMultiplier: 1,
607
- addRetryAfterHeader: false,
608
- buckets: [{ windowMs: 10_000, scale: 1 }],
609
- endpointLimits: [],
610
- includeQueryInEndpointKey: false,
611
- maxmindAsnDatabase: asnDbPath,
612
- scales: {
613
- subnet: { ipv4: 10, ipv6: 10 },
614
- asnClass: { cloud: 0.2, unknown: 1 },
615
- },
616
- }),
617
- )
618
- router.add({
619
- method: HttpMethod.GET,
620
- path: '/asn-maxmind',
621
- handler: () => new Response('ok'),
622
- })
623
-
624
- const request = () =>
625
- new Request('https://example.com/asn-maxmind', {
626
- headers: { 'x-forwarded-for': '8.8.8.8' },
627
- })
628
-
629
- const response1 = await router.fetch(request())
630
- const response2 = await router.fetch(request())
631
-
632
- expect(response1.status).toBe(200)
633
- expect(response2.status).toBe(429)
634
- })
635
-
636
- it('uses a custom getIpAddress implementation', async () => {
637
- const router = new Router()
638
- router.use(
639
- rateLimit({
640
- getUserId: async () => null,
641
- getIpAddress: async (ctx) => ctx.request.headers.get('x-test-ip') ?? 'unknown',
642
- getGlobalPeakConcurrentUsers: async () => 1,
643
- baseWindowMs: 1000,
644
- baseMaxRequestsPerBaseWindow: 1,
645
- anonymousIpMultiplier: 1,
646
- addRetryAfterHeader: false,
647
- buckets: [{ windowMs: 1000, scale: 1 }],
648
- endpointLimits: [],
649
- includeQueryInEndpointKey: false,
650
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
651
- }),
652
- )
653
- router.add({
654
- method: HttpMethod.GET,
655
- path: '/custom-ip',
656
- handler: () => new Response('ok'),
657
- })
658
-
659
- const request = (ip: string) =>
660
- new Request('https://example.com/custom-ip', {
661
- headers: { 'x-test-ip': ip },
662
- })
663
-
664
- const response1 = await router.fetch(request('203.0.113.40'))
665
- const response2 = await router.fetch(request('198.51.100.42'))
666
-
667
- expect(response1.status).toBe(200)
668
- expect(response2.status).toBe(200)
669
- })
670
-
671
- it.skipIf(!hasMaxmindDbs)('enforces country limits via MaxMind database', async () => {
672
- const router = new Router()
673
- router.use(
674
- rateLimit({
675
- getUserId: async () => 'user-1',
676
- getGlobalPeakConcurrentUsers: async () => 1,
677
- baseWindowMs: 1000,
678
- baseMaxRequestsPerBaseWindow: 5,
679
- anonymousIpMultiplier: 1,
680
- addRetryAfterHeader: false,
681
- buckets: [{ windowMs: 1000, scale: 1 }],
682
- endpointLimits: [],
683
- includeQueryInEndpointKey: false,
684
- maxmindCountryDatabase: countryDbPath,
685
- scales: {
686
- subnet: { ipv4: 10, ipv6: 10 },
687
- country: { US: 0.2, other: 1, unknown: 1 },
688
- },
689
- }),
690
- )
691
- router.add({
692
- method: HttpMethod.GET,
693
- path: '/country-maxmind',
694
- handler: () => new Response('ok'),
695
- })
696
-
697
- const request = () =>
698
- new Request('https://example.com/country-maxmind', {
699
- headers: { 'x-forwarded-for': '8.8.8.8' },
700
- })
701
-
702
- const response1 = await router.fetch(request())
703
- const response2 = await router.fetch(request())
704
-
705
- expect(response1.status).toBe(200)
706
- expect(response2.status).toBe(429)
707
- })
708
-
709
- it.skipIf(!hasMaxmindDbs)('falls back to registered country from MaxMind', async () => {
710
- const router = new Router()
711
- router.use(
712
- rateLimit({
713
- getUserId: async () => 'user-1',
714
- getGlobalPeakConcurrentUsers: async () => 1,
715
- baseWindowMs: 1000,
716
- baseMaxRequestsPerBaseWindow: 5,
717
- anonymousIpMultiplier: 1,
718
- addRetryAfterHeader: false,
719
- buckets: [{ windowMs: 1000, scale: 1 }],
720
- endpointLimits: [],
721
- includeQueryInEndpointKey: false,
722
- maxmindCountryDatabase: countryDbPath,
723
- scales: {
724
- subnet: { ipv4: 10, ipv6: 10 },
725
- country: { AU: 0.2, other: 1, unknown: 1 },
726
- },
727
- }),
728
- )
729
- router.add({
730
- method: HttpMethod.GET,
731
- path: '/country-registered',
732
- handler: () => new Response('ok'),
733
- })
734
-
735
- const request = () =>
736
- new Request('https://example.com/country-registered', {
737
- headers: { 'x-forwarded-for': '1.1.1.1' },
738
- })
739
-
740
- const response1 = await router.fetch(request())
741
- const response2 = await router.fetch(request())
742
-
743
- expect(response1.status).toBe(200)
744
- expect(response2.status).toBe(429)
745
- })
746
-
747
- it('applies non-default baseWindowMs and anonymousIpMultiplier', async () => {
748
- const router = new Router()
749
- router.use(
750
- rateLimit({
751
- getUserId: async () => null,
752
- getGlobalPeakConcurrentUsers: async () => 1,
753
- baseWindowMs: 2000,
754
- baseMaxRequestsPerBaseWindow: 2,
755
- anonymousIpMultiplier: 0.5,
756
- addRetryAfterHeader: false,
757
- buckets: [{ windowMs: 2000, scale: 1 }],
758
- endpointLimits: [],
759
- includeQueryInEndpointKey: false,
760
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
761
- }),
762
- )
763
- router.add({
764
- method: HttpMethod.GET,
765
- path: '/anon',
766
- handler: () => new Response('ok'),
767
- })
768
-
769
- const request = () =>
770
- new Request('https://example.com/anon', {
771
- headers: { 'x-forwarded-for': '203.0.113.50' },
772
- })
773
-
774
- const response1 = await router.fetch(request())
775
- const response2 = await router.fetch(request())
776
-
777
- expect(response1.status).toBe(200)
778
- expect(response2.status).toBe(429)
779
- })
780
-
781
- it('does not expire in-memory counters before the window resets', async () => {
782
- const router = new Router()
783
- router.use(
784
- rateLimit({
785
- getUserId: async () => null,
786
- getGlobalPeakConcurrentUsers: async () => 1,
787
- baseWindowMs: 10_000,
788
- baseMaxRequestsPerBaseWindow: 1,
789
- anonymousIpMultiplier: 1,
790
- addRetryAfterHeader: false,
791
- buckets: [{ windowMs: 10_000, scale: 1 }],
792
- endpointLimits: [],
793
- includeQueryInEndpointKey: false,
794
- inMemory: { ttlMs: 0 },
795
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
796
- }),
797
- )
798
- router.add({
799
- method: HttpMethod.GET,
800
- path: '/ttl',
801
- handler: () => new Response('ok'),
802
- })
803
-
804
- const request = () =>
805
- new Request('https://example.com/ttl', {
806
- headers: { 'x-forwarded-for': '203.0.113.100' },
807
- })
808
-
809
- const response1 = await router.fetch(request())
810
- await delay(30)
811
- const response2 = await router.fetch(request())
812
-
813
- expect(response1.status).toBe(200)
814
- expect(response2.status).toBe(429)
815
- })
816
-
817
- it('enforces multiple buckets', async () => {
818
- const router = new Router()
819
- router.use(
820
- rateLimit({
821
- getUserId: async () => 'user-1',
822
- getGlobalPeakConcurrentUsers: async () => 1,
823
- baseWindowMs: 1000,
824
- baseMaxRequestsPerBaseWindow: 3,
825
- anonymousIpMultiplier: 1,
826
- addRetryAfterHeader: false,
827
- buckets: [
828
- { windowMs: 1000, scale: 1 },
829
- { windowMs: 4000, scale: 0.1 },
830
- ],
831
- endpointLimits: [],
832
- includeQueryInEndpointKey: false,
833
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
834
- }),
835
- )
836
- router.add({
837
- method: HttpMethod.GET,
838
- path: '/buckets',
839
- handler: () => new Response('ok'),
840
- })
841
-
842
- const request = () =>
843
- new Request('https://example.com/buckets', {
844
- headers: { 'x-forwarded-for': '203.0.113.60' },
845
- })
846
-
847
- const response1 = await router.fetch(request())
848
- const response2 = await router.fetch(request())
849
-
850
- expect(response1.status).toBe(200)
851
- expect(response2.status).toBe(429)
852
- })
853
-
854
- it('omits query separator when the query is empty', async () => {
855
- const storage = new CaptureStorage()
856
- const router = new Router()
857
- router.use(
858
- rateLimit({
859
- getUserId: async () => null,
860
- getGlobalPeakConcurrentUsers: async () => 1,
861
- baseWindowMs: 1000,
862
- baseMaxRequestsPerBaseWindow: 1,
863
- anonymousIpMultiplier: 1,
864
- addRetryAfterHeader: false,
865
- buckets: [{ windowMs: 1000, scale: 1 }],
866
- endpointLimits: [{ pattern: '/empty-query', limit: { GET: 1 } }],
867
- includeQueryInEndpointKey: true,
868
- storage,
869
- scales: { subnet: { ipv4: 10, ipv6: 10 } },
870
- }),
871
- )
872
- router.add({
873
- method: HttpMethod.GET,
874
- path: '/empty-query',
875
- handler: () => new Response('ok'),
876
- })
877
-
878
- await router.fetch(
879
- new Request('https://example.com/empty-query', {
880
- headers: { 'x-forwarded-for': '203.0.113.110' },
881
- }),
882
- )
883
-
884
- expect(storage.keys.some((key) => key.includes('routeq:GET:/empty-query?'))).toBe(false)
885
- })
886
- })