English
Responses
Handlers return plain data. On the way out, hazejs wraps it in a consistent envelope, and errors use the same format. You can change the format globally, skip it per route, or step in at the final Response.
The response envelope
By default, a successful response looks like this:
json
{ "code": 0, "message": "ok", "data": { "id": 1, "name": "Ada" } }and a failed one like this:
json
{ "code": 404, "message": "User 9 not found", "data": null }Only JSON-like values are wrapped: plain objects, arrays, numbers, booleans, null / undefined, and objects with toJSON() (models, Date). Strings, Response objects, streams and binary data are sent unchanged.
Formatting happens at the very end. Middleware that calls await next() gets the handler's raw return value and can still change it.
Configuration
Envelope options live under response in config/app.ts:
ts
// config/app.ts
import { defineConfig, env } from '@hazejs/core'
export default defineConfig(() => ({
name: env('APP_NAME', 'my-app'),
response: {
envelope: true, // false: send success data as-is
successCode: 0,
successMessage: 'ok',
},
}))With envelope: false, successful responses contain the data itself, and errors become { status, message, data, errors? }.
Customizing the format
app/response/Formatter.ts is the single place that defines the shape of both success and error bodies. Extend ResponseFormatter and override what you need:
ts
// app/response/Formatter.ts
import type { HttpContext, NormalizedError } from '@hazejs/core'
import { ResponseFormatter } from '@hazejs/core'
export default class Formatter extends ResponseFormatter {
override success(data: unknown, ctx: HttpContext) {
return { success: true, result: data, requestId: ctx.requestId }
}
override error(error: NormalizedError, ctx: HttpContext) {
return {
success: false,
errorCode: error.code,
errorMessage: error.message,
...(error.errors && { fields: error.errors }),
requestId: ctx.requestId,
}
}
}| Method | Purpose |
|---|---|
success(data, ctx) | Builds the success body |
error(error, ctx) | Builds the error body from a NormalizedError (status, code, message, data?, errors?, stack?) |
shouldFormat(value, ctx) | Decides whether a return value is wrapped at all |
The file is discovered automatically and hot-reloads in development. Inside the formatter, read settings from ctx.config, which reflects per-app config when you use multiple apps. Each app can also have its own app/<name>/response/Formatter.ts.
Skipping the envelope
For a single response, return raw(value):
ts
// app/controller/HealthController.ts
import { raw } from '@hazejs/core'
export class HealthController {
index() {
return raw({ status: 'up' }) // {"status":"up"}
}
}For a whole route, set envelope: false in its meta:
ts
// route/api.ts
r.post('/webhooks/stripe', [WebhookController, 'stripe']).meta({ envelope: false })INFO
envelope: false and raw() only affect successful responses. Errors thrown from these routes still go through Formatter.error().
Status, headers and helpers
| Helper | Description |
|---|---|
ctx.status(code, text?) | Set the status code |
ctx.setHeader(name, value) | Set a response header |
ctx.created(data) | Status 201, returns data so you can return ctx.created(...) |
ctx.noContent() | Status 204 |
ctx.redirect(location, status = 302) | Redirect response |
ctx.sse(source, options?) | Server-Sent Events response (see below) |
ts
// app/controller/PostController.ts
import type { HttpContext } from '@hazejs/core'
export class PostController {
async store(ctx: HttpContext) {
const post = { id: 1 }
ctx.setHeader('location', `/api/posts/${post.id}`)
return ctx.created(post)
}
legacy(ctx: HttpContext) {
return ctx.redirect('/api/posts', 301)
}
accepted(ctx: HttpContext) {
ctx.status(202)
return { queued: true }
}
}Returning a Response
Any Web Response is sent as-is. Use it for files, streams or custom content types:
ts
// app/controller/FeedController.ts
export class FeedController {
rss() {
return new Response('<rss version="2.0"></rss>', {
headers: { 'content-type': 'application/rss+xml' },
})
}
}For files on a storage disk, Storage.download(path, name) builds the response for you. See File Storage.
Server-Sent Events
ctx.sse() returns a text/event-stream response that skips the envelope. Pass an async iterable to send each event and close when it ends, or a function to keep the connection open until you call close():
ts
// app/controller/ReportController.ts
import type { HttpContext } from '@hazejs/core'
export class ReportController {
progress(ctx: HttpContext) {
return ctx.sse(async function* () {
for (let percent = 0; percent <= 100; percent += 25) {
yield { event: 'progress', data: { percent } }
await new Promise(resolve => setTimeout(resolve, 500))
}
}())
}
orders(ctx: HttpContext) {
return ctx.sse((stream) => {
const off = orders.subscribe(order => stream.send({ event: 'order', id: order.id, data: order }))
stream.onClose(off) // client disconnected, close() called, or the source failed
})
}
}An event has event, data, id and retry (milliseconds). Strings in data are sent as-is, other values are JSON-encoded, and multi-line data becomes several data: lines. The stream object also has comment(text), closed, and a signal that aborts on close, which you can pass to an upstream fetch.
The helper takes care of the details that are easy to miss:
- A
: pingcomment every 15 seconds keeps proxies from dropping the connection. - When the client disconnects, the stream closes,
onClosehooks run and an iterable'sreturn()is called, sofinallyblocks in generators run. cache-control: no-cache, no-transformandx-accel-buffering: nostop proxies and Nginx from buffering. ThecompressandcacheResponsemiddleware pass event streams through untouched.- If the source throws, the error is logged and the stream closes.
| Option | Description |
|---|---|
heartbeat | Ping interval, e.g. '30s' or seconds. false turns it off. Defaults to 15. |
status | Response status, 200 by default. |
headers | Extra headers. They override the defaults. |
signal | Disconnect signal. Defaults to ctx.req.signal. |
onError | Called when the source fails. Defaults to logging the error. |
Outside a request, eventStream(source, options) from @hazejs/core builds the same response, and encodeSseEvent(event) returns the encoded text of one event. To read an SSE stream from another service, use Http.get(url).sse(). For AI chat streams, streamText().toResponse() already uses this format (see AI). Serverless platforms close long-lived responses when the function times out.
HTML views
ctx.view() renders a TSX view into a text/html response that skips the envelope. Returning a JSX node from a handler does the same:
tsx
// app/controller/HomeController.tsx
import type { HttpContext } from '@hazejs/core'
import Home from '~/app/view/Home'
export class HomeController {
index(ctx: HttpContext) {
return ctx.view(<Home name="Ada" />, { status: 200, headers: { 'cache-control': 'no-store' } })
}
about() {
return <p>About us</p> // rendered as HTML as well
}
}ctx.view(Home, props) and ctx.view('admin/Dashboard', props) (a file in app/view) work in plain .ts route files. See Views.
Cookies
ts
// app/controller/PreferenceController.ts
import type { HttpContext } from '@hazejs/core'
export class PreferenceController {
async update(ctx: HttpContext) {
ctx.setCookie('theme', 'dark', { maxAge: 60 * 60 * 24 * 365 })
await ctx.setSignedCookie('plan', 'pro') // readable, tamper-proof (signed with APP_KEY)
ctx.deleteCookie('legacy')
return { ok: true }
}
}Cookies default to httpOnly, SameSite=Lax, path /, and Secure outside debug mode. Override the defaults with cookie in config/app.ts. Sessions and CSRF are covered in Sessions, CSRF & More.
Interceptors
An interceptor receives the final Response, after formatting and error rendering, for every request. It can modify headers or return a new Response to replace it. Returning nothing keeps the original.
Put interceptors in app/interceptor/*.ts. They are loaded automatically and run in file-name order:
ts
// app/interceptor/response-time.ts
import { defineInterceptor } from '@hazejs/core'
export default defineInterceptor((response, ctx) => {
response.headers.set('x-response-time', `${Math.round(performance.now() - ctx.startedAt)}ms`)
})bash
npx haze make:interceptor ResponseTime # app/interceptor/response-time.tsInterceptors can also be registered from a service provider with app.intercept():
ts
// app/provider/AppProvider.ts
import { defineProvider } from '@hazejs/core'
export default defineProvider({
boot(app) {
app.intercept((response) => {
if (response.status >= 500)
response.headers.set('cache-control', 'no-store')
})
},
})Per-request hooks
ctx.onResponse(hook) registers a hook for the current request only. This is useful in middleware. Hooks run after interceptors, and the most recently registered hook runs first, so the outermost middleware sees the response last.
ts
// app/middleware/timing.ts
import { defineMiddleware } from '@hazejs/core'
export const timing = defineMiddleware((ctx, next) => {
ctx.onResponse((response) => {
response.headers.set('server-timing', `app;dur=${performance.now() - ctx.startedAt}`)
})
return next()
})The outbound pipeline
- The handler (or a short-circuiting middleware) returns a value, or something throws.
- Values go through
Formatter.success()unless they areraw(), the route hasenvelope: false, orshouldFormat()returnsfalse. Exceptions go throughExceptionHandlerand thenFormatter.error(). See Errors & Logging. - The result is converted to a
Response. - Interceptors run in order, then
ctx.onResponsehooks run in reverse order. - The access log line is written with the final status code.