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);