yatta/universal
One route definition, used two ways: called in process or over HTTP.
40 exported symbols and 40 members, read from src/types/universal.ts.
Construct
createApp
functionBuilds an app from a table of routes.
createApp<const R extends RouteTable, S extends ServiceMap = ServiceMap>(routes: R, options?: { services?: S; name?: string; middleware?: Middleware<S>[]; }): App<R> & DirectMethods<R> & { readonly serviceType: S; }export const app = createApp({ getUser: defineRoute( { method: "get", path: "/users/:id", params: UserId, response: User }, async ({ params, services }) => services.db.users.findById(params.id), ),}, { // Typed, so a handler's `services.db` is your database. services: { db, auth, realtime },});createClient
functionBuilds the browser client for an app.
This is the other half of the same definition. The app calls handlers in process; this reaches them over HTTP. Both surfaces come from the table, so the developer writes neither.
createClient<const R extends RouteTable>(app: App<R> & DirectMethods<R>, options?: ClientOptions): Client<R>// Server Component — direct, no HTTP.const user = await app.getUser({ params: { id } }); // Browser — over HTTP, same shape.const api = createClient(app, { baseUrl: "/api" });const user2 = await api.getUser({ params: { id } });defaultServices
functionA default service set.
Reads the framework's singletons lazily. A getter rather than a value, so importing a route file does not start a database connection or a socket — the failure mode being that reading a route table on the server to mount it would spin up everything the app owns.
defaultServices(): ServiceMapdefineRoute
functionDeclares one route.
The handler returns a plain value, not a `Response`. That is the change that makes a direct call meaningful: `invoke()` hands back the value itself rather than something that has to be unwrapped from a transport. The return type is constrained to the declared response when there is one, so a handler that drifts from its contract fails here rather than at the browser.
defineRoute<const D extends RouteSpec, S extends ServiceMap = ServiceMap, R extends DeclaredOut<D> | Response = DeclaredOut<D>>(spec: D, handler: (input: Input<D, S>) => MaybePromise<R>): Route<D, S, R>const getUser = defineRoute( { method: "get", path: "/users/:id", params: UserId, response: User }, async ({ params, services }) => { const user = await services.db.users.findById(params.id); if (!user) throw new HttpError(404, "No such user"); return user; },);invoke
async functionCalls a route as a function. No HTTP, no serialization, no connection pool.
This is the point of the whole module. On a server the caller and the handler are in the same process, so a network hop between them buys no isolation and costs a round trip, a socket, and a failure mode.
invoke<N extends string, D extends RouteSpec, S extends ServiceMap, R>(app: App<Record<N, Route<D, S, R>>> & { services: S; }, name: N, args: CallArgs<D>, options?: { ctx?: Context<never>; services?: Partial<S>; }): Promise<Awaited<R>>// A Server Component. The handler runs here, with your `db`.const user = await invoke(app, "getUser", { params: { id } }); // Type inferred from the handler, not from a shared interface.const name: string = user.name;mount
functionServes an app over HTTP.
The transport. It matches a path, validates the arguments, runs the same handler `invoke` would, and turns the plain value it returns into a response. Nothing about the route is re-declared here.
mount(app: App<any>, options?: TransportOptions): (request: Request) => Promise<Response>Bun.serve({ fetch: mount(app) });routesOf
functionReads the routes off either an app or a bare table.
Both accepted because both are natural to have on hand, and a developer should not have to know which one a function expects — or unwrap it at the call site.
withMiddleware
functionAdds middleware to a route.
Mutates and returns the route, so it reads as a modifier: `withAuth(defineRoute(...))`.
withMiddleware<S extends ServiceMap, D extends RouteSpec, R>(route: Route<D, S, R>, ...middleware: Middleware<S>[]): Route<D, S, R>Types
App
interfaceinterface App<R extends Record<string, any> = RouteTable>4 members
routespropertyroutes: Rservicespropertyservices: ServiceMapThe framework objects handlers are given.
namespropertynames: readonly (keyof R & string)[]The names in this app. Useful for generating a client or checking coverage.
getget<K extends keyof R & string>(name: K): R[K]One route by name.
ClientOptions
interfaceinterface ClientOptions6 members
baseUrlpropertybaseUrl?: stringOrigin the routes are reached through, e.g. "/api" or a full origin.
headerspropertyheaders?: Record<string, string>Sent with every request unless a call overrides it.
onRequestpropertyonRequest?: (request: Request) => voidRuns before each request: a cookie jar, an auth header.
onResponsepropertyonResponse?: (response: Response) => voidRuns after a successful response: pagination cursors, rate-limit counters.
fetchpropertyfetch?: typeof globalThis.fetchPassed to fetch, to share a cookie jar or an agent.
validateResponsespropertyvalidateResponses?: booleanValidate a response against its declared schema.
On by default. It is the only thing that turns a server that changed into an error naming the field, instead of an `undefined` three components later.
Route
interfaceA declared route: its spec, its handler, and the handler's return type kept so
interface Route<D extends RouteSpec = RouteSpec, S extends ServiceMap = ServiceMap, R = unknown>3 members
specpropertyspec: Dhandlerpropertyhandler: (input: Input<D, S>) => MaybePromise<R>Replaced once, by `createApp`, with the middleware chain around it.
middlewarepropertymiddleware?: Middleware<S>[]Checks that apply to this route only.
Run inside the app's own middleware, never instead of it, so a route-level check cannot accidentally shadow a cross-cutting one.
ServiceMap
interfaceThe framework objects a handler can use.
Injected rather than imported, so a handler can be called directly, over HTTP, or in a test with a stand-in. An open map by default, because which engines an app uses is its own business.
interface ServiceMap8 members
dbpropertydb?: unknownauthpropertyauth?: unknownrealtimepropertyrealtime?: unknowncachepropertycache?: unknownjobspropertyjobs?: unknownmailpropertymail?: unknownstoragepropertystorage?: unknownobserverpropertyobserver?: unknown
Services
interfaceServices with the engines an app actually has, so handlers get real types.
interface Services8 members
dbpropertydb: unknownauthpropertyauth: unknownrealtimepropertyrealtime: unknowncachepropertycache: unknownjobspropertyjobs: unknownmailpropertymail: unknownstoragepropertystorage: unknownobserverpropertyobserver: unknown
TransportOptions
interfaceinterface TransportOptions4 members
basePathpropertybasePath?: stringStripped before matching, when the mount point is not in the URL.
validateResponsespropertyvalidateResponses?: booleanValidate each response against its declared schema. Costs one check.
onNotFoundpropertyonNotFound?: (req: WireRequest) => WireResponse | Promise<WireResponse>Called when no route matches. Defaults to a 404 JSON body.
onErrorpropertyonError?: (error: unknown, req: WireRequest, response: WireResponse) => voidCalled with a thrown error and the response it became.
WireRequest
interfaceA request, as far as the transport layer needs to know.
interface WireRequest4 members
methodpropertymethod: stringurlpropertyurl: stringheaderspropertyheaders: Record<string, string>bodypropertybody?: string
WireResponse
interfaceinterface WireResponse3 members
statuspropertystatus: numberheaderspropertyheaders: Record<string, string>bodypropertybody: string | Uint8Array | ReadableStream | null
CallArgs
typeArguments for a direct call.
What a route accepts, minus the request context: there is no HTTP request, because nothing crossed a network. Each input is contributed only when the route declares it, so passing a body to a route without one is an error rather than a field quietly dropped.
CallResult
typeWhat a direct call hands back: the handler's own return type.
type CallResult<T> = T extends Route<any, any, infer R> ? Awaited<R> : neverClient
typeA client for an app.
type Client<R extends RouteTable> = HttpMethods<R>DeclaredOut
typeThe value a handler returns when a response schema is declared.
type DeclaredOut<D extends RouteSpec> = D extends { response: infer Res; } ? unknown extends Res ? unknown : Infer<Res> : unknownDirectMethods
typeA set of routes plus the services they use.
One of these per process on the server, and per request on a server render — never a module-level singleton that outlives a request, which would serve one user's data to the next. One method per route, each callable directly. Derived from the table, so the developer writes routes and nothing else — no wrapper per endpoint, no name string to keep in step, no signature to maintain. The method's argument and return types come from the route itself.
type DirectMethods<R extends RouteTable> = { [K in keyof R & string]: (args?: CallArgs<R[K]["spec"]>) => Promise<CallResult<R[K]>>; }HttpMethods
typeA method per route, reached over HTTP.
The same names, the same arguments and the same return types as the app's direct methods. Derived from the same table, so there is nothing to keep in step — a renamed route is a compile error here too.
type HttpMethods<R extends RouteTable> = { [K in keyof R & string]: (args?: CallArgs<R[K]["spec"]>) => Promise<DeclaredOut<R[K]["spec"]>>; }Input
typeEverything a handler is given.
type Input<D extends RouteSpec, S extends ServiceMap> = { params: D extends { params: infer P; } ? unknown extends P ? D extends { path: infer Path extends string; } ? PathParams<Path> : Record<string, string> : Infer<P> : D extends { path: infer Path extends string; } ? PathParams<Path> : Record<string, string>; query: D extends { query: infer Q; } ? unknown extends Q ? Record<string, unknown> : Infer<Q> : Record<string, unknown>; body: D extends { body: infer B; } ? unknown extends B ? unknown : Infer<B> : undefined; ctx: Context<never>; services: S; }Middleware
typeRuns before a handler, on both paths.
Return a value to answer instead of calling the handler — that is how an auth check rejects. Return `undefined` to continue. The point of it being here rather than only on the transport: a check that lives in HTTP middleware is *not* run by `app.getUser()` or `invoke()`. A Server Component calling a route directly would sail straight past an authorisation check that HTTP enforces, which is the worst possible place for that gap — the code looks guarded and is not.
type Middleware<S extends ServiceMap = ServiceMap> = (input: { name: string; ctx: Context<never>; services: S; args: Record<string, unknown>; }) => unknown | Promise<unknown>PathParams
typePath parameters as plain strings, read from the path template.
type PathParams<Path extends string> = Path extends `${infer _H}:${infer P}/${infer R}` ? { [K in StripOptional<P> | keyof PathParams<`/${R}`>]: string; } & Partial<Record<StripOptional<P>, string>> : Path extends `${infer _H}:${infer P}` ? PathParamsOne<P> : {}RouteSpec
typeThe shape a route declares: method, path, and validators.
RouteTable
typeA table of routes.
type RouteTable = Record<string, Route<RouteSpec, any, any>>Other
buildPath
unknownbuildPathbuildPathFrom
unknownbuildPathFrombySpecificity
unknownbySpecificityextractParams
unknownextractParamsHttpMethod
unknownmatchesPath
unknownmatchesPathParsedPath
unknownparseTemplate
unknownparseTemplatePathTemplateError
unknownreadQuery
unknownreadQueryRouteDef
unknownSegment
unknowntoQueryString
unknowntoQueryStringAuthConfig or JobPayload — declaring your schema once is enough for the rest to follow. See Typed keys.