From 70dc02492c6c34614d933e4f6d36044ecf6a8fc8 Mon Sep 17 00:00:00 2001 From: Carles Escrig Royo Date: Tue, 31 Mar 2026 21:33:25 +0200 Subject: [PATCH 1/4] feat(http/unstable): add radix tree router; keep linear scan as routeLinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- http/unstable_route.ts | 272 ++++++++++++++++-- http/unstable_route_bench.ts | 388 ++++++++++++++++++++++++++ http/unstable_route_test.ts | 518 ++++++++++++++++++++++++++++------- 3 files changed, 1054 insertions(+), 124 deletions(-) create mode 100644 http/unstable_route_bench.ts diff --git a/http/unstable_route.ts b/http/unstable_route.ts index 8886c2fb41af..b5f518aaa9cf 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -28,7 +28,7 @@ export type Handler = ( ) => Response | Promise; /** - * Route configuration for {@linkcode route}. + * Route configuration for {@linkcode routeRadix}. * * @experimental **UNSTABLE**: New API, yet to be vetted. */ @@ -50,11 +50,136 @@ export interface Route { handler: Handler; } +function methodMatches( + routeMethod: string | string[] | undefined, + requestMethod: string, +): boolean { + if (!routeMethod) return true; + if (Array.isArray(routeMethod)) { + return routeMethod.some((m) => m.toUpperCase() === requestMethod); + } + return routeMethod.toUpperCase() === requestMethod; +} + +/** + * Routes requests to handlers using a linear scan over all routes. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * Routes are matched in insertion order; the first matching route wins. + * Prefer {@linkcode routeRadix} for better performance on larger route tables. + * + * @example Usage + * ```ts ignore + * import { routeLinear, type Route } from "@std/http/unstable-route"; + * + * const routes: Route[] = [ + * { + * pattern: new URLPattern({ pathname: "/about" }), + * handler: () => new Response("About page"), + * }, + * { + * pattern: new URLPattern({ pathname: "/users/:id" }), + * method: "GET", + * handler: (_req, params) => new Response(params.pathname.groups.id), + * }, + * ]; + * + * function defaultHandler(_req: Request) { + * return new Response("Not found", { status: 404 }); + * } + * + * Deno.serve(routeLinear(routes, defaultHandler)); + * ``` + * + * @param routes Route configurations + * @param defaultHandler Default request handler + * @returns Request handler + */ +export function routeLinear( + routes: Route[], + defaultHandler: RequestHandler, +): RequestHandler { + // TODO(iuioiua): Use `URLPatternList` once available (https://github.com/whatwg/urlpattern/pull/166) + return (request: Request, info?: Deno.ServeHandlerInfo) => { + for (const route of routes) { + if (!methodMatches(route.method, request.method)) continue; + const match = route.pattern.exec(request.url); + if (match) return route.handler(request, match, info); + } + return defaultHandler(request, info); + }; +} + +// --------------------------------------------------------------------------- +// Radix tree router +// --------------------------------------------------------------------------- + +// Internal: Route with its original registration index for stable ordering. +interface IndexedRoute { + route: Route; + index: number; +} + +interface RouteNode { + staticChildren: Record; + paramChild: RouteNode | null; + wildcardChild: RouteNode | null; + routes: IndexedRoute[]; +} + +/** + * Extract pathname from a URL string without allocating a URL object. + * Handles both `http://host/path?query` and `http://host/path` forms. + */ +function parsePathname(url: string): string { + const authorityStart = url.indexOf("//"); + const pathStart = url.indexOf("/", authorityStart + 2); + if (pathStart === -1) return "/"; + const qmark = url.indexOf("?", pathStart); + const hash = url.indexOf("#", pathStart); + let end = url.length; + if (qmark !== -1) end = qmark; + if (hash !== -1 && hash < end) end = hash; + return url.slice(pathStart, end); +} + +/** + * Returns true if a pathname segment contains URLPattern syntax that the + * radix tree cannot model structurally — i.e. it is not a plain static + * string, a bare `:param`, or a standalone `*`. + * + * Affected syntax: + * - Optional / non-capturing groups: `{.ext}?` `{foo}` + * - Regex-constrained params: `:id(\d+)` `:lang(en|fr)` + * - Inline wildcards: `*.js` `prefix*` + */ +function isComplexSegment(segment: string): boolean { + if (segment.includes("{") || segment.includes("(")) return true; + if (segment.includes("*") && segment !== "*") return true; + if (segment.endsWith("?") || segment.endsWith("+")) return true; + return false; +} + +function createNode(): RouteNode { + return { + staticChildren: Object.create(null) as Record, + paramChild: null, + wildcardChild: null, + routes: [], + }; +} + /** * Routes requests to different handlers based on the request path and method. * * @experimental **UNSTABLE**: New API, yet to be vetted. * + * Uses a radix tree for O(segments) dispatch on static and parametric routes. + * Routes with complex URLPattern syntax (regex constraints, optional/non-capturing + * groups, inline wildcards) fall back to linear matching while preserving + * insertion order relative to tree-indexed routes. + * * @example Usage * ```ts ignore * import { route, type Route } from "@std/http/unstable-route"; @@ -96,29 +221,140 @@ export interface Route { * Allowed response can be done in this function. * @returns Request handler */ -export function route( +export function routeRadix( routes: Route[], defaultHandler: RequestHandler, ): RequestHandler { - // TODO(iuioiua): Use `URLPatternList` once available (https://github.com/whatwg/urlpattern/pull/166) + const root = createNode(); + const fallbackRoutes: IndexedRoute[] = []; + let insertionCounter = 0; + + function parseSegments(pathname: string): string[] { + return pathname.split("/").filter(Boolean); + } + + function insert(r: Route): void { + const indexed: IndexedRoute = { route: r, index: insertionCounter++ }; + const segments = parseSegments(r.pattern.pathname); + + // If any pathname segment uses URLPattern syntax the radix tree cannot + // model, fall back to linear matching. Insertion order is preserved via + // `index`. + if (segments.some(isComplexSegment)) { + fallbackRoutes.push(indexed); + return; + } + + let node = root; + + for (const segment of segments) { + if (segment === "*") { + if (!node.wildcardChild) node.wildcardChild = createNode(); + node = node.wildcardChild; + break; // Wildcards terminate the path + } else if (segment.startsWith(":")) { + if (!node.paramChild) node.paramChild = createNode(); + node = node.paramChild; + } else { + if (!(segment in node.staticChildren)) { + node.staticChildren[segment] = createNode(); + } + node = node.staticChildren[segment]!; + } + } + + node.routes.push(indexed); + } + + function collectCandidates( + node: RouteNode, + segments: string[], + index: number, + results: IndexedRoute[], + ): void { + if (index === segments.length) { + for (const r of node.routes) results.push(r); + if (node.wildcardChild) { + for (const r of node.wildcardChild.routes) results.push(r); + } + return; + } + + const segment = segments[index]!; + + // Explore ALL matching branches so insertion order can break ties. + if (segment in node.staticChildren) { + collectCandidates( + node.staticChildren[segment]!, + segments, + index + 1, + results, + ); + } + + if (node.paramChild) { + collectCandidates(node.paramChild, segments, index + 1, results); + } + + if (node.wildcardChild) { + for (const r of node.wildcardChild.routes) results.push(r); + } + } + + // Build the tree + for (const r of routes) insert(r); + + const isEmptyTree = fallbackRoutes.length === routes.length; + + // If every route fell through to fallbackRoutes, skip all radix machinery + // on each request and delegate directly to routeLinear. + if (isEmptyTree) { + return routeLinear(routes, defaultHandler); + } + return (request: Request, info?: Deno.ServeHandlerInfo) => { - for (const route of routes) { - const match = route.pattern.exec(request.url); - if (!match) continue; - if (!methodMatches(route.method, request.method)) continue; - return route.handler(request, match, info); + const pathname = parsePathname(request.url); + const segments = parseSegments(pathname); + const radixCandidates: IndexedRoute[] = []; + collectCandidates(root, segments, 0, radixCandidates); + radixCandidates.sort((a, b) => a.index - b.index); + + // When the tree found no candidates and there are no fallback routes, + // go straight to defaultHandler. + if (radixCandidates.length === 0 && fallbackRoutes.length === 0) { + return defaultHandler(request, info); } + + // Merge radix candidates with fallback routes by insertion order. + // Fast path: skip merge if one side is empty. + let candidates: IndexedRoute[]; + if (fallbackRoutes.length === 0) { + candidates = radixCandidates; + } else if (radixCandidates.length === 0) { + candidates = fallbackRoutes; + } else { + candidates = []; + let r = 0; + let f = 0; + while (r < radixCandidates.length && f < fallbackRoutes.length) { + if (radixCandidates[r]!.index < fallbackRoutes[f]!.index) { + candidates.push(radixCandidates[r++]!); + } else { + candidates.push(fallbackRoutes[f++]!); + } + } + while (r < radixCandidates.length) candidates.push(radixCandidates[r++]!); + while (f < fallbackRoutes.length) candidates.push(fallbackRoutes[f++]!); + } + + for (const { route: r } of candidates) { + if (!methodMatches(r.method, request.method)) continue; + const params = r.pattern.exec(request.url); + if (params) return r.handler(request, params, info); + } + return defaultHandler(request, info); }; } -function methodMatches( - routeMethod: string | string[] | undefined, - requestMethod: string, -): boolean { - if (!routeMethod) return true; - if (Array.isArray(routeMethod)) { - return routeMethod.some((m) => m.toUpperCase() === requestMethod); - } - return routeMethod.toUpperCase() === requestMethod; -} +export { routeRadix as route }; diff --git a/http/unstable_route_bench.ts b/http/unstable_route_bench.ts new file mode 100644 index 000000000000..0b71d9a8c8d0 --- /dev/null +++ b/http/unstable_route_bench.ts @@ -0,0 +1,388 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { type Route, routeLinear, routeRadix } from "./unstable_route.ts"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function defaultHandler(_req: Request) { + return new Response("Not Found", { status: 404 }); +} + +function noop() { + return new Response("ok"); +} + +// --------------------------------------------------------------------------- +// Route tables +// --------------------------------------------------------------------------- + +// Small table (5 routes) — static-only +const smallStaticRoutes: Route[] = [ + { pattern: new URLPattern({ pathname: "/" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/about" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/contact" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/blog" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/faq" }), handler: noop }, +]; + +// Large table (20 routes) — static-only +const largeStaticRoutes: Route[] = [ + { pattern: new URLPattern({ pathname: "/" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/about" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/contact" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/blog" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/faq" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/pricing" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/terms" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/privacy" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/login" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/signup" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/dashboard" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/settings" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/profile" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/search" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/help" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/status" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/changelog" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/docs" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/api" }), handler: noop }, + { pattern: new URLPattern({ pathname: "/health" }), handler: noop }, +]; + +// Mixed table — static + parametric + wildcard (realistic API router shape) +const mixedRoutes: Route[] = [ + { pattern: new URLPattern({ pathname: "/health" }), handler: noop }, + { + pattern: new URLPattern({ pathname: "/users" }), + method: "GET", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/users" }), + method: "POST", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + method: "GET", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + method: "PUT", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + method: "DELETE", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/posts" }), + method: "GET", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/posts/:id" }), + method: "GET", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/posts/:id/comments" }), + method: "GET", + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/posts/:id/comments/:cid" }), + method: "GET", + handler: noop, + }, + { pattern: new URLPattern({ pathname: "/static/*" }), handler: noop }, +]; + +// Complex/fallback patterns — regex constraint, optional group, inline wildcard +const complexRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/books/:id(\\d+)" }), + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/books/:slug" }), + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/file{.:ext}?" }), + handler: noop, + }, + { + pattern: new URLPattern({ pathname: "/static/*.js" }), + handler: noop, + }, +]; + +// --------------------------------------------------------------------------- +// Pre-built handlers (setup cost excluded from bench fn) +// --------------------------------------------------------------------------- + +const smallStaticHandlerLinear = routeLinear(smallStaticRoutes, defaultHandler); +const largeStaticHandlerLinear = routeLinear(largeStaticRoutes, defaultHandler); +const mixedHandlerLinear = routeLinear(mixedRoutes, defaultHandler); +const complexHandlerLinear = routeLinear(complexRoutes, defaultHandler); + +const smallStaticHandlerRadix = routeRadix(smallStaticRoutes, defaultHandler); +const largeStaticHandlerRadix = routeRadix(largeStaticRoutes, defaultHandler); +const mixedHandlerRadix = routeRadix(mixedRoutes, defaultHandler); +const complexHandlerRadix = routeRadix(complexRoutes, defaultHandler); + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +// Static — first route in table +const reqStaticFirst = new Request("http://example.com/"); +// Static — last route in small table +const reqStaticLastSmall = new Request("http://example.com/faq"); +// Static — last route in large table +const reqStaticLastLarge = new Request("http://example.com/health"); +// Static — miss (no match) +const reqStaticMiss = new Request("http://example.com/not-found"); + +// Parametric — single param +const reqParam = new Request("http://example.com/users/42"); +// Parametric — two params (shallow nesting) +const reqParamDeep = new Request("http://example.com/posts/7/comments/3"); +// Parametric — miss (method mismatch on all matching routes) +const reqParamMethodMiss = new Request("http://example.com/users/42", { + method: "PATCH", +}); + +// Wildcard +const reqWildcard = new Request("http://example.com/static/assets/logo.png"); + +// Complex patterns +const reqComplexRegex = new Request("http://example.com/books/123"); +const reqComplexOptional = new Request("http://example.com/file.ts"); +const reqComplexInlineWildcard = new Request( + "http://example.com/static/app.js", +); + +// --------------------------------------------------------------------------- +// Benchmarks — static routes +// --------------------------------------------------------------------------- + +Deno.bench({ + group: "static route — first in small table", + name: "linear", + baseline: true, + async fn() { + await smallStaticHandlerLinear(reqStaticFirst); + }, +}); + +Deno.bench({ + group: "static route — first in small table", + name: "radix", + async fn() { + await smallStaticHandlerRadix(reqStaticFirst); + }, +}); + +Deno.bench({ + group: "static route — last in small table", + name: "linear", + baseline: true, + async fn() { + await smallStaticHandlerLinear(reqStaticLastSmall); + }, +}); + +Deno.bench({ + group: "static route — last in small table", + name: "radix", + async fn() { + await smallStaticHandlerRadix(reqStaticLastSmall); + }, +}); + +Deno.bench({ + group: "static route — last in large table", + name: "linear", + baseline: true, + async fn() { + await largeStaticHandlerLinear(reqStaticLastLarge); + }, +}); + +Deno.bench({ + group: "static route — last in large table", + name: "radix", + async fn() { + await largeStaticHandlerRadix(reqStaticLastLarge); + }, +}); + +Deno.bench({ + group: "static route — miss (small table)", + name: "linear", + baseline: true, + async fn() { + await smallStaticHandlerLinear(reqStaticMiss); + }, +}); + +Deno.bench({ + group: "static route — miss (small table)", + name: "radix", + async fn() { + await smallStaticHandlerRadix(reqStaticMiss); + }, +}); + +Deno.bench({ + group: "static route — miss (large table)", + name: "linear", + baseline: true, + async fn() { + await largeStaticHandlerLinear(reqStaticMiss); + }, +}); + +Deno.bench({ + group: "static route — miss (large table)", + name: "radix", + async fn() { + await largeStaticHandlerRadix(reqStaticMiss); + }, +}); + +// --------------------------------------------------------------------------- +// Benchmarks — parametric routes +// --------------------------------------------------------------------------- + +Deno.bench({ + group: "parametric route — single param", + name: "linear", + baseline: true, + async fn() { + await mixedHandlerLinear(reqParam); + }, +}); + +Deno.bench({ + group: "parametric route — single param", + name: "radix", + async fn() { + await mixedHandlerRadix(reqParam); + }, +}); + +Deno.bench({ + group: "parametric route — two params (nested)", + name: "linear", + baseline: true, + async fn() { + await mixedHandlerLinear(reqParamDeep); + }, +}); + +Deno.bench({ + group: "parametric route — two params (nested)", + name: "radix", + async fn() { + await mixedHandlerRadix(reqParamDeep); + }, +}); + +Deno.bench({ + group: "parametric route — method mismatch", + name: "linear", + baseline: true, + async fn() { + await mixedHandlerLinear(reqParamMethodMiss); + }, +}); + +Deno.bench({ + group: "parametric route — method mismatch", + name: "radix", + async fn() { + await mixedHandlerRadix(reqParamMethodMiss); + }, +}); + +// --------------------------------------------------------------------------- +// Benchmarks — wildcard routes +// --------------------------------------------------------------------------- + +Deno.bench({ + group: "wildcard route", + name: "linear", + baseline: true, + async fn() { + await mixedHandlerLinear(reqWildcard); + }, +}); + +Deno.bench({ + group: "wildcard route", + name: "radix", + async fn() { + await mixedHandlerRadix(reqWildcard); + }, +}); + +// --------------------------------------------------------------------------- +// Benchmarks — complex/fallback patterns +// --------------------------------------------------------------------------- + +Deno.bench({ + group: "complex — regex constraint", + name: "linear", + baseline: true, + async fn() { + await complexHandlerLinear(reqComplexRegex); + }, +}); + +Deno.bench({ + group: "complex — regex constraint", + name: "radix", + async fn() { + await complexHandlerRadix(reqComplexRegex); + }, +}); + +Deno.bench({ + group: "complex — optional group", + name: "linear", + baseline: true, + async fn() { + await complexHandlerLinear(reqComplexOptional); + }, +}); + +Deno.bench({ + group: "complex — optional group", + name: "radix", + async fn() { + await complexHandlerRadix(reqComplexOptional); + }, +}); + +Deno.bench({ + group: "complex — inline wildcard with suffix", + name: "linear", + baseline: true, + async fn() { + await complexHandlerLinear(reqComplexInlineWildcard); + }, +}); + +Deno.bench({ + group: "complex — inline wildcard with suffix", + name: "radix", + async fn() { + await complexHandlerRadix(reqComplexInlineWildcard); + }, +}); diff --git a/http/unstable_route_test.ts b/http/unstable_route_test.ts index 8d014e0e7a18..8108a8cf4d8e 100644 --- a/http/unstable_route_test.ts +++ b/http/unstable_route_test.ts @@ -1,134 +1,440 @@ // Copyright 2018-2026 the Deno authors. MIT license. -import { type Route, route } from "./unstable_route.ts"; +import { type Route, routeLinear, routeRadix } from "./unstable_route.ts"; import { assertEquals } from "../assert/equals.ts"; -const routes: Route[] = [ - { - // No method — matches all HTTP methods - pattern: new URLPattern({ pathname: "/about" }), - handler: (request: Request) => new Response(new URL(request.url).pathname), - }, - { - pattern: new URLPattern({ pathname: "/users/:id" }), - method: "GET", - handler: (_request, params) => new Response(params.pathname.groups.id), - }, - { - pattern: new URLPattern({ pathname: "/users/:id" }), - method: "POST", - handler: () => new Response("Done"), - }, - { - pattern: new URLPattern({ pathname: "/resource" }), - method: ["GET", "HEAD"], - handler: (request: Request) => - new Response(request.method === "HEAD" ? null : "Ok"), - }, -]; - -function defaultHandler(request: Request) { - return new Response(new URL(request.url).pathname, { status: 404 }); -} +function testRouter(name: string, route: typeof routeRadix) { + const routes: Route[] = [ + { + // No method — matches all HTTP methods + pattern: new URLPattern({ pathname: "/about" }), + handler: (request: Request) => + new Response(new URL(request.url).pathname), + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + method: "GET", + handler: (_request, params) => new Response(params.pathname.groups.id), + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + method: "POST", + handler: () => new Response("Done"), + }, + { + pattern: new URLPattern({ pathname: "/resource" }), + method: ["GET", "HEAD"], + handler: (request: Request) => + new Response(request.method === "HEAD" ? null : "Ok"), + }, + ]; -Deno.test("route()", async (t) => { - const handler = route(routes, defaultHandler); + function defaultHandler(request: Request) { + return new Response(new URL(request.url).pathname, { status: 404 }); + } - await t.step("handles static routes", async () => { - const request = new Request("http://example.com/about"); - const response = await handler(request); - assertEquals(response?.status, 200); - assertEquals(await response?.text(), "/about"); - }); + Deno.test(name, async (t) => { + const handler = route(routes, defaultHandler); + + await t.step("handles static routes", async () => { + const request = new Request("http://example.com/about"); + const response = await handler(request); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "/about"); + }); - await t.step("handles dynamic routes", async () => { - const request1 = new Request("http://example.com/users/123"); - const response1 = await handler(request1); - assertEquals(await response1?.text(), "123"); - assertEquals(response1?.status, 200); + await t.step("handles dynamic routes", async () => { + const request1 = new Request("http://example.com/users/123"); + const response1 = await handler(request1); + assertEquals(await response1?.text(), "123"); + assertEquals(response1?.status, 200); - const request2 = new Request("http://example.com/users/123", { - method: "POST", + const request2 = new Request("http://example.com/users/123", { + method: "POST", + }); + const response2 = await handler(request2); + assertEquals(await response2?.text(), "Done"); + assertEquals(response2?.status, 200); }); - const response2 = await handler(request2); - assertEquals(await response2?.text(), "Done"); - assertEquals(response2?.status, 200); - }); - await t.step("handles default handler", async () => { - const request = new Request("http://example.com/not-found"); - const response = await handler(request); - assertEquals(response?.status, 404); - assertEquals(await response?.text(), "/not-found"); - }); + await t.step("handles default handler", async () => { + const request = new Request("http://example.com/not-found"); + const response = await handler(request); + assertEquals(response?.status, 404); + assertEquals(await response?.text(), "/not-found"); + }); - await t.step("handles multiple methods", async () => { - const getMethodRequest = new Request("http://example.com/resource"); - const getMethodResponse = await handler(getMethodRequest); - assertEquals(getMethodResponse?.status, 200); - assertEquals(await getMethodResponse?.text(), "Ok"); + await t.step("handles multiple methods", async () => { + const getMethodRequest = new Request("http://example.com/resource"); + const getMethodResponse = await handler(getMethodRequest); + assertEquals(getMethodResponse?.status, 200); + assertEquals(await getMethodResponse?.text(), "Ok"); - const headMethodRequest = new Request("http://example.com/resource", { - method: "HEAD", + const headMethodRequest = new Request("http://example.com/resource", { + method: "HEAD", + }); + const headMethodResponse = await handler(headMethodRequest); + assertEquals(headMethodResponse?.status, 200); + assertEquals(await headMethodResponse?.text(), ""); }); - const headMethodResponse = await handler(headMethodRequest); - assertEquals(headMethodResponse?.status, 200); - assertEquals(await headMethodResponse?.text(), ""); - }); - await t.step("matches all methods when method is not specified", async () => { - for (const method of ["GET", "POST", "PUT", "DELETE", "PATCH"]) { - const request = new Request("http://example.com/about", { method }); + await t.step( + "matches all methods when method is not specified", + async () => { + for (const method of ["GET", "POST", "PUT", "DELETE", "PATCH"]) { + const request = new Request("http://example.com/about", { method }); + const response = await handler(request); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "/about"); + } + }, + ); + + await t.step("does not match unspecified methods", async () => { + const request = new Request("http://example.com/users/123", { + method: "DELETE", + }); const response = await handler(request); - assertEquals(response?.status, 200); - assertEquals(await response?.text(), "/about"); - } - }); + assertEquals(response?.status, 404); + }); + + await t.step("method matching is case-insensitive", async () => { + const lowerCaseRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/test" }), + method: "post", + handler: () => new Response("matched"), + }, + ]; + const lowerCaseHandler = route(lowerCaseRoutes, defaultHandler); - await t.step("does not match unspecified methods", async () => { - const request = new Request("http://example.com/users/123", { - method: "DELETE", + const request = new Request("http://example.com/test", { + method: "POST", + }); + const response = await lowerCaseHandler(request); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "matched"); }); - const response = await handler(request); - assertEquals(response?.status, 404); - }); - await t.step("method matching is case-insensitive", async () => { - const lowerCaseRoutes: Route[] = [ - { - pattern: new URLPattern({ pathname: "/test" }), - method: "post", - handler: () => new Response("matched"), + await t.step( + "method matching is case-insensitive for arrays", + async () => { + const mixedCaseRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/test" }), + method: ["get", "Post"], + handler: () => new Response("matched"), + }, + ]; + const mixedCaseHandler = route(mixedCaseRoutes, defaultHandler); + + const getResponse = await mixedCaseHandler( + new Request("http://example.com/test"), + ); + assertEquals(getResponse?.status, 200); + + const postResponse = await mixedCaseHandler( + new Request("http://example.com/test", { method: "POST" }), + ); + assertEquals(postResponse?.status, 200); }, - ]; - const lowerCaseHandler = route(lowerCaseRoutes, defaultHandler); + ); - const request = new Request("http://example.com/test", { - method: "POST", + await t.step("handles wildcard routes", async () => { + const wildcardRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/static/*" }), + handler: () => new Response("static"), + }, + ]; + const wildcardHandler = route(wildcardRoutes, defaultHandler); + + const response = await wildcardHandler( + new Request("http://example.com/static/foo/bar.js"), + ); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "static"); + + const noMatchResponse = await wildcardHandler( + new Request("http://example.com/other/foo.js"), + ); + assertEquals(noMatchResponse?.status, 404); }); - const response = await lowerCaseHandler(request); - assertEquals(response?.status, 200); - assertEquals(await response?.text(), "matched"); - }); - await t.step("method matching is case-insensitive for arrays", async () => { - const mixedCaseRoutes: Route[] = [ - { - pattern: new URLPattern({ pathname: "/test" }), - method: ["get", "Post"], - handler: () => new Response("matched"), + await t.step("handles root path", async () => { + const rootRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/" }), + handler: () => new Response("root"), + }, + ]; + const rootHandler = route(rootRoutes, defaultHandler); + + const response = await rootHandler(new Request("http://example.com/")); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "root"); + }); + + await t.step( + "first matching route wins when static and param routes overlap", + async () => { + const priorityRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/users/me" }), + handler: () => new Response("me"), + }, + { + pattern: new URLPattern({ pathname: "/users/:id" }), + handler: (_request, params) => + new Response(params.pathname.groups.id), + }, + ]; + const priorityHandler = route(priorityRoutes, defaultHandler); + + const meResponse = await priorityHandler( + new Request("http://example.com/users/me"), + ); + assertEquals(meResponse?.status, 200); + assertEquals(await meResponse?.text(), "me"); + + const idResponse = await priorityHandler( + new Request("http://example.com/users/99"), + ); + assertEquals(idResponse?.status, 200); + assertEquals(await idResponse?.text(), "99"); }, - ]; - const mixedCaseHandler = route(mixedCaseRoutes, defaultHandler); + ); - const getResponse = await mixedCaseHandler( - new Request("http://example.com/test"), + await t.step("param with regex constraint", async () => { + const constrainedRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/books/:id(\\d+)" }), + handler: (_request, params) => + new Response("book:" + params.pathname.groups.id), + }, + { + pattern: new URLPattern({ pathname: "/books/:slug" }), + handler: (_request, params) => + new Response("slug:" + params.pathname.groups.slug), + }, + ]; + const constrainedHandler = route(constrainedRoutes, defaultHandler); + + const numericResponse = await constrainedHandler( + new Request("http://example.com/books/123"), + ); + assertEquals(numericResponse?.status, 200); + assertEquals(await numericResponse?.text(), "book:123"); + + const slugResponse = await constrainedHandler( + new Request("http://example.com/books/my-book"), + ); + assertEquals(slugResponse?.status, 200); + assertEquals(await slugResponse?.text(), "slug:my-book"); + }); + + await t.step("optional group in pattern", async () => { + const optionalRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/file{.:ext}?" }), + handler: (_request, params) => + new Response("ext:" + (params.pathname.groups.ext || "none")), + }, + ]; + const optionalHandler = route(optionalRoutes, defaultHandler); + + const withExtResponse = await optionalHandler( + new Request("http://example.com/file.ts"), + ); + assertEquals(withExtResponse?.status, 200); + assertEquals(await withExtResponse?.text(), "ext:ts"); + + const noExtResponse = await optionalHandler( + new Request("http://example.com/file"), + ); + assertEquals(noExtResponse?.status, 200); + assertEquals(await noExtResponse?.text(), "ext:none"); + }); + + await t.step("inline wildcard with suffix", async () => { + const inlineWildcardRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/static/*.js" }), + handler: () => new Response("js-file"), + }, + ]; + const inlineWildcardHandler = route( + inlineWildcardRoutes, + defaultHandler, + ); + + const jsResponse = await inlineWildcardHandler( + new Request("http://example.com/static/app.js"), + ); + assertEquals(jsResponse?.status, 200); + assertEquals(await jsResponse?.text(), "js-file"); + + const tsResponse = await inlineWildcardHandler( + new Request("http://example.com/static/app.ts"), + ); + assertEquals(tsResponse?.status, 404); + }); + + await t.step("non-capturing group in pattern", async () => { + const ncgRoutes: Route[] = [ + { + // {ersion} is a non-capturing group that matches the literal string "ersion" — + // so the full pattern matches "/version/resource". It does NOT make the group + // optional; use {ersion}? for that. + pattern: new URLPattern({ pathname: "/v{ersion}/resource" }), + handler: () => new Response("versioned"), + }, + ]; + const ncgHandler = route(ncgRoutes, defaultHandler); + + const versionedResponse = await ncgHandler( + new Request("http://example.com/version/resource"), + ); + assertEquals(versionedResponse?.status, 200); + assertEquals(await versionedResponse?.text(), "versioned"); + + const shortResponse = await ncgHandler( + new Request("http://example.com/v/resource"), + ); + assertEquals(shortResponse?.status, 404); + }); + + await t.step("hostname constraint", async () => { + const hostnameRoutes: Route[] = [ + { + pattern: new URLPattern({ + hostname: "api.example.com", + pathname: "/data", + }), + handler: () => new Response("api"), + }, + { + pattern: new URLPattern({ + hostname: "www.example.com", + pathname: "/data", + }), + handler: () => new Response("www"), + }, + ]; + const hostnameHandler = route(hostnameRoutes, defaultHandler); + + const apiResponse = await hostnameHandler( + new Request("http://api.example.com/data"), + ); + assertEquals(apiResponse?.status, 200); + assertEquals(await apiResponse?.text(), "api"); + + const wwwResponse = await hostnameHandler( + new Request("http://www.example.com/data"), + ); + assertEquals(wwwResponse?.status, 200); + assertEquals(await wwwResponse?.text(), "www"); + }); + + await t.step("search param constraint", async () => { + const searchRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/search", search: "q=:term" }), + handler: (_request, params) => + new Response("term:" + params.search.groups.term), + }, + ]; + const searchHandler = route(searchRoutes, defaultHandler); + + const matchedResponse = await searchHandler( + new Request("http://example.com/search?q=hello"), + ); + assertEquals(matchedResponse?.status, 200); + assertEquals(await matchedResponse?.text(), "term:hello"); + + const unmatchedResponse = await searchHandler( + new Request("http://example.com/search?other=x"), + ); + assertEquals(unmatchedResponse?.status, 404); + }); + + await t.step("handles URLs with fragment identifiers", async () => { + const response = await handler( + new Request("http://example.com/about#section1"), + ); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "/about"); + }); + + await t.step( + "handles URLs with both query string and fragment", + async () => { + const searchRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/search", search: "q=:term" }), + handler: (_request, params) => + new Response("term:" + params.search.groups.term), + }, + ]; + const searchHandler = route(searchRoutes, defaultHandler); + + const response = await searchHandler( + new Request("http://example.com/search?q=hello#results"), + ); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "term:hello"); + }, ); - assertEquals(getResponse?.status, 200); - const postResponse = await mixedCaseHandler( - new Request("http://example.com/test", { method: "POST" }), + await t.step( + "param route registered before static route preserves insertion order", + async () => { + const orderRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/users/:id" }), + handler: (_request, params) => + new Response("param:" + params.pathname.groups.id), + }, + { + pattern: new URLPattern({ pathname: "/users/me" }), + handler: () => new Response("static:me"), + }, + ]; + const orderHandler = route(orderRoutes, defaultHandler); + + // The param route was registered first, so it should win for "/users/me" + const response = await orderHandler( + new Request("http://example.com/users/me"), + ); + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "param:me"); + }, ); - assertEquals(postResponse?.status, 200); + + await t.step("optional param with ? modifier", async () => { + const optionalParamRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/users/:id?" }), + handler: (_request, params) => + new Response("id:" + (params.pathname.groups.id || "none")), + }, + ]; + const optionalParamHandler = route(optionalParamRoutes, defaultHandler); + + const withId = await optionalParamHandler( + new Request("http://example.com/users/42"), + ); + assertEquals(withId?.status, 200); + assertEquals(await withId?.text(), "id:42"); + + const withoutId = await optionalParamHandler( + new Request("http://example.com/users"), + ); + assertEquals(withoutId?.status, 200); + assertEquals(await withoutId?.text(), "id:none"); + }); }); -}); +} + +testRouter("routeRadix()", routeRadix); +testRouter("routeLinear()", routeLinear); From d36b5dacaa4a483efc740423152656c51c196503 Mon Sep 17 00:00:00 2001 From: Carles Escrig Royo Date: Thu, 30 Apr 2026 23:54:22 +0200 Subject: [PATCH 2/4] perf(http/unstable): reduce per-request allocations in routeRadix Replace the per-request `radixCandidates` array + `Array#sort` + merge with a single-slot match using `minIndex` pruning and inline pathname iteration. No candidate array, segments array, or sort closure on the hot path. Each `RouteNode` now tracks `minIndex` (lowest insertion index in the subtree) and `hasStaticChildren` (skip the substring slice when there are no static children). Fallback routes registered before the lowest radix index are scanned first so their hits prune the tree walk. --- http/unstable_route.ts | 257 ++++++++++++++++++++++++++++------------- 1 file changed, 178 insertions(+), 79 deletions(-) diff --git a/http/unstable_route.ts b/http/unstable_route.ts index b5f518aaa9cf..14abe623ea92 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -123,18 +123,45 @@ interface IndexedRoute { interface RouteNode { staticChildren: Record; + /** + * Whether `staticChildren` has at least one entry. Tracked as a flag so + * dispatch can skip the substring slice when there are no static children + * to look up. + */ + hasStaticChildren: boolean; paramChild: RouteNode | null; wildcardChild: RouteNode | null; routes: IndexedRoute[]; + /** + * Smallest insertion index reachable from this subtree (including its own + * `routes`). Used to prune subtrees that cannot improve on the current + * best match during dispatch. + */ + minIndex: number; } /** * Extract pathname from a URL string without allocating a URL object. - * Handles both `http://host/path?query` and `http://host/path` forms. + * + * This is a fast path tailored for `request.url`, which is always a fully + * normalized absolute URL with an authority component (e.g. `http://host/p`, + * `https://host/p?q`). It is NOT a general-purpose URL parser: + * + * - Assumes the URL contains `://` separating scheme and authority. URLs + * without an authority (e.g. `file:/path`, `mailto:x@y`) are not handled. + * - Relies on userinfo containing no literal `/` (per WHATWG URL parsing, + * `/` in userinfo is percent-encoded), so the first `/` after the + * authority delimiter unambiguously starts the path. + * + * Always call this with a `Request#url` value; do not export. */ function parsePathname(url: string): string { - const authorityStart = url.indexOf("//"); - const pathStart = url.indexOf("/", authorityStart + 2); + // Skip past the scheme delimiter `://`. Using indexOf with the full token + // avoids the corner case where a scheme contains a colon-prefixed segment + // before the authority. + const schemeEnd = url.indexOf("://"); + const authorityStart = schemeEnd === -1 ? 0 : schemeEnd + 3; + const pathStart = url.indexOf("/", authorityStart); if (pathStart === -1) return "/"; const qmark = url.indexOf("?", pathStart); const hash = url.indexOf("#", pathStart); @@ -150,9 +177,20 @@ function parsePathname(url: string): string { * string, a bare `:param`, or a standalone `*`. * * Affected syntax: - * - Optional / non-capturing groups: `{.ext}?` `{foo}` - * - Regex-constrained params: `:id(\d+)` `:lang(en|fr)` - * - Inline wildcards: `*.js` `prefix*` + * - Optional / non-capturing groups: `{.ext}?` `{foo}` + * - Regex-constrained params: `:id(\d+)` `:lang(en|fr)` + * - Inline wildcards: `*.js` `prefix*` + * - Modifier suffixes on params/groups: `:id?` `:id+` `:id*` `(\d+)?` + * + * This heuristic relies on URLPattern syntax being well-formed: URLPattern's + * own parser rejects `?`, `+`, and `*` as literal characters in pathnames + * (e.g. `new URLPattern({ pathname: "/foo+bar" })` throws at construction), + * so a segment that ends with one of those characters here is always a + * modifier, never a literal. + * + * The `:id*` "zero-or-more" modifier is caught by the inline-wildcard branch + * (`includes("*")` with `segment !== "*"`), not by an explicit `endsWith("*")` + * clause. This is intentional — the two checks subsume each other for `*`. */ function isComplexSegment(segment: string): boolean { if (segment.includes("{") || segment.includes("(")) return true; @@ -164,9 +202,11 @@ function isComplexSegment(segment: string): boolean { function createNode(): RouteNode { return { staticChildren: Object.create(null) as Record, + hasStaticChildren: false, paramChild: null, wildcardChild: null, routes: [], + minIndex: Number.POSITIVE_INFINITY, }; } @@ -180,6 +220,15 @@ function createNode(): RouteNode { * groups, inline wildcards) fall back to linear matching while preserving * insertion order relative to tree-indexed routes. * + * The tree is keyed by pathname segments only. Other URLPattern components + * (hostname, search, protocol, etc.) are not indexed; routes that constrain + * those components are inserted in the tree under their pathname, and the + * additional constraints are validated by `pattern.exec()` per request. As a + * result, multiple routes sharing the same pathname but differing on + * hostname/search/protocol receive no pruning from the tree — every + * pathname-matching candidate is tested by the URLPattern matcher in + * insertion order, just as `routeLinear` would. + * * @example Usage * ```ts ignore * import { route, type Route } from "@std/http/unstable-route"; @@ -229,13 +278,9 @@ export function routeRadix( const fallbackRoutes: IndexedRoute[] = []; let insertionCounter = 0; - function parseSegments(pathname: string): string[] { - return pathname.split("/").filter(Boolean); - } - function insert(r: Route): void { const indexed: IndexedRoute = { route: r, index: insertionCounter++ }; - const segments = parseSegments(r.pattern.pathname); + const segments = r.pattern.pathname.split("/").filter(Boolean); // If any pathname segment uses URLPattern syntax the radix tree cannot // model, fall back to linear matching. Insertion order is preserved via @@ -245,12 +290,17 @@ export function routeRadix( return; } + // Walk the tree, creating nodes as needed and updating `minIndex` along + // the way so each ancestor remembers the lowest insertion index that + // can be reached through it. This is what enables pruning at dispatch. let node = root; + if (indexed.index < node.minIndex) node.minIndex = indexed.index; for (const segment of segments) { if (segment === "*") { if (!node.wildcardChild) node.wildcardChild = createNode(); node = node.wildcardChild; + if (indexed.index < node.minIndex) node.minIndex = indexed.index; break; // Wildcards terminate the path } else if (segment.startsWith(":")) { if (!node.paramChild) node.paramChild = createNode(); @@ -258,101 +308,150 @@ export function routeRadix( } else { if (!(segment in node.staticChildren)) { node.staticChildren[segment] = createNode(); + node.hasStaticChildren = true; } node = node.staticChildren[segment]!; } + if (indexed.index < node.minIndex) node.minIndex = indexed.index; } node.routes.push(indexed); } - function collectCandidates( - node: RouteNode, - segments: string[], - index: number, - results: IndexedRoute[], - ): void { - if (index === segments.length) { - for (const r of node.routes) results.push(r); - if (node.wildcardChild) { - for (const r of node.wildcardChild.routes) results.push(r); + // Build the tree + for (const r of routes) insert(r); + + const isEmptyTree = fallbackRoutes.length === routes.length; + + // If every route fell through to fallbackRoutes, skip all radix machinery + // on each request and delegate directly to routeLinear. + if (isEmptyTree) { + return routeLinear(routes, defaultHandler); + } + + // Per-request mutable match state. Hoisted to a single object so the + // recursive walker doesn't allocate a closure per request. + interface MatchState { + request: Request; + pathname: string; + bestRoute: Route | null; + bestIndex: number; + bestParams: URLPatternResult | null; + } + + /** + * Try a single route as a candidate. Updates `state` in place when the + * route matches and has a lower insertion index than the current best. + */ + function tryRoute(state: MatchState, r: IndexedRoute): void { + if (r.index >= state.bestIndex) return; + if (!methodMatches(r.route.method, state.request.method)) return; + const params = r.route.pattern.exec(state.request.url); + if (!params) return; + state.bestRoute = r.route; + state.bestIndex = r.index; + state.bestParams = params; + } + + /** + * Walk the tree in lockstep with the pathname. `from` is the index of the + * current segment's first character in `pathname`. Each step finds the + * segment slice [start, end) and recurses on matching children. + * + * No segments array is allocated; only the substrings needed for static + * lookups are materialised. + */ + function walk(node: RouteNode, state: MatchState, from: number): void { + // Subtree pruning: if everything reachable here has a higher index than + // the current best, skip the whole branch. + if (node.minIndex >= state.bestIndex) return; + + const pathname = state.pathname; + const len = pathname.length; + + // Skip leading '/' + let start = from; + while (start < len && pathname.charCodeAt(start) === 47 /* '/' */) { + start++; + } + + if (start >= len) { + // End of pathname — try every route registered at this node, plus + // any wildcard child's routes (matches an empty trailing segment). + for (const r of node.routes) tryRoute(state, r); + const wc = node.wildcardChild; + if (wc !== null && wc.minIndex < state.bestIndex) { + for (const r of wc.routes) tryRoute(state, r); } return; } - const segment = segments[index]!; - - // Explore ALL matching branches so insertion order can break ties. - if (segment in node.staticChildren) { - collectCandidates( - node.staticChildren[segment]!, - segments, - index + 1, - results, - ); + // Find segment end. + let end = start; + while (end < len && pathname.charCodeAt(end) !== 47 /* '/' */) end++; + + // Static children: only allocate the substring if this node has any. + if (node.hasStaticChildren) { + const segment = pathname.slice(start, end); + const child = node.staticChildren[segment]; + if (child !== undefined) { + walk(child, state, end); + if (state.bestIndex === 0) return; // can't do better + } } if (node.paramChild) { - collectCandidates(node.paramChild, segments, index + 1, results); + walk(node.paramChild, state, end); + if (state.bestIndex === 0) return; } - if (node.wildcardChild) { - for (const r of node.wildcardChild.routes) results.push(r); + const wc = node.wildcardChild; + if (wc !== null && wc.minIndex < state.bestIndex) { + // A wildcard child consumes the rest of the path; try its routes. + for (const r of wc.routes) tryRoute(state, r); } } - // Build the tree - for (const r of routes) insert(r); - - const isEmptyTree = fallbackRoutes.length === routes.length; - - // If every route fell through to fallbackRoutes, skip all radix machinery - // on each request and delegate directly to routeLinear. - if (isEmptyTree) { - return routeLinear(routes, defaultHandler); - } + // Lowest insertion index reachable through the radix tree. Fallback + // routes with a smaller index than this are guaranteed to come before + // any radix candidate in insertion order, so we can scan them first + // and let pruning skip the rest of the tree if one matches. + const radixMinIndex = root.minIndex; return (request: Request, info?: Deno.ServeHandlerInfo) => { - const pathname = parsePathname(request.url); - const segments = parseSegments(pathname); - const radixCandidates: IndexedRoute[] = []; - collectCandidates(root, segments, 0, radixCandidates); - radixCandidates.sort((a, b) => a.index - b.index); - - // When the tree found no candidates and there are no fallback routes, - // go straight to defaultHandler. - if (radixCandidates.length === 0 && fallbackRoutes.length === 0) { - return defaultHandler(request, info); + const state: MatchState = { + request, + pathname: parsePathname(request.url), + bestRoute: null, + bestIndex: Number.POSITIVE_INFINITY, + bestParams: null, + }; + + // Scan fallback routes registered before any radix route first. + // This preserves insertion order without a merge step and lets the + // tree walk prune itself if one of these earlier fallbacks matches. + let fbCursor = 0; + while ( + fbCursor < fallbackRoutes.length && + fallbackRoutes[fbCursor]!.index < radixMinIndex + ) { + tryRoute(state, fallbackRoutes[fbCursor]!); + fbCursor++; } - // Merge radix candidates with fallback routes by insertion order. - // Fast path: skip merge if one side is empty. - let candidates: IndexedRoute[]; - if (fallbackRoutes.length === 0) { - candidates = radixCandidates; - } else if (radixCandidates.length === 0) { - candidates = fallbackRoutes; - } else { - candidates = []; - let r = 0; - let f = 0; - while (r < radixCandidates.length && f < fallbackRoutes.length) { - if (radixCandidates[r]!.index < fallbackRoutes[f]!.index) { - candidates.push(radixCandidates[r++]!); - } else { - candidates.push(fallbackRoutes[f++]!); - } - } - while (r < radixCandidates.length) candidates.push(radixCandidates[r++]!); - while (f < fallbackRoutes.length) candidates.push(fallbackRoutes[f++]!); - } + walk(root, state, 0); - for (const { route: r } of candidates) { - if (!methodMatches(r.method, request.method)) continue; - const params = r.pattern.exec(request.url); - if (params) return r.handler(request, params, info); + // Remaining fallback routes (those whose index is >= radixMinIndex). + // Stop as soon as the next fallback can't improve on the current best. + for (let i = fbCursor; i < fallbackRoutes.length; i++) { + const fb = fallbackRoutes[i]!; + if (fb.index >= state.bestIndex) break; + tryRoute(state, fb); } + if (state.bestRoute !== null) { + return state.bestRoute.handler(request, state.bestParams!, info); + } return defaultHandler(request, info); }; } From 8350c3f4a4b58767d69ee6e96c6e3a3343cb5f5a Mon Sep 17 00:00:00 2001 From: Carles Escrig Royo Date: Thu, 30 Apr 2026 23:44:37 +0200 Subject: [PATCH 3/4] fix(http/unstable): route backslash-escaped patterns through linear fallback URLPattern requires reserved characters (`+`, `?`, `*`) to be backslash-escaped to match literally. The escape is preserved in `pattern.pathname` (e.g. `/c\+\+`), so the radix tree was inserting such patterns as static keys that could never match the unescaped request path (`/c++`), making those routes unreachable. Treat any segment containing a backslash as complex so URLPattern remains the authoritative matcher via the linear fallback. Adds a regression test covering both single (`\+`) and consecutive (`\+\+`) escape forms. --- http/unstable_route.ts | 13 ++++++++----- http/unstable_route_test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/http/unstable_route.ts b/http/unstable_route.ts index 14abe623ea92..668678d6b02f 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -181,12 +181,14 @@ function parsePathname(url: string): string { * - Regex-constrained params: `:id(\d+)` `:lang(en|fr)` * - Inline wildcards: `*.js` `prefix*` * - Modifier suffixes on params/groups: `:id?` `:id+` `:id*` `(\d+)?` + * - Backslash escapes for literals: `\+` `\?` `\*` `\:` `\(` `\{` * - * This heuristic relies on URLPattern syntax being well-formed: URLPattern's - * own parser rejects `?`, `+`, and `*` as literal characters in pathnames - * (e.g. `new URLPattern({ pathname: "/foo+bar" })` throws at construction), - * so a segment that ends with one of those characters here is always a - * modifier, never a literal. + * Note that URLPattern's parser treats `?`, `+`, and `*` as modifier tokens + * in pathnames; literal occurrences (e.g. `/c++`) must be backslash-escaped + * (e.g. `/c\+\+`). The `pathname` property preserves the escape sequence, + * which the radix tree cannot match against an unescaped request path — + * so any segment containing `\` is routed to the linear fallback to keep + * URLPattern as the authoritative matcher. * * The `:id*` "zero-or-more" modifier is caught by the inline-wildcard branch * (`includes("*")` with `segment !== "*"`), not by an explicit `endsWith("*")` @@ -196,6 +198,7 @@ function isComplexSegment(segment: string): boolean { if (segment.includes("{") || segment.includes("(")) return true; if (segment.includes("*") && segment !== "*") return true; if (segment.endsWith("?") || segment.endsWith("+")) return true; + if (segment.includes("\\")) return true; return false; } diff --git a/http/unstable_route_test.ts b/http/unstable_route_test.ts index 8108a8cf4d8e..4719168c36d2 100644 --- a/http/unstable_route_test.ts +++ b/http/unstable_route_test.ts @@ -433,6 +433,39 @@ function testRouter(name: string, route: typeof routeRadix) { assertEquals(withoutId?.status, 200); assertEquals(await withoutId?.text(), "id:none"); }); + + await t.step( + "backslash-escaped literal in pathname matches request path", + async () => { + // URLPattern requires reserved characters (+, ?, *) to be backslash- + // escaped to match literally. The escape is preserved in + // `pattern.pathname`, so the matcher must not treat the segment + // as a static radix-tree key. + const escapedRoutes: Route[] = [ + { + pattern: new URLPattern({ pathname: "/file\\+v2" }), + handler: () => new Response("plus"), + }, + { + pattern: new URLPattern({ pathname: "/c\\+\\+" }), + handler: () => new Response("cpp"), + }, + ]; + const escapedHandler = route(escapedRoutes, defaultHandler); + + const plusResponse = await escapedHandler( + new Request("http://example.com/file+v2"), + ); + assertEquals(plusResponse?.status, 200); + assertEquals(await plusResponse?.text(), "plus"); + + const cppResponse = await escapedHandler( + new Request("http://example.com/c++"), + ); + assertEquals(cppResponse?.status, 200); + assertEquals(await cppResponse?.text(), "cpp"); + }, + ); }); } From 632ecdced9e5e280f42118fc4b791700f6f6d61d Mon Sep 17 00:00:00 2001 From: Carles Escrig Royo Date: Thu, 30 Apr 2026 23:45:14 +0200 Subject: [PATCH 4/4] refactor(http/unstable): default route export to routeLinear The radix router is brand new; keep `route` bound to the better-tested `routeLinear` while it matures. Users who want the radix optimizations opt in via the `routeRadix` named export. --- http/unstable_route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/http/unstable_route.ts b/http/unstable_route.ts index 668678d6b02f..78cab052679c 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -28,7 +28,7 @@ export type Handler = ( ) => Response | Promise; /** - * Route configuration for {@linkcode routeRadix}. + * Route configuration for {@linkcode route}. * * @experimental **UNSTABLE**: New API, yet to be vetted. */ @@ -459,4 +459,4 @@ export function routeRadix( }; } -export { routeRadix as route }; +export { routeLinear as route };