App
import { createApp, loadRoutes } from '@theoven/core'
export const app = createApp()await loadRoutes(app, `${import.meta.dir}/routes`)
export default appimport app from './app'
await app.listen(3000)Registering routes
Section titled “Registering routes”app.get('/users', handler)app.post('/users', { body: schema }, handler)app.put('/users/:id', handler)app.patch('/users/:id', handler)app.delete('/users/:id', handler)app.options('/users', handler)app.head('/users', handler)
app.route('GET', '/users', handler) // any method by nameEach takes an optional schema between the path and the handler. With one, ctx.params,
ctx.query and ctx.body are typed and validated:
app.get('/users/:id', { params: z.object({ id: z.uuid() }) }, (ctx) => ctx.params.id)Most applications use file-based routing instead and never call these directly.
HEAD is served from the matching GET route, and OPTIONS is answered from the methods a path
actually has — neither needs registering. Registering the same method and path twice throws at
registration rather than silently letting the last one win.
Middleware and bricks
Section titled “Middleware and bricks”app.use(requestLogger()) // middleware, everywhereapp.use('/admin', requireAdmin()) // middleware, scoped to a prefixapp.use(db(drizzleSqlite({ url }))) // a brickapp.use(adminRouter) // a router.use() takes all three, and tells them apart with no ambiguity: a function is middleware, a
router is recognised by its brand, anything else is a brick.
A brick contributes to the context and its type flows through the chain — so ctx.db exists after
that line and is a compile error before it. A router contributes
routes, with its prefix, tags and auth applied. See
Middleware & hooks and Bricks.
app.override(dependency, resolver)
Section titled “app.override(dependency, resolver)”Replaces a dependency for this app — the testing seam:
app.override(currentTenant, () => ({ id: 'test-tenant', plan: 'pro' }))
const response = await app.fetch(new Request('http://x/dashboard'))
app.clearOverrides()It applies wherever the dependency is used, including as a sub-dependency of another, so replacing one root swaps out everything derived from it without touching those definitions.
Configuration
Section titled “Configuration”createApp({ port: 3000, development: process.env.NODE_ENV !== 'production', trustProxy: true,})| Option | Default | |
|---|---|---|
port |
PORT, then 3000 |
port for listen() |
hostname |
Bun’s default | interface to bind |
logger |
built-in | swap in pino, winston, anything satisfying Logger |
logLevel |
info |
level for the built-in logger |
development |
NODE_ENV !== 'production' |
controls what reaches the client on an error |
onError |
— | your error handler; see below |
shutdownTimeout |
10000 |
ms close() waits for in-flight requests |
requestIdHeader |
x-request-id |
header consulted for an inbound id |
echoRequestId |
true |
echo the id on responses |
body |
— | total size, per-file size, file count, MIME allowlist |
query |
depth 5, 1000 keys | query parsing limits |
cookies |
— | cookie settings, notably the signing secret |
token |
cookie token, query access_token |
where ctx.token is looked for |
trustProxy |
false |
whether X-Forwarded-For may set ctx.ip |
validateResponses |
development |
a response that does not match its schema is a 500 |
serializeResponses |
true |
a response schema’s parsed output becomes the body |
Two of those are security settings rather than conveniences, and both default to the safe side:
These two are separate on purpose.
serializeResponses is on everywhere, because a response schema filtering the body is a
safety property, not a check: Zod strips keys it does not declare, so a row carrying a
passwordHash cannot send one. Only routes that declare a schema pay for it — about 480 ns each,
and nothing on routes that do not.
validateResponses governs only what happens when the value fails to parse: a 500 in
development, and in production a logged filtered: false with the request still succeeding.
Failing closed in production would turn a drifted schema into an outage on the deploy that
introduced it. See response schemas.
Errors
Section titled “Errors”createApp({ onError: (error, ctx) => { if (error.status >= 500) reportToSentry(error, { requestId: ctx.id }) // Return nothing and the default problem+json response is used. },})Without a handler, thrown errors become RFC 9457 problem+json. Return
a value from onError to replace that response; return nothing to keep it.
Lifecycle
Section titled “Lifecycle”app.ready()
Section titled “app.ready()”Runs every brick’s setup() — connecting the database, compiling models, mounting auth routes.
listen() calls it for you; call it directly when dispatching requests in tests.
app.listen(port?)
Section titled “app.listen(port?)”Starts the server and returns Bun’s Server. Installs signal handlers so SIGTERM drains
rather than kills.
app.close(options?)
Section titled “app.close(options?)”Stops accepting connections, waits for in-flight requests, then releases every brick’s resources
in reverse order. Requests arriving mid-drain get 503 with Retry-After rather than a dead
socket.
await app.close({ timeout: 5000 })app.fetch(request)
Section titled “app.fetch(request)”Runs the whole pipeline — routing, middleware, brick hooks, validation, guards, the handler,
serialisation — and returns a Response. No port is bound. This is how you
test an Oven app.
const response = await app.fetch(new Request('http://localhost/users/1'))Inspection
Section titled “Inspection”app.routes()
Section titled “app.routes()”Every registered route as { method, pattern }. Backs oven routes and the boot banner.
app.service(name)
Section titled “app.service(name)”A brick’s contributed service, outside a request.
await app.ready()
const db = app.service('db') // the Drizzle client, typedconst queue = app.service('queue')ctx.db is where an application wants it. A migration script, a seed and oven worker all want
the same configured value with no request in sight — and re-constructing the brick would give
them a second connection pool. Typed from what .use() contributed, so a name you never
registered is a compile error.
app.logger · app.url
Section titled “app.logger · app.url”The base logger, and the URL the server is listening on (undefined before listen()).
app.onRequest((ctx) => { /* before routing */ })app.beforeHandle((ctx) => { /* after validation, before the handler */ })app.afterHandle((ctx, result) => { /* transform the result */ })app.onResponse((ctx, response) => { /* the finished Response */ })app.onError((error, ctx) => { /* every thrown error */ })Returning a value from onRequest or beforeHandle short-circuits the request. See
Middleware & hooks for the ordering and what each is for.