npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/routekit

Install the schema library you plan to use:

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

Quick 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 router

Use 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 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:

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 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 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, and global scope 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 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.

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

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

Usage:

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 -p

The 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/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:

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.