Routing
ubean adopts void’s named export convention: a single file defines multiple HTTP method handlers via named exports such as export const GET and export const POST. At build time, exported methods are detected through static AST scanning.
Route File Conventions
- File names do not include method suffixes (unlike nitro’s
.get.ts/.post.ts) - The method is determined by top-level named exports:
GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD export defaultis supported as a fallback handler (matches all methods not explicitly defined)- Files prefixed with
_are private and are not registered as routes - Environment suffixes are supported:
.dev.ts(development only),.prod.ts(production only)
defineHandler API Design
Drawing on hono-ssr’s createDefineRoute type inference pattern, defineHandler accepts a middleware chain composed of multiple handlers by default (a single handler is the special case where the chain length is 1). defineHandlerMeta is exported as a standalone handler function and is passed directly into the defineHandler chain. Request validation and OpenAPI document definitions use the validator and describeRoute middleware provided by hono-openapi (re-exported directly from ubean); the custom defineValidator is no longer used, and defineHandlerMeta no longer accepts the { openAPI: {...} } form of configuration.
Core Design Principles:
- All handlers (including meta, validator, and business logic) are unified in calling form as
defineHandler(h1, h2, ..., finalHandler) - Types are inferred automatically by hono-openapi’s
validatormiddleware: the param/query/json/form/header/cookie types defined byvalidator(target, schema)flow to all subsequent handlers and are accessed viac.req.valid(target) - The
describeRoutemiddleware is used to define OpenAPI document metadata (tags, summary, description, operationId, deprecated, responses) and is automatically collected by hono-openapi defineHandlerMetais a pass-through middleware at runtime ((c, next) => next()), only extracted by AST at build time; it carries ubean-specific metadata (public,cache,rateLimit, and custom extension fields)- Responses use Hono Context methods directly:
c.json(),c.html(),c.text(),c.redirect(),c.header()
// routes/users/[id].ts
import { defineHandler, defineHandlerMeta, defineMiddleware, validator, describeRoute, resolver } from 'ubean/server';
import { z } from 'zod';
// permission middleware example
const requireAdmin = defineMiddleware(async (c, next) => {
if (c.get('user')?.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
await next();
});
const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email() });
const userUpdateSchema = z.object({ name: z.string().optional(), email: z.string().email().optional() });
const idParamSchema = z.object({ id: z.string() });
const includeQuerySchema = z.object({ include: z.string().optional() });
// GET /users/:id — fetch a user
// describeRoute, validator and defineHandlerMeta are passed as part of the handler chain
export const GET = defineHandler(
describeRoute({
tags: ['Users'],
summary: 'Get user by ID',
description: 'Retrieve a single user by their unique ID',
responses: {
200: {
description: 'User found',
content: { 'application/json': { schema: resolver(z.object({ user: userSchema })) } }
},
404: { description: 'User not found' }
}
}),
defineHandlerMeta({
public: false,
cache: { ttl: 60 },
rateLimit: { max: 100, window: 60 }
}),
validator('param', idParamSchema),
validator('query', includeQuerySchema),
async c => {
// ✅ type inference: c.req.valid('param') -> { id: string }
// ✅ type inference: c.req.valid('query') -> { include?: string }
const { id } = c.req.valid('param');
const { include } = c.req.valid('query');
const user = await db.select().from(users).where(eq(users.id, id)).get();
if (!user) return c.json({ error: 'User not found' }, 404);
return c.json({ user });
}
);
// PATCH /users/:id — update a user (admin required)
export const PATCH = defineHandler(
describeRoute({
tags: ['Users'],
summary: 'Update user',
responses: {
200: {
description: 'Updated',
content: { 'application/json': { schema: resolver(z.object({ success: z.boolean() })) } }
}
}
}),
defineHandlerMeta({ public: false }),
validator('param', idParamSchema),
validator('json', userUpdateSchema),
async c => {
// ✅ c.req.valid('param') → { id: string }
// ✅ c.req.valid('json') → { name?: string; email?: string }
const { id } = c.req.valid('param');
const body = c.req.valid('json');
await db.update(users).set(body).where(eq(users.id, id));
return c.json({ success: true });
}
);
// DELETE /users/:id — delete a user (admin + middleware chain + type inference)
export const DELETE = defineHandler(
describeRoute({
tags: ['Users'],
summary: 'Delete user',
responses: {
200: {
description: 'Deleted',
content: { 'application/json': { schema: resolver(z.object({ success: z.boolean() })) } }
}
}
}),
defineHandlerMeta({}),
validator('param', idParamSchema),
requireAdmin, // custom middleware runs after validator, so it can read c.req.valid('param')
async c => {
// ✅ requireAdmin can also use c.req.valid('param') to get the id
const { id } = c.req.valid('param');
await db.delete(users).where(eq(users.id, id));
return c.json({ success: true });
}
);Design Note:
describeRoute,validator, anddefineHandlerMetaare flexible in placement — they are typically placed at the front of the chain (describeRoutefor OpenAPI collection,validatorto fail fast on validation errors,defineHandlerMetafor AST extraction), but they can also be placed after specific middleware. At runtime, execution follows chain order, and types accumulate automatically from left to right via hono-openapi’s validator middleware.
File-Level Shared meta (optional export const meta)
When multiple methods in the same file share the same ubean-specific meta (such as public, cache, rateLimit, and custom extension fields), you can define file-level defaults via a top-level export const meta. Fields with the same name in defineHandlerMeta deeply override the file-level values:
// routes/users/[id].ts
import { defineHandler, defineHandlerMeta } from 'ubean/server';
export const meta = {
cache: { ttl: 60 }
};
// GET inherits the file-level cache; defineHandlerMeta adds per-method fields
export const GET = defineHandler(
describeRoute({ tags: ['Users'], summary: 'Get user', responses: { 200: { description: 'OK' } } }),
defineHandlerMeta({ public: true }),
async c => {
/* ... */
}
);Note: The file-level
metaonly includes ubean-specific metadata fields, not OpenAPI document definitions. OpenAPI metadata is defined per handler chain via thedescribeRoutemiddleware.
Public Route Example (no auth required)
// routes/auth/login.ts
import { defineHandler, defineHandlerMeta, validator, describeRoute, resolver } from 'ubean/server';
import { z } from 'zod';
const loginSchema = z.object({ email: z.string().email(), password: z.string().min(6) });
const tokenResponseSchema = z.object({ token: z.string() });
export const POST = defineHandler(
describeRoute({
tags: ['Auth'],
summary: 'User login',
responses: {
200: { description: 'Success', content: { 'application/json': { schema: resolver(tokenResponseSchema) } } }
}
}),
defineHandlerMeta({ public: true }), // auth middleware skips this route
validator('json', loginSchema),
async c => {
const { email, password } = c.req.valid('json');
return c.json({ token: '...' });
}
);defineHandler and hono-openapi Integration
ubean reuses the hono-openapi ecosystem. validator, describeRoute, and resolver are re-exported from hono-openapi, and type inference is performed automatically by hono-openapi through Hono’s middleware chain mechanism:
// imported from ubean (re-exported from hono-openapi)
import { validator, describeRoute, resolver } from 'ubean/server';
// validator: validates the given target; read it afterwards via c.req.valid(target)
// target: 'json' | 'form' | 'query' | 'param' | 'header' | 'cookie'
function validator<T extends Target, S extends StandardSchemaV1>(target: T, schema: S): MiddlewareHandler;
// describeRoute: defines OpenAPI operation metadata
function describeRoute(route: {
tags?: string[];
summary?: string;
description?: string;
operationId?: string;
deprecated?: boolean;
responses?: Record<
string,
{ description: string; content?: Record<string, { schema: ReturnType<typeof resolver> }> }
>;
}): MiddlewareHandler;
// resolver: wraps a Standard Schema for OpenAPI responses definitions
function resolver<S extends StandardSchemaV1>(schema: S): { schema: S };Key Type Inference Points:
- After the middleware returned by
validator('json', userSchema)is processed by hono-openapi, thecparameter of subsequent handlers automatically receives the complete type ofc.req.valid('json') - Types from multiple
validatorcalls are merged via Hono’s middleware chain Input type intersection, achieving type accumulation - Custom middleware that needs to access validated data should be wrapped with
defineMiddlewareto keep the type chain unbroken describeRoutedoes not affect runtime types; it is only collected during OpenAPI document generation- ubean-specific metadata in
defineHandlerMetais accessed at runtime viac.route.metaand extracted at build time via AST
Meta Merge Priority: meta defined in defineHandlerMeta > file-level export const meta > global defaults. The merge strategy is deep merge (arrays are replaced, not concatenated). At build time, AST scanning extracts meta from the first defineHandlerMeta() call in the chain.
RouteMeta Type Design
defineHandlerMeta is only for ubean-specific metadata and no longer includes OpenAPI fields:
// src/types/handler.ts
export interface RouteMeta {
/**
* Whether this is a public route (auth middleware skips authentication)
* @default false
*/
public?: boolean;
/**
* Cache config
*/
cache?: {
ttl?: number;
swr?: boolean;
};
/**
* Rate limit config
*/
rateLimit?: {
max: number;
window: number;
};
/**
* Whether to disable this route (skipped at registration during build)
*/
disabled?: boolean;
/**
* Lets users add custom meta via TypeScript module augmentation
*/
[key: string]: unknown;
}Accessing Route Meta in Middleware
In global middleware, you can access the meta of the currently matched route method (with file-level defaults already merged) via c.route.meta:
// middleware/02.auth.ts
import { defineMiddleware } from 'ubean/server';
export default defineMiddleware(async (c, next) => {
const meta = c.route.meta;
// public route: skip authentication
if (meta?.public) {
await next();
return;
}
// validate the token
const token = c.req.header('Authorization');
if (!token) {
return c.json({ error: 'Unauthorized' }, 401);
}
// validate and inject user info
const user = await verifyToken(token);
c.set('user', user);
await next();
});OpenAPI Auto-Generation
- At build time, OpenAPI Operation definitions are automatically collected from
describeRoutemiddleware via hono-openapi’s mechanism - The Standard Schema used by
validatoris the only inferable source for request parameters; response bodies declare schemas usingresolver(schema)withindescribeRoute’sresponses - AST scanning only needs to extract ubean-specific metadata from
defineHandlerMeta(public/cache/rateLimit/custom fields); OpenAPI documents are handled automatically by hono-openapi at runtime - Automatically generates an OpenAPI 3.1 spec JSON, with the endpoint defaulting to
/_openapi.json - Automatically infers route parameters (
:param→{param},**→ catch-all) - Automatically maps method names to OpenAPI operations (GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD)
- Automatically categorizes tags by path prefix:
/api/**→ “API Routes”,/_*→ “Internal” - Built-in Scalar UI (default
/_scalar) and Swagger UI support - Production mode supports both
runtime(generated at runtime) andprerender(pre-rendered at build time) strategies
Request Dispatching and HTTP Contract
API routes and Pages routes are driven by the same normalized route manifest. Duplicate registrations of the same method and path are currently silently overridden by the last registration (conflict reporting is a spec goal, not yet implemented).
| Scenario | Specified Behavior |
|---|---|
| API route hit | Dispatched by method to named exports in routes/; API routes take precedence over Pages routes at the same path |
| Pages route hit | GET/HEAD execute SSR or the client page protocol; page actions only accept their explicitly declared methods |
| Path exists but method not supported | Spec target: 405 + Allow. Current implementation falls back to the 404 handler (405/Allow not yet wired) |
| Path does not exist | Returns 404; in Pages mode, can be rendered by error.404.vue |
| URL normalization | Unified decoding rules, trailing slash policy, and catch-all parameter encoding; redirects only occur when explicitly configured |
Page actions, form enhancements, and client navigation share a single protocol: requests carry the framework version and a page navigation identifier; the server returns page data, redirects, or field errors in the negotiated format; plain form requests that do not satisfy the protocol still return standard HTML/HTTP responses. All Pages actions that change server-side state must have configurable CSRF protection.
Pages Routing Design
The page routing conventions,
definePagemacro, layouts, reuse routes, route groups, loaders/actions, special pages (404/loading/error), navigation guards, and route rules are documented in the user-facing guide: Pages and Routing and Data Loaders / Actions. This architecture doc retains only the API routing internals above.