Docs/Networking

HTTP server

Serve requests with routes, middleware, and static files.

On this page

valk.http has a multi-threaded HTTP/1.1 server with TLS and opt-in HTTP/2. You give it a handler that turns an http.Request into an http.Response.

A first server#

Valk
use valk.http

fn handle(req: http.Request) http.Response {
    let name = req.query().get("name") !? "stranger"
    return http.Response.html("<h1>Hello " + name + "</h1>")
}

fn main() {
    let server = http.Server.new("127.0.0.1", 9000, handle)
    server.show_info = true
    server.start() ! {
        println("Could not start the server")
        return
    }
}

Run it with valk build server.valk --run and open http://127.0.0.1:9000/?name=Ada. start() serves until shutdown on one worker thread per CPU; start(4) picks the count. Bind to 0.0.0.0 (or ::) to accept connections from other machines. http.serve(host, port, handle) is a one-line shortcut.

Handlers run on several threads at once, so data they share is a global (one per thread) or an atomic or locked value.

Read the request#

Member Gives
method, path, query_string The request line; path is not percent-decoded
peer_address The client's address, e.g. req.peer_address.ip()
header(name) / headers() One header (any case) or all of them
query() / query_grouped() Query parameters, decoded
form() Fields of a form body
json() / parse_json() The body as a json.Value; parse_json throws on invalid JSON
files() Uploaded files of a multipart/form-data body
cookie(name) / cookies() Request cookies
body The raw body
Valk
use valk.http

class Signup {
    email: String
    newsletter: bool (false)
}

fn handle(req: http.Request) http.Response {
    if req.method != "POST" : return http.Response.text("Use POST", 405)
    let agent = req.header("user-agent") !? "unknown"
    let input = req.parse_json() ! return http.Response.text("Invalid JSON", 400)
    let signup = input.to_type[Signup]() ! return http.Response.text("Missing email", 422)
    return http.Response.text("Welcome " + signup.email + " (" + agent + ")")
}

fn main() {
    http.serve("127.0.0.1", 9000, handle) ! return
}

For uploads, req.files() maps each field to an fs.InMemoryFile with filename, mime_type, data and save(path).

Send a response#

Constructor Sends
Response.text(body, code) text/plain
Response.html(body, code) text/html
Response.json(text, code) Already encoded JSON
Response.json_of(value, code) Any value, encoded as JSON
Response.redirect(url, code) A redirect, 302 by default
Response.file(path, filename) A file from disk
Response.stream(reader, size, content_type) A body read from an io.Reader
Response.events(channel) Server-Sent Events
Response.empty(code) No body
Response.new(body, code, content_type) Anything else
Valk
use valk.http

class Status {
    ok: bool
    version: String
}

fn handle(req: http.Request) http.Response {
    if req.path == "/old" : return http.Response.redirect("/new", 301)
    if req.path == "/report" : return http.Response.file("report.pdf", "report-2026.pdf")
    let res = http.Response.json_of(Status { ok: true, version: "1.0" })
    res.set_header("Cache-Control", "no-store")
    return res
}

fn main() {
    http.serve("127.0.0.1", 9000, handle) ! return
}

set_header replaces a header and add_header adds another field with the same name. A file response answers Range and cache requests (ETag, 304) by itself, and the filename makes the browser download it.

Routes#

http.Router[T] maps a method and a path pattern to a value, usually a handler. @name matches one part of the path and * the rest:

Valk
use valk.http

type Handler (fn(http.Request)(http.Response))

global routes: http.Router[Handler] (make_routes())

fn make_routes() http.Router[Handler] {
    let router = http.Router[Handler].new()
    router.add("GET", "/", fn(req: http.Request) http.Response { return http.Response.text("Home") })
    router.add("GET", "/users/@id", show_user)
    router.add("GET", "/files/*", fn(req: http.Request) http.Response { return http.Response.text("A file") })
    return router
}

fn show_user(req: http.Request) http.Response {
    let route = routes.find(req.method, req.path) ! return http.Response.empty(404)
    let id = route.params(req.path).get("id") !? ""
    return http.Response.text("User " + id)
}

fn handle(req: http.Request) http.Response {
    let route = routes.find(req.method, req.path) ! {
        let allowed = routes.allowed_methods(req.path)
        if allowed.length == 0 : return http.Response.text("Not found", 404)
        return http.Response.empty(405, http.Headers { "Allow" => allowed.join(", ") })
    }
    return route.handler(req)
}

fn main() {
    http.serve("127.0.0.1", 9000, handle) ! return
}

A literal part wins over @name, and @name over *. params decodes the values; the * part is under the key "*". A global with an initializer gives every worker thread its own router.

Middleware#

A middleware gets the request and next, and answers itself or through next. Middleware added first runs first:

Valk
use valk.http
use valk.time

fn handle(req: http.Request) http.Response {
    return http.Response.text("Secret")
}

fn main() {
    let server = http.Server.new("127.0.0.1", 9000, handle)
    server.add_middleware(fn(req: http.Request, next: atomic fn(http.Request)(http.Response)) http.Response {
        let start = time.mono_ms()
        let res = next(req)
        println(req.method + " " + req.path + " " + res.status + " " + (time.mono_ms() - start) + "ms")
        return res
    })
    server.add_middleware(fn(req: http.Request, next: atomic fn(http.Request)(http.Response)) http.Response {
        let token = req.header("authorization") !? ""
        if token != "Bearer letmein" : return http.Response.text("Unauthorized", 401)
        return next(req)
    })
    server.start() ! return
}

Static files#

add_static_dir serves the files in a directory before a request reaches the handler; / serves index.html:

Valk
use valk.http

fn handle(req: http.Request) http.Response {
    return http.Response.text("Not a file", 404)
}

fn main() {
    let server = http.Server.new("127.0.0.1", 9000, handle)
    server.add_static_dir("public") ! return
    server.compress = true
    server.start() ! return
}

Pass index: "" to leave directory paths to the handler. compress = true gzips text, HTML, CSS, JavaScript, JSON and SVG for clients that accept it, for all responses.

Cookies#

Valk
use valk.http

fn handle(req: http.Request) http.Response {
    let visits = (req.cookie("visits") !? "0").to_uint() !? 0
    let res = http.Response.text("Visits: " + (visits + 1))
    let cookie = http.Cookie.new("visits", (visits + 1).to_string())
    cookie.max_age = 3600
    cookie.secure = false // plain HTTP during development
    res.set_cookie(cookie)
    return res
}

fn main() {
    http.serve("127.0.0.1", 9000, handle) ! return
}

A new cookie is Secure, HttpOnly and SameSite=Lax with path /. Set domain, path, expires or same_site to change that, and res.clear_cookie(name) deletes one.

Streaming#

Response.stream(reader) without a size sends the body as it is read. Response.events(channel) sends each string sent into a sync.Channel as a Server-Sent Event, until the channel is closed:

Valk
use valk.http
use valk.sync
use valk.time

fn tick(channel: sync.Channel[String]) {
    each 0..3 as i {
        channel.send("tick " + i) ! return
        time.sleep_ms(1000)
    }
    channel.close()
}

fn handle(req: http.Request) http.Response {
    let channel: sync.Channel[String] = sync.Channel[String].new() ! return http.Response.empty(500)
    co tick(channel)
    return http.Response.events(channel)
}

fn main() {
    http.serve("127.0.0.1", 9000, handle) ! return
}

For WebSockets, a handler returns http.WebSocket.upgrade(req, fn).

Fast handlers#

A fast handler skips building Request and Response objects. It gets an http.Context and writes the answer to an http.ResponseWriter:

Valk
use valk.http

fn fast(ctx: http.Context, res: http.ResponseWriter) {
    if ctx.path.equals("/ping") {
        res.send("pong")
        return
    }
    res.send_status(404)
}

fn main() {
    let server = http.Server.new("127.0.0.1", 9000)
    server.fast(fast)
    server.start() ! return
}

The context has the same header, query, form, json, files and cookie methods. Its method, path and query_string are byte views that are only valid during the call; copy them with to_string() to keep them. The writer has send(body, code, content_type, headers), send_status, send_file, send_stream and send_websocket; only the first call counts. Fast handlers are HTTP/1.1 only.

TLS and HTTP/2#

Valk
use valk.http

fn handle(req: http.Request) http.Response {
    return http.Response.text("Secure hello")
}

fn main() {
    let server = http.Server.new("0.0.0.0", 443, handle)
    server.tls("certificate.pem", "private-key.pem") ! {
        println("Could not load the certificate")
        return
    }
    server.http2 = true
    server.start() ! return
}

tls takes PEM files and an optional minimum version (net.TlsVersion.tls_1_3). HTTP/2 is experimental, needs TLS, and falls back to HTTP/1.1 for clients without it. server.tls_client_ca("clients-ca.pem") asks clients for a certificate (mutual TLS); a handler then reads req.client_subject.

Limits and timeouts#

Set these on the server before start:

Field Default Meaning
max_request_body_size 32 MiB Largest request body (413 above it)
max_request_header_size 8 KiB Largest request line and headers
max_connections 0 (none) Open connections allowed
header_timeout_ms 5000 Time for the request head and the TLS handshake
body_timeout_ms 5000 Time for each read of a body
write_timeout_ms 5000 Time for each write
idle_timeout_ms 75000 How long a keep-alive connection may wait
min_transfer_rate 4096 Slowest accepted bytes per second; 0 turns it off
reuse_port false Let a new process listen on the same port (not on Windows)

Shut down#

stop_on_signal = true stops the server on Ctrl+C or SIGTERM: it stops accepting, gives running requests stop_on_signal_timeout_ms (5000) to finish, and start returns. To stop from code, run start in a coroutine:

Valk
use valk.http
use valk.time

fn handle(req: http.Request) http.Response {
    return http.Response.text("Hello")
}

fn main() {
    let server = http.Server.new("127.0.0.1", 9000, handle)
    let running = co server.start()
    time.sleep_ms(60_000)
    server.shutdown(5000) // waits up to 5 seconds for running requests
    await running ! {
        println("The server failed")
    }
}

request_shutdown(ms) starts the same stop without waiting. See Signals to combine it with other cleanup.