@mpen/routekit
v0.1.11
Published
Typed server-side routing utilities for Fetch-compatible runtimes.
Readme
@mpen/routekit
Typed server-side routing utilities for Fetch-compatible runtimes.
routekit is a small router around the platform Request/Response APIs. It matches
URLPattern routes, runs typed middleware, supports schema-backed handlers with Zod or
Valibot, can expose route metadata as OpenAPI, and can generate a typed API client from
the same router definitions.
Installation
bun add @mpen/routekitInstall the schema library you plan to use:
bun add zod
# or
bun add valibot @valibot/to-json-schemaQuick Start
import { Router, ok } from '@mpen/routekit'
const router = new Router()
router.get('/', () => ok({ message: 'Hello World!' }))
router.get('/users/:id', ({ path }) => {
const { id } = path as { id: string }
return ok({ id })
})
export default routerUse the router anywhere a Fetch-compatible handler is accepted:
import router from './router'
Bun.serve({
port: 3000,
fetch: router.fetch,
})You can also exercise a router directly in tests:
const response = await router.fetch(new Request('https://example.com/users/123'))
expect(await response.json()).toEqual({ id: '123' })Routing
Routes can be registered with method helpers:
router.get('/health', () => text('ok'))
router.head('/health', () => noContent())
router.post('/items', async ({ req }) => ok(await req.json()))
router.put('/items/:id', () => text('updated'))
router.patch('/items/:id', () => text('patched'))
router.delete('/items/:id', () => text('deleted'))Or with a full route definition:
import { HttpMethod } from '@mpen/http'
router.add({
name: 'items.detail',
method: HttpMethod.GET,
path: '/items/:id',
accept: 'application/json',
meta: {
openapi: {
summary: 'Fetch an item',
},
},
handler: ({ path }) => ok({ id: (path as { id: string }).id }),
})path may be a string or a URLPattern. Named path parameters are exposed on
ctx.path. When a route name is omitted, routekit derives one from the method and
path so tooling such as API client generation still has a stable name to work with.
Routers can be mounted under a prefix:
const api = new Router()
api.get('/health', () => new Response('ok'))
const app = new Router()
app.mount('/api', api)Handler Results
Handlers may return:
- a
Response - a
RoutekitResponsefrom helpers such asok(),response(),text(), orhtml() - a
string,Uint8Array,Buffer, orReadableStreamfor raw native bodies - a structured object wrapped with
ok()for content negotiation - an async generator that yields typed response directives
For structured responses, return ok(value). When no Content-Type is set, the router
serializes the body using the request's Accept header:
router.get('/profile', () => ok({ name: 'Ada' }))Use text() and html() for represented bodies that should skip negotiation:
router.get('/health', () => text('ok'))Streaming handlers yield explicit directives:
router.get('/events', async function* () {
yield head(HttpStatus.OK, { 'content-type': 'text/plain; charset=utf-8' })
yield chunk('hello ')
yield chunk('world')
})Configuration
Request body parsers, response body serializers, and loggers are configured with fluent
router methods. add... methods append to inherited configuration, while set...
methods replace inherited configuration for that router subtree.
const router = new Router()
.addRequestBodyParser(customRequestBodyParser)
.addResponseBodySerializer(customResponseBodySerializer)
.setLogger(logger)Use setRequestBodyParsers() or setResponseBodySerializers() when a subtree should
have an exact parser or serializer list instead of inheriting from its parent.
Parser and serializer mediaTypes entries may include an Accept-style q
preference. Client Accept quality wins for responses; server q values resolve
ties before registration order.
Middleware
Middleware runs in registration order and can add fields to the request context. The added fields are reflected in handler types.
import type { ContextMiddleware } from '@mpen/routekit'
const auth: ContextMiddleware<{ userId: string }> = (ctx) => {
ctx.userId = 'user-123'
}
const router = new Router().use(auth)
router.get('/me', ({ userId }) => ok({ userId }))router.use(middleware) applies middleware to the router itself, not only to routes
registered after the call. Middleware registered on a router runs for every matching
route on that router, including routes that were added before the middleware was
registered.
Use router.mount() with inline configuration when middleware should apply to a subset
of routes:
const router = new Router()
router.get('/health', () => text('ok'))
router.mount({ prefix: '/admin', middleware: auth }, (admin) => {
admin.get('/users', ({ userId }) => ok({ userId }))
admin.post('/users', ({ userId }) => ok({ createdBy: userId }))
})
router.get('/status', () => ok({ status: 'up' }))In this example, auth runs for /admin/users but not for /health or /status.
Middleware that creates a response declares it with a schema-bound factory. Its response metadata is inherited automatically by every affected route.
import { HttpStatus } from '@mpen/http'
import { response } from '@mpen/routekit'
import { defineZodMiddleware } from '@mpen/routekit/routes'
import { z } from 'zod'
const requireAuth = defineZodMiddleware({
responses: {
[HttpStatus.UNAUTHORIZED]: z.object({ error: z.literal('unauthorized') }),
},
run: (_ctx, { respond }) =>
respond(response({ error: 'unauthorized' }, { status: HttpStatus.UNAUTHORIZED })),
})Encapsulation and Inheritance
mount() composes routers from inline route blocks or existing router instances:
router.mount({ prefix, middleware }, configure)creates a scoped inline router. Use it when routes are declared together and should share extra middleware or config.router.mount(prefix, childRouter)attaches an existing router, optionally under a path prefix. Use it when routes live in another module or should be reusable as their own router.router.mount({ prefix, middleware }, childRouter)wraps an existing router with additional runtime middleware.
Parent middleware runs first, followed by scoped middleware and then child router
middleware. Request body parsers, response body serializers, loggers, and error handlers
inherit through the same router tree. Child routers can append parser/serializer config
or replace it with setRequestBodyParsers() and setResponseBodySerializers().
[!IMPORTANT]
router.use(middleware)is retroactive for that router. If you add a route, create a scoped mount, or mount a child router and later callrouter.use(auth),authstill runs for those earlier entries. To mount a child router without that middleware, keep the parent router middleware-free and apply middleware only to scoped mounts or child routers that need it.
A mounted router still keeps its own TypeScript context boundary. Handlers declared
inside the mounted router are typed from that router's middleware, even though parent
middleware also runs at runtime. Use inline mount({ middleware }, router => { ... })
when handlers should see scoped middleware context in their TypeScript types.
This differs from some other routers:
- Express uses an ordered middleware stack. Middleware registered before a mounted router can wrap it; middleware registered after the mount does not retroactively affect it.
- Hono also runs middleware in registration
order, and route grouping adds
the child router's stored routes to the parent at the time
route()is called. - Fastify is closer to RouteKit's model: child contexts can access parent plugins, hooks, and decorators, while sibling contexts stay isolated.
- Elysia encapsulates
hooks to their own instance by default and exposes explicit
local,scoped, andglobalscope controls.
Middleware can also wrap downstream results:
import { defineMiddleware } from '@mpen/routekit'
router.use(
defineMiddleware({
async run(_ctx, { next, forward }) {
const response = await next()
if (response instanceof Response) {
response.headers.set('x-powered-by', 'routekit')
}
return forward(response)
},
}),
)Built-in middleware is available from @mpen/routekit/middleware:
import { TerminalLogger } from '@mpen/logger'
import {
acceptCtx,
bodyLimit,
cors,
requestIdCtx,
requestLogger,
startTimeCtx,
} from '@mpen/routekit/middleware'
const router = new Router()
.setLogger(new TerminalLogger())
.useRequest(requestIdCtx({ writeHeaderName: 'x-request-id' }))
.useRequest(requestLogger())
router.use([
startTimeCtx(),
acceptCtx(),
bodyLimit({ maxSize: 1024 * 1024 }),
cors({ origin: 'https://app.example.com', credentials: true }),
])Every request context exposes the inherited ctx.logger. Request-boundary middleware runs
for generated responses as well as matched handlers, so requestIdCtx() and
requestLogger() correlate status, timing, and known response body-size output consistently.
It also records user_agent.original. Behind a trusted ingress proxy, opt in to the original
client-address header:
router.useRequest(requestLogger({ trustedClientAddressHeader: 'x-forwarded-for' }))This records the first forwarded address as client.address; only enable it when clients
cannot send that header directly.
rateLimit() supports fixed-window identity, subnet, ASN, country, and endpoint limits.
It can use the default in-memory storage or a custom RateLimitStorage implementation.
Zod Routes
Zod helpers validate request input, infer typed params, and attach JSON Schema metadata
to routes for OpenAPI and client generation.
import { HttpStatus } from '@mpen/http'
import { Router, ok } from '@mpen/routekit'
import { createZodRouteBuilder } from '@mpen/routekit/routes'
import { z } from 'zod'
const router = new Router()
const route = createZodRouteBuilder()
router.post(
'/books/:id',
route({
name: 'books.byId',
schema: {
request: {
path: z.object({ id: z.coerce.number().int() }),
body: z.object({
title: z.string(),
author: z.string(),
}),
},
response: {
body: {
[HttpStatus.OK]: z.object({
id: z.number().int(),
title: z.string(),
author: z.string(),
}),
},
},
},
handler: ({ params }) =>
ok({
id: params.path.id,
title: params.body.title,
author: params.body.author,
}),
}),
)Available Zod APIs:
createZodRouteBuilder(defaults)creates method-helper options or full routes with shared defaults.zodSchemaMiddleware(options)defines an explicit request/response schema boundary.defineZodMiddleware(options)declares and validates terminal responses originated by middleware.
Request validation failures return a 400 JSON response by default. Override
onRequestValidationError together with validationResponses to customize that response.
Response validation can be controlled with validateResponse.
Valibot Routes
Valibot helpers expose the same shape as the Zod helpers:
import { HttpStatus } from '@mpen/http'
import { ok } from '@mpen/routekit'
import { createValibotRouteBuilder } from '@mpen/routekit/routes'
import * as v from 'valibot'
const route = createValibotRouteBuilder()
router.post(
'/books/:id',
route({
name: 'books.byId',
schema: {
request: {
path: v.object({
id: v.pipe(
v.string(),
v.transform((value) => Number(value)),
v.integer(),
),
}),
body: v.object({
title: v.string(),
author: v.string(),
}),
},
response: {
body: {
[HttpStatus.OK]: v.object({
id: v.number(),
title: v.string(),
author: v.string(),
}),
},
},
},
handler: ({ params }) =>
ok({
id: params.path.id,
title: params.body.title,
author: params.body.author,
}),
}),
)Available Valibot APIs:
createValibotRouteBuilder(defaults)valibotSchemaMiddleware(options)defineValibotMiddleware(options)
Problem Responses
Routekit includes first-class support for standard problem responses inspired by RFC 7807 (Problem Details), with practical adjustments optimized for modern TypeScript and API clients (such as distinct success: boolean discriminators and clear nested error code/message structures). Using standard problem envelopes keeps error responses predictable, typed, and structured across your entire API.
Response Helpers
Import standard response helpers from @mpen/routekit/response/problem:
import { HttpStatus } from '@mpen/http'
import {
ok,
created,
problem,
badRequest,
unauthenticated,
permissionDenied,
notFound,
conflict,
sessionExpired,
rateLimited,
} from '@mpen/routekit/response/problem'
// 200 OK standard success envelope
// Returns { success: true, data: { ... } }
router.get('/users/:id', () => ok({ id: 'user_123' }))
// 201 Created standard success envelope
// Returns { success: true, data: { ... } }
router.post('/users', () => created({ id: 'user_123' }))
// 404 Not Found problem details envelope
// Returns { success: false, error: { code: 'not_found', message: 'User not found' } }
router.get('/users/:id', ({ path }) => {
const user = findUser(path.id)
if (!user) {
return notFound('User not found')
}
return ok(user)
})
// Custom problem envelope
// Returns { success: false, error: { code: 'out_of_stock', message: 'Item is sold out', title: 'Sold Out' } }
router.post('/items/:id/buy', () => {
return problem({
status: HttpStatus.CONFLICT,
code: 'out_of_stock',
message: 'Item is sold out',
title: 'Sold Out',
})
})All standard problem helpers (badRequest, unauthenticated, notFound, etc.) accept a human-readable message string as the first argument, or a detailed options object with custom headers and error code overrides.
Router-level Errors
You can automatically map default router-level failures (such as 404 Not Found for unmatched paths, 405 Method Not Allowed, 415 Unsupported Media Type, and uncaught server errors) to standard problem envelopes by installing the problemRootErrors() extension:
import { Router } from '@mpen/routekit'
import { problemRootErrors } from '@mpen/routekit/response/problem'
const router = new Router().install(problemRootErrors())Valibot Integration
Valibot helpers under @mpen/routekit/response/problem/valibot let you define schema boundaries and auto-validate endpoints using standard problem formats.
import { HttpStatus } from '@mpen/http'
import { ok } from '@mpen/routekit/response/problem'
import {
createValibotRouteBuilder,
okSchema,
problemSchema,
} from '@mpen/routekit/response/problem/valibot'
import * as v from 'valibot'
// Create a route builder pre-configured to handle request validation errors.
// It automatically responds with validation-failed problem envelopes on 400 or 422 errors.
const route = createValibotRouteBuilder()
router.post(
'/books',
route({
name: 'books.create',
schema: {
request: {
body: v.object({
title: v.string(),
author: v.string(),
}),
},
response: {
body: {
[HttpStatus.OK]: okSchema(
v.object({
id: v.string(),
title: v.string(),
}),
),
[HttpStatus.UNPROCESSABLE_ENTITY]: problemSchema({
code: v.literal('todo_limit_exceeded'),
}),
},
},
},
handler: ({ body }) => {
return ok({ id: '123', title: body.title })
},
}),
)By default, the route builder created by createValibotRouteBuilder() uses problemValidationErrorHandler to format query, path, and body validation failures:
- Path and query parameters validation failures return
400 Bad Requestwithvalidation_failed:pathorvalidation_failed:querycode. - Request body validation failures return
422 Unprocessable Contentwithvalidation_failed:bodycode and a list of structuredissuesindicating precisely where the failure occurred.
OpenAPI
The openapi() handler reflects the active router's registered routes and schema metadata.
import { openapi } from '@mpen/routekit/handlers'
router.get(
'/openapi.json',
openapi({
info: {
title: 'Example API',
version: '1.0.0',
},
servers: [{ url: 'https://api.example.com' }],
}),
)Route meta.openapi is merged into the generated operation, so route-level summaries,
tags, security, and custom responses can be supplied beside the handler.
Generated API Clients
Expose typed endpoints to your frontend or consumer clients by generating a fully typed API client. The Routekit CLI loads your server's router module, reads router.getRoutes(), and outputs a strongly typed client mapping each route's name, method, path, and JSON Schema metadata.
$ bunx @mpen/routekit --help
Usage: bun run packages/routekit/src/bin/gen-api-client.ts <router-file> [options]
Generate a typed API client from a routekit router module.
Arguments:
router-file Router module that exports a router with getRoutes()
Options:
-o, --output <file> File to write. Prints to stdout when omitted.
-w, --write Write to <router-file>.gen.ts beside the router file.
-p, --pretty Format written output with Prettier.
-f, --format <format> Output format: rk-api-client or ts-query-rk-problem. Defaults to rk-api-client.
--client-name <Name> Generated client class name. Defaults to ApiClient.
--import-type <Type:module> Import a type used by generated schemas. Can be repeated.
--response-type <Type> Generic response wrapper type. Defaults to ApiResponsePromise.
--help Show this help message.Options Overview
<router-file>: Path to the router module file. The module must export a RoutekitRouterinstance asdefault,router, or another named export with agetRoutes()method.-o, --output <file>: File path to write. Prints to stdout when omitted.-w, --write: A convenient shortcut to write the generated code directly to<router-file>.gen.tsbeside the router module.-p, --pretty: Automatically format the output using Prettier (requires Prettier to be installed).-f, --format <format>: Choice of output generator format:rk-api-client(default): Generates a nested, class-based HTTP client mapping properties to URL paths.ts-query-rk-problem: Generates TanStack Query integration options (queryOptions,mutationOptions) tailored for APIs using the standard@mpen/routekit/response/problemenvelopes.
Format: rk-api-client (Default)
This format generates a nested, typed class client:
bunx @mpen/routekit ./src/server/router.ts -w -pUsage:
import { FetchTransport } from '@mpen/routekit/client'
import { ApiClient } from './router.gen'
const client = new ApiClient(
new FetchTransport({
baseUrl: 'https://api.example.com',
headers: () => ({ authorization: `Bearer ${token}` }),
}),
)
// Fully typed path parameters, query params, request body, and response union
const response = await client.books.byId.post({
path: 123,
body: { title: 'Dune', author: 'Frank Herbert' },
})
if (response.ok) {
const book = await response.parseBody()
console.log(book.title)
}Routes with multiple documented response statuses generate a response union narrowed by response.status:
const response = await client.widgets.byId.post(options)
if (response.status === 400) {
const body = await response.parseBody()
console.log(body.message)
}Format: ts-query-rk-problem (TanStack Query + Problem Responses)
This format generates TanStack Query integration helpers tailored for standard @mpen/routekit/response/problem envelopes. Specify -f ts-query-rk-problem when running client generation:
bunx @mpen/routekit ./src/server/router.ts -f ts-query-rk-problem -w -pThe generated file exports createApiQueryHelpers(), which generates a nested helper structure containing queryOptions and mutationOptions:
import { useQuery, useMutation } from '@tanstack/react-query'
import { FetchTransport } from '@mpen/routekit/client'
import { createApiQueryHelpers, isRoutekitProblemError } from './router.gen'
const transport = new FetchTransport({
baseUrl: 'https://api.example.com',
headers: () => ({ authorization: `Bearer ${token}` }),
})
const api = createApiQueryHelpers(transport)
// 1. Querying data with automatically unwrapped successful payloads
function BookDetails({ bookId }: { bookId: string }) {
const { data, error, isLoading } = useQuery(api.books.byId.get({ path: bookId }))
if (isLoading) return <div>Loading...</div>
// When a request returns a non-success problem envelope, error is typed as a RoutekitProblemError
if (error) {
if (isRoutekitProblemError(error)) {
return (
<div>
Error: {error.body.error.message} ({error.body.error.code})
</div>
)
}
return <div>Unknown error occurred</div>
}
// Success data is automatically unwrapped from { success: true, data } envelope
return <h1>{data.title}</h1>
}
// 2. Mutations with typed variables and problem error handling
function CreateBookForm() {
const mutation = useMutation(api.books.create.post())
const handleSubmit = (title: string, author: string) => {
mutation.mutate(
{
body: { title, author },
},
{
onError: (error) => {
if (isRoutekitProblemError(error) && error.status === 422) {
// Access structured validation issues
console.log(error.body.issues)
}
},
},
)
}
// ...
}Exports
@mpen/routekitexportsRouter, response helpers, and core router types.@mpen/routekit/routesexports the Zod and Valibot route helpers.@mpen/routekit/middlewareexports built-in middleware.@mpen/routekit/handlersexportsopenapi().@mpen/routekit/clientexports generated-client transports, response wrappers, body codecs, and URL/header helpers.@mpen/routekit/response/problemexports RFC 7807 problem details response helpers.@mpen/routekit/response/problem/valibotexports Valibot problem schemas and Valibot-specific problem route builders.
Development
From this repository:
bun run --cwd packages/routekit build
bun test packages/routekit
bun run --cwd packages/routekit gen
bun run --cwd packages/routekit gen3The generated example clients live under packages/routekit/examples.
