import { readFile } from "node:fs/promises"; import path from "node:path"; export const API_ROUTE_METHODS = ["get", "post", "put", "patch", "delete", "options", "all"] as const; export type ApiRouteMethod = (typeof API_ROUTE_METHODS)[number]; export type ApiRouteKey = `${Uppercase} ${string}`; export type ApiRouteLocation = { line: number; column: number; }; export type ApiRoute = { method: Uppercase; path: string; key: ApiRouteKey; location: ApiRouteLocation; }; export type DuplicateApiRoute = { key: ApiRouteKey; locations: ApiRouteLocation[]; }; export type UnresolvedMountedRouter = { receiver: string; prefix: string; location: ApiRouteLocation; }; export type ApiRouteScanResult = { routes: ApiRoute[]; duplicateRoutes: DuplicateApiRoute[]; unresolvedMountedRouters: UnresolvedMountedRouter[]; }; const API_ROUTE_METHOD_PATTERN = API_ROUTE_METHODS.join("|"); const ROUTE_CALL_PATTERN = new RegExp( String.raw`\b([A-Za-z_$][\w$]*)\s*\.\s*(${API_ROUTE_METHOD_PATTERN})\s*\(\s*(["'\`])([^"'\`]+)\3`, "gi" ); const ON_SINGLE_ROUTE_CALL_PATTERN = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(["'`])([A-Za-z]+)\2\s*,\s*(["'`])([^"'`]+)\4/g; const ON_ARRAY_ROUTE_CALL_PATTERN = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*\[([\s\S]*?)\]\s*,\s*(["'`])([^"'`]+)\3/g; const HONO_INSTANCE_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+Hono\b/g; const APP_ROUTE_PATTERN = /\bapp\s*\.\s*route\s*\(\s*(["'`])([^"'`]+)\1\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g; const ROUTE_REGISTRATION_IMPORT_PATTERN = /import\s+\{\s*(register[A-Za-z0-9_$]+)\s*\}\s+from\s+(["'`])(.+?)\2/g; const ROUTE_REGISTRATION_CALL_PATTERN = /\b(register[A-Za-z0-9_$]+)\s*\(\s*app\s*\)/g; export function scanApiRoutesFromSource(source: string): ApiRouteScanResult { const { routePrefixesByReceiver, unresolvedMountedRouters } = discoverRoutePrefixesByReceiver(source); const searchableSource = maskNonRouteStringLiterals(source, new Set(routePrefixesByReceiver.keys())); const routes: ApiRoute[] = []; for (const match of searchableSource.matchAll(ROUTE_CALL_PATTERN)) { const receiver = match[1]; const prefix = routePrefixesByReceiver.get(receiver); if (prefix === undefined) { continue; } const method = match[2].toUpperCase() as Uppercase; const path = joinApiRoutePath(prefix, match[4]); const location = getSourceLocation(source, match.index ?? 0); routes.push({ method, path, key: `${method} ${path}` as ApiRouteKey, location, }); } for (const match of searchableSource.matchAll(ON_SINGLE_ROUTE_CALL_PATTERN)) { const receiver = match[1]; const prefix = routePrefixesByReceiver.get(receiver); const method = normalizeOnRouteMethod(match[3]); if (prefix === undefined || !method) { continue; } const path = joinApiRoutePath(prefix, match[5]); const location = getSourceLocation(source, match.index ?? 0); routes.push({ method, path, key: `${method} ${path}` as ApiRouteKey, location, }); } for (const match of searchableSource.matchAll(ON_ARRAY_ROUTE_CALL_PATTERN)) { const receiver = match[1]; const prefix = routePrefixesByReceiver.get(receiver); if (prefix === undefined) { continue; } const path = joinApiRoutePath(prefix, match[4]); const location = getSourceLocation(source, match.index ?? 0); for (const method of extractOnRouteMethods(match[2])) { routes.push({ method, path, key: `${method} ${path}` as ApiRouteKey, location, }); } } return { routes, duplicateRoutes: findDuplicateApiRoutes(routes), unresolvedMountedRouters, }; } export async function scanApiRoutesFromFile(filePath: string): Promise { const source = await readFile(filePath, "utf8"); return scanApiRoutesFromSource(await expandRegisteredApiRouteModules(filePath, source)); } async function expandRegisteredApiRouteModules(filePath: string, source: string) { if (path.basename(filePath) !== "app.ts") { return source; } const imports = new Map(); for (const match of source.matchAll(ROUTE_REGISTRATION_IMPORT_PATTERN)) { imports.set(match[1], match[3]); } const chunks: string[] = []; for (const match of source.matchAll(ROUTE_REGISTRATION_CALL_PATTERN)) { const importPath = imports.get(match[1]); if (!importPath?.startsWith("./routes/")) { continue; } const routeModulePath = path.resolve(path.dirname(filePath), importPath.replace(/\.js$/, ".ts")); chunks.push(await readFile(routeModulePath, "utf8")); } chunks.push(source); return chunks.join("\n"); } export function formatApiRouteKeys(routes: readonly ApiRoute[]): ApiRouteKey[] { return routes.map((route) => route.key); } export function assertNoDuplicateApiRoutes(result: ApiRouteScanResult): void { if (result.duplicateRoutes.length === 0) { return; } const duplicates = result.duplicateRoutes .map((duplicate) => { const locations = duplicate.locations.map((location) => `${location.line}:${location.column}`).join(", "); return `${duplicate.key} (${locations})`; }) .join("; "); throw new Error(`Duplicate API route keys found: ${duplicates}`); } function findDuplicateApiRoutes(routes: readonly ApiRoute[]): DuplicateApiRoute[] { const locationsByKey = new Map(); for (const route of routes) { const locations = locationsByKey.get(route.key) ?? []; locations.push(route.location); locationsByKey.set(route.key, locations); } return Array.from(locationsByKey.entries()) .filter(([, locations]) => locations.length > 1) .map(([key, locations]) => ({ key, locations })); } function getSourceLocation(source: string, index: number): ApiRouteLocation { const prefix = source.slice(0, index); const lines = prefix.split("\n"); const line = lines.length; const column = lines[lines.length - 1].length + 1; return { line, column }; } function discoverRoutePrefixesByReceiver(source: string): { routePrefixesByReceiver: Map; unresolvedMountedRouters: UnresolvedMountedRouter[]; } { const routePrefixesByReceiver = new Map([["app", ""]]); const honoReceivers = new Set(); const unresolvedMountedRouters: UnresolvedMountedRouter[] = []; for (const match of source.matchAll(HONO_INSTANCE_PATTERN)) { honoReceivers.add(match[1]); } for (const match of source.matchAll(APP_ROUTE_PATTERN)) { const [, , prefix, receiver] = match; if (honoReceivers.has(receiver)) { routePrefixesByReceiver.set(receiver, joinApiRoutePath(routePrefixesByReceiver.get(receiver) ?? "", prefix)); } else { unresolvedMountedRouters.push({ receiver, prefix, location: getSourceLocation(source, match.index ?? 0), }); } } return { routePrefixesByReceiver, unresolvedMountedRouters }; } function joinApiRoutePath(prefix: string, path: string): string { const normalizedPrefix = prefix === "/" ? "" : prefix.replace(/\/+$/, ""); const normalizedPath = path.startsWith("/") ? path : `/${path}`; return `${normalizedPrefix}${normalizedPath}` || "/"; } function maskNonRouteStringLiterals(source: string, routeReceivers: ReadonlySet): string { const mutableSource = Array.from(source); let quote: '"' | "'" | "`" | null = null; let literalStart = -1; let escaped = false; for (let index = 0; index < mutableSource.length; index += 1) { const character = mutableSource[index]; if (quote === null) { if (character === '"' || character === "'" || character === "`") { quote = character; literalStart = index; } continue; } if (escaped) { escaped = false; continue; } if (character === "\\") { escaped = true; continue; } if (character === quote) { if (!isRoutePathLiteral(source, literalStart, routeReceivers)) { maskLiteralInterior(mutableSource, literalStart, index); } quote = null; literalStart = -1; } } return mutableSource.join(""); } function isRoutePathLiteral(source: string, literalStart: number, routeReceivers: ReadonlySet): boolean { const prefix = source.slice(0, literalStart); const routeCallMatch = new RegExp(String.raw`\b([A-Za-z_$][\w$]*)\s*\.\s*(?:${API_ROUTE_METHOD_PATTERN})\s*\(\s*$`, "i").exec(prefix); if (routeCallMatch && routeReceivers.has(routeCallMatch[1])) { return true; } const onMethodLiteralMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(?:\[[\s\S]*)?$/i.exec(prefix); if (onMethodLiteralMatch && routeReceivers.has(onMethodLiteralMatch[1])) { return true; } const onSingleRouteCallMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(["'`])([A-Za-z]+)\2\s*,\s*$/i.exec(prefix); if (onSingleRouteCallMatch && routeReceivers.has(onSingleRouteCallMatch[1])) { return true; } const onArrayRouteCallMatch = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*\[[\s\S]*?\]\s*,\s*$/i.exec(prefix); if (onArrayRouteCallMatch && routeReceivers.has(onArrayRouteCallMatch[1])) { return true; } return /\bapp\s*\.\s*route\s*\(\s*$/i.test(prefix); } function maskLiteralInterior(source: string[], literalStart: number, literalEnd: number): void { for (let index = literalStart + 1; index < literalEnd; index += 1) { source[index] = source[index] === "\n" ? "\n" : " "; } } function extractOnRouteMethods(methodsSource: string): Array> { return [...methodsSource.matchAll(/(["'`])([A-Za-z]+)\1/g)] .map((match) => normalizeOnRouteMethod(match[2])) .filter((method): method is Uppercase => Boolean(method)); } function normalizeOnRouteMethod(method: string): Uppercase | null { const normalized = method.toLowerCase(); if (!(API_ROUTE_METHODS as readonly string[]).includes(normalized)) { return null; } return normalized.toUpperCase() as Uppercase; }