@@ -28,7 +28,7 @@ export type Handler = (
2828) => Response | Promise < Response > ;
2929
3030/**
31- * Route configuration for {@linkcode route }.
31+ * Route configuration for {@linkcode routeRadix }.
3232 *
3333 * @experimental **UNSTABLE**: New API, yet to be vetted.
3434 */
@@ -50,11 +50,136 @@ export interface Route {
5050 handler : Handler ;
5151}
5252
53+ function methodMatches (
54+ routeMethod : string | string [ ] | undefined ,
55+ requestMethod : string ,
56+ ) : boolean {
57+ if ( ! routeMethod ) return true ;
58+ if ( Array . isArray ( routeMethod ) ) {
59+ return routeMethod . some ( ( m ) => m . toUpperCase ( ) === requestMethod ) ;
60+ }
61+ return routeMethod . toUpperCase ( ) === requestMethod ;
62+ }
63+
64+ /**
65+ * Routes requests to handlers using a linear scan over all routes.
66+ *
67+ * @experimental **UNSTABLE**: New API, yet to be vetted.
68+ *
69+ * Routes are matched in insertion order; the first matching route wins.
70+ * Prefer {@linkcode routeRadix} for better performance on larger route tables.
71+ *
72+ * @example Usage
73+ * ```ts ignore
74+ * import { routeLinear, type Route } from "@std/http/unstable-route";
75+ *
76+ * const routes: Route[] = [
77+ * {
78+ * pattern: new URLPattern({ pathname: "/about" }),
79+ * handler: () => new Response("About page"),
80+ * },
81+ * {
82+ * pattern: new URLPattern({ pathname: "/users/:id" }),
83+ * method: "GET",
84+ * handler: (_req, params) => new Response(params.pathname.groups.id),
85+ * },
86+ * ];
87+ *
88+ * function defaultHandler(_req: Request) {
89+ * return new Response("Not found", { status: 404 });
90+ * }
91+ *
92+ * Deno.serve(routeLinear(routes, defaultHandler));
93+ * ```
94+ *
95+ * @param routes Route configurations
96+ * @param defaultHandler Default request handler
97+ * @returns Request handler
98+ */
99+ export function routeLinear (
100+ routes : Route [ ] ,
101+ defaultHandler : RequestHandler ,
102+ ) : RequestHandler {
103+ // TODO(iuioiua): Use `URLPatternList` once available (https://github.com/whatwg/urlpattern/pull/166)
104+ return ( request : Request , info ?: Deno . ServeHandlerInfo ) => {
105+ for ( const route of routes ) {
106+ if ( ! methodMatches ( route . method , request . method ) ) continue ;
107+ const match = route . pattern . exec ( request . url ) ;
108+ if ( match ) return route . handler ( request , match , info ) ;
109+ }
110+ return defaultHandler ( request , info ) ;
111+ } ;
112+ }
113+
114+ // ---------------------------------------------------------------------------
115+ // Radix tree router
116+ // ---------------------------------------------------------------------------
117+
118+ // Internal: Route with its original registration index for stable ordering.
119+ interface IndexedRoute {
120+ route : Route ;
121+ index : number ;
122+ }
123+
124+ interface RouteNode {
125+ staticChildren : Record < string , RouteNode > ;
126+ paramChild : RouteNode | null ;
127+ wildcardChild : RouteNode | null ;
128+ routes : IndexedRoute [ ] ;
129+ }
130+
131+ /**
132+ * Extract pathname from a URL string without allocating a URL object.
133+ * Handles both `http://host/path?query` and `http://host/path` forms.
134+ */
135+ function parsePathname ( url : string ) : string {
136+ const authorityStart = url . indexOf ( "//" ) ;
137+ const pathStart = url . indexOf ( "/" , authorityStart + 2 ) ;
138+ if ( pathStart === - 1 ) return "/" ;
139+ const qmark = url . indexOf ( "?" , pathStart ) ;
140+ const hash = url . indexOf ( "#" , pathStart ) ;
141+ let end = url . length ;
142+ if ( qmark !== - 1 ) end = qmark ;
143+ if ( hash !== - 1 && hash < end ) end = hash ;
144+ return url . slice ( pathStart , end ) ;
145+ }
146+
147+ /**
148+ * Returns true if a pathname segment contains URLPattern syntax that the
149+ * radix tree cannot model structurally — i.e. it is not a plain static
150+ * string, a bare `:param`, or a standalone `*`.
151+ *
152+ * Affected syntax:
153+ * - Optional / non-capturing groups: `{.ext}?` `{foo}`
154+ * - Regex-constrained params: `:id(\d+)` `:lang(en|fr)`
155+ * - Inline wildcards: `*.js` `prefix*`
156+ */
157+ function isComplexSegment ( segment : string ) : boolean {
158+ if ( segment . includes ( "{" ) || segment . includes ( "(" ) ) return true ;
159+ if ( segment . includes ( "*" ) && segment !== "*" ) return true ;
160+ if ( segment . endsWith ( "?" ) || segment . endsWith ( "+" ) ) return true ;
161+ return false ;
162+ }
163+
164+ function createNode ( ) : RouteNode {
165+ return {
166+ staticChildren : Object . create ( null ) as Record < string , RouteNode > ,
167+ paramChild : null ,
168+ wildcardChild : null ,
169+ routes : [ ] ,
170+ } ;
171+ }
172+
53173/**
54174 * Routes requests to different handlers based on the request path and method.
55175 *
56176 * @experimental **UNSTABLE**: New API, yet to be vetted.
57177 *
178+ * Uses a radix tree for O(segments) dispatch on static and parametric routes.
179+ * Routes with complex URLPattern syntax (regex constraints, optional/non-capturing
180+ * groups, inline wildcards) fall back to linear matching while preserving
181+ * insertion order relative to tree-indexed routes.
182+ *
58183 * @example Usage
59184 * ```ts ignore
60185 * import { route, type Route } from "@std/http/unstable-route";
@@ -96,29 +221,140 @@ export interface Route {
96221 * Allowed response can be done in this function.
97222 * @returns Request handler
98223 */
99- export function route (
224+ export function routeRadix (
100225 routes : Route [ ] ,
101226 defaultHandler : RequestHandler ,
102227) : RequestHandler {
103- // TODO(iuioiua): Use `URLPatternList` once available (https://github.com/whatwg/urlpattern/pull/166)
228+ const root = createNode ( ) ;
229+ const fallbackRoutes : IndexedRoute [ ] = [ ] ;
230+ let insertionCounter = 0 ;
231+
232+ function parseSegments ( pathname : string ) : string [ ] {
233+ return pathname . split ( "/" ) . filter ( Boolean ) ;
234+ }
235+
236+ function insert ( r : Route ) : void {
237+ const indexed : IndexedRoute = { route : r , index : insertionCounter ++ } ;
238+ const segments = parseSegments ( r . pattern . pathname ) ;
239+
240+ // If any pathname segment uses URLPattern syntax the radix tree cannot
241+ // model, fall back to linear matching. Insertion order is preserved via
242+ // `index`.
243+ if ( segments . some ( isComplexSegment ) ) {
244+ fallbackRoutes . push ( indexed ) ;
245+ return ;
246+ }
247+
248+ let node = root ;
249+
250+ for ( const segment of segments ) {
251+ if ( segment === "*" ) {
252+ if ( ! node . wildcardChild ) node . wildcardChild = createNode ( ) ;
253+ node = node . wildcardChild ;
254+ break ; // Wildcards terminate the path
255+ } else if ( segment . startsWith ( ":" ) ) {
256+ if ( ! node . paramChild ) node . paramChild = createNode ( ) ;
257+ node = node . paramChild ;
258+ } else {
259+ if ( ! ( segment in node . staticChildren ) ) {
260+ node . staticChildren [ segment ] = createNode ( ) ;
261+ }
262+ node = node . staticChildren [ segment ] ! ;
263+ }
264+ }
265+
266+ node . routes . push ( indexed ) ;
267+ }
268+
269+ function collectCandidates (
270+ node : RouteNode ,
271+ segments : string [ ] ,
272+ index : number ,
273+ results : IndexedRoute [ ] ,
274+ ) : void {
275+ if ( index === segments . length ) {
276+ for ( const r of node . routes ) results . push ( r ) ;
277+ if ( node . wildcardChild ) {
278+ for ( const r of node . wildcardChild . routes ) results . push ( r ) ;
279+ }
280+ return ;
281+ }
282+
283+ const segment = segments [ index ] ! ;
284+
285+ // Explore ALL matching branches so insertion order can break ties.
286+ if ( segment in node . staticChildren ) {
287+ collectCandidates (
288+ node . staticChildren [ segment ] ! ,
289+ segments ,
290+ index + 1 ,
291+ results ,
292+ ) ;
293+ }
294+
295+ if ( node . paramChild ) {
296+ collectCandidates ( node . paramChild , segments , index + 1 , results ) ;
297+ }
298+
299+ if ( node . wildcardChild ) {
300+ for ( const r of node . wildcardChild . routes ) results . push ( r ) ;
301+ }
302+ }
303+
304+ // Build the tree
305+ for ( const r of routes ) insert ( r ) ;
306+
307+ const isEmptyTree = fallbackRoutes . length === routes . length ;
308+
309+ // If every route fell through to fallbackRoutes, skip all radix machinery
310+ // on each request and delegate directly to routeLinear.
311+ if ( isEmptyTree ) {
312+ return routeLinear ( routes , defaultHandler ) ;
313+ }
314+
104315 return ( request : Request , info ?: Deno . ServeHandlerInfo ) => {
105- for ( const route of routes ) {
106- const match = route . pattern . exec ( request . url ) ;
107- if ( ! match ) continue ;
108- if ( ! methodMatches ( route . method , request . method ) ) continue ;
109- return route . handler ( request , match , info ) ;
316+ const pathname = parsePathname ( request . url ) ;
317+ const segments = parseSegments ( pathname ) ;
318+ const radixCandidates : IndexedRoute [ ] = [ ] ;
319+ collectCandidates ( root , segments , 0 , radixCandidates ) ;
320+ radixCandidates . sort ( ( a , b ) => a . index - b . index ) ;
321+
322+ // When the tree found no candidates and there are no fallback routes,
323+ // go straight to defaultHandler.
324+ if ( radixCandidates . length === 0 && fallbackRoutes . length === 0 ) {
325+ return defaultHandler ( request , info ) ;
110326 }
327+
328+ // Merge radix candidates with fallback routes by insertion order.
329+ // Fast path: skip merge if one side is empty.
330+ let candidates : IndexedRoute [ ] ;
331+ if ( fallbackRoutes . length === 0 ) {
332+ candidates = radixCandidates ;
333+ } else if ( radixCandidates . length === 0 ) {
334+ candidates = fallbackRoutes ;
335+ } else {
336+ candidates = [ ] ;
337+ let r = 0 ;
338+ let f = 0 ;
339+ while ( r < radixCandidates . length && f < fallbackRoutes . length ) {
340+ if ( radixCandidates [ r ] ! . index < fallbackRoutes [ f ] ! . index ) {
341+ candidates . push ( radixCandidates [ r ++ ] ! ) ;
342+ } else {
343+ candidates . push ( fallbackRoutes [ f ++ ] ! ) ;
344+ }
345+ }
346+ while ( r < radixCandidates . length ) candidates . push ( radixCandidates [ r ++ ] ! ) ;
347+ while ( f < fallbackRoutes . length ) candidates . push ( fallbackRoutes [ f ++ ] ! ) ;
348+ }
349+
350+ for ( const { route : r } of candidates ) {
351+ if ( ! methodMatches ( r . method , request . method ) ) continue ;
352+ const params = r . pattern . exec ( request . url ) ;
353+ if ( params ) return r . handler ( request , params , info ) ;
354+ }
355+
111356 return defaultHandler ( request , info ) ;
112357 } ;
113358}
114359
115- function methodMatches (
116- routeMethod : string | string [ ] | undefined ,
117- requestMethod : string ,
118- ) : boolean {
119- if ( ! routeMethod ) return true ;
120- if ( Array . isArray ( routeMethod ) ) {
121- return routeMethod . some ( ( m ) => m . toUpperCase ( ) === requestMethod ) ;
122- }
123- return routeMethod . toUpperCase ( ) === requestMethod ;
124- }
360+ export { routeRadix as route } ;
0 commit comments