Response
Build HTTP responses with typed helpers for every status code, headers, cookies, file downloads, and streaming.
Introduction
ctx.response is a TekirResponse builder. Calling any of its methods produces a native Bun Response that tekir sends to the client. The builder is fluent: header and cookie methods return this so you can chain them before the final status-code method.
import type { HttpContext } from '@tekir/core'
export function index({ response }: HttpContext) {
// response is a TekirResponse: a fluent response builder.
// Every method returns a native Bun Response that tekir sends to the client.
return response.ok([])
}Auto-serialisation
You do not have to call response.* at all for simple cases. When a handler returns a plain JavaScript value, tekir serialises it automatically:
// These three are equivalent, all produce 200 application/json
return response.ok({ id: 1, name: 'Widget' })
return { id: 1, name: 'Widget' }
return Response.json({ id: 1, name: 'Widget' })
// Return a string → 200 text/plain
return 'Hello!'
// Return null/undefined → 204 No Content
return nullUse the response helpers when you need a specific status code, custom headers, cookies, or file downloads.
Compiled Routes & Performance
Tekir keeps handlers that only return plain values on a lightweight response path. The full request and response builders are created lazily when your handler or middleware actually reads ctx.request or ctx.response. This preserves the fast path without changing response semantics.
Calling response.cookie(), response.clearCookie(), response.header(), or response.status() always creates real per-request state. The state is applied even when the final value is returned by another helper function, middleware, a stream, or a native Response. These operations therefore have their normal header serialization and signing cost; routes that do not use them do not pay that cost.
Core Methods
json(data?)
Serialises data as JSON and sets Content-Type: application/json. Uses the current status code (default 200).
export async function show({ params, response }: HttpContext) {
// Serialises any object / array to JSON with Content-Type: application/json
return response.ok({ id: params.id, name: 'Alice' })
}html(data)
Sends a string with Content-Type: text/html; charset=utf-8.
export function welcome({ response }: HttpContext) {
return response.html('<h1>Welcome!</h1>')
// Content-Type: text/html; charset=utf-8
}text(data)
Sends a string with Content-Type: text/plain; charset=utf-8.
export function robots({ response }: HttpContext) {
return response.text('User-agent: *\nDisallow: /admin')
// Content-Type: text/plain; charset=utf-8
}send(data?)
A smart auto-detect method: objects and arrays become JSON, strings become text (or HTML when the content starts with <), null becomes 204 No Content, and a Response is passed through unchanged.
export async function show({ params, response }: HttpContext) {
const data = await fetchData(params.id)
// send() auto-detects the type:
// object / array → JSON
// string → text (or HTML if it starts with '<')
// null → 204 No Content
// Response → passed through unchanged
return response.send(data)
}stream(readable)
Sends a ReadableStream body. Use this for server-sent events, live logs, or any large response you want to start sending before it is fully ready.
export function stream({ response }: HttpContext) {
const stream = new ReadableStream({
start(controller) {
let count = 0
const id = setInterval(() => {
controller.enqueue(new TextEncoder().encode(`data: tick ${++count}\n\n`))
if (count >= 5) { clearInterval(id); controller.close() }
}, 1000)
}
})
return response
.header('Content-Type', 'text/event-stream')
.header('Cache-Control', 'no-cache')
.stream(stream)
}download() and attachment()
Both methods serve a file from the filesystem with Content-Disposition: attachment, which tells the browser to download rather than display it. download(filePath)uses the file's own name; attachment(filePath, filename) lets you choose the download filename.
export async function exportCsv({ response }: HttpContext) {
// Sends the file with Content-Disposition: attachment
// The browser will prompt a "Save As" dialog.
return response.download('./storage/export.csv')
}
export async function report({ response }: HttpContext) {
// attachment() lets you customise the filename shown in the dialog
return response.attachment('./storage/report-2024.csv', 'monthly-report.csv')
}redirect(url, status?)
Redirects to url. The default status is 302 (temporary). Pass 301 for a permanent redirect. For more specific redirect codes, see the 3xx helpers below.
export async function logout({ response }: HttpContext) {
// 302 Found (default)
return response.redirect('/login')
}
export function oldPath({ response }: HttpContext) {
// 301 Moved Permanently
return response.redirect('/new-path', 301)
}redirect.back(fallback?) sends the user back to the page they came from, read from the Referer header. To stop an attacker from crafting a Referer that bounces the user off-site, the Referer host is checked before it is reused. Configure the hosts you trust with server.configure({ trustedHosts }); when set, the Referer host is matched against that list instead of the client-supplied Host header. Each entry matches case-insensitively, and a leading *. matches subdomains (for example *.example.com). If the Referer is missing or not trusted, the fallback path is used instead.
// start/kernel.ts — declare the hosts you trust
server.configure({
trustedHosts: ['example.com', '*.example.com']
})
// Anywhere in a handler:
export function save({ response }: HttpContext) {
// Redirects to the page the user came from (the Referer).
// The Referer host is checked against trustedHosts before it is used.
return response.redirect.back('/dashboard') // '/dashboard' is the fallback
}status(code)
Sets the HTTP status code and returns this for chaining. Call before any method that builds the response body.
export async function show({ params, response }: HttpContext) {
const item = await findItem(params.id)
if (!item) {
// Set status code and return immediately in one chain
return response.status(404).json({ message: 'Not found' })
}
return response.ok(item)
}2xx Success Helpers
All 2xx helpers accept an optional data argument that is serialised as JSON. Calling them without an argument sends the status with no body (or an empty JSON object).
// 200 OK, data is optional; omitting it returns an empty 200
response.ok()
response.ok({ id: 1, name: 'Alice' })
// 201 Created: always sends a JSON body
response.created({ id: 42 })
// 202 Accepted: useful for queued jobs
response.accepted({ jobId: 'abc123' })
// 204 No Content: no body sent
response.noContent()3xx Redirect Helpers
All 3xx helpers accept a URL string and set the Location header.
// 301 Moved Permanently
response.movedPermanently('/new-url')
// 302 Found (temporary redirect)
response.found('/login')
// 303 See Other (POST → GET redirect after form submission)
response.seeOther('/dashboard')
// 304 Not Modified (for conditional GET)
response.notModified()
// 307 Temporary Redirect (preserves HTTP method)
response.temporaryRedirect('/mirror')
// 308 Permanent Redirect (preserves HTTP method)
response.permanentRedirect('/new-url')4xx Client Error Helpers
All 4xx helpers accept an optional data object. When omitted, a default { message: "..." } is sent.
// 400 Bad Request
response.badRequest({ message: 'Invalid email format' })
// 401 Unauthorized: missing or invalid credentials
response.unauthorized({ message: 'Please log in' })
// 402 Payment Required
response.paymentRequired({ message: 'Upgrade your plan' })
// 403 Forbidden: authenticated but not allowed
response.forbidden({ message: 'Insufficient permissions' })
// 404 Not Found
response.notFound({ message: 'User not found' })
// 405 Method Not Allowed
response.methodNotAllowed()
// 406 Not Acceptable (content negotiation failure)
response.notAcceptable()
// 408 Request Timeout
response.requestTimeout()
// 409 Conflict (duplicate resource)
response.conflict({ message: 'Email already in use' })
// 410 Gone (permanently removed)
response.gone()
// 412 Precondition Failed
response.preconditionFailed()
// 413 Payload Too Large
response.payloadTooLarge({ message: 'File exceeds 10 MB limit' })
// 415 Unsupported Media Type
response.unsupportedMediaType({ message: 'Expected application/json' })
// 422 Unprocessable Entity (validation errors)
response.unprocessableEntity({ fields: { email: ['Invalid email'] } })
// 429 Too Many Requests
response.tooManyRequests({ message: 'Slow down', retryAfter: 60 })5xx Server Error Helpers
All 5xx helpers work the same way as 4xx helpers.
// 500 Internal Server Error
response.internalServerError({ message: 'Something went wrong' })
// 501 Not Implemented
response.notImplemented()
// 502 Bad Gateway
response.badGateway()
// 503 Service Unavailable
response.serviceUnavailable({ message: 'Down for maintenance' })
// 504 Gateway Timeout
response.gatewayTimeout()When an unhandled exception reaches the framework, the 500 response no longer exposes the error message or stack trace in production: clients receive a generic Internal Server Error. The original message and stack are only included in the response when debug mode is enabled, which you turn on with server.configure({ development: true }). This applies to both plain routes and routes that run through middleware.
Response Headers
Four methods control response headers. They all return this for chaining and must be called before the method that builds the body (e.g. ok()).
header(name, value): set or overwrite a header.safeHeader(name, value): set only if the header is not already present.append(name, value): append a value to a multi-value header.removeHeader(name): delete a header.
export function data({ response }: HttpContext) {
return response
// Set (overwrites existing value)
.header('X-Custom', 'value')
// Safe set (only sets if header not already present)
.safeHeader('X-Request-Id', crypto.randomUUID())
// Append (adds another value for the same header)
.append('Vary', 'Accept')
// Remove a header
.removeHeader('X-Powered-By')
.ok({ data: [] })
}Response Cookies
Cookie methods also return this and can be chained. Cookies are collected and sent as Set-Cookie headers when the response is built.
import env from '#env'
// Set a plain cookie
response.cookie('theme', 'dark')
// With full options
response.cookie('session_id', token, {
path: '/',
domain: 'example.com',
maxAge: 60 * 60 * 24 * 7, // 1 week in seconds
httpOnly: true,
secure: true,
sameSite: 'lax'
})
// Signed cookie: the value is HMAC-signed so tampering is detectable
response.signedCookie('cart_id', '12345', env.APP_KEY)
// Encrypted cookie: the value is base64-encoded + signed
response.encryptedCookie('prefs', JSON.stringify(userPrefs), env.APP_KEY)
// Delete a cookie (sets Max-Age=0)
response.clearCookie('old_session')// Methods chain, so combine with a response
return response
.cookie('theme', 'dark', { maxAge: 86400 })
.signedCookie('user_id', String(user.id), env.APP_KEY)
.ok({ message: 'Preferences saved' })The signed cookie appends an HMAC signature to the value. When reading it back with request.signedCookie(), tekir verifies the signature in constant time and returns null if it has been tampered with. The standalone helper verifySignedCookieValue(token, secret) exported from @tekir/core does the same check from any context that has the raw cookie string.
The encrypted cookie now produces an authenticated AES-256-GCM ciphertext, not a base64 payload plus HMAC. The output shape is iv.ciphertext.authTag; anyone tampering with the cookie fails the auth-tag check on decrypt. Read it back with the new decryptCookieValue<T>(token, secret) helper from @tekir/core, which returns null for any tampered or malformed value. Cookies issued by older releases (the signed-and-encoded format) cannot be decrypted with the new reader and need to be re-issued.
onFinish()
Register a callback that fires after the response has been sent. Use it for fire-and-forget side effects that should not add latency: analytics events, audit logs, or cache warming.
export async function checkout({ response, store }: HttpContext) {
// onFinish() registers a callback that fires after the response is sent.
// Use it for cleanup, async logging, or analytics that should not delay the response.
response.onFinish(() => {
analytics.track('checkout', { userId: store.user.id })
})
return response.ok({ orderId: 123 })
}