Middleware

Middleware in Davaux is a function that runs before your route handler and can modify the request context, short-circuit the response, or pass control to the next handler in the chain. There are two places to register middleware, ordered from outermost to innermost.

App middleware — src/middleware.ts

Create src/middleware.ts (outside the routes directory) to register middleware that runs on every request — including requests that result in a 404. This is the right place for infrastructure concerns like sessions, security headers, CORS, and logging:

// src/middleware.ts
import { defineMiddleware } from 'davaux'
import { helmet } from '@davaux/helmet'
import { sessionMiddleware } from '@davaux/session'
import { csrfMiddleware } from '@davaux/csrf'

const helmetMw = helmet()
const sessionMw = sessionMiddleware({ secret: process.env.SESSION_SECRET! })
const csrfMw = csrfMiddleware()

export default defineMiddleware((ctx, next) =>
  helmetMw(ctx, () => sessionMw(ctx, () => csrfMw(ctx, next)))
)

Because this file sits outside src/routes/, it is clearly separated from the routing tree and runs before any route matching occurs.

Note: Scoped _middleware.ts does NOT run for 404 requests because there is no matched route. If you need to run code for every request including not-found ones, use src/middleware.ts.

Scoped middleware — _middleware.ts

Place a _middleware.ts file in any route directory. It runs for all routes in that directory and any subdirectories, but only when a route is matched:

// src/routes/dashboard/_middleware.ts
import { defineMiddleware, redirect } from 'davaux'

export default defineMiddleware(async (ctx, next) => {
  const userId = ctx.state.session.get('userId')
  if (!userId) {
    redirect('/login')
  }

  const user = await db.users.findById(userId)
  ctx.state.user = user
  await next()
})

With this file in place, every page under /dashboard/ gets authentication checked automatically. The ctx.state.user value is available downstream.

Execution order

For a request to /dashboard/settings:

  1. src/middleware.ts
  2. src/routes/_middleware.ts (if present)
  3. src/routes/dashboard/_middleware.ts (if present)
  4. src/routes/dashboard/settings.page.tsx

Each layer calls next() to pass control to the next layer. If a layer does not call next(), the chain stops there.

Composition pattern

Multiple middleware can be chained in a single file by wrapping them. This is how src/middleware.ts chains packages that don't natively compose:

// src/middleware.ts
import { defineMiddleware } from 'davaux'
import { helmet } from '@davaux/helmet'
import { cors } from '@davaux/cors'
import { logger } from '@davaux/logger'

const helmetMw = helmet()
const corsMw = cors({ origin: process.env.ALLOWED_ORIGIN ?? '*' })
const logMw = logger()

export default defineMiddleware((ctx, next) =>
  logMw(ctx, () => helmetMw(ctx, () => corsMw(ctx, next)))
)

For scoped middleware with multiple concerns:

// src/routes/api/_middleware.ts
import { defineMiddleware, redirect } from 'davaux'
import { rateLimit } from '@davaux/rate-limit'

const limit = rateLimit({ windowMs: 60_000, max: 100 })

export default defineMiddleware(async (ctx, next) => {
  await limit(ctx, async () => {
    const auth = ctx.req.headers['authorization']
    if (!auth?.startsWith('Bearer ')) {
      ctx.res.writeHead(401, { 'Content-Type': 'application/json' })
      ctx.res.end(JSON.stringify({ error: 'Unauthorized' }))
      return
    }

    ctx.state.apiUser = parseToken(auth.slice(7))
    await next()
  })
})

Passing data via ctx.state

ctx.state is the primary mechanism for middleware-to-handler communication. Augment the State interface for full TypeScript autocomplete:

// src/types.d.ts
import 'davaux'

declare module 'davaux' {
  interface State {
    user?: { id: string; name: string; role: 'admin' | 'member' }
    requestId?: string
    apiUser?: { sub: string; scopes: string[] }
  }
}

With that declaration in place, ctx.state.user is correctly typed everywhere — in middleware, pages, and API handlers.

Short-circuiting

Write to ctx.res directly and skip calling next() to stop the chain:

export default defineMiddleware(async (ctx, next) => {
  if (ctx.req.method === 'OPTIONS') {
    ctx.res.writeHead(204, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
    })
    ctx.res.end()
    return  // chain stops here
  }
  await next()
})

Or use redirect() to send a redirect response and stop the chain:

export default defineMiddleware(async (ctx, next) => {
  if (!ctx.state.session.get('userId')) {
    redirect('/login')  // throws RedirectError — chain stops
  }
  await next()
})