Response envelope and context helpers
Every JSON response from an @ltv/cwb app uses one envelope, whether it comes from a c.respond* helper, the global error handler or the not-found handler.
The envelope
{
"success": true,
"data": {"id": "1", "name": "Jane"},
"meta": {
"timestamp": 1757829600000,
"requestId": "tz4a98xxat96iws9zmbrgj3a",
"traceId": "optional",
"pagination": {"page": 1, "limit": 10, "total": 42, "totalPages": 5, "hasNext": true, "hasPrev": false}
}
}
{
"success": false,
"error": {
"id": "k3x9m2p8q1w7e4r6t5y0u2i8",
"code": "NOT_FOUND",
"message": "User not found",
"details": {}
},
"meta": {"timestamp": 1757829600000, "requestId": "tz4a98xxat96iws9zmbrgj3a"}
}
| Field | Description |
|---|---|
meta.timestamp | Date.now() when the response was built |
meta.requestId | The request id from the request-id middleware (also in X-Request-Id) |
meta.traceId | c.get('traceId'), if your middleware set it; omitted otherwise |
meta.pagination | Only from c.respondPaginated |
error.id | A CUID2 generated per error; the error handler logs the same id |
error.details | Optional; omitted when undefined |
Helpers
The helpers are methods on Hono's Context, typed through module augmentation (ContextResponseHelpers).
| Helper | Status | Body |
|---|---|---|
c.respond(data, status?) | 200 | success |
c.respondCreated(data) | 201 | success |
c.respondAccepted(data) | 202 | success |
c.respondNoContent() | 204 | empty; keeps headers set earlier |
c.respondPaginated(data, {page, limit, total}, status?) | 200 | success with meta.pagination |
c.respondError(code, message, status?, details?) | 400 | error |
c.respondBadRequest(message?, details?) | 400 | BAD_REQUEST, default message Bad Request |
c.respondUnauthorized(message?) | 401 | UNAUTHORIZED, default Unauthorized |
c.respondForbidden(message?) | 403 | FORBIDDEN, default Forbidden |
c.respondNotFound(message?) | 404 | NOT_FOUND, default Not Found |
c.respondConflict(message?) | 409 | CONFLICT, default Conflict |
c.respondValidationError(message?, details?) | 422 | VALIDATION_ERROR, default Validation Error |
c.respondInternalError(message?) | 500 | INTERNAL_ERROR, default Internal Server Error |
import {contentJson, OpenAPIRoute} from 'chanfana';
import type {Context} from 'hono';
import {z} from 'zod';
import {jsonCreateResponses} from '@ltv/cwb';
const OrderInput = z.object({sku: z.string(), quantity: z.number().int().min(1)});
const Order = OrderInput.extend({id: z.string()});
export class OrderCreate extends OpenAPIRoute {
schema = {
request: {body: contentJson(OrderInput)},
responses: jsonCreateResponses('Order', Order),
};
async handle(c: Context) {
const {body} = await this.getValidatedData<typeof this.schema>();
if (body.sku === 'discontinued') {
return c.respondConflict('This product is no longer sold');
}
c.header('Location', '/v1/orders/ord_1');
return c.respondCreated({id: 'ord_1', ...body});
}
}
Status codes
status parameters are typed as Hono's ContentfulStatusCode, so statuses that must not have a body, such as 204 and 304, don't compile. The Workers runtime rejects a body on them. Use c.respondNoContent() for 204:
import type {ContentfulStatusCode} from 'hono/utils/http-status';
const status: ContentfulStatusCode = 207;
return c.respond(results, status);
respondNoContent() uses c.body(null, 204), so X-Request-Id and headers set with c.header() are kept.
Returning errors versus throwing them
c.respondError* sends exactly the code and message you pass; the error handler is not involved, so nothing is logged or masked. Throwing an ApiError goes through the error handler, which logs the error, runs reporters and applies production masking. Prefer throwing for failures you want to observe, and the helpers for expected outcomes such as a failed permission check. See Error handling.
Pagination
c.respondPaginated(data, {page, limit, total}) adds meta.pagination, computed by the exported buildPaginationMeta(page, limit, total):
| Field | Value |
|---|---|
page | 1-based page |
limit | Items per page |
total | Items across all pages |
totalPages | ceil(total / limit), 0 when there are no items |
hasNext | page < totalPages |
hasPrev | page > 1 |
It throws a 400 BAD_REQUEST ApiError when page or limit is not a positive integer, or total is not a non-negative integer (NaN, fractions and Infinity included). Validate and coerce query parameters in the route schema so it never throws on client input:
import {OpenAPIRoute} from 'chanfana';
import type {Context} from 'hono';
import {z} from 'zod';
import {jsonListResponses} from '@ltv/cwb';
const Order = z.object({id: z.string(), sku: z.string()});
export class OrderList extends OpenAPIRoute {
schema = {
request: {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
},
responses: jsonListResponses('Orders', Order), // data: Order[]
};
async handle(c: Context) {
const {query} = await this.getValidatedData<typeof this.schema>();
const {rows, total} = await loadOrders(query.page, query.limit);
return c.respondPaginated(rows, {page: query.page, limit: query.limit, total});
}
}
With total: 42 and limit: 20, page 3 returns {page: 3, limit: 20, total: 42, totalPages: 3, hasNext: false, hasPrev: true}.
Typing responses on the client
ApiResponse<T> is a union discriminated on success: ApiSuccessResponse<T> | ApiErrorResponse, both with an optional meta: ApiResponseMeta. Narrow on success before reading data or error:
import type {ApiResponse} from '@ltv/cwb';
interface User {
id: string;
name: string;
}
const res = await fetch('https://api.example.com/api/v1/users/1');
const body = (await res.json()) as ApiResponse<User>;
if (body.success) {
console.log(body.data.name);
} else {
console.log(body.error.code, body.error.id);
}
Importing types only (import type) keeps the library's runtime, which needs cloudflare:workers, out of non-Worker clients.
Using the helpers without createApp
createApp installs the contextHelpers middleware. On a plain Hono app, add it yourself:
import {Hono} from 'hono';
import {contextHelpers} from '@ltv/cwb';
const app = new Hono();
app.use('*', contextHelpers);
app.get('/ping', (c) => c.respond({pong: true}));
Without contextHelpers, c.respond still type-checks (the augmentation applies to every Context) but is undefined at runtime.
To document these envelopes in OpenAPI, use the matching schema helpers.