From d522ffa083161b8cd66dfb1c3f7748bd2126ed22 Mon Sep 17 00:00:00 2001 From: Carles Escrig Royo Date: Tue, 31 Mar 2026 21:33:25 +0200 Subject: [PATCH] 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);