HTTP Routing & Middleware

The HTTP module provides routing, middleware, request/response helpers, and a Bun.serve() wrapper with WebSocket support.

Quick Start

import { router } from '@strav/http'

router.get('/health', (ctx) => ctx.json({ status: 'ok' }))

router.group({ prefix: '/api' }, (r) => {
  r.get('/users', listUsers)
  r.post('/users', createUser)
  r.get('/users/:id', showUser)
})

The router is a singleton resolved from the DI container — import it directly from '@strav/http'.

Context

Every handler receives a Context — the single object you interact with for reading the request and building a response.

Request Helpers

router.get('/search', async (ctx) => {
  ctx.method                    // 'GET'
  ctx.path                      // '/search'
  ctx.params.id                 // route params (from :id patterns)
  ctx.query.get('q')            // query string params
  ctx.header('Authorization')   // read a header
  ctx.cookie('session_id')      // read a cookie by name
  ctx.subdomain                 // extracted from Host header
  ctx.request                   // the raw Bun Request (always accessible)

  const data = await ctx.body<{ name: string }>()  // auto-parse JSON/form/text
})

Body parsing detects the content type automatically:

  • application/json — parsed as JSON.
  • multipart/form-data / application/x-www-form-urlencoded — parsed as FormData.
  • Everything else — returned as text.

The body is cached, so calling ctx.body() multiple times is safe.

Query String

Read query parameters with typed defaults:

ctx.qs('page')          // string | null
ctx.qs('page', 1)       // number (parsed, falls back to default if invalid/missing)
ctx.qs('search', '')    // string

Form Inputs

Extract string fields from a form body. Avoids repetitive form.get('x') as string ?? '' casting:

const { name, email, password } = await ctx.inputs('name', 'email', 'password')
// All values are strings, '' if missing

With no arguments, returns all non-file fields:

const allFields = await ctx.inputs()

File Uploads

Extract file fields from a form body:

const { avatar } = await ctx.files('avatar')
// avatar is File | null

With no arguments, returns all file fields:

const allFiles = await ctx.files()

For validated uploads with size/type checks, see the Storage guide.

Response Helpers

Every method returns a standard Response:

ctx.json({ users: [] })              // 200 JSON
ctx.json({ error: 'nope' }, 404)     // custom status
ctx.text('hello')                    // 200 plain text
ctx.html('

Hi

') // 200 HTML ctx.redirect('/login') // 302 redirect ctx.redirect('/login', 301) // custom status ctx.setHeader('X-Custom', 'value') // set response header ctx.setCookie('token', value, { httpOnly: true, maxAge: 3600 }) ctx.stream(readableStream) // stream response ctx.file('./public/image.png') // serve static file ctx.download('./report.pdf', 'report-2024.pdf') // force download ctx.view('users/index', { users }) // render template

Route Definition

Basic Routes

router.get('/users', handler)
router.post('/users', handler)
router.put('/users/:id', handler)
router.patch('/users/:id', handler)
router.delete('/users/:id', handler)
router.head('/resource', handler)
router.options('/resource', handler)

Route Parameters

Dynamic segments are captured as params:

router.get('/users/:id', (ctx) => {
  const userId = ctx.params.id
})

// Optional parameters
router.get('/posts/:slug?', (ctx) => {
  const slug = ctx.params.slug || 'index'
})

// Wildcard (captures rest of path)
router.get('/files/*', (ctx) => {
  const filepath = ctx.params['*']
})

Route Groups

Group routes with shared prefix and middleware:

router.group({ prefix: '/api/v1', middleware: [auth()] }, (r) => {
  r.get('/profile', getProfile)
  r.put('/profile', updateProfile)

  r.group({ prefix: '/admin', middleware: [admin()] }, (r) => {
    r.get('/users', listUsers)
    r.delete('/users/:id', deleteUser)
  })
})

Controller Routes

Use controller classes with automatic DI resolution:

// Tuple syntax: [Controller, method]
router.get('/users', [UserController, 'index'])
router.post('/users', [UserController, 'store'])
router.get('/users/:id', [UserController, 'show'])
router.put('/users/:id', [UserController, 'update'])
router.delete('/users/:id', [UserController, 'destroy'])

// Resource shorthand (generates all RESTful routes)
router.resource('/posts', PostController)
// Generates:
// GET    /posts           → index
// GET    /posts/create    → create
// POST   /posts           → store
// GET    /posts/:id       → show
// GET    /posts/:id/edit  → edit
// PUT    /posts/:id       → update
// DELETE /posts/:id       → destroy

Middleware

Middleware are functions that run before your handler. They can modify the context, perform authentication, log requests, etc.

Writing Middleware

import type { Context, Next } from '@strav/http'

async function logger(ctx: Context, next: Next) {
  const start = Date.now()

  await next()  // Call next middleware/handler

  const ms = Date.now() - start
  console.log(`${ctx.method} ${ctx.path} - ${ms}ms`)
}

async function requireAuth(ctx: Context, next: Next) {
  if (!ctx.user) {
    return ctx.json({ error: 'Unauthorized' }, 401)
  }

  await next()
}

Applying Middleware

// Global middleware (all routes)
router.use(logger)
router.use(cors())

// Route-specific middleware
router.get('/profile', [auth()], getProfile)
router.post('/admin', [auth(), admin()], adminAction)

// Group middleware
router.group({ middleware: [auth()] }, (r) => {
  r.get('/dashboard', getDashboard)
  r.get('/settings', getSettings)
})

Built-in Middleware

import {
  session,
  auth,
  csrf,
  cors,
  throttle,
  validateRequest
} from '@strav/http'

// Session management
router.use(session({
  secret: 'your-secret-key',
  maxAge: 86400, // 24 hours
}))

// Authentication (requires session)
router.use(auth())

// CSRF protection
router.use(csrf())

// CORS
router.use(cors({
  origin: 'https://app.example.com',
  credentials: true,
}))

// Rate limiting
router.post('/api/login', [throttle(5, 60)], login) // 5 attempts per minute

// Request validation
const createUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18),
})

router.post('/users', [validateRequest(createUserSchema)], createUser)

Error Handling

Throwing Errors

import { HttpException, NotFoundException, ValidationException } from '@strav/http'

router.get('/users/:id', async (ctx) => {
  const user = await findUser(ctx.params.id)

  if (!user) {
    throw new NotFoundException('User not found')
  }

  return ctx.json(user)
})

// Custom status code
throw new HttpException('Payment required', 402)

// Validation errors with details
throw new ValidationException({
  email: 'Email is required',
  age: 'Must be 18 or older',
})

Global Error Handler

router.onError((error, ctx) => {
  console.error(`Error handling ${ctx.method} ${ctx.path}:`, error)

  if (error instanceof ValidationException) {
    return ctx.json({
      error: 'Validation failed',
      details: error.errors
    }, 422)
  }

  if (error instanceof HttpException) {
    return ctx.json({ error: error.message }, error.status)
  }

  // Unexpected errors
  return ctx.json({ error: 'Internal server error' }, 500)
})

WebSocket Support

router.ws('/chat', {
  open(ws) {
    console.log('Client connected')
    ws.send('Welcome!')
  },

  message(ws, message) {
    console.log('Received:', message)

    // Broadcast to all clients
    ws.publish('chat', message)
  },

  close(ws) {
    console.log('Client disconnected')
  }
})

// Client-side
const ws = new WebSocket('ws://localhost:3000/chat')
ws.onmessage = (event) => console.log(event.data)

Static Files

// Serve static files from public directory
router.static('/', './public')

// With custom path prefix
router.static('/assets', './public/assets')

// With options
router.static('/downloads', './files', {
  maxAge: 3600,           // Cache for 1 hour
  index: 'index.html',    // Default file
  dotfiles: 'ignore',     // Hide dotfiles
})

Advanced Patterns

Subdomain Routing

router.get('/', (ctx) => {
  switch (ctx.subdomain) {
    case 'api':
      return ctx.json({ version: '1.0' })
    case 'admin':
      return ctx.view('admin/dashboard')
    default:
      return ctx.view('home')
  }
})

Content Negotiation

router.get('/users', async (ctx) => {
  const users = await getUsers()

  // Check Accept header
  if (ctx.accepts('json')) {
    return ctx.json(users)
  }

  if (ctx.accepts('html')) {
    return ctx.view('users/index', { users })
  }

  if (ctx.accepts('xml')) {
    return ctx.xml(usersToXml(users))
  }

  return ctx.text('Not Acceptable', 406)
})

Streaming Responses

router.get('/download/large-file', async (ctx) => {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 1000000; i++) {
        controller.enqueue(`Line ${i}\n`)

        // Yield to prevent blocking
        if (i % 1000 === 0) {
          await Bun.sleep(0)
        }
      }
      controller.close()
    }
  })

  return ctx.stream(stream, {
    headers: {
      'Content-Type': 'text/plain',
      'Content-Disposition': 'attachment; filename="data.txt"'
    }
  })
})

Testing Routes

import { test, expect } from 'bun:test'
import { router } from '@strav/http'

test('GET /health returns ok', async () => {
  const response = await router.handle(
    new Request('http://localhost/health')
  )

  expect(response.status).toBe(200)

  const data = await response.json()
  expect(data).toEqual({ status: 'ok' })
})

test('POST /users creates user', async () => {
  const response = await router.handle(
    new Request('http://localhost/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'John Doe',
        email: 'john@example.com'
      })
    })
  )

  expect(response.status).toBe(201)
})