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

@routepact/hono

v0.5.0

Published

Hono adapter for type-safe route pacts - endpoint builders, request/response validation middleware, and router setup

Readme

@routepact/hono

Hono adapter for @routepact/core pacts. Provides a fluent router builder, automatic request/response validation, and support for both typed and native Hono middleware.

Installation

npm install @routepact/hono @routepact/core hono

You also need a schema library that implements the Standard Schema interface (e.g. Zod, Valibot, ArkType) for defining your pacts.

npm install zod    # or valibot, arktype, etc.

Core concepts

createRouter(pact)

Creates a RouterBuilder for the given pact. Chain .use(), .useNative(), and .routes() to configure it, then pass the result to toHonoRouter.

import { createRouter, toHonoRouter } from "@routepact/hono";
import { definePact } from "@routepact/core";
import { z } from "zod";

const PostPacts = definePact({
  getById: {
    method: "get",
    path: "/posts/:id",
    response: { 200: z.object({ id: z.string(), title: z.string() }) },
  },
  create: {
    method: "post",
    path: "/posts",
    request: z.object({ title: z.string() }),
    response: { 201: z.object({ id: z.string(), title: z.string() }) },
  },
});

const built = createRouter(PostPacts).routes({
  getById: (route) =>
    route.handler(({ params }) => ({
      status: 200,
      body: { id: params.id, title: "Hello" },
    })),
  create: (route) =>
    route.handler(({ body }) => ({
      status: 201,
      body: { id: "1", title: body.title },
    })),
});

export default toHonoRouter(built);

toHonoRouter(built, options?)

Converts a BuiltRouter into a Hono app instance. The pact is validated here, so this throws "API Router Setup Failed" at boot — before the server accepts traffic — if a handler is missing, a route is duplicated, or the pact itself is malformed. The error lists every problem found, not just the first.

options takes onStreamError, which is required when the pact has SSE routes. See SSE handler failures.

Handler return value

Return { status, body } from the handler. status must be one of the codes declared in the pact's response map, and body is typed to — and validated against — that status's schema. The return type is a discriminated union over the declared statuses, so TypeScript checks that each body matches its status. For a status declared as null (no body), return just { status }.

// response: { 200: Post, 404: z.object({ error: z.string() }), 204: null }
route.handler(({ params }) =>
  exists(params.id)
    ? { status: 200, body: { id: params.id, title: "Hello" } }
    : { status: 404, body: { error: "not found" } },
)
route.handler(() => ({ status: 204 })) // 204 declared as null — no body

Server-Sent Events (SSE)

Mark a pact route with sse: true to create a streaming endpoint. SSE routes declare an events schema (not response). The handler receives a sendEvent function instead of returning { status, body } — call it one or more times to push typed events to the client. A discriminated union is the natural events schema, letting you send different event shapes in a single stream:

const EventPacts = definePact({
  stream: {
    method: "get",
    path: "/events/:roomId",
    sse: true,
    events: z.discriminatedUnion("type", [
      z.object({ type: z.literal("message"), text: z.string() }),
      z.object({ type: z.literal("ping"), timestamp: z.number() }),
    ]),
  },
});

const built = createRouter(EventPacts).routes({
  stream: (route) =>
    route.handler(async ({ params, sendEvent }) => {
      await sendEvent({ type: "message", text: `Hello from ${params.roomId}` });
      await sendEvent({ type: "ping", timestamp: Date.now() });
      // handler returns void — stream closes when the function resolves
    }),
});

export default toHonoRouter(built, {
  // Required for SSE routes — see "SSE handler failures" below.
  onStreamError: (error) => logger.error({ error }, "SSE stream failed"),
});
  • Each sendEvent call validates the data against the events schema and writes a data: ...\n\n SSE frame
  • The route may be get or post — a post stream carries a request schema, validated before the stream opens, and reaches the handler as body
  • Content-Type: text/event-stream is set automatically
  • Middleware (router-level and route-level) runs normally before the handler
  • The handler return type is void — do not return { status, body } for SSE routes
  • The connection closes when the handler function returns. For a long-lived stream, keep the handler alive with a loop. Use c.req.raw.signal to detect client disconnect:
route.handler(async ({ sendEvent, c }) => {
  const signal = c.req.raw.signal;

  while (!signal.aborted) {
    await sendEvent({ type: "ping", timestamp: Date.now() });
    await new Promise(r => setTimeout(r, 30_000));
  }
})

Handler context

| Property | Type | Description | | ------------ | ----------------------------- | ---------------------------------------------------------------------------------- | | params | inferred from path string | Path parameters as matched by the router, percent-decoded (e.g. { id: string } for /posts/:id). {} if no params. | | query | inferred from query schema | Validated query parameters. {} if the pact has no query schema — undeclared params are not passed through. | | body | inferred from request schema | Parsed and validated request body. undefined if the pact has no request schema. | | extensions | merged middleware returns | Typed object with all additions returned by upstream middleware. {} if none. | | c | Context | Hono Context — use for headers, cookies, raw request/response, etc. |


Middleware

Middleware and validation order

The two registration sites run on opposite sides of validation:

router middleware  →  validation  →  route middleware  →  handler

That split is not arbitrary — it follows what each one can see. Router middleware is typed against any route in the pact, so its body is undefined and its query is {}; it cannot depend on validated input, so it is safe to run first. Route middleware is typed against its own route's schemas and may require a validated body, so it runs after.

The practical consequence: a guard registered with createRouter(pact).use(...) rejects a request before the schema engine runs. Put authentication there and an anonymous caller never reaches your schemas — they get your 401 rather than a 400 describing a body they were never allowed to send.

createRouter(PostPacts)
  .use(authenticate)   // runs first; rejects before validation
  .routes({ ... });

Route-level middleware runs after validation, which is the right place for authorization — by then the caller is authenticated, so a validation error reveals nothing they could not already see. If some routes need a guard and others do not, group them into separate routers rather than reaching for route-level auth.

Router middleware also runs for requests that later fail validation, which is usually what you want for logging, metrics and rate limiting.

Router-level middleware (.use())

Runs for every route in the router. Can return an object to add typed properties to extensions in downstream middleware and handlers.

const built = createRouter(PostPacts)
  .use(({ c }) => ({ userId: c.req.header("x-user-id") ?? "" }))
  .routes({
    getById: (route) =>
      route.handler(({ extensions }) => ({
        status: 200,
        body: { id: "1", title: `Viewed by ${extensions.userId}` },
      })),
    // ...
  });

If the middleware doesn't need to add anything to extensions, return void (or nothing):

createRouter(PostPacts).use(() => {
  console.log("request received");
});

Route-level middleware (.use())

Runs only for a specific route. Same signature as router-level middleware, but has access to the route's params, query, and body in addition to extensions.

route
  .use(({ params }) => ({ capturedId: params.id }))
  .handler(({ extensions }) => ({
    status: 200,
    body: { id: extensions.capturedId, title: "Hello" },
  }))

defineMiddleware

Helper for defining reusable typed middleware. Pre-typed with the Hono framework context so you get autocomplete on c.

import { defineMiddleware } from "@routepact/hono";

// No requirements — everything inferred from the function body
const withAuth = defineMiddleware(({ c }) => {
  const userId = c.req.header("x-user-id");
  if (!userId) throw new Error("Unauthorized");
  return { userId };
});

// Use at router or route level
createRouter(PostPacts)
  .use(withAuth)
  .routes({ ... });

// Or on a specific route
route.use(withAuth).handler(({ extensions }) => {
  // extensions.userId is typed as string
});

Middleware defined with defineMiddleware can be shared across routers and routes.

Declaring requirements

Use type parameters to declare what the middleware requires and what it adds:

defineMiddleware<TAdds, TRequirements>(fn)

| Parameter | Default | Description | | --- | --- | --- | | TAdds | void | Object added to extensions, or void for guards that add nothing | | TRequirements | {} | Config object with optional extensions (shape extensions must have), params (path params that must be present), query (shape the query must have), and/or body (shape the request body must have) |

Guard that requires a prior extension and a specific path param:

type User = { id: string; role: string };

// Requires extensions.user (set by withAuth) and params.spaceId (from /:spaceId route)
const spaceGuard = defineMiddleware<void, { extensions: { user: User }; params: { spaceId: string } }>(
  ({ extensions, params }) => {
    if (!canAccess(extensions.user, params.spaceId)) {
      throw new Error("Forbidden");
    }
  }
);

createRouter(SpacePacts).routes({
  getSpace: (route) =>
    route
      .use(withAuth)    // adds extensions.user
      .use(spaceGuard)  // TypeScript enforces: user in extensions ✓, :spaceId in path ✓
      .handler(...)
});

Middleware that adds to extensions and requires a prior extension:

// Adds extensions.role, but requires extensions.userId to already be present
const withRole = defineMiddleware<{ role: string }, { extensions: { userId: string } }>(
  ({ extensions }) => ({ role: getRole(extensions.userId) })
);

Middleware that reads the request body:

// Reads body.orgId — only usable on routes whose request schema includes orgId
const withOrg = defineMiddleware<{ org: Org }, { body: { orgId: string } }>(
  ({ body }) => ({ org: getOrg(body.orgId) })
);

createRouter(OrgPacts).routes({
  create: (route) =>
    route
      .use(withOrg)   // TypeScript enforces: request schema includes orgId ✓
      .handler(({ extensions }) => ({
        status: 201,
        body: { id: extensions.org.id },
      })),
});

TypeScript enforces requirements at the call site — using a middleware with unmet requirements is a compile error.

Native Hono middleware (.useNative())

Use standard Hono MiddlewareHandler functions directly. Native middleware runs in registration order alongside internal middleware and supports onion-style execution (code after await next() runs after the handler).

import { compress } from "hono/compress";

// Router level
createRouter(PostPacts)
  .useNative(compress())
  .routes({ ... });

// Route level — with onion execution
route
  .useNative(async (c, next) => {
    console.log("before handler");
    await next();
    console.log("after handler");
  })
  .handler(() => ({ status: 200, body: { id: "1", title: "Hello" } }))

Middleware execution order

Middleware runs in registration order. Router-level middleware (both internal and native) always runs before route-level middleware. Native middleware supports onion-style execution.

createRouter(PostPacts)
  .useNative(async (_c, next) => { calls.push("router-native"); await next(); })
  .routes({
    getById: (route) =>
      route
        .use(() => calls.push("route-internal"))
        .useNative(async (_c, next) => { calls.push("route-native"); await next(); })
        .handler(() => { calls.push("handler"); return { status: 200, body: { id: "1", title: "Hi" } }; }),
  });

// Order: router-native -> route-internal -> route-native -> handler

Controller pattern

HonoHandlerContext is a convenience type that combines the route's inferred types with the Hono framework context. Use it to type handler methods defined outside the inline builder chain — e.g. in a controller class.

import { createRouter, toHonoRouter, HonoHandlerContext } from "@routepact/hono";

type AuthExtensions = { userId: string };

class PostController {
  getById(ctx: HonoHandlerContext<typeof PostPacts["getById"], AuthExtensions>) {
    return { status: 200, body: { id: ctx.params.id, title: "Hello" } };
  }
}

const controller = new PostController();

const built = createRouter(PostPacts)
  .use(({ c }): AuthExtensions => ({ userId: c.req.header("x-user-id") ?? "" }))
  .routes({
    getById: (route) => route.handler(controller.getById.bind(controller)),
    // ...
  });

Note: When passing a class method as a handler, this must be bound explicitly — otherwise it will be undefined at call time. Use .bind(controller) or wrap it in an arrow function:

// bind
route.handler(controller.getById.bind(controller))
// arrow wrapper
route.handler((ctx) => controller.getById(ctx))

The second type parameter (TExtensions) defaults to Record<never, never> and can be omitted when no middleware adds extensions.


Validation

Request and response validation is applied automatically when a pact has the corresponding schemas.

Request validation — if the pact has a request schema (for POST/PATCH/PUT) or a query schema, the incoming data is validated before the handler runs. On failure, a RequestValidationError (status 400) is thrown.

JSON request bodies — a route that declares a request schema only accepts application/json (including charset parameters and +json suffixes such as application/vnd.api+json). Anything else throws an UnsupportedMediaTypeError (status 415) before the body is read.

This is a security boundary, not a formatting preference. text/plain, application/x-www-form-urlencoded and multipart/form-data are CORS-safelisted: a browser sends them cross-origin without a preflight. Accepting a JSON payload under one of those content types would let any origin drive a state-changing request against a cookie-authenticated API. Requiring application/json — which is not safelisted — puts every cross-origin write behind a preflight your CORS config controls.

Note this covers request bodies only. A GET or DELETE route with side effects needs no body and is still reachable cross-origin, so cookie-authenticated APIs should also set SameSite cookies or use a CSRF token.

Array query params — a query string carries no arity, so the pact decides it. Declare query: z.object({ tags: z.array(z.string()) }) and ?tags=a&tags=b arrives as ["a", "b"], while a lone ?tags=a still arrives as ["a"]. Scalar params declared alongside arrays keep their single value.

Response validation — the handler's body is validated against the schema for the status it returned. A status mapped to null (or returned with no body) skips validation. On failure, a ResponseValidationError (status 500) is thrown.

SSE handler failures — once the first event is written the headers are on the wire, so a later failure cannot become an error status and never reaches app.onError. Routepact closes the stream and passes the error to onStreamError. This is the only place these errors surface, so wire it to your logger:

const app = toHonoRouter(built, {
  onStreamError: (error) => logger.error({ error }, "SSE stream failed"),
});

This is required for any router with SSE routes — routepact will not log on your behalf, and silently dropping these errors is how they get lost in production. Pass () => {} to discard them deliberately; the router refuses to build otherwise, so you find out at boot rather than at 3am. The client sees the events sent before the failure and then a cut stream, which @routepact/client reports to the subscription's onError — a bare EventSource instead reconnects on its own, so a handler that fails deterministically will be retried.

Register an error handler on the Hono app to format validation errors:

import { RequestValidationError, ResponseValidationError, ValidationError } from "@routepact/hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";

const app = toHonoRouter(built);

app.onError((error, c) => {
  if (error instanceof RequestValidationError) {
    return c.json({ message: "Invalid request" }, 400);
  }
  if (error instanceof ResponseValidationError) {
    console.error("Response validation failed:", error.cause);
    return c.json({ message: "Internal server error" }, 500);
  }
  // Or catch any validation error — `status` carries the right code, including
  // 415 for a body that was not sent as JSON.
  if (error instanceof ValidationError) {
    return c.json({ message: error.message }, error.status as ContentfulStatusCode);
  }
  return c.json({ message: "Internal server error" }, 500);
});

ValidationError.cause holds the underlying Standard Schema issues.

This handler does not see failures inside an SSE stream — by the time one happens the response has already started. Those go to onStreamError.


Duplicate route detection

toHonoRouter throws at startup if two routes in a pact share the same HTTP method and path. Same path with different methods is allowed.


Type reference

| Export | Description | | ------------------------- | ------------------------------------------------------------------------- | | createRouter(pact) | Creates a RouterBuilder typed for Hono | | toHonoRouter(built) | Converts a BuiltRouter into a Hono app instance | | defineMiddleware<TAdds, TRequirements>(fn) | Creates a reusable typed middleware with Hono framework context; TRequirements is a config object with optional extensions, params, query, and body fields | | ValidationError | Base class — has status and cause: StandardSchemaV1.Issue[] | | RequestValidationError | Thrown on invalid request body or query (400) | | UnsupportedMediaTypeError | Thrown when a declared request body is not sent as JSON (415) | | ResponseValidationError | Thrown on invalid response body (500) | | HonoFrameworkContext | { c: Context } — the Hono context passed to all middleware and handlers | | HonoHandlerContext<TRoute, TExtensions> | Handler context type for a given pact route and middleware extensions — use to type controller methods |