/** * Route discovery from a Fastify instance. * Pure functions, no side effects. * * Fastify 5 removed the public `routes` array. We capture routes via the `onRoute` * hook during plugin registration and store them in a WeakMap keyed by the instance. */ import { extractContract } from './contract.js' import type { RouteContract } from '../types.js' interface CapturedRoute { method: string url: string schema?: Record prefix?: string sourceFile?: string } export interface DiscoveryResult { routes: RouteContract[] source: 'captured' | 'legacy-routes-array' | 'print-routes' | 'none' hasSchemaMetadata: boolean warnings: string[] } // WeakMap to store captured routes per Fastify instance (no memory leaks) const capturedRoutes = new WeakMap() /** * Extract the source file that registered a route from a stack trace. * Finds the first frame outside node_modules and APOPHIS internal directories. */ function extractSourceFile(stack: string): string | undefined { const lines = stack.split('\n') for (let i = 3; i < lines.length; i++) { const line = lines[i] if (!line) continue const match = line.match(/\((.*?):\d+:\d+\)/) ?? line.match(/at\s+(.*?):\d+:\d+/) if (!match || !match[1]) continue const filePath = match[1] if (!filePath.includes('node_modules') && !filePath.includes('/apophis/') && !filePath.includes('\\apophis\\')) { return filePath } } return undefined } /** * Capture a route for discovery. * Called from the plugin's `onRoute` hook. */ export const captureRoute = ( instance: object, route: CapturedRoute ): void => { route.sourceFile = extractSourceFile(new Error().stack ?? '') const existing = capturedRoutes.get(instance) ?? [] existing.push(route) capturedRoutes.set(instance, existing) } /** * Fallback route discovery for Fastify 5 when routes were registered before * the APOPHIS plugin (e.g., external apps loaded by CLI). * Parses printRoutes() output to discover route paths and methods. * * printRoutes() output looks like: * └── / * ├── users (GET, HEAD) * └── items/ * └── :id (POST) */ function parsePrintRoutesOutput(output: string): Array<{ method: string; path: string }> { const routes: Array<{ method: string; path: string }> = [] const lines = output.split('\n') const depthPrefixes: string[] = [] for (const line of lines) { const trimmed = line.trimStart() if (!trimmed) continue const indent = line.length - line.trimStart().length const depth = Math.floor(indent / 2) depthPrefixes.length = depth const branchMatch = trimmed.match(/^(├──|└──)\s+(.+)$/) if (!branchMatch) continue let segment = branchMatch[2]! const isIntermediate = segment.endsWith('/') if (isIntermediate) { segment = segment.slice(0, -1) } const methodMatch = segment.match(/^(.+?)\s+\(([^)]+)\)$/) if (methodMatch) { const rawPath = methodMatch[1]! const methodList = methodMatch[2]!.split(',').map(m => m.trim()) const fullPath = '/' + [...depthPrefixes, rawPath].filter(Boolean).join('/') for (const method of methodList) { if (method === 'HEAD') continue routes.push({ method, path: fullPath }) } } else { depthPrefixes.push(segment) } } return routes } /** * Fallback route discovery for Fastify 5 when onRoute hook wasn't installed * before route registration. */ function discoverRoutesFallback( instance: { hasRoute?: (opts: { method: string; url: string }) => boolean printRoutes?: () => string } ): DiscoveryResult { let routes: RouteContract[] = [] let source: DiscoveryResult['source'] = 'none' let warnings: string[] = [] if (typeof instance.printRoutes === 'function') { const output = instance.printRoutes() const parsed = parsePrintRoutesOutput(output) if (parsed.length > 0) { routes = parsed.map(r => extractContract(r.path, r.method, undefined)) source = 'print-routes' warnings.push( 'Route schemas were not available during discovery. ' + 'Behavioral contracts (x-ensures, x-requires, x-outbound, x-variants, x-timeout) ' + 'will not be detected. Register APOPHIS or install route discovery before defining routes, ' + 'or use createFastify() from @apophis/fastify.' ) } } return { routes, source, hasSchemaMetadata: false, warnings } } export interface DiscoverRouteDetailsInput { routes?: Array<{ method: string; url: string; schema?: Record }> hasRoute?: (opts: { method: string; url: string }) => boolean printRoutes?: () => string } /** * Discover routes from a Fastify instance with full metadata about discovery quality. * * Use this function when you need to know whether routes were discovered * with full schema metadata (captured / legacy-routes-array) or as * schema-less fallback paths (print-routes / none). */ export const discoverRouteDetails = (instance: DiscoverRouteDetailsInput): DiscoveryResult => { const captured = capturedRoutes.get(instance) if (captured && captured.length > 0) { return { routes: captured.map((route) => { const contract = extractContract(route.url, route.method, route.schema) contract.sourceFile = route.sourceFile return contract }), source: 'captured', hasSchemaMetadata: true, warnings: [], } } if (Array.isArray(instance.routes) && instance.routes.length > 0) { return { routes: instance.routes.map((route) => extractContract(route.url, route.method, route.schema) ), source: 'legacy-routes-array', hasSchemaMetadata: true, warnings: [], } } return discoverRoutesFallback(instance) } /** * Discover routes from a Fastify instance. * * First checks captured routes (from onRoute hook), then falls back to * the legacy `routes` array for Fastify 4 compatibility. */ export const discoverRoutes = (instance: DiscoverRouteDetailsInput): RouteContract[] => { return discoverRouteDetails(instance).routes }