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