diff --git a/http/unstable_route.ts b/http/unstable_route.ts index 8886c2fb41af..78cab052679c 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -50,11 +50,188 @@ 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; + /** + * 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. + * + * 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 { + // 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); + 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*` + * - Modifier suffixes on params/groups: `:id?` `:id+` `:id*` `(\d+)?` + * - Backslash escapes for literals: `\+` `\?` `\*` `\:` `\(` `\{` + * + * 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("*")` + * clause. This is intentional — the two checks subsume each other for `*`. + */ +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; +} + +function createNode(): RouteNode { + return { + staticChildren: Object.create(null) as Record, + hasStaticChildren: false, + paramChild: null, + wildcardChild: null, + routes: [], + minIndex: Number.POSITIVE_INFINITY, + }; +} + /** * 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. + * + * 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"; @@ -96,29 +273,190 @@ 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 insert(r: Route): void { + const indexed: IndexedRoute = { route: r, index: insertionCounter++ }; + 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 + // `index`. + if (segments.some(isComplexSegment)) { + fallbackRoutes.push(indexed); + 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(); + node = node.paramChild; + } 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); + } + + // 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; + } + + // 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) { + walk(node.paramChild, state, end); + if (state.bestIndex === 0) return; + } + + 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); + } + } + + // 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) => { - 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 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++; + } + + walk(root, state, 0); + + // 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); }; } -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 { routeLinear 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..4719168c36d2 100644 --- a/http/unstable_route_test.ts +++ b/http/unstable_route_test.ts @@ -1,134 +1,473 @@ // 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, 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); + + const request = new Request("http://example.com/test", { + method: "POST", + }); + const response = await lowerCaseHandler(request); assertEquals(response?.status, 200); - assertEquals(await response?.text(), "/about"); - } - }); + 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"), + }, + ]; + 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); + }, + ); + + 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"); - await t.step("does not match unspecified methods", async () => { - const request = new Request("http://example.com/users/123", { - method: "DELETE", + const noMatchResponse = await wildcardHandler( + new Request("http://example.com/other/foo.js"), + ); + assertEquals(noMatchResponse?.status, 404); }); - 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("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 lowerCaseHandler = route(lowerCaseRoutes, defaultHandler); + ); - const request = new Request("http://example.com/test", { - method: "POST", + 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"); }); - 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("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"); }, - ]; - const mixedCaseHandler = route(mixedCaseRoutes, defaultHandler); + ); + + 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); - const getResponse = await mixedCaseHandler( - new Request("http://example.com/test"), + // 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(getResponse?.status, 200); - const postResponse = await mixedCaseHandler( - new Request("http://example.com/test", { method: "POST" }), + 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"); + }); + + 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"); + }, ); - assertEquals(postResponse?.status, 200); }); -}); +} + +testRouter("routeRadix()", routeRadix); +testRouter("routeLinear()", routeLinear);