Skip to content

Commit d522ffa

Browse files
committed
feat(http/unstable): add radix tree router; keep linear scan as routeLinear
Add routeRadix(), a radix tree router that provides O(segments) dispatch for static, parametric, and wildcard routes. Routes with complex URLPattern syntax (regex constraints, optional groups, inline wildcards, modifier suffixes) fall back to linear matching while preserving insertion order. - routeRadix: radix tree with fallback to linear for complex patterns - routeLinear: the original linear scan, extracted as its own export - route: re-exported alias for routeRadix (backward compatible) The radix router matches routeLinear semantics exactly — insertion order is always respected, even when static and parametric routes overlap at the same tree depth. Benchmarks show 1.5–9x improvement on static/parametric/wildcard routes, with negligible overhead on complex fallback patterns.
1 parent 1cd63ca commit d522ffa

3 files changed

Lines changed: 1054 additions & 124 deletions

File tree

http/unstable_route.ts

Lines changed: 254 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)