Skip to content

App

src/app.ts
import { createApp, loadRoutes } from '@theoven/core'
export const app = createApp()
await loadRoutes(app, `${import.meta.dir}/routes`)
export default app
src/index.ts
import app from './app'
await app.listen(3000)
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 name

Each 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.

app.use(requestLogger()) // middleware, everywhere
app.use('/admin', requireAdmin()) // middleware, scoped to a prefix
app.use(db(drizzleSqlite({ url }))) // a brick
app.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.

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.

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.

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.

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.

Starts the server and returns Bun’s Server. Installs signal handlers so SIGTERM drains rather than kills.

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 })

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'))

Every registered route as { method, pattern }. Backs oven routes and the boot banner.

A brick’s contributed service, outside a request.

await app.ready()
const db = app.service('db') // the Drizzle client, typed
const 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.

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.