@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
@@ -38,7 +38,23 @@ export function registerGroupOrganization(RouterClass: typeof Router): void {
38
38
  this.currentGroup = newGroup
39
39
 
40
40
  // Execute the callback to register routes in this group
41
- callback()
41
+ const result = callback() as unknown
42
+
43
+ // Async callbacks (e.g. `register()`'s dynamic import) register
44
+ // their routes after this call returns — the group must stay
45
+ // active until the promise settles, and callers must await.
46
+ if (result instanceof Promise) {
47
+ return result.then(
48
+ () => {
49
+ this.currentGroup = previousGroup
50
+ return this
51
+ },
52
+ (error: unknown) => {
53
+ this.currentGroup = previousGroup
54
+ throw error
55
+ },
56
+ ) as unknown as Router
57
+ }
42
58
 
43
59
  // Restore the previous group
44
60
  this.currentGroup = previousGroup
@@ -289,3 +289,39 @@ async function resolveStringHandler(
289
289
  export function createHandlerResolver(config: RouterConfig): (handler: unknown, req: EnhancedRequest) => Promise<Response> {
290
290
  return (handler: unknown, req: EnhancedRequest) => resolveHandler(handler, req, config)
291
291
  }
292
+
293
+ /**
294
+ * Precompile the dispatch branch for a handler whose shape never changes
295
+ * (a registered route's handler). `resolveHandler` re-sniffs the handler
296
+ * type — instanceof / typeof / prototype checks — on every request; this
297
+ * resolves the branch once and returns a specialized invoker for the
298
+ * per-request hot path.
299
+ */
300
+ export function createHandlerInvoker(
301
+ handler: unknown,
302
+ config: RouterConfig,
303
+ ): (req: EnhancedRequest) => Promise<Response> {
304
+ // Static Response routes
305
+ if (handler instanceof Response) {
306
+ return async () => handler.clone() as Response
307
+ }
308
+
309
+ // Plain function handlers (the common case)
310
+ if (isCallableHandler(handler)) {
311
+ return async (req: EnhancedRequest) => wrapResponse(await handler(req))
312
+ }
313
+
314
+ // Class constructors with a handle() method
315
+ if (
316
+ typeof handler === 'function'
317
+ && handler.prototype
318
+ && typeof handler.prototype.handle === 'function'
319
+ ) {
320
+ const HandlerClass = handler as new () => { handle: (req: EnhancedRequest) => unknown }
321
+ return async (req: EnhancedRequest) => wrapResponse(await new HandlerClass().handle(req))
322
+ }
323
+
324
+ // Strings (action paths, Controller@method) and anything exotic keep
325
+ // the full resolution flow
326
+ return (req: EnhancedRequest) => resolveHandler(handler, req, config)
327
+ }
@@ -147,13 +147,17 @@ export function registerHttpMethods(RouterClass: typeof Router): void {
147
147
  registerNamedRoute(name, route.path)
148
148
  }
149
149
 
150
- // Add to optimized route compiler if available
151
- if (this.addRouteToCompiler) {
150
+ // Add to optimized route compiler if available. Domain-scoped routes
151
+ // stay out of the shared trie — its cache is keyed by method+path
152
+ // only, so a domain route in the trie could be served to the wrong
153
+ // host. They are matched by the domain-aware fallback instead.
154
+ if (this.addRouteToCompiler && !domain) {
152
155
  this.addRouteToCompiler(route)
153
156
  }
154
157
 
155
- // Clear route cache when new routes are added
158
+ // Clear route caches when new routes are added
156
159
  this.routeCache.clear()
160
+ this._allowedMethodsCache?.clear()
157
161
 
158
162
  return this
159
163
  },
@@ -251,6 +255,23 @@ export function registerHttpMethods(RouterClass: typeof Router): void {
251
255
  configurable: true,
252
256
  },
253
257
 
258
+ // HTTP HEAD method. Note: GET routes already answer HEAD requests
259
+ // automatically — register an explicit HEAD route only when the HEAD
260
+ // behavior must differ from GET.
261
+ head: {
262
+ value(
263
+ path: string,
264
+ handler: ActionHandler,
265
+ type?: 'api' | 'web',
266
+ name?: string,
267
+ middleware?: (string | MiddlewareHandler)[],
268
+ ): Router {
269
+ return this.addRoute('HEAD', path, handler, type, name, middleware)
270
+ },
271
+ writable: true,
272
+ configurable: true,
273
+ },
274
+
254
275
  // Register a route that responds to multiple HTTP methods
255
276
  match: {
256
277
  value(
@@ -311,6 +332,15 @@ export function registerHttpMethods(RouterClass: typeof Router): void {
311
332
  url = url.replace(`{${param}?}`, encodeURIComponent(value))
312
333
  }
313
334
 
335
+ // Drop unfilled optional placeholders and tidy up the slashes
336
+ // they leave behind (`/posts/{page?}` → `/posts`)
337
+ if (url.includes('{')) {
338
+ url = url.replace(/\{[^}]+\?\}/g, '').replace(/\/{2,}/g, '/')
339
+ if (url.length > 1 && url.endsWith('/')) {
340
+ url = url.slice(0, -1)
341
+ }
342
+ }
343
+
314
344
  return url
315
345
  },
316
346
  writable: true,
@@ -140,6 +140,9 @@ declare module './router' {
140
140
  renderView: (view: string, data?: Record<string, any>, options?: { layout?: string }) => Promise<string>
141
141
  view: (path: string, view: string, data?: Record<string, any>, options?: { layout?: string, status?: number, headers?: Record<string, string> }) => Router
142
142
 
143
+ // Keep the most recently registered route off the native route table
144
+ withoutNativeDispatch: () => Router
145
+
143
146
  // Route constraint methods
144
147
  where: ((_param: string, _pattern: string | RegExp) => Router) & ((constraints: Record<string, string | RegExp>) => Router)
145
148
  whereNumber: (param: string) => Router
@@ -162,6 +165,9 @@ declare module './router' {
162
165
  edit?: ActionHandler
163
166
  }) => Router
164
167
 
168
+ // HTTP HEAD method (GET routes already answer HEAD automatically)
169
+ head: (path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: any[]) => Router
170
+
165
171
  // Redirect and utility methods
166
172
  onError: (handler: (error: Error) => Response | Promise<Response>) => Router
167
173
  redirect: (url: string, status?: 301 | 302 | 303 | 307 | 308) => Response
@@ -18,6 +18,9 @@ export function registerMiddlewareHandling(RouterClass: typeof Router): void {
18
18
  this.globalMiddleware.push(resolvedMiddleware)
19
19
  }
20
20
  }
21
+ // Invalidate per-route compiled middleware chains — they bake in
22
+ // the global middleware stack as of build time
23
+ this._mwEpoch = (this._mwEpoch || 0) + 1
21
24
  return this
22
25
  },
23
26
  writable: true,
@@ -46,20 +46,38 @@ export function registerOptimizedRouteMatching(RouterClass: typeof Router): void
46
46
  value(path: string, method: HTTPMethod, domain?: string): MatchResult | undefined {
47
47
  this.initializeRouteCompiler()
48
48
 
49
- // Use the optimized compiler for matching
50
- const match = this.routeCompiler.match(path, method)
49
+ // Domain-scoped routes are matched by the domain-aware fallback.
50
+ // The trie (and its cache key) is method+path only, so letting it
51
+ // answer for hosts with registered domain routes would let one
52
+ // domain's cached match poison another's.
53
+ if (domain && this.domains[domain]) {
54
+ const domainMatch = this.fallbackMatchRoute(path, method, domain)
55
+ if (domainMatch) {
56
+ return domainMatch
57
+ }
58
+ }
59
+ else {
60
+ // Use the optimized compiler for matching
61
+ const match = this.routeCompiler.match(path, method)
51
62
 
52
- if (match) {
53
- // Check domain constraints if specified
54
- if (domain && match.route.domain && match.route.domain !== domain) {
55
- return undefined
63
+ if (match && (!domain || !match.route.domain || match.route.domain === domain)) {
64
+ return match
56
65
  }
57
66
 
58
- return match
67
+ // Fallback to original matching for edge cases (optional params, etc.)
68
+ const fallback = this.fallbackMatchRoute(path, method, domain)
69
+ if (fallback) {
70
+ return fallback
71
+ }
72
+ }
73
+
74
+ // A HEAD request is served by the matching GET route when no
75
+ // explicit HEAD route exists (RFC 9110 §9.3.2)
76
+ if (method === 'HEAD') {
77
+ return this.matchRoute(path, 'GET', domain)
59
78
  }
60
79
 
61
- // Fallback to original matching for edge cases
62
- return this.fallbackMatchRoute(path, method, domain)
80
+ return undefined
63
81
  },
64
82
  writable: true,
65
83
  configurable: true,
@@ -80,6 +98,12 @@ export function registerOptimizedRouteMatching(RouterClass: typeof Router): void
80
98
  return this.routeCache.get(cacheKey)
81
99
  }
82
100
 
101
+ // Bound the legacy cache — high-cardinality dynamic paths would
102
+ // otherwise grow it without limit
103
+ if (this.routeCache.size >= 10_000) {
104
+ this.routeCache.clear()
105
+ }
106
+
83
107
  // Fast path for static routes
84
108
  if (this.staticRoutes.has(method)) {
85
109
  const staticRoute = this.staticRoutes.get(method)!.get(url.pathname)
@@ -128,6 +152,31 @@ export function registerOptimizedRouteMatching(RouterClass: typeof Router): void
128
152
  }
129
153
  }
130
154
 
155
+ // Try wildcard routes — domain-scoped first, then global
156
+ const wildcardPools: Route[][] = domain && this.domains[domain]
157
+ ? [this.domains[domain], this.routes]
158
+ : [this.routes]
159
+
160
+ for (const pool of wildcardPools) {
161
+ for (const route of pool) {
162
+ if (route.method !== method || !route.path.endsWith('*')) {
163
+ continue
164
+ }
165
+ const basePath = route.path.slice(0, -1) // Remove the '*'
166
+ if (url.pathname.startsWith(basePath)) {
167
+ const result = {
168
+ route,
169
+ params: { wildcard: url.pathname.slice(basePath.length) },
170
+ }
171
+ this.routeCache.set(cacheKey, result)
172
+ return result
173
+ }
174
+ }
175
+ }
176
+
177
+ // Cache the miss too — repeated requests for an unknown path skip
178
+ // the full scan (the cache is cleared whenever routes change)
179
+ this.routeCache.set(cacheKey, undefined)
131
180
  return undefined
132
181
  },
133
182
  writable: true,
@@ -11,6 +11,12 @@ export interface RouteCompilerOptions {
11
11
  enablePriorityOptimization: boolean
12
12
  cacheSize: number
13
13
  precompilePatterns: boolean
14
+ /**
15
+ * Record per-match timing via `performance.now()`. Off by default —
16
+ * timing every request costs two clock reads per match on the hot path.
17
+ * Match/hit/miss counters are always collected (they're just integers).
18
+ */
19
+ enableProfiling: boolean
14
20
  }
15
21
 
16
22
  /**
@@ -30,8 +36,16 @@ export interface RouteMatchStats {
30
36
  */
31
37
  export class RouteCompiler {
32
38
  private trie: RouteTrie
39
+ // Bounded match cache with FIFO eviction (oldest key in Map iteration
40
+ // order). Cache hits are pure reads — refreshing recency on every hit
41
+ // (true LRU) costs a delete+set per request and buys almost nothing
42
+ // for route tables, where the hot set is small and stable. The old
43
+ // implementation kept a `cacheKeys` array whose indexOf/splice
44
+ // bookkeeping cost O(cacheSize) on every request.
33
45
  private matchCache: Map<string, MatchResult | null> = new Map()
34
- private cacheKeys: string[] = [] // For LRU tracking
46
+ // O(1) duplicate detection by `method:path` (the old approach scanned
47
+ // every compiled route on each addRoute call — O(n²) registration).
48
+ private routeKeys: Set<string> = new Set()
35
49
  private stats: RouteMatchStats = {
36
50
  totalMatches: 0,
37
51
  cacheHits: 0,
@@ -50,6 +64,7 @@ export class RouteCompiler {
50
64
  enablePriorityOptimization: true,
51
65
  cacheSize: 1000,
52
66
  precompilePatterns: true,
67
+ enableProfiling: false,
53
68
  ...options,
54
69
  }
55
70
 
@@ -66,11 +81,12 @@ export class RouteCompiler {
66
81
  this.precompileRoutePattern(route)
67
82
  }
68
83
 
69
- // Check for exact duplicates before adding
70
- const isDuplicate = this.checkForDuplicateRoute(route)
71
- if (isDuplicate) {
84
+ // Check for exact duplicates before adding — O(1) set lookup
85
+ const routeKey = `${route.method}:${route.path}`
86
+ if (this.routeKeys.has(routeKey)) {
72
87
  return false
73
88
  }
89
+ this.routeKeys.add(routeKey)
74
90
 
75
91
  // Add to trie for fast matching
76
92
  this.trie.addRoute(route)
@@ -87,24 +103,6 @@ export class RouteCompiler {
87
103
  return true
88
104
  }
89
105
 
90
- /**
91
- * Check if a route is an exact duplicate of an existing route
92
- */
93
- private checkForDuplicateRoute(route: Route): boolean {
94
- const routes = this.trie.getAllRoutes()
95
-
96
- for (const existingRoute of routes) {
97
- if (
98
- existingRoute.route.path === route.path
99
- && existingRoute.route.method === route.method
100
- ) {
101
- return true
102
- }
103
- }
104
-
105
- return false
106
- }
107
-
108
106
  /**
109
107
  * Pre-compile route patterns for faster matching
110
108
  */
@@ -191,24 +189,33 @@ export class RouteCompiler {
191
189
  path: string,
192
190
  constraints?: Record<string, string>,
193
191
  ): { exec: (pathname: string) => { groups?: Record<string, string> } | null } {
194
- // Convert {param} to named capture groups and {param:pattern} to constrained capture groups
195
- let regexPattern = path.replace(/\{([^}:]+)(?::([^}]+))?\}/g, (_match, name, ..._args) => {
196
- const pattern = _args[0]
197
- // If there's a constraint for this parameter, use it instead of the default pattern
198
- if (constraints && constraints[name]) {
199
- return `(?<${name}>${constraints[name]})`
200
- }
201
- // If there's an inline pattern in the route path, use it
202
- else if (pattern) {
203
- return `(?<${name}>${pattern})`
204
- }
205
- // Default pattern for parameters without constraints
206
- return `(?<${name}>[^/]+)`
192
+ // Pull params out as placeholder tokens so the static text can be
193
+ // regex-escaped safely — without this, a literal dot in a path like
194
+ // `/api/v1.0/users` would match any character.
195
+ const paramTokens: string[] = []
196
+ const withTokens = path.replace(/\{([^}]+)\}/g, (_m, inner: string) => {
197
+ paramTokens.push(inner)
198
+ return `\u0000${paramTokens.length - 1}\u0000`
199
+ })
200
+
201
+ let regexPattern = withTokens.replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
202
+
203
+ // Convert placeholder tokens to named capture groups. Optional params
204
+ // (`{id?}`) make both the parameter and its leading slash optional.
205
+ regexPattern = regexPattern.replace(/\/?\u0000(\d+)\u0000/g, (token, idx: string) => {
206
+ const inner = paramTokens[Number(idx)]
207
+ const isOptional = inner.endsWith('?')
208
+ const core = isOptional ? inner.slice(0, -1) : inner
209
+ const colonIndex = core.indexOf(':')
210
+ const name = colonIndex === -1 ? core : core.slice(0, colonIndex)
211
+ const inlinePattern = colonIndex === -1 ? undefined : core.slice(colonIndex + 1)
212
+ const pattern = constraints?.[name] ?? inlinePattern ?? '[^/]+'
213
+ const group = `(?<${name}>${pattern})`
214
+ const leadingSlash = token.startsWith('/') ? '/' : ''
215
+ return isOptional ? `(?:${leadingSlash}${group})?` : `${leadingSlash}${group}`
207
216
  })
208
217
 
209
- // Escape forward slashes and add anchors
210
- regexPattern = `^${regexPattern.replace(/\//g, '\\/')}$`
211
- const regex = new RegExp(regexPattern)
218
+ const regex = new RegExp(`^${regexPattern}$`)
212
219
 
213
220
  return {
214
221
  exec: (pathname: string) => {
@@ -227,21 +234,20 @@ export class RouteCompiler {
227
234
  * Match a request path and method to a route
228
235
  */
229
236
  match(path: string, method: HTTPMethod): MatchResult | null {
230
- const startTime = performance.now()
237
+ const startTime = this.options.enableProfiling ? performance.now() : 0
231
238
  this.stats.totalMatches++
232
239
 
233
240
  // Check cache first if enabled
234
241
  if (this.options.enableCaching) {
235
242
  const cacheKey = `${method}:${path}`
236
- if (this.matchCache.has(cacheKey)) {
243
+ const cached = this.matchCache.get(cacheKey)
244
+ if (cached !== undefined || this.matchCache.has(cacheKey)) {
237
245
  this.stats.cacheHits++
238
-
239
- // Update LRU order - move this key to the end (most recently used)
240
- this.updateCacheLRU(cacheKey)
241
-
242
- const cached = this.matchCache.get(cacheKey)
243
- this.updateMatchTime(startTime)
244
- return cached || null
246
+ if (startTime)
247
+ this.updateMatchTime(startTime)
248
+ // Hand each request its own params object — cached results are
249
+ // shared, and handlers are allowed to mutate `req.params`.
250
+ return cached ? { route: cached.route, params: { ...cached.params } } : null
245
251
  }
246
252
  this.stats.cacheMisses++
247
253
  }
@@ -263,46 +269,29 @@ export class RouteCompiler {
263
269
  this.addToCache(cacheKey, result)
264
270
  }
265
271
 
266
- this.updateMatchTime(startTime)
267
- return result
272
+ if (startTime)
273
+ this.updateMatchTime(startTime)
274
+ return result ? { route: result.route, params: { ...result.params } } : null
268
275
  }
269
276
 
270
277
  /**
271
278
  * Add an entry to the cache with LRU eviction if needed
272
279
  */
273
280
  private addToCache(key: string, value: MatchResult | null): void {
274
- // If cache is full, evict least recently used item
281
+ // If cache is full, evict the least recently used entry (oldest key
282
+ // in Map iteration order)
275
283
  if (this.matchCache.size >= this.options.cacheSize && !this.matchCache.has(key)) {
276
- // Remove least recently used item (first in array)
277
- if (this.cacheKeys.length > 0) {
278
- const lruKey = this.cacheKeys.shift()!
284
+ const lruKey = this.matchCache.keys().next().value
285
+ if (lruKey !== undefined) {
279
286
  this.matchCache.delete(lruKey)
280
287
  }
281
288
  }
282
289
 
283
- // Add new item to cache
284
290
  this.matchCache.set(key, value)
285
-
286
- // Update LRU tracking
287
- this.updateCacheLRU(key)
288
- }
289
-
290
- /**
291
- * Update LRU tracking for a cache key
292
- */
293
- private updateCacheLRU(key: string): void {
294
- // Remove key from current position if it exists
295
- const index = this.cacheKeys.indexOf(key)
296
- if (index !== -1) {
297
- this.cacheKeys.splice(index, 1)
298
- }
299
-
300
- // Add key to end of array (most recently used)
301
- this.cacheKeys.push(key)
302
291
  }
303
292
 
304
293
  /**
305
- * Update average match time statistics
294
+ * Update average match time statistics (only when profiling is enabled)
306
295
  */
307
296
  private updateMatchTime(startTime: number): void {
308
297
  const matchTime = performance.now() - startTime
@@ -419,7 +408,7 @@ export class RouteCompiler {
419
408
  clear(): void {
420
409
  this.trie.clear()
421
410
  this.matchCache.clear()
422
- this.cacheKeys = []
411
+ this.routeKeys.clear()
423
412
  this.stats = {
424
413
  totalMatches: 0,
425
414
  cacheHits: 0,
@@ -5,6 +5,7 @@ import { matchPath } from '../utils'
5
5
  /**
6
6
  * Route matching extension for Router class
7
7
  */
8
+ // eslint-disable-next-line pickier/no-unused-vars -- false positive: used on the next line
8
9
  export function registerRouteMatching(RouterClass: typeof Router): void {
9
10
  Object.defineProperties(RouterClass.prototype, {
10
11
  /**
@@ -291,6 +292,24 @@ export function registerRouteMatching(RouterClass: typeof Router): void {
291
292
  configurable: true,
292
293
  },
293
294
 
295
+ /**
296
+ * Keep the most recently registered route on the fetch-handler
297
+ * matcher even when serving with `nativeRoutes: true`. Useful when a
298
+ * route must respect registration-order precedence against an
299
+ * overlapping pattern (Bun's native router resolves by specificity).
300
+ */
301
+ withoutNativeDispatch: {
302
+ value(): Router {
303
+ const lastRoute = this.routes[this.routes.length - 1]
304
+ if (lastRoute) {
305
+ lastRoute.nativeDispatch = false
306
+ }
307
+ return this
308
+ },
309
+ writable: true,
310
+ configurable: true,
311
+ },
312
+
294
313
  /**
295
314
  * Apply a numeric constraint to a parameter
296
315
  */
@@ -290,7 +290,13 @@ export class RouteTrie {
290
290
  }
291
291
 
292
292
  /**
293
- * Recursively match path segments
293
+ * Recursively match path segments.
294
+ *
295
+ * Candidates are tried in specificity order — static, then parameter,
296
+ * then wildcard — and the first full match wins. This both fixes the
297
+ * old behavior (where a wildcard sibling could shadow a static route)
298
+ * and avoids allocating candidate arrays and params copies per segment:
299
+ * `params` is mutated in place and rolled back on backtrack.
294
300
  */
295
301
  private matchSegments(
296
302
  node: TrieNode,
@@ -313,65 +319,45 @@ export class RouteTrie {
313
319
  }
314
320
 
315
321
  const segment = segments[segmentIndex]
316
- const candidates: Array<{ node: TrieNode, newParams: Record<string, string> }> = []
317
322
 
318
323
  // Try static match first (highest priority)
319
- if (node.children.has(segment)) {
320
- candidates.push({
321
- node: node.children.get(segment)!,
322
- newParams: { ...params },
323
- })
324
+ const staticChild = node.children.get(segment)
325
+ if (staticChild) {
326
+ const match = this.matchSegments(staticChild, segments, segmentIndex + 1, params, method)
327
+ if (match)
328
+ return match
324
329
  }
325
330
 
326
331
  // Try parameter match
327
- if (node.paramChild) {
328
- const paramNode = node.paramChild
329
- let matches = true
330
-
331
- if (paramNode.pattern) {
332
- matches = paramNode.pattern.test(segment)
333
- }
334
-
335
- if (matches && paramNode.paramName) {
336
- candidates.push({
337
- node: paramNode,
338
- newParams: { ...params, [paramNode.paramName]: segment },
339
- })
340
- }
341
- }
342
-
343
- // Try wildcard match (lowest priority)
344
- if (node.wildcardChild) {
345
- const remainingPath = segments.slice(segmentIndex).join('/')
346
- const match = this.matchSegments(
347
- node.wildcardChild,
348
- [], // Empty segments for wildcard - it matches everything
349
- 0,
350
- { ...params, wildcard: remainingPath },
351
- method,
352
- )
332
+ const paramNode = node.paramChild
333
+ if (paramNode && paramNode.paramName
334
+ && (!paramNode.pattern || paramNode.pattern.test(segment))) {
335
+ const previous = params[paramNode.paramName]
336
+ params[paramNode.paramName] = segment
337
+ const match = this.matchSegments(paramNode, segments, segmentIndex + 1, params, method)
353
338
  if (match)
354
339
  return match
340
+ // Backtrack: restore params before trying lower-priority branches
341
+ if (previous === undefined)
342
+ delete params[paramNode.paramName]
343
+ else
344
+ params[paramNode.paramName] = previous
355
345
  }
356
346
 
357
- // Try all candidates and return the best match
358
- let bestMatch: RouteMatch | null = null
359
-
360
- for (const candidate of candidates) {
361
- const match = this.matchSegments(
362
- candidate.node,
363
- segments,
364
- segmentIndex + 1,
365
- candidate.newParams,
366
- method,
367
- )
368
-
369
- if (match && (!bestMatch || match.score > bestMatch.score)) {
370
- bestMatch = match
347
+ // Try wildcard match (lowest priority) — consumes all remaining segments
348
+ if (node.wildcardChild) {
349
+ const compiled = node.wildcardChild.getRoute(method)
350
+ if (compiled) {
351
+ params.wildcard = segments.slice(segmentIndex).join('/')
352
+ return {
353
+ route: compiled.route,
354
+ params,
355
+ score: compiled.priority,
356
+ }
371
357
  }
372
358
  }
373
359
 
374
- return bestMatch
360
+ return null
375
361
  }
376
362
 
377
363
  /**