@mpen/routekit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.mts +4 -0
- package/dist/client/react.d.mts +178 -0
- package/dist/client/react.mjs +142 -0
- package/dist/client.d.mts +433 -0
- package/dist/client.mjs +264 -0
- package/dist/content-BuDOmhH_.mjs +102 -0
- package/dist/core-CzUCxvGk.d.mts +140 -0
- package/dist/core-DbmQauwS.mjs +81 -0
- package/dist/handlers.d.mts +72 -0
- package/dist/handlers.mjs +153 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1152 -0
- package/dist/middleware.d.mts +388 -0
- package/dist/middleware.mjs +1222 -0
- package/dist/request-Dn0zc-xm.mjs +1025 -0
- package/dist/response/content.d.mts +79 -0
- package/dist/response/content.mjs +2 -0
- package/dist/response/json-rpc.d.mts +1 -0
- package/dist/response/json-rpc.mjs +1 -0
- package/dist/response/problem/valibot.d.mts +230 -0
- package/dist/response/problem/valibot.mjs +258 -0
- package/dist/response/problem.d.mts +415 -0
- package/dist/response/problem.mjs +183 -0
- package/dist/response/status.d.mts +45 -0
- package/dist/response/status.mjs +2 -0
- package/dist/responses-B379Ep9Y.d.mts +296 -0
- package/dist/responses-BpVrgeYi.mjs +101 -0
- package/dist/router-Cwb7ak0J.d.mts +1819 -0
- package/dist/routes.d.mts +282 -0
- package/dist/routes.mjs +311 -0
- package/dist/status-C-8mw-FB.mjs +59 -0
- package/dist/valibot-D7liFYyB.d.mts +290 -0
- package/dist/valibot-Du97X-TS.mjs +326 -0
- package/package.json +8 -2
- package/src/bin/gen-api-client.test.ts +0 -70
- package/src/bin/gen-api-client.ts +0 -986
- package/src/client/headers.ts +0 -31
- package/src/client/index.ts +0 -8
- package/src/client/promise.ts +0 -11
- package/src/client/react/index.test.tsx +0 -266
- package/src/client/react/index.ts +0 -431
- package/src/client/responses.test.ts +0 -151
- package/src/client/responses.ts +0 -278
- package/src/client/transport.ts +0 -74
- package/src/client/transports/body-codec.ts +0 -61
- package/src/client/transports/fetch.ts +0 -113
- package/src/client/tsconfig.json +0 -9
- package/src/client/types.ts +0 -15
- package/src/client/url.ts +0 -31
- package/src/index.ts +0 -63
- package/src/router/fetch-types.ts +0 -13
- package/src/router/handlers/index.ts +0 -2
- package/src/router/handlers/openapi/index.ts +0 -2
- package/src/router/handlers/openapi/openapi.ts +0 -293
- package/src/router/integration/zod-openapi.test.ts +0 -74
- package/src/router/lib/charset.test.ts +0 -22
- package/src/router/lib/charset.ts +0 -133
- package/src/router/lib/collections.ts +0 -3
- package/src/router/lib/format.test.ts +0 -67
- package/src/router/lib/format.ts +0 -35
- package/src/router/lib/host.ts +0 -4
- package/src/router/lib/json-schema.ts +0 -6
- package/src/router/lib/media-type.test.ts +0 -122
- package/src/router/lib/media-type.ts +0 -289
- package/src/router/lib/pathname.test.ts +0 -18
- package/src/router/lib/pathname.ts +0 -19
- package/src/router/lib/route-names.ts +0 -70
- package/src/router/lib/route-normalize.test.ts +0 -36
- package/src/router/lib/route-normalize.ts +0 -67
- package/src/router/lib/schema-merge.ts +0 -56
- package/src/router/middleware/accept-ctx.test.ts +0 -33
- package/src/router/middleware/accept-ctx.ts +0 -12
- package/src/router/middleware/body-limit.test.ts +0 -112
- package/src/router/middleware/body-limit.ts +0 -121
- package/src/router/middleware/content-type-context.ts +0 -0
- package/src/router/middleware/cors.test.ts +0 -269
- package/src/router/middleware/cors.ts +0 -490
- package/src/router/middleware/csrf.test.ts +0 -106
- package/src/router/middleware/csrf.ts +0 -192
- package/src/router/middleware/define.ts +0 -249
- package/src/router/middleware/index.ts +0 -34
- package/src/router/middleware/jsxhtml-response.ts +0 -0
- package/src/router/middleware/oas-swagger.ts +0 -0
- package/src/router/middleware/rate-limit.test.ts +0 -886
- package/src/router/middleware/rate-limit.ts +0 -920
- package/src/router/middleware/request-id-ctx.test.ts +0 -183
- package/src/router/middleware/request-id-ctx.ts +0 -135
- package/src/router/middleware/request-logger-format.test.ts +0 -16
- package/src/router/middleware/request-logger-format.ts +0 -269
- package/src/router/middleware/request-logger.test.ts +0 -267
- package/src/router/middleware/request-logger.ts +0 -131
- package/src/router/middleware/start-time-ctx.ts +0 -5
- package/src/router/request.ts +0 -611
- package/src/router/response/core.ts +0 -181
- package/src/router/response/directives.ts +0 -233
- package/src/router/response/formats/content/bodyless.ts +0 -54
- package/src/router/response/formats/content/content.ts +0 -79
- package/src/router/response/formats/content/index.ts +0 -2
- package/src/router/response/formats/json-rpc/index.ts +0 -2
- package/src/router/response/formats/problem/badRequest.ts +0 -90
- package/src/router/response/formats/problem/conflict.ts +0 -90
- package/src/router/response/formats/problem/created.ts +0 -40
- package/src/router/response/formats/problem/index.ts +0 -27
- package/src/router/response/formats/problem/notFound.ts +0 -90
- package/src/router/response/formats/problem/permissionDenied.ts +0 -90
- package/src/router/response/formats/problem/problem.test.ts +0 -888
- package/src/router/response/formats/problem/rateLimited.ts +0 -90
- package/src/router/response/formats/problem/responses.ts +0 -219
- package/src/router/response/formats/problem/root-errors.ts +0 -48
- package/src/router/response/formats/problem/sessionExpired.ts +0 -90
- package/src/router/response/formats/problem/types.ts +0 -170
- package/src/router/response/formats/problem/unauthenticated.ts +0 -90
- package/src/router/response/formats/problem/valibot.ts +0 -410
- package/src/router/response/formats/status/index.ts +0 -1
- package/src/router/response/formats/status/responses.ts +0 -59
- package/src/router/response/formats/status/status.test.ts +0 -21
- package/src/router/response/framers.ts +0 -85
- package/src/router/response/index.ts +0 -28
- package/src/router/response/openapi.test.ts +0 -96
- package/src/router/response/openapi.ts +0 -1
- package/src/router/response/serializers.ts +0 -66
- package/src/router/response/stream.ts +0 -35
- package/src/router/router.test.ts +0 -1571
- package/src/router/router.ts +0 -1965
- package/src/router/routes/index.ts +0 -46
- package/src/router/routes/valibot/index.ts +0 -18
- package/src/router/routes/valibot/valibot.ts +0 -1393
- package/src/router/routes/valibot.test.ts +0 -286
- package/src/router/routes/zod/index.ts +0 -18
- package/src/router/routes/zod/zod.ts +0 -1318
- package/src/router/routes/zod.test.ts +0 -280
- package/src/router/server-interface.ts +0 -31
- 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
|
-
}
|