@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
@@ -1,12 +1,80 @@
1
1
  import type { Server } from 'bun'
2
- import type { EnhancedRequest, HTTPMethod, ServerOptions } from '../types'
2
+ import type { EnhancedRequest, HTTPMethod, Route, ServerOptions } from '../types'
3
3
  import type { Router } from './router'
4
- import { RequestWithMacros } from '../request/macros'
4
+ import { getParsedCookies, getParsedURL, RequestWithMacros } from '../request/macros'
5
5
  import { runWithRequest, setCurrentRequest } from '../request/context'
6
+ import { createHandlerInvoker } from './handler-resolver'
7
+
8
+ // Helpers that frameworks layered on bun-router treat as guaranteed but
9
+ // that aren't shaped as built-in macros. Registered once at module load —
10
+ // they ride the shared macro prototype instead of being closure-assigned
11
+ // per request. (`cookie` is already a built-in macro.)
12
+ if (!RequestWithMacros.hasMacro('getParam')) {
13
+ RequestWithMacros.macro('getParam', function (this: EnhancedRequest, name: string, defaultValue?: unknown) {
14
+ const value = this.params?.[name]
15
+ return value !== undefined ? value : defaultValue
16
+ })
17
+ }
18
+ // `get(key)` — read query params (and only query params; the full input
19
+ // merge with body etc. is provided by the `input` macro). Only registered
20
+ // when no macro already claimed the name.
21
+ if (!RequestWithMacros.hasMacro('get')) {
22
+ RequestWithMacros.macro('get', function (this: EnhancedRequest, key: string, defaultValue?: unknown) {
23
+ const value = getParsedURL(this).searchParams.get(key)
24
+ return value !== null && value !== undefined ? value : defaultValue
25
+ })
26
+ }
27
+ // `cookies` — dual-shape, mirroring the response macros (#1857): callable
28
+ // as `req.cookies()` (the legacy built-in macro form, returns the parsed
29
+ // map) while also carrying the name→value entries for direct access and
30
+ // the get/set/delete/getAll utility methods `EnhancedRequest` declares.
31
+ // Installed as a shared-prototype accessor: the hybrid materializes on
32
+ // first access and is cached as an own property, so requests that never
33
+ // touch cookies do zero cookie work.
34
+ RequestWithMacros.macroAccessor('cookies', function (this: EnhancedRequest) {
35
+ const req = this
36
+ const cookies = (): Record<string, string> => ({ ...getParsedCookies(req) })
37
+
38
+ // Direct map access (req.cookies.session). defineProperty instead of
39
+ // Object.assign — a cookie literally named `name` or `length` would
40
+ // collide with the function's own non-writable properties.
41
+ for (const [key, value] of Object.entries(getParsedCookies(req))) {
42
+ Object.defineProperty(cookies, key, {
43
+ value,
44
+ writable: true,
45
+ enumerable: true,
46
+ configurable: true,
47
+ })
48
+ }
49
+
50
+ cookies.get = (name: string) => getParsedCookies(req)[name]
51
+ cookies.set = (name: string, value: string, options: any = {}) => {
52
+ if (!req._cookiesToSet) {
53
+ req._cookiesToSet = []
54
+ }
55
+ req._cookiesToSet.push({ name, value, options })
56
+ }
57
+ cookies.delete = (name: string, options: any = {}) => {
58
+ if (!req._cookiesToDelete) {
59
+ req._cookiesToDelete = []
60
+ }
61
+ req._cookiesToDelete.push({ name, options })
62
+ }
63
+ cookies.getAll = () => ({ ...getParsedCookies(req) })
64
+
65
+ Object.defineProperty(req, 'cookies', {
66
+ value: cookies,
67
+ writable: true,
68
+ configurable: true,
69
+ enumerable: true,
70
+ })
71
+ return cookies
72
+ })
6
73
 
7
74
  /**
8
75
  * Server handling extension for Router class
9
76
  */
77
+ // eslint-disable-next-line pickier/no-unused-vars -- false positive: used on the next line
10
78
  export function registerServerHandling(RouterClass: typeof Router): void {
11
79
  Object.defineProperties(RouterClass.prototype, {
12
80
  /**
@@ -55,6 +123,18 @@ export function registerServerHandling(RouterClass: typeof Router): void {
55
123
  serverOptions.development = options.development
56
124
  }
57
125
 
126
+ // Opt-in: hand compatible routes to Bun's native router so they
127
+ // skip the fetch handler's URL parsing and matching entirely.
128
+ // Incompatible routes and 404/405/HEAD/preflight semantics keep
129
+ // flowing through the fetch fallback unchanged.
130
+ this._nativeRoutesEnabled = options.nativeRoutes === true
131
+ if (this._nativeRoutesEnabled) {
132
+ const nativeRoutes = this._buildNativeRoutes()
133
+ if (nativeRoutes) {
134
+ serverOptions.routes = nativeRoutes
135
+ }
136
+ }
137
+
58
138
  // Apply WebSocket configuration if provided
59
139
  if (this.wsConfig) {
60
140
  serverOptions.websocket = this.wsConfig
@@ -94,13 +174,22 @@ export function registerServerHandling(RouterClass: typeof Router): void {
94
174
  // Close the current server
95
175
  this.serverInstance.stop()
96
176
 
97
- // Start a new server with the same configuration
98
- this.serverInstance = Bun.serve({
177
+ // Start a new server with the same configuration. The native
178
+ // route table is rebuilt so routes registered since serve()
179
+ // join it.
180
+ const reloadOptions: any = {
99
181
  port,
100
182
  hostname,
101
183
  fetch: this.handleRequest.bind(this),
102
184
  websocket: this.wsConfig || undefined,
103
- })
185
+ }
186
+ if (this._nativeRoutesEnabled) {
187
+ const nativeRoutes = this._buildNativeRoutes()
188
+ if (nativeRoutes) {
189
+ reloadOptions.routes = nativeRoutes
190
+ }
191
+ }
192
+ this.serverInstance = Bun.serve(reloadOptions)
104
193
 
105
194
  if (this.config.verbose) {
106
195
  console.log(`🔄 Server reloaded at http://${hostname}:${port}`)
@@ -125,77 +214,264 @@ export function registerServerHandling(RouterClass: typeof Router): void {
125
214
  },
126
215
 
127
216
  /**
128
- * Internal: actual request handling, executed inside the request context scope.
217
+ * Internal: dispatch a matched route — enhance the request, run the
218
+ * cached middleware chain (global + route middleware + handler) and
219
+ * apply queued cookies. Shared by the fetch handler and the native
220
+ * Bun route wrappers.
129
221
  */
130
- handleRequestImpl: {
131
- async value(req: Request): Promise<Response> {
222
+ _dispatchMatchedRoute: {
223
+ async value(matchedRoute: Route, req: Request, params: Record<string, string>): Promise<Response> {
224
+ const enhancedReq = this.enhanceRequest(req, params)
225
+ setCurrentRequest(enhancedReq)
226
+
227
+ // Add the matched route to the request
228
+ enhancedReq.route = matchedRoute
229
+
230
+ // Resolve the middleware chain for this route. The composed
231
+ // chain is cached on the route and only rebuilt when `use()`
232
+ // registers new global middleware or the route's own stack
233
+ // changes — building the closure chain per request was a
234
+ // hot-path cost.
235
+ const route = matchedRoute as Route & {
236
+ _compiledChain?: (req: EnhancedRequest) => Promise<Response | null>
237
+ _chainEpoch?: number
238
+ _chainMwLen?: number
239
+ }
240
+ const epoch: number = this._mwEpoch || 0
241
+ const routeMwLen = route.middleware ? route.middleware.length : 0
242
+
243
+ let chain = route._compiledChain
244
+ if (!chain || route._chainEpoch !== epoch || route._chainMwLen !== routeMwLen) {
245
+ // The handler's dispatch branch (function vs class vs string
246
+ // action) is resolved once here instead of per request
247
+ const invoke = createHandlerInvoker(route.handler, this.config)
248
+
249
+ if (routeMwLen === 0 && this.globalMiddleware.length === 0) {
250
+ // No middleware: the chain is the bare invoker — no
251
+ // closure tower, no next() allocations per request
252
+ chain = invoke
253
+ }
254
+ else {
255
+ const middlewareStack = routeMwLen > 0
256
+ ? [...this.globalMiddleware, ...route.middleware]
257
+ : [...this.globalMiddleware]
258
+ middlewareStack.push((handlerReq: EnhancedRequest, _next: any) => invoke(handlerReq))
259
+ chain = this.buildMiddlewareChain(middlewareStack)!
260
+ }
261
+ route._compiledChain = chain
262
+ route._chainEpoch = epoch
263
+ route._chainMwLen = routeMwLen
264
+ }
265
+
266
+ let response: Response | null
132
267
  try {
133
- // Create URL for route matching
134
- const url = new URL(req.url)
268
+ response = await chain!(enhancedReq)
269
+ }
270
+ catch (error) {
271
+ if (this.errorHandler) {
272
+ response = await this.errorHandler(error as Error)
273
+ }
274
+ else {
275
+ throw error
276
+ }
277
+ }
135
278
 
136
- // Handle CORS preflight OPTIONS requests - but check for registered OPTIONS routes first
137
- // This ensures explicitly registered OPTIONS routes work while still providing CORS support
138
- if (req.method === 'OPTIONS') {
139
- const hostname = url.hostname || req.headers.get('host')?.split(':')[0] || 'localhost'
140
- const optionsMatch = this.matchRoute(url.pathname, 'OPTIONS', hostname)
141
- if (!optionsMatch) {
142
- // No explicit OPTIONS route - return generic CORS preflight response
143
- return new Response(null, {
144
- status: 204,
145
- headers: {
146
- 'Access-Control-Allow-Origin': '*',
147
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
148
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
149
- 'Access-Control-Max-Age': '86400',
150
- 'Access-Control-Allow-Credentials': 'true',
151
- },
152
- })
279
+ // Apply modified cookies to the response
280
+ if (response) {
281
+ return this.applyModifiedCookies(response, enhancedReq)
282
+ }
283
+
284
+ // This should not happen since we're always returning a response now
285
+ return new Response('No response from middleware chain', { status: 500 })
286
+ },
287
+ writable: true,
288
+ configurable: true,
289
+ },
290
+
291
+ /**
292
+ * Internal: build Bun.serve's native `routes` table from compatible
293
+ * registered routes (see `ServerOptions.nativeRoutes`). Returns null
294
+ * when nothing qualifies.
295
+ */
296
+ _buildNativeRoutes: {
297
+ value(): Record<string, Record<string, (req: Request) => Promise<Response>>> | null {
298
+ const NATIVE_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'])
299
+
300
+ // Convert `{param}` paths to Bun's `:param` syntax. Returns null
301
+ // for shapes the native router can't express with our semantics:
302
+ // optional params, constraints, mixed segments like `user-{id}`,
303
+ // and non-trailing wildcards.
304
+ const convertPath = (path: string): string | null => {
305
+ if (path === '*') {
306
+ return '/*'
307
+ }
308
+ const segments = path.split('/')
309
+ const converted: string[] = []
310
+ for (let i = 0; i < segments.length; i++) {
311
+ const segment = segments[i]
312
+ if (segment === '') {
313
+ converted.push(segment)
314
+ continue
153
315
  }
154
- // Let the registered OPTIONS route handle it (fall through to normal route matching)
316
+ if (segment === '*') {
317
+ // Wildcards only in the trailing position
318
+ return i === segments.length - 1 ? [...converted, '*'].join('/') : null
319
+ }
320
+ const paramMatch = segment.match(/^\{([A-Z_$][\w$]*)\}$/i)
321
+ if (paramMatch) {
322
+ converted.push(`:${paramMatch[1]}`)
323
+ continue
324
+ }
325
+ if (segment.includes('{') || segment.includes(':') || segment.includes('*')) {
326
+ return null
327
+ }
328
+ converted.push(segment)
155
329
  }
330
+ return converted.join('/')
331
+ }
156
332
 
157
- // Get domain from the host header
158
- const hostname = url.hostname || req.headers.get('host')?.split(':')[0] || 'localhost'
333
+ // Wrap a route in the same context/error envelope the fetch
334
+ // handler provides, so handlers can't tell which router matched
335
+ const self = this
336
+ const wrapRoute = (route: Route, isWildcard: boolean) => {
337
+ return (req: Request & { params?: Record<string, string> }) => {
338
+ return runWithRequest(req as EnhancedRequest, async () => {
339
+ try {
340
+ let params: Record<string, string> = req.params ?? {}
341
+ if (isWildcard) {
342
+ // Bun doesn't expose the wildcard remainder as a param;
343
+ // mirror the fetch matcher's `wildcard` key
344
+ const pathname = new URL(req.url).pathname
345
+ const basePath = route.path === '*' ? '/' : route.path.slice(0, -1)
346
+ params = { ...params, wildcard: pathname.slice(basePath.length) }
347
+ }
348
+ return await self._dispatchMatchedRoute(route, req, params)
349
+ }
350
+ catch (error) {
351
+ console.error('Error handling request:', error)
352
+ if (self.errorHandler) {
353
+ return self.errorHandler(error as Error)
354
+ }
355
+ return new Response(JSON.stringify({
356
+ success: false,
357
+ message: 'Internal Server Error',
358
+ error: error instanceof Error ? error.message : String(error),
359
+ }), {
360
+ status: 500,
361
+ headers: {
362
+ 'Content-Type': 'application/json',
363
+ 'Access-Control-Allow-Origin': '*',
364
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
365
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
366
+ },
367
+ })
368
+ }
369
+ })
370
+ }
371
+ }
159
372
 
160
- // Find a matching route
161
- const match = this.matchRoute(url.pathname, req.method as HTTPMethod, hostname)
373
+ const natives: Record<string, Record<string, (req: Request) => Promise<Response>>> = {}
374
+ // Bun resolves same-shape patterns by specificity, not by our
375
+ // registration order — only the first registration of a given
376
+ // shape goes native; later ones would silently shadow it
377
+ const claimedShapes = new Map<string, string>()
162
378
 
163
- // Enhance the request with params and other utilities
164
- const enhancedReq = this.enhanceRequest(req, match?.params || {})
165
- setCurrentRequest(enhancedReq)
379
+ for (const route of this.routes as Route[]) {
380
+ if (!NATIVE_METHODS.has(route.method)) {
381
+ continue
382
+ }
383
+ // Explicit per-route opt-out (router.withoutNativeDispatch())
384
+ if (route.nativeDispatch === false) {
385
+ continue
386
+ }
387
+ // Constrained params need our matcher
388
+ if (route.constraints && Object.keys(route.constraints).length > 0) {
389
+ continue
390
+ }
391
+ // Response-handler routes are already served via Bun's static map
392
+ if (route.handler instanceof Response) {
393
+ continue
394
+ }
395
+ const bunPath = convertPath(route.path)
396
+ if (!bunPath) {
397
+ continue
398
+ }
166
399
 
167
- if (match) {
168
- // Add the matched route to the request
169
- enhancedReq.route = match.route
400
+ const shape = bunPath.replace(/:[^/]+/g, ':p')
401
+ const claimedBy = claimedShapes.get(shape)
402
+ if (claimedBy && claimedBy !== bunPath) {
403
+ continue
404
+ }
405
+ claimedShapes.set(shape, bunPath)
170
406
 
171
- // Collect all middleware to run
172
- const middlewareStack = [...this.globalMiddleware]
407
+ const entry = (natives[bunPath] ??= {})
408
+ if (entry[route.method]) {
409
+ continue // first registration wins, matching the fetch matcher
410
+ }
411
+ entry[route.method] = wrapRoute(route, route.path.endsWith('*'))
412
+ }
173
413
 
174
- // Add route-specific middleware
175
- if (match.route.middleware && match.route.middleware.length > 0) {
176
- middlewareStack.push(...match.route.middleware)
177
- }
414
+ // GET routes answer HEAD automatically (mirrors the fetch
415
+ // matcher's HEAD→GET fallback, but stays on the native fast path)
416
+ for (const entry of Object.values(natives)) {
417
+ if (entry.GET && !entry.HEAD) {
418
+ entry.HEAD = entry.GET
419
+ }
420
+ }
178
421
 
179
- // Create a final middleware that executes the route handler
180
- const routeHandlerMiddleware = async (req: EnhancedRequest, _next: any) => {
181
- return await this.resolveHandler(match.route.handler, req)
182
- }
422
+ return Object.keys(natives).length > 0 ? natives : null
423
+ },
424
+ writable: true,
425
+ configurable: true,
426
+ },
183
427
 
184
- // Add the route handler as the final middleware
185
- middlewareStack.push(routeHandlerMiddleware)
428
+ /**
429
+ * Internal: actual request handling, executed inside the request context scope.
430
+ */
431
+ handleRequestImpl: {
432
+ async value(req: Request): Promise<Response> {
433
+ try {
434
+ // Create URL for route matching, and share it with the request
435
+ // macros (path()/root()/get()/fingerprint() reuse it instead of
436
+ // reparsing req.url)
437
+ const url = new URL(req.url)
438
+ ;(req as any)._parsedURL = url
186
439
 
187
- // Run middleware stack with the route handler at the end
188
- const response = await this.runMiddleware(enhancedReq, middlewareStack)
440
+ // Get domain from the host header
441
+ const hostname = url.hostname || req.headers.get('host')?.split(':')[0] || 'localhost'
189
442
 
190
- // Apply modified cookies to the response
191
- if (response) {
192
- return this.applyModifiedCookies(response, enhancedReq)
443
+ // Find a matching route
444
+ const match = this.matchRoute(url.pathname, req.method as HTTPMethod, hostname)
445
+
446
+ // CORS preflight: when no explicit OPTIONS route is registered,
447
+ // answer with a generic preflight response. A request with an
448
+ // Origin header gets that origin reflected (plus Vary: Origin)
449
+ // so credentials stay usable — `Access-Control-Allow-Credentials`
450
+ // combined with a wildcard origin is rejected by browsers.
451
+ if (req.method === 'OPTIONS' && !match) {
452
+ const origin = req.headers.get('origin')
453
+ const preflightHeaders: Record<string, string> = {
454
+ 'Access-Control-Allow-Origin': origin || '*',
455
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
456
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
457
+ 'Access-Control-Max-Age': '86400',
458
+ }
459
+ if (origin) {
460
+ preflightHeaders['Access-Control-Allow-Credentials'] = 'true'
461
+ preflightHeaders.Vary = 'Origin'
193
462
  }
463
+ return new Response(null, { status: 204, headers: preflightHeaders })
464
+ }
194
465
 
195
- // This should not happen since we're always returning a response now
196
- return new Response('No response from middleware chain', { status: 500 })
466
+ if (match) {
467
+ return await this._dispatchMatchedRoute(match.route, req, match.params)
197
468
  }
198
469
 
470
+ // Enhance the request with params and other utilities (the
471
+ // matched-route path enhances inside _dispatchMatchedRoute)
472
+ const enhancedReq = this.enhanceRequest(req, {})
473
+ setCurrentRequest(enhancedReq)
474
+
199
475
  // No route found - check if the path exists with a different method (405 vs 404).
200
476
  // The 404/405 responses below now (a) include path + method in the body so client
201
477
  // debugging is one grep away, and (b) flow through globalMiddleware so cross-cutting
@@ -282,93 +558,31 @@ export function registerServerHandling(RouterClass: typeof Router): void {
282
558
  },
283
559
 
284
560
  /**
285
- * Enhance a request with params and other utilities
561
+ * Enhance a request with params and other utilities.
562
+ *
563
+ * Per-request work is intentionally minimal: assign `params` and
564
+ * insert the shared macro prototype. Everything else (cookies,
565
+ * input helpers, URL parsing) lives on the prototype and
566
+ * materializes lazily on first access.
286
567
  */
287
568
  enhanceRequest: {
288
569
  value(req: Request, params: Record<string, string> = {}): EnhancedRequest {
289
- // Lazy cookie parsing
290
- let parsedCookies: Record<string, string> | null = null
291
-
292
- const getCookies = () => {
293
- if (parsedCookies === null) {
294
- parsedCookies = {}
295
- const cookieHeader = req.headers.get('cookie') || ''
296
-
297
- cookieHeader.split(';').forEach((cookie) => {
298
- const parts = cookie.trim().split('=')
299
- if (parts.length >= 2) {
300
- const name = parts[0].trim()
301
- const value = parts.slice(1).join('=').trim()
302
- parsedCookies![name] = decodeURIComponent(value)
303
- }
304
- })
305
- }
306
- return parsedCookies
570
+ const enhancedReq = req as unknown as EnhancedRequest
571
+ if ('params' in req) {
572
+ // Bun's native-route requests expose `params` as a readonly
573
+ // prototype getter — plain assignment throws. An own property
574
+ // shadows it (and carries our augmentations, e.g. `wildcard`).
575
+ Object.defineProperty(enhancedReq, 'params', {
576
+ value: params,
577
+ writable: true,
578
+ configurable: true,
579
+ enumerable: true,
580
+ })
307
581
  }
308
-
309
- // Create cookie utilities with lazy parsing
310
- const cookies = {
311
- get: (name: string) => getCookies()[name],
312
- set: (name: string, value: string, options: any = {}) => {
313
- const enhancedRequest = req as EnhancedRequest
314
- if (!enhancedRequest._cookiesToSet) {
315
- enhancedRequest._cookiesToSet = []
316
- }
317
- enhancedRequest._cookiesToSet.push({ name, value, options })
318
- },
319
- delete: (name: string, options: any = {}) => {
320
- const enhancedRequest = req as EnhancedRequest
321
- if (!enhancedRequest._cookiesToDelete) {
322
- enhancedRequest._cookiesToDelete = []
323
- }
324
- enhancedRequest._cookiesToDelete.push({ name, options })
325
- },
326
- getAll: () => ({ ...getCookies() }),
327
- }
328
-
329
- // Create enhanced request
330
- const enhancedReq = Object.assign(req, {
331
- params,
332
- cookies: getCookies(), // Set cookies as plain object for direct access
333
- _cookiesToSet: [],
334
- _cookiesToDelete: [],
335
- }) as unknown as EnhancedRequest
336
-
337
- // Attach registered request macros (input, has, bearerToken, etc.) so
338
- // they're available in middleware and handlers without manual setup.
339
- // Applied *before* the cookie utility so the cookies macro doesn't
340
- // overwrite the get/set/delete object form consumers depend on.
341
- RequestWithMacros.applyMacros(enhancedReq)
342
-
343
- // Attach the helpers consumers reach for that aren't shaped as
344
- // generic input macros — `getParam`, the function-form `cookie`,
345
- // the parsed-query `get` shorthand. These mirror the methods the
346
- // bare `Router.enhanceRequest` (router.ts) attaches; without them
347
- // here, requests that flow through the registered server.ts
348
- // override are missing exactly the helpers that frameworks
349
- // layered on bun-router (Stacks, etc.) treat as guaranteed.
350
- ;(enhancedReq as any).getParam = <T = string>(name: string, defaultValue?: T): T | undefined => {
351
- const value = params?.[name] as T | undefined
352
- return value !== undefined ? value : defaultValue
353
- }
354
- ;(enhancedReq as any).cookie = (name: string, defaultValue?: string): string | null => {
355
- const value = getCookies()[name]
356
- return value !== undefined ? value : (defaultValue ?? null)
357
- }
358
- // `get(key)` — read query params (and only query params; the full
359
- // input merge with body etc. is provided by the `input` macro).
360
- // Skip if a macro already registered it under the same name.
361
- if (typeof (enhancedReq as any).get !== 'function') {
362
- ;(enhancedReq as any).get = <T = unknown>(key: string, defaultValue?: T): T | undefined => {
363
- const url = new URL(req.url)
364
- const value = url.searchParams.get(key) as T | null
365
- return value !== null && value !== undefined ? value : defaultValue
366
- }
582
+ else {
583
+ enhancedReq.params = params
367
584
  }
368
-
369
- // Add cookie methods to the request (overrides any macro named `cookies`).
370
- Object.assign(enhancedReq, { cookies: { ...getCookies(), ...cookies } })
371
-
585
+ RequestWithMacros.applyMacros(enhancedReq)
372
586
  return enhancedReq
373
587
  },
374
588
  writable: true,
@@ -380,6 +594,14 @@ export function registerServerHandling(RouterClass: typeof Router): void {
380
594
  */
381
595
  applyModifiedCookies: {
382
596
  value(response: Response, req: EnhancedRequest): Response {
597
+ // Fast path: nothing to apply — return the response untouched
598
+ // instead of paying for a clone on every request
599
+ const hasSet = req._cookiesToSet && req._cookiesToSet.length > 0
600
+ const hasDelete = req._cookiesToDelete && req._cookiesToDelete.length > 0
601
+ if (!hasSet && !hasDelete) {
602
+ return response
603
+ }
604
+
383
605
  // Clone the response to modify headers
384
606
  const newResponse = new Response(response.body, {
385
607
  status: response.status,
@@ -2,7 +2,7 @@
2
2
  * Router Validation Integration
3
3
  *
4
4
  * Integration layer for validation and macros with the router.
5
- * Provides RouteBuilder and FluentRouter for building routes with validation support.
5
+ * Provides RouteBuilder and ValidationFluentRouter for building routes with validation support.
6
6
  */
7
7
 
8
8
  import type { EnhancedRequest, MiddlewareHandler, NextFunction, RouteHandler } from '../types'
@@ -62,9 +62,15 @@ export class RouteBuilder {
62
62
  }
63
63
 
64
64
  /**
65
- * Fluent router for enhanced routing functionality
65
+ * Minimal route collector with validation support.
66
+ *
67
+ * Renamed from `FluentRouter`: this module used to declare a third class
68
+ * with that name. It was shadowed by the canonical `FluentRouter` exported
69
+ * from `fluent-routing.ts` (explicit exports win over `export *`), so
70
+ * `createFluentRouter()` silently returned a different class than the
71
+ * public `FluentRouter` symbol. The distinct name removes the ambiguity.
66
72
  */
67
- export class FluentRouter {
73
+ export class ValidationFluentRouter {
68
74
  private routes: Array<{
69
75
  method: string
70
76
  path: string
@@ -320,8 +326,8 @@ export const RouteHelpers: {
320
326
  /**
321
327
  * Fluent router factory
322
328
  */
323
- export function createFluentRouter(): FluentRouter {
324
- return new FluentRouter()
329
+ export function createFluentRouter(): ValidationFluentRouter {
330
+ return new ValidationFluentRouter()
325
331
  }
326
332
 
327
333
  /**
@@ -44,11 +44,13 @@ export class AuthTester {
44
44
  withJWT(token: string, type: 'Bearer' | 'Basic' = 'Bearer'): AuthTester {
45
45
  const headers = new Headers(this.request.headers)
46
46
  headers.set('Authorization', `${type} ${token}`)
47
- // Create a new request object with updated headers
47
+ // Create a new request object with updated headers. The spread drops
48
+ // prototype methods (e.g. `clone`) from the inferred type, so re-assert
49
+ // the shape — the mock still carries them at runtime.
48
50
  this.request = {
49
51
  ...this.request,
50
52
  headers,
51
- }
53
+ } as EnhancedRequest
52
54
  return this
53
55
  }
54
56
 
@@ -66,7 +68,7 @@ export class AuthTester {
66
68
  withApiKey(apiKey: string, headerName: string = 'X-API-Key'): AuthTester {
67
69
  const headers = new Headers(this.request.headers)
68
70
  headers.set(headerName, apiKey)
69
- this.request = { ...this.request, headers }
71
+ this.request = { ...this.request, headers } as EnhancedRequest
70
72
  return this
71
73
  }
72
74
 
@@ -77,7 +79,7 @@ export class AuthTester {
77
79
  const credentials = btoa(`${username}:${password}`)
78
80
  const headers = new Headers(this.request.headers)
79
81
  headers.set('Authorization', `Basic ${credentials}`)
80
- this.request = { ...this.request, headers }
82
+ this.request = { ...this.request, headers } as EnhancedRequest
81
83
  return this
82
84
  }
83
85
 
@@ -6,12 +6,15 @@
6
6
 
7
7
  import type { RouteHandler, TypedRequest } from './route-inference'
8
8
 
9
- // Base middleware interface with generic constraints
9
+ // Base middleware interface with generic constraints. The defaults are
10
+ // the narrowest types that hold for every middleware (`Request` in,
11
+ // `Response` out via an async `next`) — `any` defaults let unconstrained
12
+ // middleware slip through composition unchecked.
10
13
  export interface TypedMiddleware<
11
- TInput = any,
12
- TOutput = TInput,
14
+ TInput = Request,
15
+ TOutput = Response,
13
16
  _TContext = object,
14
- TNext = any,
17
+ TNext = () => Promise<TOutput>,
15
18
  > {
16
19
  (
17
20
  request: TInput,