@fresho/router
v0.6.2
Published
Type-safe route definitions with auto-generated clients and OpenAPI docs
Maintainers
Readme
@fresho/router
Type-safe routing for Cloudflare Workers, Deno, Bun, and Node.js. Define routes once, get validated handlers, typed clients, and OpenAPI docs.
~2KB gzipped. Zero dependencies.
import { route, router } from "@fresho/router";
const api = router({
health: router({
get: async () => ({ status: "ok" }),
}),
users: router({
// GET /users - list with optional limit
get: route({
query: { limit: "number?" },
handler: async (c) => db.users.list(c.query.limit),
}),
// POST /users - create user
post: route({
body: { name: "string", email: "string" },
handler: async (c) => db.users.create(c.body),
}),
// GET /users/:id - get by id
$id: router({
// Bare function shorthand instead of `route` type
get: async (c) => db.users.get(c.path.id),
}),
}),
});
// Cloudflare Worker / Deno / Bun
export default { fetch: api.handler() };Features
- Type-safe path params —
$idcreates dynamic segments whose names are inferred by handlers and clients - Schema validation — query and body validated at runtime, typed at compile time
- Property-based routing — property names become URL segments
- Typed HTTP client — call your API with full type safety
- Typed local client — test handlers directly without HTTP
- OpenAPI generation — auto-generate docs from your routes
- MCP server — expose routes as tools for AI agents
- Middleware — cors, auth, rate limiting, and more
- Streaming — SSE and JSON lines helpers
- HTTP-compliant — automatic HEAD support for GET routes
- Zero dependencies — just Web APIs
Installation
npm install @fresho/routerbun add @fresho/routerRuntime Support
The full test suite runs in Node.js 22/24 and Bun 1.3.14. CI also deploys the built
package to a local Cloudflare workerd runtime and verifies routing, validation,
middleware, and streaming behavior.
Path Convention
Property names become URL path segments:
router({
api: router({ // /api
users: router({ // /api/users
get: async () => ...,
$id: router({ // /api/users/:id
get: async (c) => c.path.id,
}),
}),
}),
});- Regular properties → static segments (
users→/users) $paramproperties → dynamic segments ($id→/:id)- HTTP methods (
get,post, etc.) → handlers at that path
Schemas
Define query and body schemas using shorthand syntax:
// Primitives
{ name: 'string' } // required string
{ age: 'number' } // required number (coerced from string in query)
{ active: 'boolean' } // required boolean (accepts "true"/"false"/"1"/"0")
// Optional (append ?)
{ name: 'string?' } // optional string
// Arrays
{ tags: 'string[]' } // string array
{ scores: 'number[]' } // number array
// Field descriptions (for OpenAPI and MCP tools)
{ folder: ['string?', 'Mailbox folder, INBOX by default'] }
// Nested objects
{
address: {
street: 'string',
city: 'string',
zip: 'number?'
}
}Types are automatically inferred:
post: route({
body: {
title: "string",
tags: "string[]",
metadata: { priority: "number", draft: "boolean?" },
},
handler: async (c) => {
c.body.title; // string
c.body.tags; // string[]
c.body.metadata; // { priority: number, draft: boolean | undefined }
},
});Typing Best Practices
Schemas (query/body) provide runtime validation AND type inference:
get: route({
query: { limit: "number?" },
handler: async (c) => c.query.limit, // number | undefined
});Context (route.ctx<T>()) provides types only for things schemas don't cover:
interface MyContext {
path: { id: string }; // from $id segment
env: { DB: Database }; // runtime environment
user: { name: string }; // from auth middleware
}
get: route.ctx<MyContext>()({
query: { include: "string?" }, // validated
handler: async (c) => ({
id: c.path.id, // from context
user: c.user.name, // from context
include: c.query.include, // from schema
}),
});Don't add explicit type annotations to handlers — let types flow from schemas and context:
// GOOD: types inferred
handler: async (c) => c.query.limit;
// BAD: redundant annotation
handler: async (c: { query: { limit?: number } }) => c.query.limit;Nested Routers
Compose routers:
const users = router({
get: async () => db.users.list(),
post: route({
body: { name: 'string' },
handler: async (c) => db.users.create(c.body),
}),
$id: router({
get: async (c) => db.users.get(c.path.id),
delete: async (c) => db.users.delete(c.path.id),
}),
});
const api = router({
users,
posts: router({ ... }),
});
// Routes:
// GET /users
// POST /users
// GET /users/:id
// DELETE /users/:idMiddleware
Add middleware to routers:
import { router, route } from "@fresho/router";
import { cors, errorHandler, jwtAuth } from "@fresho/router/middleware";
const api = router(
{
hello: router({
get: async () => ({ message: "world" }),
}),
},
cors(),
errorHandler(),
jwtAuth({ secret: process.env.JWT_SECRET, claims: (p) => ({ user: p.sub }) })
);Built-in middleware: cors, errorHandler, logger, rateLimit, requestId, timeout, basicAuth, bearerAuth, jwtAuth, contentType, cookies.
See Middleware Documentation for detailed usage.
Authentication Utilities
For standalone auth utilities (JWT signing/verification, Basic auth, OAuth), import from @fresho/router/auth:
import { createJwks, jwtSign, jwtVerify, parseBasicAuth } from "@fresho/router/auth";
// Sign a JWT
const token = await jwtSign({ uid: "user-123" }, secret, { expiresIn: "1h" });
// Verify a JWT
const payload = await jwtVerify(token, secret);
// Verify a third-party OAuth/OIDC token against the provider's JWKS
const jwks = createJwks("https://example.auth0.com/.well-known/jwks.json");
const claims = await jwtVerify(accessToken, jwks, {
algorithms: ["RS256"],
issuer: "https://example.auth0.com/",
audience: "https://api.example.com",
});
// Parse Basic auth header
const creds = parseBasicAuth(request.headers.get("Authorization"));Available utilities:
- JWT:
jwtSign,jwtVerify,createJwks(HS*, RS*, PS*, ES256/384) - Basic Auth:
parseBasicAuth,encodeBasicAuth,extractBasicAuthToken - OAuth:
encodeOAuthState,decodeOAuthState,exchangeCode,refreshAccessToken,revokeToken,buildAuthorizationUrl,OAUTH_PROVIDERS
See Authentication Documentation for detailed usage.
HTTP Client
Generate a typed client for your API:
// === Server (api.ts) ===
import { route, router } from "@fresho/router";
export const api = router({
users: router({
get: route({
query: { limit: "number?" },
handler: async (c) => ({ users: [], limit: c.query.limit }),
}),
$id: router({
get: async (c) => ({ id: c.path.id, name: "Alice" }),
}),
}),
});
// === Client ===
import { createHttpClient } from "@fresho/router";
import type { api } from "./api"; // Type-only import!
const client = createHttpClient<typeof api>({
baseUrl: "https://api.example.com",
});
// Direct call for implicit GET
const users = await client.users({ query: { limit: 10 } });
// Path params
const user = await client.users.$id({ path: { id: "123" } });
// user is typed as { id: string, name: string }
// Explicit HTTP methods use $-prefix
await client.users.$get(); // GET /users
await client.users.$post({ body: { name: "Bob" } }); // POST /usersThe $id segment determines the id key in the client's path option, so missing or misspelled
parameter names are caught at compile time.
Routers Built by a Factory
If your router is created inside a factory so its handlers can close over injected dependencies, derive the API type from the factory's return type. The type-only import keeps server code out of the client bundle:
import { createHttpClient } from "@fresho/router";
import type { createServer } from "./server";
type Api = ReturnType<typeof createServer>["api"];
const client = createHttpClient<Api>({
baseUrl: "https://api.example.com",
});HTTP Method Syntax
HTTP methods are prefixed with $ to distinguish them from path navigation:
// $-prefixed = execute HTTP method
client.users.$get(); // GET /users
client.users.$post({}); // POST /users
client.users.$put({}); // PUT /users
client.users.$delete(); // DELETE /users
// No prefix = navigate to path segment
client.api.get.$get(); // GET /api/get (navigate to "get", then execute GET)This allows routes with path segments named after HTTP methods (e.g., /api/get, /resources/delete).
Building URLs Without Fetching
Use $url to derive a URL string from the typed routes without firing a request. It accepts the
same path/query options as a real call, including the parameter names inferred from $id
segments:
// Absolute URL when baseUrl is configured:
client.media.playlist.$url({ query: { channel: "classics", chunks: 1 } });
// → "https://api.example.com/media/playlist?channel=classics&chunks=1"
client.users.$id.$url({ path: { id: "123" } });
// → "https://api.example.com/users/123"
// Relative URL (pathname + search) when no baseUrl is set:
createHttpClient<typeof api>({}).users.$id.$url({ path: { id: "123" } });
// → "/users/123"This keeps route paths as the single source of truth for URLs embedded in response bodies, deep links, or server-side URL generation — cases where raw: true can't help, since Response.url only exists after a request runs.
Local Client
Test handlers directly without HTTP overhead:
import { createLocalClient } from "@fresho/router";
const client = createLocalClient(api);
client.configure({ env: { DB: mockDb } });
const user = await client.users.$id({ path: { id: "123" } });
assert.equal(user.name, "Alice");OpenAPI Generation
Generate OpenAPI 3.0 documentation:
import { generateDocs } from "@fresho/router";
const spec = generateDocs(api, {
title: "My API",
version: "1.0.0",
});
// Serve at /openapi.json
const docs = router({
openapi: router({
get: async () => spec,
}),
});MCP Server
Expose routes as Model Context Protocol tools, so agents can use the same API as your other clients. Requires the optional peer dependency @modelcontextprotocol/sdk:
npm install @modelcontextprotocol/sdkimport { router } from "@fresho/router";
import { mcp } from "@fresho/router/mcp";
const api = router({
users,
billing,
mcp: mcp({ users }, { name: "my-api", version: "1.0.0" }, bearerAuth({ verify })),
});mcp() requires endpoint middleware (usually auth). Pass public: true instead when the endpoint is open, or already guarded by middleware on its parent routers.
Besides name and version, the options take instructions for agents and title, icons ({ src, mimeType?, sizes? }) and websiteUrl, which clients may show in their UI.
- Tools — one per route, named from the method and path (
get_users,post_users,get_users_by_id, ...) unless the route setsoperationId(also used in OpenAPI). Descriptions come fromroute.description. - Arguments — flat: path params, query fields and body fields side by side (
{ id, text }), as JSON Schema derived from the route's schemas. Names must be unique across the three; a clash throws whenmcp()is called. Use[type, description]tuples (e.g.{ folder: ['string?', 'Mailbox folder'] }) inqueryandbodyschemas to document fields for agents and OpenAPI. Array query params are left out, since the router can't parse them from query strings. Without a body schema,post/put/patchtools send any other arguments as the body. - Annotations —
gettools are markedreadOnlyHint,deletetoolsdestructiveHintandidempotentHint, andputtoolsidempotentHint, so clients can decide when to ask for confirmation. Override per route withmcp: { annotations: { ... } }. - Calls — each tool call is dispatched as an HTTP request through the given routers with the caller's headers forwarded. Middleware on the MCP endpoint (passed to
mcp()or on its parent routers) runs once per MCP request, and what it adds to the context (e.g.c.user) reaches handlers; middleware on the given routers and below runs on every call. Middleware on the given routers' own parents elsewhere in your app does not run, so guard the endpoint or the routers themselves. Responses become text content, plusstructuredContentwhen the body is a JSON object; a non-2xx status setsisError, and streaming (SSE, NDJSON) responses are rejected. - Safety — requests with an
Originother than the endpoint's get 403 (as the MCP spec requires against DNS rebinding), so browser-hosted clients on other origins can't connect. Path parameters can't be.or.., and handler exceptions become a genericInternal error. - Transport — stateless Streamable HTTP with JSON responses;
GETandDELETEreturn 405. - Pass a router to expose all of its routes, or a record of routers to mount them under their keys.
OAuth
Put auth middleware on the endpoint so unauthenticated clients get an HTTP 401, and set oauth to advertise your authorization server (RFC 9728):
const oauth = { authorizationServers: ["https://auth.example.com"], scopes: ["users:read"] };
const app = router({
users,
mcp: mcp({ users }, { name: "my-api", version: "1.0.0", oauth }, jwtAuth({ ... })),
// Optional: metadata at the origin root, for clients that probe it before calling the endpoint.
".well-known": oauthProtectedResource(oauth),
});401s from the endpoint gain WWW-Authenticate: Bearer resource_metadata="https://host/mcp/.well-known/oauth-protected-resource", and that URL serves the metadata without the endpoint's middleware (keep auth off the endpoint's parent routers, or the metadata is guarded too). oauthProtectedResource only handles single-segment endpoint paths such as /mcp. Validate that tokens were issued for this server (the aud claim), not just by your authorization server.
Streaming
Server-Sent Events
import { sseResponse } from "@fresho/router";
export interface ItemEvents {
message: { connected: boolean };
update: { count: number };
removed: { id: string };
}
events: router({
get: async () =>
sseResponse<ItemEvents>(async (send, close) => {
send({ data: { connected: true } });
send({ event: "update", data: { count: 1 } });
send({ event: "removed", data: { id: "item-1" } });
close();
}),
});The event map checks event names and their corresponding payloads. The reserved message key types
events that omit the event field. Export the map for consumers, or extract it from a typed client
method:
import type { SSEEventsOf } from "@fresho/router";
type ClientEvents = SSEEventsOf<ReturnType<typeof client.events>>;HTTP and local clients return streaming routes as raw Response objects instead of parsing their
bodies as JSON, so callers can consume response.body as data arrives.
JSON Lines
import { streamJsonLines } from "@fresho/router";
logs: router({
get: async () =>
streamJsonLines(async (send, close) => {
send({ level: "info", message: "Starting..." });
send({ level: "info", message: "Done" });
close();
}),
});Cloudflare Workers
import { route, router } from '@fresho/router';
const api = router({ ... });
export default {
fetch: api.handler(),
};Typed Context
Use route.ctx<T>() for environment bindings and middleware-added properties:
interface AppContext {
env: { DB: D1Database };
user: { id: string }; // from auth middleware
}
const api = router(
{
profile: router({
get: route.ctx<AppContext>()({
handler: async (c) => {
const data = await c.env.DB.prepare("...").all();
return { userId: c.user.id, data };
},
}),
}),
},
jwtAuth({
secret: (c) => c.env.JWT_SECRET,
claims: (p) => ({ user: { id: p.sub } }),
})
);
export default { fetch: api.handler() };HEAD Requests
Per RFC 9110, HEAD requests are automatically handled for any GET route:
users: router({
get: async () => ({ users: await db.getUsers() }),
});
// GET /users → 200 with body
// HEAD /users → 200 with no body (same headers)Define an explicit head handler if you need different behavior:
users: router({
head: async () => new Response(null, { headers: { "X-Count": "100" } }),
get: async () => ({ users: await db.getUsers() }),
});Size
| Usage | Minified | Gzipped | | --------------------------- | -------- | ------- | | Core (routing + validation) | 4.2 KB | 1.9 KB | | + HTTP client | 5.8 KB | 2.4 KB | | + OpenAPI docs | 5.3 KB | 2.3 KB | | + cors, errorHandler | 6.5 KB | 2.7 KB | | + all middleware | 11.6 KB | 4.4 KB |
Tree-shakeable: only pay for what you import.
License
MIT
