@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.
Files changed (83) hide show
  1. package/dist/auth.d.ts +22 -8
  2. package/dist/chunk-1ahs68ys.js +18 -0
  3. package/dist/{chunk-j0e7z7hd.js → chunk-cgptvjdf.js} +153 -75
  4. package/dist/chunk-g3ybefhg.js +1923 -0
  5. package/dist/cli.js +13 -11
  6. package/dist/container/container.d.ts +1 -0
  7. package/dist/container/contextual-binding.d.ts +0 -1
  8. package/dist/container/decorators.d.ts +2 -5
  9. package/dist/container/index.d.ts +16 -0
  10. package/dist/container/index.js +322 -0
  11. package/dist/file-serving/static-files.d.ts +16 -0
  12. package/dist/index.d.ts +17 -1
  13. package/dist/index.js +1086 -1270
  14. package/dist/middleware/cors.d.ts +9 -0
  15. package/dist/middleware/csrf.d.ts +7 -0
  16. package/dist/middleware/ddos_protection.d.ts +16 -1
  17. package/dist/middleware/rate_limit.d.ts +7 -1
  18. package/dist/middleware/request_tracer.d.ts +15 -9
  19. package/dist/middleware/response_cache.d.ts +2 -1
  20. package/dist/request/context.d.ts +12 -1
  21. package/dist/request/macros.d.ts +40 -2
  22. package/dist/response/macros.d.ts +3 -3
  23. package/dist/router/fluent-routing.d.ts +50 -13
  24. package/dist/router/handler-resolver.d.ts +8 -0
  25. package/dist/router/index.d.ts +2 -0
  26. package/dist/router/route-compiler.d.ts +8 -10
  27. package/dist/router/route-trie.d.ts +7 -1
  28. package/dist/router/router.d.ts +13 -2
  29. package/dist/router/validation-integration.d.ts +10 -4
  30. package/dist/types/middleware-types.d.ts +1 -1
  31. package/dist/types/route-inference.d.ts +9 -2
  32. package/dist/types.d.ts +62 -9
  33. package/dist/utils.d.ts +1 -1
  34. package/package.json +11 -1
  35. package/src/auth.ts +32 -12
  36. package/src/container/container.ts +58 -12
  37. package/src/container/contextual-binding.ts +4 -1
  38. package/src/container/decorators.ts +16 -7
  39. package/src/container/index.ts +49 -0
  40. package/src/errors/circuit-breaker.ts +3 -2
  41. package/src/errors/graceful-degradation.ts +2 -0
  42. package/src/file-serving/static-files.ts +144 -7
  43. package/src/index.ts +53 -1
  44. package/src/middleware/cors.ts +76 -8
  45. package/src/middleware/csrf.ts +80 -22
  46. package/src/middleware/ddos_protection.ts +27 -3
  47. package/src/middleware/input_validation.ts +10 -6
  48. package/src/middleware/performance_alerting.ts +2 -0
  49. package/src/middleware/performance_monitor.ts +2 -0
  50. package/src/middleware/rate_limit.ts +7 -1
  51. package/src/middleware/request_signing.ts +2 -0
  52. package/src/middleware/request_tracer.ts +34 -17
  53. package/src/middleware/response_cache.ts +48 -13
  54. package/src/middleware/session.ts +3 -3
  55. package/src/observability/metrics.ts +1 -1
  56. package/src/request/context.ts +50 -3
  57. package/src/request/enhanced-request.ts +20 -1
  58. package/src/request/macros.ts +183 -49
  59. package/src/response/macros.ts +3 -3
  60. package/src/router/file-based-routing.ts +17 -10
  61. package/src/router/fluent-routing.ts +117 -48
  62. package/src/router/group-organization.ts +17 -1
  63. package/src/router/handler-resolver.ts +36 -0
  64. package/src/router/http-methods.ts +33 -3
  65. package/src/router/index.ts +6 -0
  66. package/src/router/middleware.ts +3 -0
  67. package/src/router/optimized-route-matching.ts +58 -9
  68. package/src/router/route-compiler.ts +62 -73
  69. package/src/router/route-matching.ts +19 -0
  70. package/src/router/route-trie.ts +34 -48
  71. package/src/router/router.ts +72 -60
  72. package/src/router/server.ts +361 -139
  73. package/src/router/validation-integration.ts +11 -5
  74. package/src/testing/auth-testing.ts +6 -4
  75. package/src/types/middleware-types.ts +7 -4
  76. package/src/types/route-inference.ts +29 -14
  77. package/src/types.ts +63 -7
  78. package/src/url.ts +7 -2
  79. package/src/utils.ts +107 -39
  80. package/src/validation/validator.ts +7 -4
  81. package/src/websocket/clustering.ts +8 -1
  82. package/dist/router/fluent-router.d.ts +0 -315
  83. package/src/router/fluent-router.ts +0 -927
@@ -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
- return this.macros.delete(name)
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
- * Apply macros to a request object
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 macros = requestMacroRegistry.all()
175
+ const epoch = requestMacroRegistry.getEpoch()
176
+ if (epoch !== macroProtoEpoch) {
177
+ macroProtoCache = new WeakMap()
178
+ macroProtoEpoch = epoch
179
+ }
84
180
 
85
- macros.forEach((macro) => {
86
- ;(request as any)[macro.name] = macro.handler.bind(request)
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
- const userAgent = this.headers.get('user-agent') || ''
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
- const userAgent = this.headers.get('user-agent') || ''
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 cookies = this.headers.get('cookie')
402
- if (!cookies)
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
- const cookies: Record<string, string> = {}
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 new URL(this.url).pathname
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 = new URL(this.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
- // Convert pattern to regex
478
- const regexPattern = pattern
479
- .replace(/\*/g, '.*')
480
- .replace(/\?/g, '.')
481
-
482
- const regex = new RegExp(`^${regexPattern}$`)
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 = new URL(this.url)
631
+ const url = getParsedURL(this)
498
632
  const data = {
499
633
  method: this.method,
500
634
  path: url.pathname,
@@ -11,17 +11,17 @@ export interface ResponseMacro {
11
11
  handler: (...args: any[]) => Response
12
12
  }
13
13
 
14
- export interface ApiResponse<T = any> {
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, any>
19
+ meta?: Record<string, unknown>
20
20
  links?: Record<string, string>
21
21
  timestamp?: string
22
22
  }
23
23
 
24
- export interface PaginatedResponse<T = any> extends ApiResponse<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
- for (const route of routes) {
543
- const handler = createViewHandler(route.filePath, viewsDir, config, this.config.queryPreservation)
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
- const existingRoute = this.routes.find(
547
- (r: { path: string; method: string }) => r.path === route.routePath && r.method === 'GET'
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 as any, this.modelBindings as any)
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 = (this.router as any).namedMiddleware.get(name)
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
- const middlewareHandler = middlewareFactory(params)
160
- ;(this.router as any).conditionalMiddleware.push({
188
+ this.router.addConditionalMiddleware({
161
189
  condition: this.condition,
162
- middleware: [middlewareHandler],
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: Array<{
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: any) => {
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: any) => {
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: any, options: {
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(): Array<{
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 _url = new URL(request.url)
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
- if (this.matchesRoute(route, request)) {
615
- return await this.executeRoute(route, enhancedReq)
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
- if (this.matchesRoute(route, request)) {
623
- return await this.executeRoute(route, request)
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
- * Check if route matches request
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 matchesRoute(route: any, request: EnhancedRequest): boolean {
634
- if (route.method !== request.method) {
635
- return false
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 url = new URL(request.url)
639
- // eslint-disable-next-line regexp/no-super-linear-backtracking
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(route: any, request: EnhancedRequest): Promise<Response> {
650
- const allMiddleware = [...this.globalMiddleware, ...route.middleware]
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
- return await middleware(request, next)
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 route URL with parameters
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
- // Mock route URL generation
754
- let url = `/${name}`
814
+ let url = getNamedRoutePath(name) ?? `/${name}`
755
815
 
756
816
  for (const [key, value] of Object.entries(params)) {
757
- url = url.replace(`{${key}}`, value)
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: any, status: number = 200): Response => {
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' },