@routepact/hono
v0.1.22
Published
Hono adapter for type-safe route pacts - endpoint builders, request/response validation middleware, and router setup
Maintainers
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 honoYou 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: z.object({ id: z.string(), title: z.string() }),
},
create: {
method: "post",
path: "/posts",
request: z.object({ title: z.string() }),
response: 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)
Converts a BuiltRouter into a Hono app instance. Throws "API Router Setup Failed" if any handler is missing or a duplicate route is detected.
Handler return value
Return { status, body } from the handler. When a response schema is defined on the pact, body is validated before sending. Return { status: 204, body: undefined } to send an empty response.
route.handler(() => ({ status: 200, body: { id: "1", title: "Hello" } }))
route.handler(() => ({ status: 204, body: undefined }))Server-Sent Events (SSE)
Mark a pact route with sse: true to create a streaming endpoint. 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 response schema for SSE, letting you send different event shapes in a single stream:
const EventPacts = definePact({
stream: {
method: "get",
path: "/events/:roomId",
sse: true,
response: 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
}),
});- Each
sendEventcall validates the data against theresponseschema and writes adata: ...\n\nSSE frame Content-Type: text/event-streamis 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.signalto 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 (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. |
| `body` | inferred from `request` schema | Parsed and validated request body. `never` 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
### 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.
```ts
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, TReqs>(fn)| Parameter | Default | Description |
| --- | --- | --- |
| TAdds | void | Object added to extensions, or void for guards that add nothing |
| TReqs | {} | Config object with optional requires (shape extensions must have) and/or params (path params that must be present) |
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, { requires: { 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 }, { requires: { userId: string } }>(
({ extensions }) => ({ role: getRole(extensions.userId) })
);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 -> handlerController 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,
thismust be bound explicitly — otherwise it will beundefinedat 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.
**Response validation** — if the pact has a `response` schema, the return value of the handler is validated before it is sent. On failure, a `ResponseValidationError` (status 500) is thrown.
Register an error handler on the Hono app to format validation errors:
```ts
import { RequestValidationError, ResponseValidationError, ValidationError } from "@routepact/hono";
const app = toHonoRouter(built);
app.onError((error, c) => {
if (error instanceof RequestValidationError) {
return c.json({ message: "Invalid request", errors: err.cause }, 400);
}
if (error instanceof ResponseValidationError) {
console.error("Response validation failed:", err.cause);
return c.json({ message: "Internal server error" }, 500);
}
// Or catch any validation error:
if (error instanceof ValidationError) {
return c.json({ message: err.message }, err.status as any);
}
return c.json({ message: "Internal server error" }, 500);
});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, TReqs>(fn) | Creates a reusable typed middleware with Hono framework context; TReqs is a config object with optional requires and params fields |
| ValidationError | Base class — has status and cause: StandardSchemaV1.Issue[] |
| RequestValidationError | Thrown on invalid request body or query (400) |
| 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 |
