@routepact/hono
v0.5.0
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: { 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 bodyServer-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
sendEventcall validates the data against theeventsschema and writes adata: ...\n\nSSE frame - The route may be
getorpost— apoststream carries arequestschema, validated before the stream opens, and reaches the handler asbody 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 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 → handlerThat 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 -> 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.
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 |
