@routegraph/core
v1.0.0
Published
The scanner, loader, and validator behind RouteGraph — zero runtime dependencies, framework-agnostic.
Readme
@routegraph/core
The engine behind RouteGraph: scans a routes/ directory, loads route files, validates requests against Zod schemas, and exposes the result to a framework adapter. Zero runtime dependencies.
Installation
pnpm add @routegraph/core zodzod is a peer dependency (>=3.0.0) — install it yourself.
Usage (standalone, before adding an adapter)
import { RouteGraph } from '@routegraph/core'
const graph = new RouteGraph({ routesDir: './routes' })
await graph.load()
console.log(graph.getRoutes().map(r => `${r.method} ${r.urlPath}`))
// [ 'GET /health', 'GET /users', 'GET /users/:id', ... ]@routegraph/core on its own doesn't serve HTTP requests — pair it with a framework adapter (@routegraph/express, @routegraph/hono, @routegraph/fastify, @routegraph/elysia, or @routegraph/koa) to actually handle requests.
The RouteGraph class
class RouteGraph extends EventEmitter {
public options: RouteGraphOptions
public globalMiddleware: Middleware[]
constructor(options: RouteGraphOptions)
load(): Promise<void>
reload(): Promise<void>
getRoutes(): LoadedRoute[]
getRoute(method: HttpMethod, path: string): LoadedRoute | undefined
toOpenAPISpec(): OpenAPIObject
on(event: 'loaded', cb: (routes: LoadedRoute[]) => void): this
on(event: 'reloaded', cb: (diff: RouteDiff) => void): this
on(event: 'error', cb: (err: Error) => void): this
}Constructor options
interface RouteGraphOptions {
routesDir: string // path to your routes/ directory
baseUrl?: string // prefix applied to every route, e.g. '/api/v1'
middleware?: Middleware[] // global middleware, runs before every route's own middleware
onError?: (err: Error) => void // called (in addition to the 'error' event) when load() fails
logger?: Logger // reserved for future use — not currently read internally
}load()
Scans routesDir, dynamically imports every matching route file, applies baseUrl if set, checks for duplicate method + urlPath pairs (throws DuplicateRouteError), and sorts routes so static segments win over dynamic ones at the same depth. Emits 'loaded' with the final LoadedRoute[] on success. Any failure throws — see Startup errors are fatal below — this is not caught internally.
reload()
Re-runs load(), diffs the new route list against the previous one by filePath, and emits 'reloaded' with a RouteDiff. This is what @routegraph/watcher calls on a file change; you can also call it yourself.
getRoutes() / getRoute(method, path)
Read access to the currently loaded routes. Adapters call getRoute() fresh on every incoming request (rather than caching the route object) specifically so a reload() is visible immediately on an already-running server.
toOpenAPISpec()
Builds an OpenAPI 3.1.0 document from every loaded route's config.request/config.response Zod schemas (path/query/header parameters, request bodies for POST/PUT/PATCH, per-status response schemas, plus an automatic 400 entry for any route with request validation). The info block is fixed ({ title: 'RouteGraph API', version: '1.0.0' }) — it is not derived from your package.json.
Type reference
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface RouteConfig {
description?: string
tags?: string[]
deprecated?: boolean
middleware?: Middleware[]
request?: { params?: ZodTypeAny; query?: ZodTypeAny; body?: ZodTypeAny; headers?: ZodTypeAny }
response?: { [statusCode: number]: ZodTypeAny }
}
type RouteHandler<TConfig extends RouteConfig = RouteConfig> =
(req: InferRequest<TConfig>, res: NormalizedResponse) => Promise<void>
interface NormalizedRequest {
method: HttpMethod
path: string
params: Record<string, string>
query: Record<string, string | string[]>
headers: Record<string, string>
body: unknown
raw: unknown // the framework's native request object — an escape hatch
}
interface NormalizedResponse {
status(code: number): this
json(data: unknown): void
send(data: string): void
setHeader(key: string, value: string): this
end(): void
}
interface RouteNode {
filePath: string
method: HttpMethod
urlPath: string
segments: Array<{ type: 'static'; value: string } | { type: 'dynamic'; name: string }>
isDynamic: boolean
depth: number
}
interface LoadedRoute extends RouteNode {
handler: RouteHandler
config: RouteConfig
middleware: Middleware[] // config.middleware, defaulted to []
}InferRequest<TConfig> maps each of params/query/body/headers to z.infer<...> when TConfig['request'] declares a schema for it, and otherwise falls back to NormalizedRequest's raw type for that field.
Errors
class RouteGraphError extends Error { code: string }
class ValidationError extends RouteGraphError {
code: 'VALIDATION_ERROR'
issues: ValidationIssue[]
}
class RouteLoadError extends RouteGraphError {
code: 'ROUTE_LOAD_ERROR'
filePath: string
originalError: unknown
}
class DuplicateRouteError extends RouteGraphError {
code: 'DUPLICATE_ROUTE'
method: HttpMethod
urlPath: string
}RouteLoadError and DuplicateRouteError are thrown from graph.load() — startup errors are fatal by design (see DECISIONS.md); they are not caught internally. ValidationError's issues shape ({ field, message, code }[]) is what every adapter returns in a request's 400 response body.
File convention
See the repo README and ARCHITECTURE.md for the full scanner spec — this package's scan() function is what implements it.
