Chapter 14: Building Servers
Every app eventually needs a backend. Tova's server block lets you define routes, middleware, database models, authentication, and more in a single cohesive file. No boilerplate, no framework configuration, no dependency wrangling. You write what you mean, and Tova generates a production-ready HTTP server that runs on Bun.
By the end of this chapter, you'll build a complete REST API for a task manager with auth, validation, route groups, and scheduled jobs.
The Server Block
Everything starts with the server keyword:
server {
fn hello() {
{ message: "Hello from Tova!" }
}
}Run it with tova run server.tova and you get a server on port 3000. Every function inside a server block is automatically exposed as an RPC endpoint at /rpc/<function_name>. But explicit routes give you full control over your API.
How It Works Under the Hood
Tova compiles your server block into a Bun HTTP server with routing, CORS headers, JSON parsing, request logging, and graceful shutdown -- all generated at compile time. No runtime framework overhead.
Routes
Define HTTP endpoints with route METHOD "path" => handler:
server {
fn get_users() {
[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
}
fn create_user(name, email) {
respond(201, { name: name, email: email })
}
route GET "/api/users" => get_users
route POST "/api/users" => create_user
}Tova supports all standard HTTP methods:
route GET "/items" => list_items
route POST "/items" => create_item
route PUT "/items/:id" => update_item
route PATCH "/items/:id" => patch_item
route DELETE "/items/:id" => delete_itemRoute Parameters
Use :param in the path to capture URL segments. Tova extracts them automatically and passes them to your handler:
server {
fn get_user(id) {
// id is extracted from the URL path
{ user_id: id }
}
fn update_user(id, name, email) {
// id from URL, name and email from POST body
{ updated: id, name: name, email: email }
}
route GET "/api/users/:id" => get_user
route PUT "/api/users/:id" => update_user
}For GET requests, handler parameters are matched from URL path params and query string params. For POST/PUT/PATCH, they come from the request body (JSON).
Request and Response
The Request Context
When your handler takes a parameter named req, it receives the full request context:
server {
fn debug_request(req) {
{
method: req.method,
url: req.url,
headers: req.headers,
query: req.query,
cookies: req.cookies
}
}
route GET "/api/debug" => debug_request
}You can also combine req with other parameters:
server {
fn update_item(req, id) {
user_agent = req.headers["user-agent"]
{ updated: id, agent: user_agent }
}
route PUT "/api/items/:id" => update_item
}Response Helpers
Tova provides built-in response functions:
server {
fn create_item(name) {
if name == "" {
return respond(400, { error: "Name is required" })
}
respond(201, { name: name, created: true })
}
fn go_home(req) {
redirect("/dashboard")
}
fn login(req) {
// Set a cookie with options
set_cookie("session", "abc123", {
httpOnly: true,
maxAge: 86400,
sameSite: "Strict"
})
}
fn events(req) {
// Server-sent events via streaming
stream(fn(send, close) {
send("connected")
send("data: hello")
close()
})
}
}Return values are automatically wrapped in JSON responses. If you return a plain object or array, Tova wraps it in Response.json(). If you use respond(), you control the status code and body explicitly.
The respond() Function
respond(status, body) creates a full HTTP Response with the given status code and JSON body. It also accepts an optional third argument for custom headers: respond(200, data, { "X-Custom": "value" }).
Typed Parameters and Validation
Add type annotations to handler parameters for automatic runtime validation:
server {
fn create_user(name: String, age: Int, active: Bool) {
{ name: name, age: age, active: active }
}
fn set_price(amount: Float) {
{ price: amount }
}
fn set_tags(tags: [String]) {
{ tags: tags }
}
}If a client sends the wrong type, Tova returns a 400 Validation Failed response automatically with details about which fields failed. No manual validation code needed.
Middleware
Middleware wraps every request, letting you add logging, authentication checks, header injection, or anything else that should happen on every route:
server {
middleware fn logger(req, next) {
start = Date.now()
result = next(req)
elapsed = Date.now() - start
print("[{req.method}] {req.url} - {elapsed}ms")
result
}
middleware fn add_headers(req, next) {
result = next(req)
// result passes through; headers added by Tova's CORS system
result
}
fn hello() { "world" }
}Middleware functions take req (the request) and next (a function that calls the next middleware or the route handler). Always call next(req) and return its result unless you want to short-circuit the chain.
Middleware Chain Order
Middleware runs in the order you declare it. The first middleware wraps the second, which wraps the third, and so on:
server {
middleware fn first(req, next) {
print("Before first")
result = next(req)
print("After first")
result
}
middleware fn second(req, next) {
print("Before second")
result = next(req)
print("After second")
result
}
fn hello() { "world" }
}
// Output order: Before first -> Before second -> handler -> After second -> After firstRoute Groups
Group related routes under a shared prefix with routes:
server {
routes "/api/v1" {
fn get_users() { [] }
fn create_user(name: String) { name }
route GET "/users" => get_users
route POST "/users" => create_user
}
}The routes above resolve to /api/v1/users. Route groups keep your API organized and make versioning straightforward.
Scoped Middleware
Route groups can have their own middleware that only applies to routes inside the group:
server {
middleware fn global_logger(req, next) {
print("All requests hit this")
next(req)
}
routes "/api/v1" {
middleware fn v1_auth(req, next) {
// Only /api/v1/* routes go through this
next(req)
}
fn get_users() { [] }
route GET "/users" => get_users
}
routes "/api/v2" {
middleware fn v2_auth(req, next) {
// Only /api/v2/* routes go through this
next(req)
}
fn get_users_v2() { [] }
route GET "/users" => get_users_v2
}
}API Versioning
Route groups support version metadata and deprecation headers:
server {
routes "/api/v1" version: "1" deprecated: true sunset: "2026-06-01" {
fn get_users() { [] }
route GET "/users" => get_users
}
routes "/api/v2" version: "2" {
fn get_users_v2() { [] }
route GET "/users" => get_users_v2
}
}Deprecated versions automatically include Deprecation and Sunset headers in responses, helping clients migrate.
JSON Responses and Parsing
Tova handles JSON automatically. Return any object or array from a handler, and it becomes a JSON response:
server {
fn get_config() {
// Automatically becomes: Content-Type: application/json
{
version: "1.0",
features: ["auth", "logging", "websockets"],
limits: {
max_upload: 10485760,
rate: 100
}
}
}
route GET "/api/config" => get_config
}For POST/PUT requests, Tova parses the JSON body and extracts parameters by name:
server {
fn create_item(name, price, tags) {
// Client sends: { "name": "Widget", "price": 25, "tags": ["new"] }
// Tova extracts name, price, and tags from the body
{ created: name, price: price, tags: tags }
}
route POST "/api/items" => create_item
}Database Basics
Declare a database connection with the db block:
server {
db {
path: "./app.sqlite"
}
fn get_users() {
db.query("SELECT * FROM users")
}
fn create_user(name, email) {
db.run("INSERT INTO users (name, email) VALUES (?, ?)", [name, email])
respond(201, { ok: true })
}
route GET "/api/users" => get_users
route POST "/api/users" => create_user
}For SQLite, use path. For PostgreSQL or MySQL, use driver and url:
server {
db {
driver: "postgres"
url: "postgres://localhost:5432/myapp"
}
fn get_users() {
db.query("SELECT * FROM users")
}
}Tova auto-detects db usage and generates the appropriate imports. When the server shuts down, db.close() is called automatically.
Model Declarations
Define data models in a shared block and reference them with model in your server:
shared {
type User {
id: Int
name: String
email: String
active: Bool
}
type Task {
id: Int
title: String
done: Bool
priority: Int
}
}
server {
db {
path: "./app.sqlite"
}
model User
model Task
fn get_users() {
db.query("SELECT * FROM users")
}
route GET "/api/users" => get_users
}The model declaration tells Tova to generate a SQL table schema based on the type fields. Int maps to INTEGER, String to TEXT, Bool to BOOLEAN, and Float to REAL (or DOUBLE PRECISION in PostgreSQL). Fields named id get PRIMARY KEY AUTOINCREMENT.
Shared Types
Types in the shared block are available to both server and browser code. This means your API request/response types stay in sync automatically. When you use body: User on a route, Tova validates incoming requests against the type's fields.
Route Body Type Validation
Annotate route bodies with shared types for automatic deep validation:
shared {
type CreateUser {
name: String
email: String
age: Int
}
}
server {
route POST "/api/users" body: CreateUser => fn(req) {
// req.body is guaranteed to match CreateUser's shape
// Invalid requests get a 400 with detailed error messages
respond(201, { ok: true })
}
route POST "/api/users/batch" body: [CreateUser] => fn(req) {
// Also works with arrays of typed objects
respond(201, { count: len(req.body) })
}
}Authentication
Set up JWT or API key authentication with the auth block:
server {
auth { type: "jwt", secret: "your_secret_key" }
fn public_data() {
{ data: "anyone can see this" }
}
fn private_data() {
{ data: "only authenticated users" }
}
fn admin_action() {
{ data: "admin only" }
}
// No auth required
route GET "/api/public" => public_data
// Requires valid JWT
route GET "/api/private" with auth => private_data
// Requires valid JWT AND admin role
route POST "/api/admin" with auth, role("admin") => admin_action
}The with auth decorator on a route checks the Authorization: Bearer <token> header. If the token is invalid or expired, Tova returns 401 Unauthorized. The role("admin") decorator checks the role claim in the JWT payload and returns 403 Forbidden if it doesn't match.
API Key Authentication
For simpler use cases, use API key authentication:
server {
auth {
type: "api_key"
header: "X-API-Key"
keys: ["key_abc123", "key_def456"]
}
fn protected_data() {
{ data: "requires API key" }
}
route GET "/api/data" with auth => protected_data
}Secret Management
Never hardcode secrets in production code. Use environment variables with the env declaration:
server {
env JWT_SECRET: String
auth { type: "jwt", secret: JWT_SECRET }
}Environment Variables
Declare and validate environment variables at startup:
server {
env DATABASE_URL: String
env PORT: Int = 3000
env DEBUG: Bool = false
env MAX_RETRIES: Int = 3
fn status() {
{ port: PORT, debug: DEBUG }
}
}Required variables (no default) cause the server to exit immediately with a clear error if they're missing. Variables with defaults are optional. Types are coerced automatically -- "3000" becomes 3000 for Int, "true" becomes true for Bool.
WebSocket Endpoints
Add real-time communication with the ws block:
server {
ws {
on_open fn(ws) {
print("Client connected")
ws.send("Welcome!")
}
on_message fn(ws, msg) {
print("Received: {msg}")
// Echo back
ws.send("Echo: {msg}")
}
on_close fn(ws, code, reason) {
print("Client disconnected: {code} {reason}")
}
on_error fn(ws, error) {
print("WebSocket error: {error}")
}
}
fn hello() { "world" }
}WebSocket connections are upgraded automatically when a client sends a WebSocket handshake. The ws object provides send(message) for sending data back to the client.
Building a Chat Room
server {
var clients = []
ws {
on_open fn(ws) {
clients = [...clients, ws]
// Broadcast to all
for client in clients {
client.send("A new user joined!")
}
}
on_message fn(ws, msg) {
// Broadcast message to all connected clients
for client in clients {
client.send(msg)
}
}
on_close fn(ws, code, reason) {
clients = clients |> filter(fn(c) c != ws)
}
}
fn status() {
{ connected: len(clients) }
}
route GET "/api/status" => status
}Server-Sent Events
For one-way real-time streaming (server to client), use the stream() helper:
server {
fn event_feed(req) {
stream(fn(send, close) {
send("event: connected")
send("data: hello")
// In practice, you'd send events over time
close()
})
}
route GET "/api/events" => event_feed
}The stream() function creates a ReadableStream with text/event-stream content type, perfect for SSE. The send callback enqueues data, and close ends the stream.
For generator-based SSE, use yield in your handler:
server {
fn stream_updates(req) {
yield "Starting stream..."
yield "Processing batch 1"
yield "Processing batch 2"
yield "Complete!"
}
route GET "/api/stream" => stream_updates
}Tova detects yield in the handler and automatically wraps it in an SSE-compatible ReadableStream.
Health Checks
Add a health check endpoint with a single line:
server {
health "/health"
fn hello() { "world" }
}This generates a GET /health endpoint that returns:
{ "status": "ok", "uptime": 12345 }Health Checks with Database
If you have a database, you can add a database health check:
server {
db {
path: "./app.sqlite"
}
health "/health" { check_db }
fn get_data() {
db.query("SELECT * FROM items")
}
}The check_db option runs a SELECT 1 query to verify the database connection is alive.
Error Handlers
Define a global error handler for unhandled exceptions:
server {
on_error fn(err, req) {
print("Error on {req.method} {req.url}: {err}")
respond(500, {
error: "Internal server error",
message: "Something went wrong"
})
}
fn risky_operation() {
// If this throws, on_error catches it
db.query("SELECT * FROM nonexistent_table")
}
route GET "/api/risky" => risky_operation
}Without a custom error handler, Tova still catches errors and returns a generic 500 response. But defining on_error lets you log, report to error tracking services, or return custom error formats.
Static Files
Serve static files from a directory:
server {
static "/assets" => "./public"
fn hello() { "world" }
}Any request starting with /assets/ will look for a matching file in the ./public directory. For example, GET /assets/style.css serves ./public/style.css.
CORS Configuration
Configure Cross-Origin Resource Sharing:
server {
cors {
origins: ["https://myapp.com", "https://admin.myapp.com"]
methods: ["GET", "POST", "PUT", "DELETE"]
headers: ["Content-Type", "Authorization"]
}
fn hello() { "world" }
}Without a cors block, Tova defaults to Access-Control-Allow-Origin: * (open to all origins). In production, always restrict to your specific domains.
CORS in Production
Open CORS (*) is fine during development but dangerous in production. Always list your specific origins.
Rate Limiting
Protect your API from abuse:
server {
rate_limit { max: 100, window: 60 }
fn hello() { "world" }
}This limits each client IP to 100 requests per 60-second window. Exceeding the limit returns 429 Too Many Requests with a Retry-After header. Tova automatically cleans up expired rate limit entries.
Per-Route Rate Limits
Apply stricter limits to specific routes:
server {
fn login(email, password) {
// authenticate...
{ token: "..." }
}
fn get_data() {
{ items: [] }
}
// Strict rate limit on login: 10 per 60 seconds
route POST "/api/login" with rate_limit(10, 60) => login
// Default rate limiting on other routes
route GET "/api/data" => get_data
}Scheduled Jobs
Run tasks on a timer or cron schedule:
server {
schedule "5m" fn cleanup() {
print("Running cleanup every 5 minutes")
}
schedule "1h" fn hourly_report() {
print("Generating hourly report")
}
schedule "*/5 * * * *" fn cron_task() {
print("Cron: every 5 minutes")
}
fn hello() { "world" }
}Simple intervals use shorthand: "30s" (seconds), "5m" (minutes), "1h" (hours). For complex schedules, use standard cron expressions.
Scheduled tasks are registered after the server starts and their intervals are cleaned up during graceful shutdown.
Lifecycle Hooks
Run code when the server starts or stops:
server {
on_start fn() {
print("Server is ready!")
}
on_stop fn() {
print("Server is shutting down, cleaning up...")
}
fn hello() { "world" }
}on_start runs after Bun.serve() is called. on_stop runs during graceful shutdown (SIGINT/SIGTERM). You can have multiple hooks of each type.
Named Servers and Multi-Server
For microservice architectures, use named server blocks:
server "api" {
health "/health"
fn get_users() { [] }
fn create_user(name) { name }
route GET "/api/users" => get_users
route POST "/api/users" => create_user
}
server "events" {
health "/health"
fn push_event(kind, data) { kind }
route POST "/events" => push_event
}Each named server compiles to a separate file and runs on its own port. Tova automatically generates RPC proxies between them -- the api server gets a events.push_event() function, and the events server gets api.get_users() and api.create_user(). No manual HTTP client code needed.
Service Discovery
For deployment, configure service discovery:
server "api" {
discover "events" at "http://events.local:4000"
fn get_users() { [] }
}
server "events" {
fn push_event(kind) { kind }
}Pub/Sub Event Bus
For event-driven architectures within a server:
server {
subscribe "user.created" fn(data) {
print("New user: {data.name}")
// Send welcome email, update analytics, etc.
}
subscribe "order.placed" fn(data) {
print("New order: {data.id}")
}
fn create_user(name, email) {
user = { name: name, email: email }
publish("user.created", user)
respond(201, user)
}
route POST "/api/users" => create_user
}The publish(event, data) function dispatches to all handlers that subscribe to that event. In multi-server setups, events are forwarded between peers automatically.
Route Decorators
Routes support multiple decorators for composable middleware:
server {
auth { type: "jwt", secret: "secret" }
fn get_data() { [] }
fn slow_query() { db.query("SELECT ...") }
// Auth required
route GET "/api/data" with auth => get_data
// Auth + admin role
route DELETE "/api/data" with auth, role("admin") => get_data
// Timeout after 5 seconds (returns 504)
route GET "/api/slow" with timeout(5000) => slow_query
// Auth + rate limit + timeout
route POST "/api/heavy" with auth, rate_limit(10, 60), timeout(10000) => slow_query
}Auto-Generated API Docs
When your server uses shared types, Tova generates an OpenAPI 3.0 specification automatically:
shared {
type User {
id: Int
name: String
email: String
}
}
server {
fn get_users(req) { [] }
fn create_user(name: String, email: String) { "ok" }
route GET "/api/users" -> [User] => get_users
route POST "/api/users" body: User => fn(req) { "ok" }
}The -> [User] annotation on the GET route declares the response type. Tova uses these annotations to generate:
/openapi.json-- the full OpenAPI spec/docs-- a documentation UI
Putting It All Together
Here is a complete task manager API that uses most of the features from this chapter:
server {
health "/health"
cors {
origins: ["http://localhost:5173"]
methods: ["GET", "POST", "PUT", "DELETE"]
headers: ["Content-Type", "Authorization"]
}
auth { type: "jwt", secret: "my_secret_key" }
rate_limit { max: 100, window: 60 }
middleware fn logger(req, next) {
start = Date.now()
result = next(req)
elapsed = Date.now() - start
print("[{req.method}] {req.url} - {elapsed}ms")
result
}
on_error fn(err, req) {
print("Server error: {err}")
respond(500, { error: "Something went wrong" })
}
var tasks = []
var next_id = 1
fn list_tasks() {
tasks
}
fn get_task(id) {
found = tasks |> find(fn(t) t.id == toInt(id))
match found {
Some(task) => task
None => respond(404, { error: "Task not found" })
}
}
fn create_task(title: String, priority: String) {
task = {
id: next_id,
title: title,
priority: priority,
done: false,
created_at: Date.now()
}
next_id += 1
tasks = [...tasks, task]
respond(201, task)
}
fn update_task(id, title, priority, done) {
tasks = tasks |> map(fn(t) {
if t.id == toInt(id) {
{ ...t, title: title, priority: priority, done: done }
} else {
t
}
})
respond(200, { ok: true })
}
fn delete_task(id) {
tasks = tasks |> filter(fn(t) t.id != toInt(id))
respond(200, { ok: true })
}
routes "/api/v1" {
route GET "/tasks" => list_tasks
route GET "/tasks/:id" => get_task
route POST "/tasks" with auth => create_task
route PUT "/tasks/:id" with auth => update_task
route DELETE "/tasks/:id" with auth, role("admin") => delete_task
}
schedule "5m" fn cleanup_done() {
tasks = tasks |> filter(fn(t) !t.done)
print("Cleaned up completed tasks")
}
}This single file gives you:
- A RESTful API with all CRUD operations
- JWT authentication on write operations
- Admin-only delete
- Rate limiting (100 requests/minute)
- CORS configured for your frontend
- Request logging middleware
- Global error handling
- Health check endpoint
- A scheduled job to clean up completed tasks
- Automatic input validation on typed parameters
- Graceful shutdown with request draining
Exercises
Exercise 14.1: Build a simple bookmarks API. Create a server with routes to list, create, and delete bookmarks. Each bookmark should have a url, title, and tags (an array of strings). Add a GET "/api/bookmarks/search" route that takes a tag query parameter and returns matching bookmarks.
Exercise 14.2: Add middleware to your bookmarks API that measures response time and sets an X-Response-Time header on every response. Also add a rate_limit that allows 50 requests per 30-second window, and a health check at /health.
Exercise 14.3: Create a server with two route groups: /api/v1 and /api/v2. In v1, the GET "/users" route returns [{ name: "Alice" }]. In v2, it returns [{ name: "Alice", email: "alice@test.com" }]. Mark v1 as deprecated with a sunset date. Add scoped middleware on v2 that logs "v2 request" for every request.
Challenge
Build a real-time notification system with these features:
- A REST API for creating notifications (
POST /api/notificationswithtitle,message,priority) - WebSocket support so connected clients receive notifications instantly when one is created (use
publishto bridge the REST handler and WebSocket broadcaster) - An SSE endpoint (
GET /api/notifications/stream) as an alternative to WebSocket for clients that don't support it - JWT authentication on all write endpoints
- A scheduled job that runs every hour to delete notifications older than 24 hours
- Rate limiting of 20 notification creates per minute per client
- A route group under
/api/v1for all endpoints - A health check with database connectivity check