@hoajs/json
Format route responses into a consistent JSON envelope for success and error cases in Hoa.
- Builds a success body using the success schema.
- Catches errors, optionally merges error headers, and builds a failure body using the fail schema.
- Skips building a body for HEAD and OPTIONS requests.
Quick Start
import { Hoa } from 'hoa'
import { json } from '@hoajs/json'
const app = new Hoa()
app.use(json())
app.use(async (ctx, next) => {
if (ctx.req.pathname === '/') {
ctx.res.body = 'Hello, Hoa!'
}
if (ctx.req.pathname === '/error') {
ctx.throw(400, 'Bad request')
}
})- Request to
/:- Response status: 200
- Response JSON body:
{ code: 200, data: "Hello, Hoa!" }
- Request to
/error:- Response status: 400
- Response JSON body:
{ code: 400, message: "Bad request" }
Options
expose:
boolean- When
true, exposes the original error message globally in the fail body. Can be overridden per-error by settingerror.exposeon the thrown error. - Default:
false.
- When
status:
number | ((ctx: HoaContext, error?: Error) => number | Promise<number>)- Status schema or a fixed status code. If a function, it is called as
(ctx, error?)and may be async. - Default: for success, uses
ctx.res.status; for failure, useserror.status || error.statusCode || 500.
- Status schema or a fixed status code. If a function, it is called as
success:
Record<string, ((ctx: HoaContext) => any | Promise<any>) | any>- Keys and resolvers used to compose the success JSON body; values may be literals or async functions.
- Default:
{ code: ctx.res.status, data: ctx.res.body || null }.
fail:
Record<string, ((ctx: HoaContext, error: Error) => any | Promise<any>) | any>- Keys and resolvers used to compose the error JSON body; values may be literals or async functions.
- Default:
{ code: error.status || error.statusCode || 500, message: (error.expose ?? options.expose) ? error.message : statusTextMapping[error.status || error.statusCode || 500] }.
Examples
Force success status to 200
app.use(json({ status: 200 }))
// Route sets custom status; wrapper still responds with 200
app.use(async (ctx, next) => {
if (ctx.req.pathname === '/') {
ctx.res.status = 201
ctx.res.body = 'Hello, Hoa!'
return
}
await next()
})Response status: 200, body:
{ "code": 201, "data": "Hello, Hoa!" }Custom success schema
app.use(json({
success: {
code: () => 204,
data: () => 'No content'
}
}))Response status: 200, body:
{ "code": 204, "data": "No content" }Custom fail schema
app.use(json({
fail: {
code: () => 410,
data: () => 'Gone'
}
}))
app.use(async (ctx, next) => {
if (ctx.req.pathname === '/error') {
ctx.throw(400, 'Bad request')
}
await next()
})Response status: 400, body:
{ "code": 410, "data": "Gone" }expose option
options.expose controls whether error messages from plain Error objects (where error.expose is not set) are included in the fail body. When false (the default), the HTTP status text is used instead.
Note:
ctx.throw()creates anHttpErrorwhich auto-setserror.exposetotruefor 4xx errors andfalsefor 5xx errors. Sinceerror.exposeis always defined onHttpError,options.exposedoes not affect those — it only applies to plainErrorobjects whereerror.exposeisundefined.
app.use(json({ expose: true }))
app.use(async (ctx) => {
if (ctx.req.pathname === '/error') {
throw new Error('something went wrong internally')
}
})- With
expose: true→{ "code": 500, "message": "something went wrong internally" } - With
expose: false(default) →{ "code": 500, "message": "Internal Server Error" }
For HttpError (from ctx.throw), error.expose is always set and takes precedence via error.expose ?? options.expose:
app.use(json({ expose: false }))
app.use(async (ctx) => {
// 4xx: HttpError auto-sets expose=true → message always shown regardless of options.expose
ctx.throw(400, 'Bad request')
// 5xx: HttpError auto-sets expose=false → message hidden regardless of options.expose
ctx.throw(500, 'DB connection failed')
// Explicit per-error override via options object:
ctx.throw(500, { message: 'DB connection failed', expose: true })
})Error headers merge
If the thrown error contains a headers property, those headers are merged into the response.
app.use(async (ctx) => {
if (ctx.req.pathname === '/error-headers') {
ctx.throw(418, { message: "I'm a teapot", headers: { 'x-error-id': 'abc123' } })
}
})Response:
- status: 418
- headers:
{ "x-error-id": "abc123" } - body:
{ "code": 418, "message": "I'm a teapot" }
The middleware automatically merges any error.headers into ctx.res.headers, allowing custom error metadata to propagate to the client.
HEAD/OPTIONS example
The JSON middleware skips building a body for HEAD and OPTIONS requests.
app.use(async (ctx) => {
if (ctx.req.method === 'HEAD' && ctx.req.pathname === '/head') {
ctx.res.status = 204
return
}
if (ctx.req.method === 'OPTIONS' && ctx.req.pathname === '/options') {
ctx.res.set('Allow', 'GET,HEAD,OPTIONS')
ctx.res.status = 204
return
}
})
// HEAD request
const headRes = await app.fetch(new Request('http://localhost/head', { method: 'HEAD' }))
// HTTP status: 204
// Response body: '' (empty) — JSON body is not constructed for HEAD
// OPTIONS request
const optionsRes = await app.fetch(new Request('http://localhost/options', { method: 'OPTIONS' }))
// HTTP status: 204
// Headers: { 'Allow': 'GET,HEAD,OPTIONS' }
// Response body: '' (empty) — JSON body is not constructed for OPTIONSRaw mode
Enable raw response mode to bypass JSON formatting when necessary.
- Success path: When
ctx._rawis truthy, the middleware does nothing — it preservesctx.res.statusandctx.res.bodyas-is. - Error path: When
ctx._rawis truthy and an error is thrown, the middleware rethrows the error. The application's default error handler responds with plain text and mergeserr.headersinto the response.
Example (success):
app.use(json())
app.use(async (ctx) => {
if (ctx.req.pathname === '/raw') {
ctx._raw = true
ctx.res.status = 200
ctx.res.body = { ok: true }
}
})
// Response: status 200, JSON body { "ok": true }Example (error):
app.use(json())
app.use(async (ctx) => {
if (ctx.req.pathname === '/raw-error') {
ctx._raw = true
ctx.throw(418, { message: "I'm a teapot", headers: { 'x-error-id': 'raw123' } })
}
})
// Response: status 418, header 'x-error-id: raw123', plain text body "I'm a teapot"