@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,920 +0,0 @@
1
- import type { HttpMethod } from '@mpen/http'
2
- import { HttpStatus } from '@mpen/http'
3
- import { type RoutekitResponse } from '../response'
4
- import { text } from '../response/formats/content'
5
- import { defineMiddleware, type DeclaredMiddleware } from './define'
6
- import type { AnyContext, RequestContext } from '../types'
7
-
8
- export interface RateBucket {
9
- windowMs: number
10
- scale: number
11
- }
12
-
13
- export interface AsnRecord {
14
- asn: number
15
- organization: string
16
- }
17
-
18
- export interface RateLimitIdentityInput {
19
- userId: string | number | null | undefined
20
- ipAddress: string
21
- }
22
-
23
- export type MethodLimit = number | Partial<Record<HttpMethod, number>>
24
-
25
- export interface EndpointLimit {
26
- pattern: string | URLPattern | ConstructorParameters<typeof URLPattern>
27
- /**
28
- * Max requests per baseWindowMs before bucket expansion.
29
- */
30
- limit: MethodLimit
31
- }
32
-
33
- export type AsnClass =
34
- | 'cloud'
35
- | 'hosting'
36
- | 'cdn'
37
- | 'residential'
38
- | 'mobile'
39
- | 'unknown'
40
- | (string & {})
41
-
42
- export interface FixedWindowCounter {
43
- resetAtMs: number
44
- count: number
45
- }
46
-
47
- export interface RateLimitStorage<C> {
48
- /**
49
- * Return null/undefined if missing or expired.
50
- */
51
- readCounter(ctx: C, key: string): Promise<FixedWindowCounter | null | undefined>
52
-
53
- /**
54
- * ttlMs MUST be respected by the implementation.
55
- * Redis implementations should set key expiry accordingly.
56
- */
57
- writeCounter(ctx: C, key: string, counter: FixedWindowCounter, ttlMs: number): Promise<void>
58
- }
59
-
60
- export interface RateLimitOptions<C> {
61
- // --- identity
62
- getUserId(ctx: C): Promise<string | number | null | undefined>
63
-
64
- /**
65
- * Default implementation:
66
- * - X-Forwarded-For (first IP)
67
- * - else X-Real-IP
68
- */
69
- getIpAddress?: (ctx: C) => Promise<string>
70
-
71
- /**
72
- * Used to scale country + ASN caps.
73
- * Called once during initialization.
74
- */
75
- getGlobalPeakConcurrentUsers(ctx: C): Promise<number>
76
-
77
- // --- base definition
78
- baseWindowMs: number
79
- baseMaxRequestsPerBaseWindow: number
80
-
81
- /**
82
- * Applied when userId is falsy and identity falls back to IP.
83
- */
84
- anonymousIpMultiplier: number
85
-
86
- addRetryAfterHeader: boolean
87
-
88
- // --- buckets
89
- buckets: RateBucket[]
90
-
91
- // --- endpoint handling
92
- normalizeQuery?: (url: URL) => string
93
- endpointLimits: EndpointLimit[]
94
- includeQueryInEndpointKey: boolean
95
-
96
- // --- MaxMind (optional)
97
- maxmindAsnDatabase?: string
98
- maxmindCountryDatabase?: string
99
-
100
- // --- resolvers (optional overrides)
101
- getAsn?(ctx: C, input: RateLimitIdentityInput): Promise<AsnRecord | null>
102
-
103
- getCountryCode?(ctx: C, input: RateLimitIdentityInput): Promise<string | null>
104
-
105
- // --- ASN classification
106
- asnToClass?: (asn: number, organization: string) => AsnClass
107
-
108
- // --- scaling
109
- scales: {
110
- country?: Record<string, number> & {
111
- unknown: number
112
- other: number
113
- }
114
-
115
- asnClass?: Record<string, number> & {
116
- unknown: number
117
- }
118
-
119
- subnet: {
120
- ipv4: number
121
- ipv6: number
122
- byAsnClass?: Record<string, number> & { unknown: number }
123
- ipv4Prefix?: number // default 24
124
- ipv6Prefix?: number // default 64
125
- }
126
- }
127
-
128
- // --- storage
129
- /**
130
- * If omitted, use in-memory LRU store.
131
- */
132
- storage?: RateLimitStorage<C>
133
-
134
- inMemory?: {
135
- maxEntries?: number
136
- /**
137
- * Extra retention (ms) after the fixed window reset before eviction.
138
- * Defaults to 1000ms for a small safety buffer.
139
- */
140
- ttlMs?: number
141
- }
142
- }
143
-
144
- type EndpointMatcher = {
145
- pattern: URLPattern
146
- limit: MethodLimit
147
- }
148
-
149
- type SubnetInfo = {
150
- key: string
151
- version: 'ipv4' | 'ipv6' | 'unknown'
152
- }
153
-
154
- type GeoResolvers<C> = {
155
- getAsn: (ctx: C, input: RateLimitIdentityInput) => Promise<AsnRecord | null>
156
- getCountry: (ctx: C, input: RateLimitIdentityInput) => Promise<string | null>
157
- }
158
-
159
- type MaxmindModule = {
160
- open: (path: string) => Promise<{ get: (ip: string) => any }>
161
- }
162
-
163
- const DEFAULT_MAX_ENTRIES = 100_000
164
-
165
- const ASN_OVERRIDES: Record<number, AsnClass> = {
166
- // Cloud hyperscalers
167
- 16509: 'cloud', // AWS
168
- 14618: 'cloud', // Amazon.com (enterprise)
169
- 15169: 'cloud', // Google
170
- 8075: 'cloud', // Microsoft Azure
171
- 31898: 'cloud', // Oracle Cloud
172
- 45102: 'cloud', // Alibaba Cloud
173
- 132203: 'cloud', // Tencent Cloud
174
- 36351: 'cloud', // IBM Cloud
175
-
176
- // CDN
177
- 13335: 'cdn', // Cloudflare
178
- 54113: 'cdn', // Fastly
179
- 20940: 'cdn', // Akamai
180
-
181
- // Other cloud/hosting
182
- 14061: 'cloud', // DigitalOcean
183
- 20473: 'cloud', // Vultr
184
- 63949: 'cloud', // Linode (Akamai)
185
- 24940: 'hosting', // Hetzner
186
- 16276: 'hosting', // OVHcloud
187
- 12876: 'hosting', // Scaleway
188
- 8560: 'hosting', // IONOS
189
- 47583: 'hosting', // Hostinger
190
- 22612: 'hosting', // Namecheap
191
- }
192
-
193
- const KEYWORDS: Record<Extract<AsnClass, string>, string[]> = {
194
- cdn: ['cloudflare', 'fastly', 'akamai', 'cdn'],
195
- cloud: [
196
- 'amazon',
197
- 'aws',
198
- 'google',
199
- 'gcp',
200
- 'microsoft',
201
- 'azure',
202
- 'oracle',
203
- 'alibaba',
204
- 'tencent',
205
- 'digitalocean',
206
- 'vultr',
207
- 'linode',
208
- 'ibm',
209
- ],
210
- hosting: [
211
- 'hosting',
212
- 'host',
213
- 'colo',
214
- 'datacenter',
215
- 'data center',
216
- 'ovh',
217
- 'scaleway',
218
- 'ionos',
219
- 'hostinger',
220
- 'namecheap',
221
- ],
222
- mobile: ['mobile', 'wireless', 'cellular', 'lte', '5g'],
223
- residential: ['telecom', 'broadband', 'cable', 'fiber'],
224
- unknown: [],
225
- }
226
-
227
- class InMemoryRateLimitStorage<C> implements RateLimitStorage<C> {
228
- private readonly maxEntries: number
229
- private readonly store = new Map<string, { counter: FixedWindowCounter; expiresAtMs: number }>()
230
-
231
- constructor(maxEntries: number) {
232
- this.maxEntries = maxEntries
233
- }
234
-
235
- async readCounter(_ctx: C, key: string): Promise<FixedWindowCounter | null> {
236
- const entry = this.store.get(key)
237
- if (!entry) return null
238
- if (entry.expiresAtMs <= Date.now()) {
239
- this.store.delete(key)
240
- return null
241
- }
242
- this.store.delete(key)
243
- this.store.set(key, entry)
244
- return entry.counter
245
- }
246
-
247
- async writeCounter(
248
- _ctx: C,
249
- key: string,
250
- counter: FixedWindowCounter,
251
- ttlMs: number,
252
- ): Promise<void> {
253
- const expiresAtMs = Date.now() + ttlMs
254
- if (this.store.has(key)) this.store.delete(key)
255
- this.store.set(key, { counter, expiresAtMs })
256
- this.evictIfNeeded()
257
- }
258
-
259
- private evictIfNeeded() {
260
- while (this.store.size > this.maxEntries) {
261
- const firstKey = this.store.keys().next().value
262
- if (!firstKey) return
263
- this.store.delete(firstKey)
264
- }
265
- }
266
- }
267
-
268
- function defaultGetIpAddress<Ctx extends object = AnyContext>(
269
- ctx: RequestContext<Ctx>,
270
- ): Promise<string> {
271
- const forwardedFor = ctx.request.headers.get('x-forwarded-for')
272
- if (forwardedFor) {
273
- const first = forwardedFor.split(',')[0]?.trim()
274
- if (first) return Promise.resolve(cleanIpAddress(first))
275
- }
276
- const realIp = ctx.request.headers.get('x-real-ip')
277
- if (realIp) return Promise.resolve(cleanIpAddress(realIp.trim()))
278
- return Promise.resolve('unknown')
279
- }
280
-
281
- function cleanIpAddress(ip: string): string {
282
- let value = ip.trim()
283
- if (!value) return 'unknown'
284
- if (value.startsWith('[')) {
285
- const closing = value.indexOf(']')
286
- if (closing !== -1) value = value.slice(1, closing)
287
- }
288
- const zoneIndex = value.indexOf('%')
289
- if (zoneIndex !== -1) value = value.slice(0, zoneIndex)
290
- if (value.includes('.')) {
291
- const lastSegment = value.split(':').pop()?.trim()
292
- if (lastSegment && parseIpv4Address(lastSegment)) return lastSegment
293
- if (value.split(':').length === 2) {
294
- const segment = value.split(':')[0]
295
- if (!segment) return 'unknown'
296
- value = segment
297
- }
298
- }
299
- if (parseIpv4Address(value) || parseIpv6Hextets(value)) return value
300
- return 'unknown'
301
- }
302
-
303
- function normalizeQueryString(url: URL): string {
304
- const entries = Array.from(url.searchParams.entries())
305
- entries.sort((a, b) => {
306
- const keyCompare = a[0].localeCompare(b[0])
307
- if (keyCompare !== 0) return keyCompare
308
- return a[1].localeCompare(b[1])
309
- })
310
- const normalized = new URLSearchParams()
311
- for (const [key, value] of entries) {
312
- normalized.append(key, value)
313
- }
314
- return normalized.toString()
315
- }
316
-
317
- function getBucketMax(baseMax: number, baseWindowMs: number, bucket: RateBucket): number {
318
- return Math.floor(baseMax * (bucket.windowMs / baseWindowMs) * bucket.scale)
319
- }
320
-
321
- function getBucketResetAt(nowMs: number, windowMs: number): number {
322
- return Math.floor(nowMs / windowMs) * windowMs + windowMs
323
- }
324
-
325
- async function applyFixedWindowLimit<C>(
326
- ctx: C,
327
- storage: RateLimitStorage<C>,
328
- key: string,
329
- windowMs: number,
330
- max: number,
331
- nowMs: number,
332
- retentionMs: number,
333
- ): Promise<{ allowed: boolean; resetAtMs: number }> {
334
- const stored = await storage.readCounter(ctx, key)
335
- const resetAtMs = getBucketResetAt(nowMs, windowMs)
336
- const counter = stored && stored.resetAtMs > nowMs ? stored : { resetAtMs, count: 0 }
337
- const nextCount = counter.count + 1
338
- const updated: FixedWindowCounter = { resetAtMs: counter.resetAtMs, count: nextCount }
339
- const ttlAligned = Math.max(1, counter.resetAtMs - nowMs + 1000)
340
- const ttlEffective = ttlAligned + Math.max(0, retentionMs)
341
- await storage.writeCounter(ctx, key, updated, ttlEffective)
342
-
343
- return { allowed: nextCount <= max, resetAtMs: counter.resetAtMs }
344
- }
345
-
346
- function toURLPattern(pattern: EndpointLimit['pattern']): URLPattern {
347
- const URLPatternCtor = ensureURLPattern()
348
- if (pattern instanceof URLPatternCtor) return pattern
349
- if (Array.isArray(pattern)) return new URLPatternCtor(...pattern)
350
- if (typeof pattern === 'string') {
351
- if (pattern.startsWith('http://') || pattern.startsWith('https://')) {
352
- return new URLPatternCtor(pattern)
353
- }
354
- return new URLPatternCtor({ pathname: pattern })
355
- }
356
- return new URLPatternCtor(pattern)
357
- }
358
-
359
- function resolveEndpointLimit(
360
- method: string,
361
- url: URL,
362
- matchers: EndpointMatcher[],
363
- ): number | null {
364
- const normalizedMethod = method.toUpperCase()
365
- let minLimit: number | null = null
366
- for (const matcher of matchers) {
367
- if (!matcher.pattern.test(url)) continue
368
- const limit = matcher.limit
369
- const methodLimit =
370
- typeof limit === 'number' ? limit : limit[normalizedMethod as HttpMethod]
371
- if (methodLimit == null) continue
372
- minLimit = minLimit == null ? methodLimit : Math.min(minLimit, methodLimit)
373
- }
374
- return minLimit
375
- }
376
-
377
- function parseIpv4Address(ip: string): number[] | null {
378
- const parts = ip.split('.')
379
- if (parts.length !== 4) return null
380
- const bytes: number[] = []
381
- for (const part of parts) {
382
- if (!part) return null
383
- const value = Number(part)
384
- if (!Number.isInteger(value) || value < 0 || value > 255) return null
385
- bytes.push(value)
386
- }
387
- return bytes
388
- }
389
-
390
- function parseIpv6Hextets(ip: string): number[] | null {
391
- let value = ip.toLowerCase()
392
- const zoneIndex = value.indexOf('%')
393
- if (zoneIndex !== -1) value = value.slice(0, zoneIndex)
394
-
395
- const halves = value.split('::')
396
- if (halves.length > 2) return null
397
-
398
- const left = halves[0] ? halves[0].split(':') : []
399
- const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []
400
-
401
- const leftParsed = parseIpv6Segments(left)
402
- if (!leftParsed) return null
403
- const rightParsed = parseIpv6Segments(right)
404
- if (!rightParsed) return null
405
-
406
- if (halves.length === 1) {
407
- if (leftParsed.length !== 8) return null
408
- return leftParsed
409
- }
410
-
411
- const missing = 8 - (leftParsed.length + rightParsed.length)
412
- if (missing < 0) return null
413
- return [...leftParsed, ...new Array<number>(missing).fill(0), ...rightParsed]
414
- }
415
-
416
- function parseIpv6Segments(parts: string[]): number[] | null {
417
- const hextets: number[] = []
418
- for (let index = 0; index < parts.length; index += 1) {
419
- const part = parts[index]
420
- if (!part) return null
421
- if (part.includes('.')) {
422
- if (index !== parts.length - 1) return null
423
- const bytes = parseIpv4Address(part)
424
- if (!bytes) return null
425
- if (bytes.length !== 4) return null
426
- const [b0, b1, b2, b3] = bytes
427
- if (b0 == null || b1 == null || b2 == null || b3 == null) return null
428
- hextets.push((b0 << 8) | b1, (b2 << 8) | b3)
429
- continue
430
- }
431
- const value = Number.parseInt(part, 16)
432
- if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null
433
- hextets.push(value)
434
- }
435
- return hextets
436
- }
437
-
438
- function deriveSubnet(ipAddress: string, ipv4Prefix = 24, ipv6Prefix = 64): SubnetInfo {
439
- const ipv4 = parseIpv4Address(ipAddress)
440
- if (ipv4) {
441
- if (ipv4.length !== 4) {
442
- return { key: 'subnet:unknown', version: 'unknown' }
443
- }
444
- const [o1, o2, o3] = ipv4
445
- if (o1 == null || o2 == null || o3 == null) {
446
- return { key: 'subnet:unknown', version: 'unknown' }
447
- }
448
- if (!Number.isInteger(ipv4Prefix) || ipv4Prefix < 0 || ipv4Prefix > 32) {
449
- return { key: 'subnet:unknown', version: 'unknown' }
450
- }
451
- if (ipv4Prefix !== 24) {
452
- const ipInt = ((o1 << 24) | (o2 << 16) | (o3 << 8) | (ipv4[3] ?? 0)) >>> 0
453
- const mask = ipv4Prefix === 0 ? 0 : (0xffffffff << (32 - ipv4Prefix)) >>> 0
454
- const network = ipInt & mask
455
- const b0 = (network >>> 24) & 0xff
456
- const b1 = (network >>> 16) & 0xff
457
- const b2 = (network >>> 8) & 0xff
458
- const b3 = network & 0xff
459
- return {
460
- key: `subnet:ip4:${b0}.${b1}.${b2}.${b3}/${ipv4Prefix}`,
461
- version: 'ipv4',
462
- }
463
- }
464
- return {
465
- key: `subnet:ip24:${o1}.${o2}.${o3}.0/24`,
466
- version: 'ipv4',
467
- }
468
- }
469
-
470
- const ipv6 = parseIpv6Hextets(ipAddress)
471
- if (ipv6 && ipv6.length === 8) {
472
- const h1 = ipv6[0]!
473
- const h2 = ipv6[1]!
474
- const h3 = ipv6[2]!
475
- const h4 = ipv6[3]!
476
- if (!Number.isInteger(ipv6Prefix) || ipv6Prefix < 0 || ipv6Prefix > 128) {
477
- return { key: 'subnet:unknown', version: 'unknown' }
478
- }
479
- if (ipv6Prefix !== 64) {
480
- const masked = maskIpv6(ipv6, ipv6Prefix)
481
- const keyParts = masked.map((value) => value.toString(16))
482
- return {
483
- key: `subnet:ip6:${keyParts.join(':')}/${ipv6Prefix}`,
484
- version: 'ipv6',
485
- }
486
- }
487
- return {
488
- key: `subnet:ip64:${h1.toString(16)}:${h2.toString(16)}:${h3.toString(16)}:${h4.toString(16)}::/64`,
489
- version: 'ipv6',
490
- }
491
- }
492
-
493
- return { key: 'subnet:unknown', version: 'unknown' }
494
- }
495
-
496
- function maskIpv6(hextets: number[], prefix: number): number[] {
497
- const masked: number[] = []
498
- let remaining = prefix
499
- for (const hextet of hextets) {
500
- if (remaining >= 16) {
501
- masked.push(hextet)
502
- remaining -= 16
503
- continue
504
- }
505
- if (remaining <= 0) {
506
- masked.push(0)
507
- continue
508
- }
509
- const mask = ((0xffff << (16 - remaining)) & 0xffff) >>> 0
510
- masked.push(hextet & mask)
511
- remaining = 0
512
- }
513
- return masked
514
- }
515
-
516
- function defaultAsnToClass(asn: number, organization: string): AsnClass {
517
- const override = ASN_OVERRIDES[asn]
518
- if (override) return override
519
- const normalizedOrg = organization.toLowerCase()
520
- const entries = Object.entries(KEYWORDS) as Array<[string, string[]]>
521
- for (const [asnClass, keywords] of entries) {
522
- if (asnClass === 'unknown') continue
523
- if (keywords.some((keyword) => normalizedOrg.includes(keyword))) {
524
- return asnClass as AsnClass
525
- }
526
- }
527
- return 'unknown'
528
- }
529
-
530
- function normalizeAsnClass(asnClass: AsnClass | null | undefined): AsnClass {
531
- if (!asnClass) return 'unknown'
532
- return asnClass
533
- }
534
-
535
- function normalizeCountryCode(code: string | null | undefined): string | null {
536
- if (!code) return null
537
- const trimmed = code.trim()
538
- if (!trimmed) return null
539
- return trimmed.toUpperCase()
540
- }
541
-
542
- async function loadMaxmindModule(): Promise<MaxmindModule> {
543
- try {
544
- return (await import('maxmind')) as MaxmindModule
545
- } catch (err) {
546
- throw new Error(
547
- 'maxmind is required for ASN or country lookups; install it as a peer dependency',
548
- { cause: err },
549
- )
550
- }
551
- }
552
-
553
- function createGeoResolvers<C>(options: RateLimitOptions<C>): GeoResolvers<C> {
554
- let maxmindModulePromise: Promise<MaxmindModule> | null = null
555
- let asnReaderPromise: Promise<{ get: (ip: string) => any } | null> | null = null
556
- let countryReaderPromise: Promise<{ get: (ip: string) => any } | null> | null = null
557
-
558
- const loadMaxmind = () => {
559
- if (!maxmindModulePromise) {
560
- maxmindModulePromise = loadMaxmindModule()
561
- }
562
- return maxmindModulePromise
563
- }
564
-
565
- const loadAsnReader = async (): Promise<{ get: (ip: string) => any } | null> => {
566
- if (!options.maxmindAsnDatabase) return null
567
- if (!asnReaderPromise) {
568
- asnReaderPromise = loadMaxmind().then((module) =>
569
- module.open(options.maxmindAsnDatabase!),
570
- )
571
- }
572
- return asnReaderPromise
573
- }
574
-
575
- const loadCountryReader = async (): Promise<{ get: (ip: string) => any } | null> => {
576
- if (!options.maxmindCountryDatabase) return null
577
- if (!countryReaderPromise) {
578
- countryReaderPromise = loadMaxmind().then((module) =>
579
- module.open(options.maxmindCountryDatabase!),
580
- )
581
- }
582
- return countryReaderPromise
583
- }
584
-
585
- const getAsn =
586
- options.getAsn ??
587
- (async (_ctx: C, input) => {
588
- const reader = await loadAsnReader()
589
- if (!reader) return null
590
- const record = reader.get(input.ipAddress)
591
- const asn = record?.autonomous_system_number ?? record?.autonomousSystemNumber
592
- const organization =
593
- record?.autonomous_system_organization ??
594
- record?.autonomousSystemOrganization ??
595
- record?.organization
596
- if (typeof asn !== 'number' || !organization) return null
597
- return { asn, organization: String(organization) }
598
- })
599
-
600
- const getCountry =
601
- options.getCountryCode ??
602
- (async (_ctx: C, input) => {
603
- const reader = await loadCountryReader()
604
- if (!reader) return null
605
- const record = reader.get(input.ipAddress)
606
- const code =
607
- record?.country?.iso_code ??
608
- record?.country?.isoCode ??
609
- record?.registered_country?.iso_code ??
610
- record?.registeredCountry?.isoCode ??
611
- record?.represented_country?.iso_code ??
612
- record?.representedCountry?.isoCode
613
- return normalizeCountryCode(typeof code === 'string' ? code : null)
614
- })
615
-
616
- return { getAsn, getCountry }
617
- }
618
-
619
- function formatRetryAfterSeconds(resetAtMs: number, nowMs: number): string {
620
- const seconds = Math.max(1, Math.ceil((resetAtMs - nowMs) / 1000))
621
- return String(seconds)
622
- }
623
-
624
- function buildTooManyRequests(
625
- addRetryAfterHeader: boolean,
626
- resetAtMs: number,
627
- nowMs: number,
628
- ): RoutekitResponse<string, HttpStatus.TOO_MANY_REQUESTS> {
629
- const response = text('Too Many Requests', { status: HttpStatus.TOO_MANY_REQUESTS })
630
- if (addRetryAfterHeader) {
631
- response.headers.set('Retry-After', formatRetryAfterSeconds(resetAtMs, nowMs))
632
- }
633
- return response
634
- }
635
-
636
- /**
637
- * Enforce per-identity, subnet, ASN, country, and endpoint rate limits using fixed-window buckets.
638
- *
639
- * @example
640
- * ```ts
641
- * router.use(rateLimit({
642
- * getUserId: async ({user}) => user?.id,
643
- * getGlobalPeakConcurrentUsers: async () => 5000,
644
- * baseWindowMs: 60_000,
645
- * baseMaxRequestsPerBaseWindow: 120,
646
- * anonymousIpMultiplier: 0.5,
647
- * addRetryAfterHeader: true,
648
- * buckets: [{windowMs: 60_000, scale: 1}],
649
- * endpointLimits: [{pattern: '/login', limit: {POST: 10}}],
650
- * includeQueryInEndpointKey: false,
651
- * scales: {subnet: {ipv4: 2, ipv6: 1}},
652
- * }))
653
- * ```
654
- *
655
- * @param options - Configuration for identity sources, buckets, scaling, and storage.
656
- * @returns Middleware that enforces rate limits and returns 429 responses when exceeded.
657
- */
658
- export function rateLimit<Ctx extends object = AnyContext>(
659
- options: RateLimitOptions<RequestContext<Ctx>>,
660
- ): DeclaredMiddleware<{}, Ctx> {
661
- if (!options.buckets.length) {
662
- throw new Error('rateLimit requires at least one bucket')
663
- }
664
-
665
- const storage =
666
- options.storage ??
667
- new InMemoryRateLimitStorage<RequestContext<Ctx>>(
668
- options.inMemory?.maxEntries ?? DEFAULT_MAX_ENTRIES,
669
- )
670
- const getIpAddress = options.getIpAddress ?? defaultGetIpAddress
671
- const normalizeQuery = options.normalizeQuery ?? normalizeQueryString
672
- const endpointMatchers = options.endpointLimits.map((limit) => ({
673
- pattern: toURLPattern(limit.pattern),
674
- limit: limit.limit,
675
- }))
676
- const asnToClass = options.asnToClass ?? defaultAsnToClass
677
- const geoResolvers = createGeoResolvers(options)
678
-
679
- const asnLimitEnabled = Boolean(
680
- options.scales.asnClass && (options.getAsn || options.maxmindAsnDatabase),
681
- )
682
- const countryLimitEnabled = Boolean(
683
- options.scales.country && (options.getCountryCode || options.maxmindCountryDatabase),
684
- )
685
- const subnetAsnClassEnabled = Boolean(
686
- options.scales.subnet.byAsnClass && (options.getAsn || options.maxmindAsnDatabase),
687
- )
688
- const retentionMs = options.storage ? 0 : (options.inMemory?.ttlMs ?? 1000)
689
-
690
- return defineMiddleware({
691
- responses: {
692
- [HttpStatus.TOO_MANY_REQUESTS]: {
693
- schema: { type: 'string' },
694
- parse(value: unknown): string {
695
- if (typeof value !== 'string') {
696
- throw new TypeError('Rate limit responses must contain a string body.')
697
- }
698
- return value
699
- },
700
- },
701
- },
702
- async run(ctx, { next, forward, respond }) {
703
- const reject = (addRetryAfterHeader: boolean, resetAtMs: number, nowMs: number) =>
704
- respond(buildTooManyRequests(addRetryAfterHeader, resetAtMs, nowMs))
705
- const nowMs = (ctx as any).startTime ?? Date.now()
706
- const url = ctx.request.url
707
- const method = ctx.request.method.toUpperCase()
708
-
709
- const userId = await options.getUserId(ctx)
710
- const ipAddress = cleanIpAddress(await getIpAddress(ctx))
711
-
712
- const identityKey = userId ? `identity:user:${userId}` : `identity:ip:${ipAddress}`
713
- const identityMultiplier = userId ? 1 : options.anonymousIpMultiplier
714
-
715
- const subnet = deriveSubnet(
716
- ipAddress,
717
- options.scales.subnet.ipv4Prefix ?? 24,
718
- options.scales.subnet.ipv6Prefix ?? 64,
719
- )
720
- const subnetScaleBase =
721
- subnet.version === 'ipv6'
722
- ? options.scales.subnet.ipv6
723
- : subnet.version === 'ipv4'
724
- ? options.scales.subnet.ipv4
725
- : Math.min(options.scales.subnet.ipv4, options.scales.subnet.ipv6)
726
- let asnRecord: AsnRecord | null = null
727
- let asnClass: AsnClass = 'unknown'
728
-
729
- if (asnLimitEnabled || subnetAsnClassEnabled) {
730
- asnRecord = await geoResolvers.getAsn(ctx, { userId, ipAddress })
731
- asnClass = normalizeAsnClass(
732
- asnRecord ? asnToClass(asnRecord.asn, asnRecord.organization) : 'unknown',
733
- )
734
- }
735
-
736
- const subnetClassScale = options.scales.subnet.byAsnClass?.[asnClass] ?? 1
737
- const subnetMultiplier = subnetScaleBase * subnetClassScale
738
-
739
- let countryCode: string | null = null
740
- if (countryLimitEnabled) {
741
- countryCode = normalizeCountryCode(
742
- await geoResolvers.getCountry(ctx, { userId, ipAddress }),
743
- )
744
- }
745
-
746
- const endpointBaseLimit = endpointMatchers.length
747
- ? resolveEndpointLimit(method, url, endpointMatchers)
748
- : null
749
- const endpointKeyBase = buildEndpointKeyBase(
750
- url,
751
- method,
752
- options.includeQueryInEndpointKey,
753
- normalizeQuery,
754
- )
755
- const endpointIdentityKey = endpointKeyBase
756
- ? `endpoint:${endpointKeyBase}:${identityKey}`
757
- : null
758
- const endpointSubnetKey = endpointKeyBase
759
- ? `endpoint:${endpointKeyBase}:${subnet.key}`
760
- : null
761
-
762
- const globalPeak =
763
- asnLimitEnabled || countryLimitEnabled
764
- ? await options.getGlobalPeakConcurrentUsers(ctx)
765
- : 1
766
-
767
- for (const bucket of options.buckets) {
768
- const bucketMax = getBucketMax(
769
- options.baseMaxRequestsPerBaseWindow,
770
- options.baseWindowMs,
771
- bucket,
772
- )
773
- const bucketSuffix = `:w${bucket.windowMs}`
774
-
775
- const identityMax = Math.floor(bucketMax * identityMultiplier)
776
- const identityResult = await applyFixedWindowLimit(
777
- ctx,
778
- storage,
779
- `${identityKey}${bucketSuffix}`,
780
- bucket.windowMs,
781
- identityMax,
782
- nowMs,
783
- retentionMs,
784
- )
785
- if (!identityResult.allowed) {
786
- return reject(options.addRetryAfterHeader, identityResult.resetAtMs, nowMs)
787
- }
788
-
789
- const subnetMax = Math.floor(bucketMax * subnetMultiplier)
790
- const subnetResult = await applyFixedWindowLimit(
791
- ctx,
792
- storage,
793
- `${subnet.key}${bucketSuffix}`,
794
- bucket.windowMs,
795
- subnetMax,
796
- nowMs,
797
- retentionMs,
798
- )
799
- if (!subnetResult.allowed) {
800
- return reject(options.addRetryAfterHeader, subnetResult.resetAtMs, nowMs)
801
- }
802
-
803
- if (asnLimitEnabled && options.scales.asnClass) {
804
- const asnScale =
805
- options.scales.asnClass[asnClass] ?? options.scales.asnClass.unknown
806
- const asnMax = Math.floor(bucketMax * asnScale * globalPeak)
807
- const asnKey = asnRecord ? `asn:${asnRecord.asn}` : 'asn:unknown'
808
- const asnResult = await applyFixedWindowLimit(
809
- ctx,
810
- storage,
811
- `${asnKey}${bucketSuffix}`,
812
- bucket.windowMs,
813
- asnMax,
814
- nowMs,
815
- retentionMs,
816
- )
817
- if (!asnResult.allowed) {
818
- return reject(options.addRetryAfterHeader, asnResult.resetAtMs, nowMs)
819
- }
820
- }
821
-
822
- if (countryLimitEnabled && options.scales.country) {
823
- const countryScale = countryCode
824
- ? (options.scales.country[countryCode] ?? options.scales.country.other)
825
- : options.scales.country.unknown
826
- const countryMax = Math.floor(bucketMax * countryScale * globalPeak)
827
- const countryKey = countryCode ? `country:${countryCode}` : 'country:unknown'
828
- const countryResult = await applyFixedWindowLimit(
829
- ctx,
830
- storage,
831
- `${countryKey}${bucketSuffix}`,
832
- bucket.windowMs,
833
- countryMax,
834
- nowMs,
835
- retentionMs,
836
- )
837
- if (!countryResult.allowed) {
838
- return reject(options.addRetryAfterHeader, countryResult.resetAtMs, nowMs)
839
- }
840
- }
841
-
842
- if (
843
- endpointBaseLimit != null &&
844
- endpointKeyBase &&
845
- endpointIdentityKey &&
846
- endpointSubnetKey
847
- ) {
848
- const endpointBucketMax = getBucketMax(
849
- endpointBaseLimit,
850
- options.baseWindowMs,
851
- bucket,
852
- )
853
- const endpointIdentityMax = Math.floor(endpointBucketMax * identityMultiplier)
854
- const endpointSubnetMax = Math.floor(endpointBucketMax * subnetMultiplier)
855
-
856
- const endpointIdentityResult = await applyFixedWindowLimit(
857
- ctx,
858
- storage,
859
- `${endpointIdentityKey}${bucketSuffix}`,
860
- bucket.windowMs,
861
- endpointIdentityMax,
862
- nowMs,
863
- retentionMs,
864
- )
865
- if (!endpointIdentityResult.allowed) {
866
- return reject(
867
- options.addRetryAfterHeader,
868
- endpointIdentityResult.resetAtMs,
869
- nowMs,
870
- )
871
- }
872
-
873
- const endpointSubnetResult = await applyFixedWindowLimit(
874
- ctx,
875
- storage,
876
- `${endpointSubnetKey}${bucketSuffix}`,
877
- bucket.windowMs,
878
- endpointSubnetMax,
879
- nowMs,
880
- retentionMs,
881
- )
882
- if (!endpointSubnetResult.allowed) {
883
- return reject(
884
- options.addRetryAfterHeader,
885
- endpointSubnetResult.resetAtMs,
886
- nowMs,
887
- )
888
- }
889
- }
890
- }
891
-
892
- return forward(await next())
893
- },
894
- })
895
- }
896
-
897
- function buildEndpointKeyBase(
898
- url: URL,
899
- method: string,
900
- includeQuery: boolean,
901
- normalizeQuery: (url: URL) => string,
902
- ): string | null {
903
- const pathname = url.pathname
904
- const normalizedMethod = method.toUpperCase()
905
- if (!includeQuery) {
906
- return `route:${normalizedMethod}:${pathname}`
907
- }
908
- const query = normalizeQuery(url)
909
- if (!query) {
910
- return `routeq:${normalizedMethod}:${pathname}`
911
- }
912
- return `routeq:${normalizedMethod}:${pathname}?${query}`
913
- }
914
-
915
- function ensureURLPattern(): typeof URLPattern {
916
- if (typeof URLPattern === 'undefined') {
917
- throw new Error('URLPattern is not available in this runtime')
918
- }
919
- return URLPattern
920
- }