# @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

```bash
bun add @mpen/routekit
```

Install the schema library you plan to use:

```bash
bun add zod
# or
bun add valibot @valibot/to-json-schema
```

## Quick Start

```ts
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 router
```

Use the router anywhere a Fetch-compatible handler is accepted:

```ts
import router from './router'

Bun.serve({
    port: 3000,
    fetch: router.fetch,
})
```

You can also exercise a router directly in tests:

```ts
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:

```ts
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:

```ts
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:

```ts
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 `RoutekitResponse` from helpers such as `ok()`, `response()`, `text()`, or `html()`
- a `string`, `Uint8Array`, `Buffer`, or `ReadableStream` for 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:

```ts
router.get('/profile', () => ok({ name: 'Ada' }))
```

Use `text()` and `html()` for represented bodies that should skip negotiation:

```ts
router.get('/health', () => text('ok'))
```

Streaming handlers yield explicit directives:

```ts
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.

```ts
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.

```ts
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:

```ts
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.

```ts
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 call `router.use(auth)`, `auth` still 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](https://expressjs.com/en/guide/using-middleware.html) 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](https://hono.dev/docs/guides/middleware) also runs middleware in registration
  order, and [route grouping](https://hono.dev/docs/api/routing#grouping-ordering) adds
  the child router's stored routes to the parent at the time `route()` is called.
- [Fastify](https://fastify.dev/docs/latest/Reference/Encapsulation/) is closer to
  RouteKit's model: child contexts can access parent plugins, hooks, and decorators,
  while sibling contexts stay isolated.
- [Elysia](https://elysiajs.com/tutorial/getting-started/encapsulation/) encapsulates
  hooks to their own instance by default and exposes explicit `local`, `scoped`, and
  `global` scope controls.

Middleware can also wrap downstream results:

```ts
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`:

```ts
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:

```ts
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.

```ts
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:

```ts
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`:

```ts
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:

```ts
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.

```ts
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 Request` with `validation_failed:path` or `validation_failed:query` code.
- Request body validation failures return `422 Unprocessable Content` with `validation_failed:body` code and a list of structured `issues` indicating precisely where the failure occurred.

## OpenAPI

The `openapi()` handler reflects the active router's registered routes and schema metadata.

```ts
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.

```bash
$ 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 Routekit `Router` instance as `default`, `router`, or another named export with a `getRoutes()` 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.ts` beside 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/problem` envelopes.

---

### Format: `rk-api-client` (Default)

This format generates a nested, typed class client:

```bash
bunx @mpen/routekit ./src/server/router.ts -w -p
```

Usage:

```ts
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`:

```ts
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:

```bash
bunx @mpen/routekit ./src/server/router.ts -f ts-query-rk-problem -w -p
```

The generated file exports `createApiQueryHelpers()`, which generates a nested helper structure containing `queryOptions` and `mutationOptions`:

```tsx
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/routekit` exports `Router`, response helpers, and core router types.
- `@mpen/routekit/routes` exports the Zod and Valibot route helpers.
- `@mpen/routekit/middleware` exports built-in middleware.
- `@mpen/routekit/handlers` exports `openapi()`.
- `@mpen/routekit/client` exports generated-client transports, response wrappers, body codecs, and URL/header helpers.
- `@mpen/routekit/response/problem` exports RFC 7807 problem details response helpers.
- `@mpen/routekit/response/problem/valibot` exports Valibot problem schemas and Valibot-specific problem route builders.

## Development

From this repository:

```bash
bun run --cwd packages/routekit build
bun test packages/routekit
bun run --cwd packages/routekit gen
bun run --cwd packages/routekit gen3
```

The generated example clients live under `packages/routekit/examples`.
