import { RATE_LIMIT_PLUGIN_CODES, RateLimitPluginError, } from "../error-codes/error-codes.js"; /** * A compiled path pattern ready for matching against inbound request * paths. Created once per route rule at plugin init and reused per * request. */ export interface CompiledPathPattern { readonly source: string; readonly regex: RegExp; readonly paramNames: readonly string[]; } /** * Result of matching a compiled pattern against a path. */ export interface PathMatchResult { readonly matched: boolean; readonly params: Readonly>; } const PARAM_RE = /:[A-Za-z_][A-Za-z0-9_]*/g; function escapeRegex(literal: string): string { return literal.replace(/[.+?^${}()|[\]\\*]/g, "\\$&"); } /** * Compile a Hono-style path pattern into a `CompiledPathPattern`. * * Supported forms: * - exact literal: `"/api/users"` matches only `/api/users` * - trailing wildcard: `"/api/users/*"` matches anything under * `/api/users/` * - inline wildcard: `"/api/*"` matches one path segment * - named param: `"/api/users/:id"` captures `id` * * Throws `RateLimitPluginError(INVALID_PATH_PATTERN)` when the * compiled regex cannot be built — defensive guard so any future * pattern-syntax mistake surfaces via the typed code rather than a * bare `SyntaxError`. */ export function compilePathPattern(source: string): CompiledPathPattern { const paramNames: string[] = []; let cursor = 0; let regexBody = ""; PARAM_RE.lastIndex = 0; for (;;) { const match = PARAM_RE.exec(source); if (match === null) break; const literal = source.slice(cursor, match.index); regexBody += escapeRegex(literal).replace(/\\\*/g, "[^/]+"); const name = match[0].slice(1); paramNames.push(name); regexBody += "([^/]+)"; cursor = match.index + match[0].length; } const tail = source.slice(cursor); if (tail.endsWith("/*")) { regexBody += escapeRegex(tail.slice(0, -2)).replace(/\\\*/g, "[^/]+"); regexBody += "(?:/.*)?"; } else { regexBody += escapeRegex(tail).replace(/\\\*/g, "[^/]+"); } let regex: RegExp; try { regex = new RegExp(`^${regexBody}$`); } catch (err) { throw new RateLimitPluginError( RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN, `Failed to compile path pattern '${source}'`, { cause: err, context: { source, statusCode: 500 } } ); } return { source, regex, paramNames }; } /** * Test a compiled pattern against a concrete path. Returns a * `PathMatchResult` carrying any captured params keyed by the names * declared in the source pattern. */ export function matchPath( pattern: CompiledPathPattern, path: string ): PathMatchResult { const match = pattern.regex.exec(path); if (match === null) return { matched: false, params: Object.freeze({}) }; const params: Record = {}; for (let i = 0; i < pattern.paramNames.length; i++) { const name = pattern.paramNames[i] as string; // `([^/]+)` is non-optional so a successful regex match guarantees // every capture group resolved to a non-empty string. params[name] = match[i + 1] as string; } return { matched: true, params: Object.freeze(params) }; } /** * Convenience helper: compile and match in a single call. Suitable for * one-off tests; in production the plugin caches `CompiledPathPattern` * per route rule. */ export function isPathMatch(source: string, path: string): boolean { return matchPath(compilePathPattern(source), path).matched; }