/** * A tiny fetch-style router for the Workers API. * * Patterns support `:name` parameters and a trailing `*` wildcard. Route * matching is exact on method + path length so no server framework is needed. */ export interface RouteContext { request: Request; env: Env; params: Record; url: URL; } export type RouteHandler = (context: RouteContext) => Promise | Response; export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'; interface Route { method: Method; segments: string[]; handler: RouteHandler; } export class Router { private readonly routes: Route[] = []; add(method: Method, path: string, handler: RouteHandler): void { this.routes.push({ method, segments: splitPath(path), handler }); } match( method: Method, pathname: string, ): { handler: RouteHandler; params: Record } | null { const segments = splitPath(pathname); for (const route of this.routes) { if (route.method !== method) continue; const params = matchSegments(route.segments, segments); if (params) return { handler: route.handler, params }; } return null; } /** True when a different method has a matching path, for accurate 405s. */ hasPath(pathname: string): boolean { const segments = splitPath(pathname); return this.routes.some((route) => matchSegments(route.segments, segments) !== null); } } function splitPath(path: string): string[] { return path.split('/').filter((segment) => segment !== ''); } function matchSegments( pattern: readonly string[], actual: readonly string[], ): Record | null { const params: Record = {}; for (let index = 0; index < pattern.length; index += 1) { const expected = pattern[index]!; if (expected === '*') { params['*'] = actual.slice(index).join('/'); return params; } const value = actual[index]; if (value === undefined) return null; if (expected.startsWith(':')) { // Malformed percent-encoding must be a non-match, not a 500. try { params[expected.slice(1)] = decodeURIComponent(value); } catch { return null; } } else if (expected !== value) { return null; } } return pattern.length === actual.length ? params : null; }