@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
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/src/router/index.ts
CHANGED
|
@@ -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
|
package/src/router/middleware.ts
CHANGED
|
@@ -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
|
-
//
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
if (
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
+
const cached = this.matchCache.get(cacheKey)
|
|
244
|
+
if (cached !== undefined || this.matchCache.has(cacheKey)) {
|
|
237
245
|
this.stats.cacheHits++
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
if (
|
|
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.
|
|
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
|
*/
|
package/src/router/route-trie.ts
CHANGED
|
@@ -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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
360
|
+
return null
|
|
375
361
|
}
|
|
376
362
|
|
|
377
363
|
/**
|