@stacksjs/bun-router 0.0.15 → 0.0.17
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/auth.d.ts +22 -8
- package/dist/chunk-1ahs68ys.js +18 -0
- package/dist/{chunk-j0e7z7hd.js → chunk-cgptvjdf.js} +153 -75
- package/dist/chunk-g3ybefhg.js +1923 -0
- package/dist/cli.js +13 -11
- package/dist/container/container.d.ts +1 -0
- package/dist/container/contextual-binding.d.ts +0 -1
- package/dist/container/decorators.d.ts +2 -5
- package/dist/container/index.d.ts +16 -0
- package/dist/container/index.js +322 -0
- package/dist/file-serving/static-files.d.ts +16 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.js +1086 -1270
- package/dist/middleware/cors.d.ts +9 -0
- package/dist/middleware/csrf.d.ts +7 -0
- package/dist/middleware/ddos_protection.d.ts +16 -1
- package/dist/middleware/rate_limit.d.ts +7 -1
- package/dist/middleware/request_tracer.d.ts +15 -9
- package/dist/middleware/response_cache.d.ts +2 -1
- package/dist/request/context.d.ts +12 -1
- package/dist/request/macros.d.ts +40 -2
- package/dist/response/macros.d.ts +3 -3
- package/dist/router/fluent-routing.d.ts +50 -13
- package/dist/router/handler-resolver.d.ts +8 -0
- package/dist/router/index.d.ts +2 -0
- package/dist/router/route-compiler.d.ts +8 -10
- package/dist/router/route-trie.d.ts +7 -1
- package/dist/router/router.d.ts +13 -2
- package/dist/router/validation-integration.d.ts +10 -4
- package/dist/types/middleware-types.d.ts +1 -1
- package/dist/types/route-inference.d.ts +9 -2
- package/dist/types.d.ts +62 -9
- package/dist/utils.d.ts +1 -1
- package/package.json +11 -1
- package/src/auth.ts +32 -12
- package/src/container/container.ts +58 -12
- package/src/container/contextual-binding.ts +4 -1
- package/src/container/decorators.ts +16 -7
- package/src/container/index.ts +49 -0
- package/src/errors/circuit-breaker.ts +3 -2
- package/src/errors/graceful-degradation.ts +2 -0
- package/src/file-serving/static-files.ts +144 -7
- package/src/index.ts +53 -1
- package/src/middleware/cors.ts +76 -8
- package/src/middleware/csrf.ts +80 -22
- package/src/middleware/ddos_protection.ts +27 -3
- package/src/middleware/input_validation.ts +10 -6
- package/src/middleware/performance_alerting.ts +2 -0
- package/src/middleware/performance_monitor.ts +2 -0
- package/src/middleware/rate_limit.ts +7 -1
- package/src/middleware/request_signing.ts +2 -0
- package/src/middleware/request_tracer.ts +34 -17
- package/src/middleware/response_cache.ts +48 -13
- package/src/middleware/session.ts +3 -3
- package/src/observability/metrics.ts +1 -1
- package/src/request/context.ts +50 -3
- package/src/request/enhanced-request.ts +20 -1
- package/src/request/macros.ts +183 -49
- package/src/response/macros.ts +3 -3
- package/src/router/file-based-routing.ts +17 -10
- package/src/router/fluent-routing.ts +117 -48
- package/src/router/group-organization.ts +17 -1
- package/src/router/handler-resolver.ts +36 -0
- package/src/router/http-methods.ts +33 -3
- package/src/router/index.ts +6 -0
- package/src/router/middleware.ts +3 -0
- package/src/router/optimized-route-matching.ts +58 -9
- package/src/router/route-compiler.ts +62 -73
- package/src/router/route-matching.ts +19 -0
- package/src/router/route-trie.ts +34 -48
- package/src/router/router.ts +72 -60
- package/src/router/server.ts +361 -139
- package/src/router/validation-integration.ts +11 -5
- package/src/testing/auth-testing.ts +6 -4
- package/src/types/middleware-types.ts +7 -4
- package/src/types/route-inference.ts +29 -14
- package/src/types.ts +63 -7
- package/src/url.ts +7 -2
- package/src/utils.ts +107 -39
- package/src/validation/validator.ts +7 -4
- package/src/websocket/clustering.ts +8 -1
- package/dist/router/fluent-router.d.ts +0 -315
- package/src/router/fluent-router.ts +0 -927
package/src/request/macros.ts
CHANGED
|
@@ -9,6 +9,58 @@ import type { EnhancedRequest } from '../types'
|
|
|
9
9
|
export interface RequestMacro {
|
|
10
10
|
name: string
|
|
11
11
|
handler: (this: EnhancedRequest, ...args: any[]) => any
|
|
12
|
+
/**
|
|
13
|
+
* Accessor macros are installed as getters on the macro prototype —
|
|
14
|
+
* `req.<name>` invokes the handler instead of returning a function.
|
|
15
|
+
* Assignment shadows the accessor with an own property.
|
|
16
|
+
*/
|
|
17
|
+
accessor?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Hoisted hot-path helpers — compiled once instead of per call
|
|
21
|
+
const MOBILE_REGEX = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
|
|
22
|
+
const BOT_REGEX = /bot|crawler|spider|crawling/i
|
|
23
|
+
const GLOB_PATTERN_CACHE = new Map<string, RegExp>()
|
|
24
|
+
|
|
25
|
+
/** Parsed-state cache slots attached to a request on first use. */
|
|
26
|
+
interface RequestParseCache {
|
|
27
|
+
_parsedURL?: URL
|
|
28
|
+
_parsedCookies?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getParsedURL(req: EnhancedRequest): URL {
|
|
32
|
+
const cache = req as EnhancedRequest & RequestParseCache
|
|
33
|
+
if (!cache._parsedURL) {
|
|
34
|
+
cache._parsedURL = new URL(req.url)
|
|
35
|
+
}
|
|
36
|
+
return cache._parsedURL
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getParsedCookies(req: EnhancedRequest): Record<string, string> {
|
|
40
|
+
const cache = req as EnhancedRequest & RequestParseCache
|
|
41
|
+
if (!cache._parsedCookies) {
|
|
42
|
+
const cookies: Record<string, string> = {}
|
|
43
|
+
const cookieHeader = req.headers.get('cookie')
|
|
44
|
+
if (cookieHeader) {
|
|
45
|
+
for (const cookie of cookieHeader.split(';')) {
|
|
46
|
+
const eqIndex = cookie.indexOf('=')
|
|
47
|
+
if (eqIndex === -1)
|
|
48
|
+
continue
|
|
49
|
+
const key = cookie.slice(0, eqIndex).trim()
|
|
50
|
+
if (!key)
|
|
51
|
+
continue
|
|
52
|
+
const value = cookie.slice(eqIndex + 1).trim()
|
|
53
|
+
try {
|
|
54
|
+
cookies[key] = decodeURIComponent(value)
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
cookies[key] = value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
cache._parsedCookies = cookies
|
|
62
|
+
}
|
|
63
|
+
return cache._parsedCookies
|
|
12
64
|
}
|
|
13
65
|
|
|
14
66
|
/**
|
|
@@ -16,12 +68,15 @@ export interface RequestMacro {
|
|
|
16
68
|
*/
|
|
17
69
|
class RequestMacroRegistry {
|
|
18
70
|
private macros: Map<string, RequestMacro> = new Map()
|
|
71
|
+
// Bumped on every mutation so cached macro prototypes can be rebuilt
|
|
72
|
+
private epoch = 0
|
|
19
73
|
|
|
20
74
|
/**
|
|
21
75
|
* Register a request macro
|
|
22
76
|
*/
|
|
23
|
-
register(name: string, handler: (this: EnhancedRequest, ...args: any[]) => any): void {
|
|
24
|
-
this.macros.set(name, { name, handler })
|
|
77
|
+
register(name: string, handler: (this: EnhancedRequest, ...args: any[]) => any, accessor = false): void {
|
|
78
|
+
this.macros.set(name, { name, handler, accessor })
|
|
79
|
+
this.epoch++
|
|
25
80
|
}
|
|
26
81
|
|
|
27
82
|
/**
|
|
@@ -49,7 +104,10 @@ class RequestMacroRegistry {
|
|
|
49
104
|
* Remove a macro
|
|
50
105
|
*/
|
|
51
106
|
remove(name: string): boolean {
|
|
52
|
-
|
|
107
|
+
const removed = this.macros.delete(name)
|
|
108
|
+
if (removed)
|
|
109
|
+
this.epoch++
|
|
110
|
+
return removed
|
|
53
111
|
}
|
|
54
112
|
|
|
55
113
|
/**
|
|
@@ -57,6 +115,14 @@ class RequestMacroRegistry {
|
|
|
57
115
|
*/
|
|
58
116
|
clear(): void {
|
|
59
117
|
this.macros.clear()
|
|
118
|
+
this.epoch++
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Monotonic registry version (see `RequestWithMacros.applyMacros`)
|
|
123
|
+
*/
|
|
124
|
+
getEpoch(): number {
|
|
125
|
+
return this.epoch
|
|
60
126
|
}
|
|
61
127
|
}
|
|
62
128
|
|
|
@@ -65,6 +131,15 @@ class RequestMacroRegistry {
|
|
|
65
131
|
*/
|
|
66
132
|
export const requestMacroRegistry: RequestMacroRegistry = new RequestMacroRegistry()
|
|
67
133
|
|
|
134
|
+
// Marker symbols for prototype-based macro attachment
|
|
135
|
+
const MACRO_PROTO = Symbol('bunRouterMacroProto')
|
|
136
|
+
const MACRO_BASE = Symbol('bunRouterMacroBase')
|
|
137
|
+
|
|
138
|
+
// Cache of generated macro prototypes, keyed by the request's original
|
|
139
|
+
// prototype. Rebuilt whenever the registry epoch moves.
|
|
140
|
+
let macroProtoCache = new WeakMap<object, Record<PropertyKey, unknown>>()
|
|
141
|
+
let macroProtoEpoch = -1
|
|
142
|
+
|
|
68
143
|
/**
|
|
69
144
|
* Request class with macro support
|
|
70
145
|
*/
|
|
@@ -77,15 +152,71 @@ export class RequestWithMacros {
|
|
|
77
152
|
}
|
|
78
153
|
|
|
79
154
|
/**
|
|
80
|
-
*
|
|
155
|
+
* Register an accessor macro: `req.<name>` evaluates the getter
|
|
156
|
+
* (instead of exposing a callable). Assignment to the property
|
|
157
|
+
* shadows the accessor with an own value, so consumers that set
|
|
158
|
+
* `req.<name> = ...` keep working.
|
|
159
|
+
*/
|
|
160
|
+
static macroAccessor(name: string, getter: (this: EnhancedRequest) => any): void {
|
|
161
|
+
requestMacroRegistry.register(name, getter, true)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Apply macros to a request object.
|
|
166
|
+
*
|
|
167
|
+
* Macros are attached via a shared prototype inserted between the
|
|
168
|
+
* request and its original prototype — one `setPrototypeOf` per
|
|
169
|
+
* request. The previous implementation looped over every macro and
|
|
170
|
+
* `bind`-assigned it, costing ~40 property writes and closures per
|
|
171
|
+
* request on the dispatch hot path. Macro handlers receive the
|
|
172
|
+
* request as `this`, so no per-request binding is needed.
|
|
81
173
|
*/
|
|
82
174
|
static applyMacros(request: EnhancedRequest): EnhancedRequest {
|
|
83
|
-
const
|
|
175
|
+
const epoch = requestMacroRegistry.getEpoch()
|
|
176
|
+
if (epoch !== macroProtoEpoch) {
|
|
177
|
+
macroProtoCache = new WeakMap()
|
|
178
|
+
macroProtoEpoch = epoch
|
|
179
|
+
}
|
|
84
180
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
181
|
+
let base = (Object.getPrototypeOf(request) ?? Object.prototype) as Record<PropertyKey, unknown>
|
|
182
|
+
if (base[MACRO_PROTO]) {
|
|
183
|
+
// Request was already enhanced. Current generation: nothing to do.
|
|
184
|
+
if (macroProtoCache.get(base[MACRO_BASE] as object) === base) {
|
|
185
|
+
return request
|
|
186
|
+
}
|
|
187
|
+
// Stale generation: rebuild on top of the original prototype
|
|
188
|
+
base = base[MACRO_BASE] as Record<PropertyKey, unknown>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let proto = macroProtoCache.get(base)
|
|
192
|
+
if (!proto) {
|
|
193
|
+
proto = Object.create(base) as Record<PropertyKey, unknown>
|
|
194
|
+
for (const macro of requestMacroRegistry.all()) {
|
|
195
|
+
if (macro.accessor) {
|
|
196
|
+
Object.defineProperty(proto, macro.name, {
|
|
197
|
+
get: macro.handler,
|
|
198
|
+
// Assignment shadows the accessor with an own property
|
|
199
|
+
set(value: unknown) {
|
|
200
|
+
Object.defineProperty(this, macro.name, {
|
|
201
|
+
value,
|
|
202
|
+
writable: true,
|
|
203
|
+
configurable: true,
|
|
204
|
+
enumerable: true,
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
configurable: true,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
proto[macro.name] = macro.handler
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
Object.defineProperty(proto, MACRO_PROTO, { value: true })
|
|
215
|
+
Object.defineProperty(proto, MACRO_BASE, { value: base })
|
|
216
|
+
macroProtoCache.set(base, proto)
|
|
217
|
+
}
|
|
88
218
|
|
|
219
|
+
Object.setPrototypeOf(request, proto)
|
|
89
220
|
return request
|
|
90
221
|
}
|
|
91
222
|
|
|
@@ -150,18 +281,14 @@ export const BuiltInRequestMacros = {
|
|
|
150
281
|
* Check if request is from mobile device
|
|
151
282
|
*/
|
|
152
283
|
isMobile(this: EnhancedRequest): boolean {
|
|
153
|
-
|
|
154
|
-
const mobileRegex = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
|
|
155
|
-
return mobileRegex.test(userAgent)
|
|
284
|
+
return MOBILE_REGEX.test(this.headers.get('user-agent') || '')
|
|
156
285
|
},
|
|
157
286
|
|
|
158
287
|
/**
|
|
159
288
|
* Check if request is from bot/crawler
|
|
160
289
|
*/
|
|
161
290
|
isBot(this: EnhancedRequest): boolean {
|
|
162
|
-
|
|
163
|
-
const botRegex = /bot|crawler|spider|crawling/i
|
|
164
|
-
return botRegex.test(userAgent)
|
|
291
|
+
return BOT_REGEX.test(this.headers.get('user-agent') || '')
|
|
165
292
|
},
|
|
166
293
|
|
|
167
294
|
/**
|
|
@@ -220,6 +347,29 @@ export const BuiltInRequestMacros = {
|
|
|
220
347
|
return null
|
|
221
348
|
},
|
|
222
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Get the raw, unparsed request body as a string.
|
|
352
|
+
*
|
|
353
|
+
* The body stream can only be consumed once, so this caches the result on
|
|
354
|
+
* `_rawBody`. Signature-verifying callbacks (Stripe/GitHub/Slack webhooks)
|
|
355
|
+
* need the exact bytes the client sent, before any JSON parsing — a parsed
|
|
356
|
+
* `jsonBody` re-serialized is NOT byte-identical and will fail HMAC checks.
|
|
357
|
+
* A framework body parser that has already read the body may populate
|
|
358
|
+
* `_rawBody` up front so this returns without re-reading.
|
|
359
|
+
*/
|
|
360
|
+
async rawBody(this: EnhancedRequest): Promise<string> {
|
|
361
|
+
if (typeof this._rawBody === 'string')
|
|
362
|
+
return this._rawBody
|
|
363
|
+
try {
|
|
364
|
+
const text = await this.clone().text()
|
|
365
|
+
this._rawBody = text
|
|
366
|
+
return text
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return ''
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
223
373
|
/**
|
|
224
374
|
* Get basic auth credentials
|
|
225
375
|
*/
|
|
@@ -398,38 +548,15 @@ export const BuiltInRequestMacros = {
|
|
|
398
548
|
* Get cookie value
|
|
399
549
|
*/
|
|
400
550
|
cookie(this: EnhancedRequest, name: string, defaultValue?: string): string | null {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
return defaultValue || null
|
|
404
|
-
|
|
405
|
-
const cookieArray = cookies.split(';')
|
|
406
|
-
for (const cookie of cookieArray) {
|
|
407
|
-
const [key, value] = cookie.trim().split('=')
|
|
408
|
-
if (key === name) {
|
|
409
|
-
return decodeURIComponent(value)
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
return defaultValue || null
|
|
551
|
+
const value = getParsedCookies(this)[name]
|
|
552
|
+
return value !== undefined ? value : (defaultValue || null)
|
|
414
553
|
},
|
|
415
554
|
|
|
416
555
|
/**
|
|
417
556
|
* Get all cookies
|
|
418
557
|
*/
|
|
419
558
|
cookies(this: EnhancedRequest): Record<string, string> {
|
|
420
|
-
|
|
421
|
-
const cookieHeader = this.headers.get('cookie')
|
|
422
|
-
|
|
423
|
-
if (cookieHeader) {
|
|
424
|
-
cookieHeader.split(';').forEach((cookie) => {
|
|
425
|
-
const [key, value] = cookie.trim().split('=')
|
|
426
|
-
if (key && value) {
|
|
427
|
-
cookies[key] = decodeURIComponent(value)
|
|
428
|
-
}
|
|
429
|
-
})
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return cookies
|
|
559
|
+
return { ...getParsedCookies(this) }
|
|
433
560
|
},
|
|
434
561
|
|
|
435
562
|
/**
|
|
@@ -450,7 +577,7 @@ export const BuiltInRequestMacros = {
|
|
|
450
577
|
* Get request path without query string
|
|
451
578
|
*/
|
|
452
579
|
path(this: EnhancedRequest): string {
|
|
453
|
-
return
|
|
580
|
+
return getParsedURL(this).pathname
|
|
454
581
|
},
|
|
455
582
|
|
|
456
583
|
/**
|
|
@@ -464,7 +591,7 @@ export const BuiltInRequestMacros = {
|
|
|
464
591
|
* Get request root URL
|
|
465
592
|
*/
|
|
466
593
|
root(this: EnhancedRequest): string {
|
|
467
|
-
const url =
|
|
594
|
+
const url = getParsedURL(this)
|
|
468
595
|
return `${url.protocol}//${url.host}`
|
|
469
596
|
},
|
|
470
597
|
|
|
@@ -474,12 +601,19 @@ export const BuiltInRequestMacros = {
|
|
|
474
601
|
is(this: EnhancedRequest, pattern: string): boolean {
|
|
475
602
|
const path = this.path()
|
|
476
603
|
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
604
|
+
// Compiled glob patterns are memoized — route checks tend to reuse a
|
|
605
|
+
// small set of patterns across many requests
|
|
606
|
+
let regex = GLOB_PATTERN_CACHE.get(pattern)
|
|
607
|
+
if (!regex) {
|
|
608
|
+
const regexPattern = pattern
|
|
609
|
+
.replace(/\*/g, '.*')
|
|
610
|
+
.replace(/\?/g, '.')
|
|
611
|
+
regex = new RegExp(`^${regexPattern}$`)
|
|
612
|
+
if (GLOB_PATTERN_CACHE.size >= 1000) {
|
|
613
|
+
GLOB_PATTERN_CACHE.clear()
|
|
614
|
+
}
|
|
615
|
+
GLOB_PATTERN_CACHE.set(pattern, regex)
|
|
616
|
+
}
|
|
483
617
|
return regex.test(path)
|
|
484
618
|
},
|
|
485
619
|
|
|
@@ -494,7 +628,7 @@ export const BuiltInRequestMacros = {
|
|
|
494
628
|
* Get request fingerprint for caching
|
|
495
629
|
*/
|
|
496
630
|
fingerprint(this: EnhancedRequest): string {
|
|
497
|
-
const url =
|
|
631
|
+
const url = getParsedURL(this)
|
|
498
632
|
const data = {
|
|
499
633
|
method: this.method,
|
|
500
634
|
path: url.pathname,
|
package/src/response/macros.ts
CHANGED
|
@@ -11,17 +11,17 @@ export interface ResponseMacro {
|
|
|
11
11
|
handler: (...args: any[]) => Response
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export interface ApiResponse<T =
|
|
14
|
+
export interface ApiResponse<T = unknown> {
|
|
15
15
|
data?: T
|
|
16
16
|
message?: string
|
|
17
17
|
error?: string
|
|
18
18
|
errors?: Record<string, string[]>
|
|
19
|
-
meta?: Record<string,
|
|
19
|
+
meta?: Record<string, unknown>
|
|
20
20
|
links?: Record<string, string>
|
|
21
21
|
timestamp?: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export interface PaginatedResponse<T =
|
|
24
|
+
export interface PaginatedResponse<T = unknown> extends ApiResponse<T[]> {
|
|
25
25
|
meta: {
|
|
26
26
|
current_page: number
|
|
27
27
|
per_page: number
|
|
@@ -538,19 +538,26 @@ export function registerFileBasedRouting(RouterClass: typeof Router): void {
|
|
|
538
538
|
// Store viewsDir for use in handlers
|
|
539
539
|
this._viewsDir = viewsDir
|
|
540
540
|
|
|
541
|
-
// Register each discovered route
|
|
542
|
-
|
|
543
|
-
|
|
541
|
+
// Register each discovered route. The existing GET paths are
|
|
542
|
+
// collected once — the previous per-route `.find()` scan made
|
|
543
|
+
// startup O(routes²) for large route tables.
|
|
544
|
+
const existingGetPaths = new Set<string>()
|
|
545
|
+
for (const r of this.routes as Array<{ path: string, method: string }>) {
|
|
546
|
+
if (r.method === 'GET') {
|
|
547
|
+
existingGetPaths.add(r.path)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
544
550
|
|
|
551
|
+
for (const route of routes) {
|
|
545
552
|
// Only register if no explicit route exists for this path
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
if (!existingRoute) {
|
|
551
|
-
this.get(route.routePath, handler, 'web')
|
|
552
|
-
this._fileBasedRoutes.push(route)
|
|
553
|
+
if (existingGetPaths.has(route.routePath)) {
|
|
554
|
+
continue
|
|
553
555
|
}
|
|
556
|
+
|
|
557
|
+
const handler = createViewHandler(route.filePath, viewsDir, config, this.config.queryPreservation)
|
|
558
|
+
this.get(route.routePath, handler, 'web')
|
|
559
|
+
this._fileBasedRoutes.push(route)
|
|
560
|
+
existingGetPaths.add(route.routePath)
|
|
554
561
|
}
|
|
555
562
|
|
|
556
563
|
this._fileRoutesInitialized = true
|
|
@@ -6,7 +6,34 @@ import { createModelBindingMiddleware } from '../model-binding'
|
|
|
6
6
|
import { createRouteCacheMiddleware, RouteCacheFactory } from '../routing/route-caching'
|
|
7
7
|
import { createRateLimitMiddleware, parseThrottleString, ThrottleFactory } from '../routing/route-throttling'
|
|
8
8
|
import { DomainGroup, DomainMatcher, SubdomainRouter } from '../routing/subdomain-routing'
|
|
9
|
-
import { registerNamedRoute } from '../url'
|
|
9
|
+
import { getNamedRoutePath, registerNamedRoute } from '../url'
|
|
10
|
+
import { matchPath } from '../utils'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A route registered on a {@link FluentRouter}
|
|
14
|
+
*/
|
|
15
|
+
export interface RegisteredFluentRoute {
|
|
16
|
+
method: string
|
|
17
|
+
path: string
|
|
18
|
+
handler: RouteHandler
|
|
19
|
+
middleware: MiddlewareHandler[]
|
|
20
|
+
name?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Laravel-style resource controller shape accepted by
|
|
25
|
+
* {@link FluentRouter.resource}. Every action is optional — only the
|
|
26
|
+
* actions present (and allowed by `only`/`except`) are registered.
|
|
27
|
+
*/
|
|
28
|
+
export interface FluentResourceController {
|
|
29
|
+
index?: RouteHandler
|
|
30
|
+
create?: RouteHandler
|
|
31
|
+
store?: RouteHandler
|
|
32
|
+
show?: RouteHandler
|
|
33
|
+
edit?: RouteHandler
|
|
34
|
+
update?: RouteHandler
|
|
35
|
+
destroy?: RouteHandler
|
|
36
|
+
}
|
|
10
37
|
|
|
11
38
|
/**
|
|
12
39
|
* Fluent route builder with chainable API
|
|
@@ -97,7 +124,9 @@ export class FluentRouteBuilder {
|
|
|
97
124
|
// Add model binding middleware
|
|
98
125
|
if (Object.keys(this.modelBindings).length > 0) {
|
|
99
126
|
const parameters = Object.keys(this.modelBindings).map(name => ({ name }))
|
|
100
|
-
const modelBindingMiddleware = createModelBindingMiddleware(parameters
|
|
127
|
+
const modelBindingMiddleware = createModelBindingMiddleware(parameters, this.modelBindings)
|
|
128
|
+
// The model-binding middleware's `next` is `() => Promise<Response>`;
|
|
129
|
+
// the fluent executor always provides one, so the adapter is safe
|
|
101
130
|
finalMiddleware.push(modelBindingMiddleware as unknown as MiddlewareHandler)
|
|
102
131
|
}
|
|
103
132
|
|
|
@@ -150,16 +179,15 @@ export class FluentConditionalBuilder {
|
|
|
150
179
|
|
|
151
180
|
middleware(middlewareName: string): this {
|
|
152
181
|
const [name, params] = middlewareName.split(':')
|
|
153
|
-
const middlewareFactory =
|
|
182
|
+
const middlewareFactory = this.router.resolveNamedMiddleware(name)
|
|
154
183
|
|
|
155
184
|
if (!middlewareFactory) {
|
|
156
185
|
throw new Error(`Unknown middleware: ${name}`)
|
|
157
186
|
}
|
|
158
187
|
|
|
159
|
-
|
|
160
|
-
;(this.router as any).conditionalMiddleware.push({
|
|
188
|
+
this.router.addConditionalMiddleware({
|
|
161
189
|
condition: this.condition,
|
|
162
|
-
middleware: [
|
|
190
|
+
middleware: [middlewareFactory(params)],
|
|
163
191
|
})
|
|
164
192
|
|
|
165
193
|
return this
|
|
@@ -230,13 +258,7 @@ export class FluentRouteGroupBuilder {
|
|
|
230
258
|
* Fluent router with chainable API and advanced features
|
|
231
259
|
*/
|
|
232
260
|
export class FluentRouter {
|
|
233
|
-
private routes:
|
|
234
|
-
method: string
|
|
235
|
-
path: string
|
|
236
|
-
handler: RouteHandler
|
|
237
|
-
middleware: MiddlewareHandler[]
|
|
238
|
-
name?: string
|
|
239
|
-
}> = []
|
|
261
|
+
private routes: RegisteredFluentRoute[] = []
|
|
240
262
|
|
|
241
263
|
private globalMiddleware: MiddlewareHandler[] = []
|
|
242
264
|
private subdomainRouter = new SubdomainRouter()
|
|
@@ -286,7 +308,7 @@ export class FluentRouter {
|
|
|
286
308
|
|
|
287
309
|
// Auth middleware
|
|
288
310
|
this.namedMiddleware.set('auth', () => {
|
|
289
|
-
return async (req: EnhancedRequest, next:
|
|
311
|
+
return async (req: EnhancedRequest, next: NextFunction) => {
|
|
290
312
|
const token = req.headers.get('Authorization')?.replace('Bearer ', '')
|
|
291
313
|
if (!token) {
|
|
292
314
|
return new Response('Unauthorized', { status: 401 })
|
|
@@ -299,7 +321,7 @@ export class FluentRouter {
|
|
|
299
321
|
|
|
300
322
|
// CORS middleware
|
|
301
323
|
this.namedMiddleware.set('cors', () => {
|
|
302
|
-
return async (_req: EnhancedRequest, next:
|
|
324
|
+
return async (_req: EnhancedRequest, next: NextFunction) => {
|
|
303
325
|
const response = await next()
|
|
304
326
|
if (response) {
|
|
305
327
|
const headers = new Headers(response.headers)
|
|
@@ -350,6 +372,22 @@ export class FluentRouter {
|
|
|
350
372
|
return new FluentConditionalBuilder(this, condition)
|
|
351
373
|
}
|
|
352
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Look up a named middleware factory.
|
|
377
|
+
* @internal Used by the fluent builders — avoids `as any` reach-ins.
|
|
378
|
+
*/
|
|
379
|
+
resolveNamedMiddleware(name: string): ((params?: string) => MiddlewareHandler) | undefined {
|
|
380
|
+
return this.namedMiddleware.get(name)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Register conditional middleware, evaluated per request in {@link handle}.
|
|
385
|
+
* @internal Used by the fluent builders.
|
|
386
|
+
*/
|
|
387
|
+
addConditionalMiddleware(conditional: ConditionalMiddleware): void {
|
|
388
|
+
this.conditionalMiddleware.push(conditional)
|
|
389
|
+
}
|
|
390
|
+
|
|
353
391
|
/**
|
|
354
392
|
* Apply middleware with parameters
|
|
355
393
|
*/
|
|
@@ -547,7 +585,7 @@ export class FluentRouter {
|
|
|
547
585
|
/**
|
|
548
586
|
* Create resource routes (Laravel-style)
|
|
549
587
|
*/
|
|
550
|
-
resource(name: string, controller:
|
|
588
|
+
resource(name: string, controller: FluentResourceController, options: {
|
|
551
589
|
only?: string[]
|
|
552
590
|
except?: string[]
|
|
553
591
|
model?: BunQueryBuilderModel
|
|
@@ -584,13 +622,7 @@ export class FluentRouter {
|
|
|
584
622
|
/**
|
|
585
623
|
* Get all registered routes
|
|
586
624
|
*/
|
|
587
|
-
getRoutes():
|
|
588
|
-
method: string
|
|
589
|
-
path: string
|
|
590
|
-
handler: RouteHandler
|
|
591
|
-
middleware: MiddlewareHandler[]
|
|
592
|
-
name?: string
|
|
593
|
-
}> {
|
|
625
|
+
getRoutes(): RegisteredFluentRoute[] {
|
|
594
626
|
return [...this.routes, ...this.subdomainRouter.getAllRoutes()]
|
|
595
627
|
}
|
|
596
628
|
|
|
@@ -598,10 +630,9 @@ export class FluentRouter {
|
|
|
598
630
|
* Handle incoming request
|
|
599
631
|
*/
|
|
600
632
|
async handle(request: EnhancedRequest): Promise<Response | null> {
|
|
601
|
-
const
|
|
633
|
+
const url = new URL(request.url)
|
|
602
634
|
|
|
603
635
|
// Try subdomain routing first
|
|
604
|
-
const url = new URL(request.url)
|
|
605
636
|
const domain = url.hostname
|
|
606
637
|
const match = this.subdomainRouter.findDomainGroup(domain)
|
|
607
638
|
if (match) {
|
|
@@ -611,16 +642,18 @@ export class FluentRouter {
|
|
|
611
642
|
|
|
612
643
|
// Find matching route in domain group
|
|
613
644
|
for (const route of match.group.getRoutes()) {
|
|
614
|
-
|
|
615
|
-
|
|
645
|
+
const params = this.matchRouteParams(route, request.method, url.pathname)
|
|
646
|
+
if (params) {
|
|
647
|
+
return await this.executeRoute(route, enhancedReq, params)
|
|
616
648
|
}
|
|
617
649
|
}
|
|
618
650
|
}
|
|
619
651
|
|
|
620
652
|
// Find matching route
|
|
621
653
|
for (const route of this.routes) {
|
|
622
|
-
|
|
623
|
-
|
|
654
|
+
const params = this.matchRouteParams(route, request.method, url.pathname)
|
|
655
|
+
if (params) {
|
|
656
|
+
return await this.executeRoute(route, request, params)
|
|
624
657
|
}
|
|
625
658
|
}
|
|
626
659
|
|
|
@@ -628,32 +661,58 @@ export class FluentRouter {
|
|
|
628
661
|
}
|
|
629
662
|
|
|
630
663
|
/**
|
|
631
|
-
*
|
|
664
|
+
* Match a route against the request, extracting path parameters.
|
|
665
|
+
*
|
|
666
|
+
* Delegates to the same `matchPath` used by the main `Router`, so the
|
|
667
|
+
* fluent API has identical semantics for `{param}`, optional `{param?}`,
|
|
668
|
+
* and wildcard segments (the previous ad-hoc regex neither escaped
|
|
669
|
+
* static text nor extracted params at all).
|
|
670
|
+
*
|
|
671
|
+
* @returns the extracted params, or `null` when the route doesn't match
|
|
632
672
|
*/
|
|
633
|
-
private
|
|
634
|
-
|
|
635
|
-
|
|
673
|
+
private matchRouteParams(
|
|
674
|
+
route: RegisteredFluentRoute,
|
|
675
|
+
method: string,
|
|
676
|
+
pathname: string,
|
|
677
|
+
): Record<string, string> | null {
|
|
678
|
+
if (route.method !== method) {
|
|
679
|
+
return null
|
|
636
680
|
}
|
|
637
681
|
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
const routePattern = route.path.replace(/\{([^:}]+):?([^}]*)\}/g, '([^/]+)')
|
|
641
|
-
const regex = new RegExp(`^${routePattern}$`)
|
|
642
|
-
|
|
643
|
-
return regex.test(url.pathname)
|
|
682
|
+
const params: Record<string, string> = {}
|
|
683
|
+
return matchPath(route.path, pathname, params) ? params : null
|
|
644
684
|
}
|
|
645
685
|
|
|
646
686
|
/**
|
|
647
|
-
* Execute route with middleware
|
|
687
|
+
* Execute route with middleware (global → conditional → route-specific)
|
|
648
688
|
*/
|
|
649
|
-
private async executeRoute(
|
|
650
|
-
|
|
689
|
+
private async executeRoute(
|
|
690
|
+
route: RegisteredFluentRoute,
|
|
691
|
+
request: EnhancedRequest,
|
|
692
|
+
params: Record<string, string>,
|
|
693
|
+
): Promise<Response> {
|
|
694
|
+
// Expose extracted path parameters to middleware and the handler
|
|
695
|
+
;(request as { params?: Record<string, string> }).params = params
|
|
696
|
+
|
|
697
|
+
const allMiddleware = [...this.globalMiddleware]
|
|
698
|
+
|
|
699
|
+
// Conditional middleware (registered via `when(...)`) runs when its
|
|
700
|
+
// condition holds for this request — previously it was collected but
|
|
701
|
+
// never consulted
|
|
702
|
+
for (const conditional of this.conditionalMiddleware) {
|
|
703
|
+
if (await conditional.condition(request)) {
|
|
704
|
+
allMiddleware.push(...conditional.middleware)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
allMiddleware.push(...route.middleware)
|
|
651
709
|
|
|
652
710
|
let index = 0
|
|
653
711
|
const next = async (): Promise<Response> => {
|
|
654
712
|
if (index < allMiddleware.length) {
|
|
655
713
|
const middleware = allMiddleware[index++]
|
|
656
|
-
|
|
714
|
+
const result = await middleware(request, next)
|
|
715
|
+
return result ?? new Response(null)
|
|
657
716
|
}
|
|
658
717
|
return await route.handler(request)
|
|
659
718
|
}
|
|
@@ -747,14 +806,24 @@ export const RouteFactory: {
|
|
|
747
806
|
*/
|
|
748
807
|
export const RouterUtils = {
|
|
749
808
|
/**
|
|
750
|
-
* Generate
|
|
809
|
+
* Generate a URL for a named route. Resolves the path from the shared
|
|
810
|
+
* named-route registry (the same one `router.route()`/`url()` use);
|
|
811
|
+
* falls back to `/<name>` when the name was never registered.
|
|
751
812
|
*/
|
|
752
813
|
route: (name: string, params: Record<string, string> = {}, query: Record<string, string> = {}): string => {
|
|
753
|
-
|
|
754
|
-
let url = `/${name}`
|
|
814
|
+
let url = getNamedRoutePath(name) ?? `/${name}`
|
|
755
815
|
|
|
756
816
|
for (const [key, value] of Object.entries(params)) {
|
|
757
|
-
|
|
817
|
+
const encoded = encodeURIComponent(value)
|
|
818
|
+
url = url.replace(`{${key}}`, encoded)
|
|
819
|
+
url = url.replace(`{${key}?}`, encoded)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Drop unfilled optional placeholders and tidy the slashes they leave
|
|
823
|
+
if (url.includes('?}')) {
|
|
824
|
+
url = url.replace(/\{[^}]+\?\}/g, '').replace(/\/{2,}/g, '/')
|
|
825
|
+
if (url.length > 1 && url.endsWith('/'))
|
|
826
|
+
url = url.slice(0, -1)
|
|
758
827
|
}
|
|
759
828
|
|
|
760
829
|
const queryString = new URLSearchParams(query).toString()
|
|
@@ -778,7 +847,7 @@ export const RouterUtils = {
|
|
|
778
847
|
/**
|
|
779
848
|
* JSON response
|
|
780
849
|
*/
|
|
781
|
-
json: (data:
|
|
850
|
+
json: (data: unknown, status: number = 200): Response => {
|
|
782
851
|
return new Response(JSON.stringify(data), {
|
|
783
852
|
status,
|
|
784
853
|
headers: { 'Content-Type': 'application/json' },
|