radlang — HTTP (http & httpserver)

This document covers radlang’s built-in HTTP support: the http namespace for making client requests, and the httpserver namespace for building servers with routing, middleware, and CORS.

For language fundamentals see the Overview.


Overview

Both namespaces are compiler-intrinsic — the names http and httpserver are reserved and dispatched directly to the runtime, so they need no import.

fn main() {
    // Client: fetch a URL (synchronous twin — see "Async by default" below)
    HttpResponse res = http.getSync("http://example.com")
    sys.output(res.status.toString())   // 200
    sys.output(res.body)
}

Several built-in types support this API:

TypeRole
HttpResponseResult of a client request / value a handler returns
HttpRequestThe incoming request passed to a server handler
HttpServerAn opaque server handle from httpserver.serve
NextFnArg-less next() callback in middleware; advances the chain

The HTTP client (http)

http.get(url, headers?)                  : Future<HttpResponse>
http.delete(url, headers?)               : Future<HttpResponse>
http.post(url, body, headers?)           : Future<HttpResponse>
http.put(url, body, headers?)            : Future<HttpResponse>
http.patch(url, body, headers?)          : Future<HttpResponse>
http.request(method, url, headers, body) : Future<HttpResponse>

Every verb takes an optional trailing headers argument — either a string[] of "Name: value" lines or a Map<string,string> (see maps). Content-Type is just a header — there is no separate content-type argument; set it in the headers when a request needs one:

http.getSync(url, ["Authorization: Bearer abc123"])
http.getSync(url, { "Authorization": "Bearer abc123", "Accept": "application/json" })
http.postSync(url, body, { "Content-Type": "application/json", "X-Trace": "42" })

Async by default

The request verbs are asynchronous by default — each runs the round-trip on a worker thread and returns a Future<HttpResponse>, so a busy server or scheduler is never blocked waiting on a socket. Consume the future with await (inside an async fn) or .resolve(cb) (a non-blocking microtask callback):

async fn fetchTitle(): string {
    HttpResponse res = await http.get("http://example.com")
    return res.body
}

Each verb has a synchronous …Sync twingetSync, postSync, putSync, patchSync, deleteSync, requestSync — that blocks and returns the HttpResponse directly. Use the twins in straight-line scripts; the examples in this section use them for brevity.

Every response is an HttpResponse with these fields:

FieldTypeMeaning
statusintHTTP status code (e.g. 200)
bodystringResponse body
contentTypestringThe response Content-Type (a convenience reader for the Content-Type header)

Client responses also capture all response headers off the wire, read via methods (case-insensitive):

HttpResponse r = http.getSync(url)
r.header("ETag")          // string ("" if absent)
r.hasHeader("Set-Cookie") // bool
r.headerAll("Set-Cookie") // string[] — every value of a repeated header
r.headers()               // string[] — every "Name: value" line
fn main() {
    // GET (sync twin)
    HttpResponse r = http.getSync("http://localhost:8080/hello")
    if r.status == 200 {
        sys.output(r.body)
    }

    // POST with a body + content type (a header)
    HttpResponse p = http.postSync("http://localhost:8080/echo", "payload",
        { "Content-Type": "text/plain" })
    sys.output(p.body)

    // DELETE
    http.deleteSync("http://localhost:8080/item/1")
}

Set an outgoing Content-Type (or any header) through the headers argument — it is just a header like any other.

For full control over the method, http.request / http.requestSync take the headers as a string[] of "Name: value" lines:

fn main() {
    string[] headers = ["Authorization: Bearer abc123", "Accept: application/json"]
    HttpResponse r = http.requestSync("GET", "http://localhost:8080/me", headers, "")
    sys.output(r.status.toString())
}

URL encoding

Two helpers percent-encode / percent-decode strings for use in URLs and query strings (the RFC 3986 unreserved set A–Z a–z 0–9 - _ . ~ is left untouched; decoding also turns + into a space):

http.encode(s: string) : string      // "a b/c" -> "a%20b%2Fc"
http.decode(s: string) : string      // "a%20b" -> "a b"

Timeouts and transport errors

Every request has a connect + transfer timeout (default 30 seconds). Bound it for all subsequent requests with:

http.timeout(seconds: int) : void    // e.g. http.timeout(5)

A transport failure — connection refused, DNS resolution failure, TLS error, or a timeout — never crashes or hangs the program:

  • On the synchronous (…Sync) path it raises a catchable Error whose message describes the failure and whose code is the underlying transport error code. Wrap the call in try/catch:

    http.timeout(5)
    try {
        HttpResponse r = http.getSync("http://localhost:1/health")
        sys.output(r.body)
    } catch (Error e) {
        sys.output("request failed: " + e.message)
    }
  • On the async path (await http.get(...) / .resolve) the future resolves to an HttpResponse with status == 0 and the failure message in body (throwing from the scheduler’s completion tick would unwind the wrong frame, so the failure is delivered as a value). Check status != 0 before using it, or use the …Sync twin inside a try/catch when you want a throw.

Resilience (retry, circuit breaker, proxy)

Client resilience is configured process-wide and applies to every subsequent request (sync and async alike). All three are optional; the defaults leave behaviour unchanged.

http.retry(count: int, backoffMs: int = 200) : void
http.circuitBreaker(threshold: int, cooldownMs: int) : void
http.proxy(url: string) : void      // "" disables
  • retry — retry failed requests up to count extra times with exponential backoff (backoffMs * 2^(attempt-1)). A request is retried on a transport error or a 5xx response; 4xx and 2xx are returned immediately. With the default count of 0, retries are off.

    http.retry(3)        // up to 3 retries, 200ms/400ms/800ms backoff
    http.retry(2, 50)    // up to 2 retries, 50ms/100ms backoff
  • circuitBreaker — per-host failure tracking. After threshold consecutive failures (transport error or 5xx) to a host, the circuit opens and further requests to that host fail fast with a circuit breaker open error for cooldownMs. It then half-opens to allow one probe; a success closes it, a failure re-opens it. Off unless threshold > 0.

    http.circuitBreaker(5, 10000)   // open after 5 fails, 10s cooldown
  • proxy — route all requests through an HTTP proxy. Pass "" to clear.

    http.proxy("http://127.0.0.1:8888")

Connections reuse a shared DNS cache across requests automatically (no configuration), avoiding repeat lookups to the same host.


The HTTP server (httpserver)

Creating and starting

httpserver.serve(port: int) : HttpServer
server.listen() : void     // start; blocks the current thread
server.start()  : void     // start on a background thread; returns immediately
server.close()  : void     // stop the server

Register routes on the server, then start it. Use listen() for a normal foreground server, or start() when you need the program to keep running (for example, to make client requests against your own server in a test).

listen() automatically scales across every CPU core — see Multi-core scaling. start() stays single-process.

HTTPS (TLS)

Serve HTTPS natively — no reverse proxy required — by attaching a certificate and private key before starting the server:

server.tls(certPath: string, keyPath: string) : void
fn main() {
    HttpServer server = httpserver.serve(8443)
    server.tls("certs/cert.pem", "certs/key.pem")   // enable HTTPS
    server.get("/", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "hello over TLS")
    })
    server.listen()
}

certPath and keyPath are PEM file paths loaded at runtime, so private keys stay out of the compiled binary (same posture as .radenv). The certificate file holds the leaf certificate first, followed by any intermediate chain certificates; the key may be RSA or EC (prime256v1). Paths resolve relative to the project root. If either file is missing or malformed the server refuses to start rather than silently falling back to plaintext.

TLS is powered by a vendored, statically-linked BearSSL engine (no OpenSSL dependency), which negotiates TLS 1.2 with modern forward-secret ECDHE cipher suites. Everything downstream — routing, middleware, static assets, server.ssr — is served encrypted with no code change.

Generate a self-signed certificate for local development with:

openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \
    -days 365 -subj "/CN=localhost"

Calling a TLS server from the http client. For a real certificate signed by a public CA, no configuration is needed. To reach an internal service or a self-signed dev server, point the client at the certificate to trust, or (dev only) disable verification, via the environment:

RADLANG_TLS_CA=/path/to/ca.pem     # pin a CA bundle / self-signed cert to trust
RADLANG_TLS_INSECURE=1             # disable peer/host verification (dev only)

RADLANG_TLS_INSECURE logs a one-time warning so it can’t silently weaken a production build.

Not yet over TLS: server.ws / server.sse / server.stream (WebSocket and streaming) still take over the raw socket and are skipped on a TLS listener — wss:// is a follow-up. The manual server.accept() / server.respond() loop is also plaintext-only; use listen() / start() for HTTPS.

Compression (gzip)

Responses are gzip-encoded automatically — there is nothing to enable. When a request advertises Accept-Encoding: gzip, the server compresses the response body and adds Content-Encoding: gzip and Vary: Accept-Encoding, provided:

  • the content type is text-ish (text/*, application/json, application/javascript, application/xml, image/svg+xml, and friends), and
  • the body is at least ~860 bytes (below that gzip rarely pays for its overhead).

Already-compressed types (images, video, fonts, archives) and small bodies are sent as-is. Both dynamic handler responses and static assets served from server.useStatic are covered; static files above 8 MB stream uncompressed to keep memory bounded.

On the client side the http namespace advertises the encodings libcurl was built with and transparently inflates gzip/deflate responses, so http.getSync(url).body is always the decoded text — no manual step. Setting an explicit Accept-Encoding header yourself opts out of that auto-decode and hands you the raw encoded body.

Compression is powered by a vendored, statically-linked miniz (no zlib dependency). The same codec is exposed as the reusable compress namespace — gzip/deflate (de)compression over strings or bytes, gzip metadata, and ZIP archive create/extract — for use outside HTTP.

Caching (ETag / conditional requests)

Every static asset served through server.useStatic carries HTTP caching validators automatically — there is nothing to enable. Each 200 response gets:

  • ETag — a strong validator derived from the file’s modification time and size (e.g. "6710a3c0-1f4"). It changes whenever the file changes.
  • Last-Modified — the file’s modification time as an HTTP date.
  • Cache-Control — the caching policy (see below).

When a client revalidates — sending back the ETag in If-None-Match or the date in If-Modified-Since — and nothing has changed, the server replies 304 Not Modified with no body, skipping the transfer entirely. Browsers do this on their own once they’ve cached a response, so a repeat visit re-validates cheaply instead of re-downloading. If-None-Match takes precedence over If-Modified-Since (RFC 7232), and If-None-Match: * matches any existing file.

Cache-Control policy. By default assets are sent public, max-age=3600 (cacheable for an hour), with one deliberate exception: HTML is always no-cache so an SPA/SSR index shell is never served stale — it still carries an ETag, so revalidation is a cheap 304 rather than a full download. Tune the max-age with server.cache:

server.cache(maxAgeSeconds: int) : void
fn main() {
    HttpServer server = httpserver.serve(8080)
    server.useStatic("dist")
    server.cache(86400)   // cache non-HTML assets for a day
    server.listen()
}

server.cache(0) sets no-cache for everything (assets still revalidate via the ETag) — a useful setting during development. The configured max-age applies to cacheable assets only; HTML always revalidates regardless.

For dynamic responses, set the headers yourself — the request headers and hash namespace give you everything needed to revalidate by hand:

server.get("/report", fn(HttpRequest req): HttpResponse {
    string body = buildReport()
    string etag = "\"" + hash.sha1(body) + "\""
    if (req.header("If-None-Match") == etag) {
        return HttpResponse(304, "").setHeader("ETag", etag)
    }
    return HttpResponse(200, body).setHeader("ETag", etag)
})

Routing

server.get(path, handler)
server.post(path, handler)
server.put(path, handler)
server.delete(path, handler)
server.patch(path, handler)

A handler is a function fn(HttpRequest): HttpResponse. Construct the response with HttpResponse(status, body) or HttpResponse(status, body, headers), where headers is a Map<string,string> or string[]. The content type defaults to text/plain; set a different one as a Content-Type header. res.contentType reads it back.

fn main() {
    HttpServer server = httpserver.serve(8080)

    server.get("/hello", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "hello world")            // text/plain
    })

    server.get("/data", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "[]", { "Content-Type": "application/json" })
    })

    server.post("/echo", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, req.body)
    })

    server.listen()   // blocks, serving requests
}

Async handlers

A route handler may also be a named async fn(HttpRequest): HttpResponse. Each request is served in its own coroutine, so a handler’s await points suspend and resume transparently — you write straight-line code that awaits a db read, an outbound http.get, or any other future, and return the response normally:

async fn showUser(HttpRequest req): HttpResponse {
    DbUsers[] rows = await conn.select().from(UsersTbl).where("id = 1")
    return HttpResponse(200, rows[0].name, "text/plain")
}

fn main() {
    HttpServer server = httpserver.serve(8080)
    server.get("/user", showUser)     // pass the async fn by name
    server.listen()
}

Only named async functions are supported as handlers today — an inline async fn(...) literal is not yet parseable. (A plain inline handler can still reach async work synchronously via sys.blockOn(future), but a named async fn with await is the clean form.)

The request object

HttpRequest carries the incoming request:

FieldTypeMeaning
methodstringHTTP verb (GET, POST, …)
pathstringRequest path
querystringRaw query string
bodystringRequest body
headersstringRaw headers block

Three helper methods pull named values out:

req.param(name: string)     : string     // a captured route parameter (:name / *)
req.query(name: string)     : string     // a query-string parameter, URL-decoded
req.header(name: string)    : string     // a request header value (case-insensitive)
req.hasHeader(name: string) : bool        // is the header present?
req.headerAll(name: string) : string[]    // every value of a repeated header
req.headers()               : string[]    // every "Name: value" line
  • req.param reads a segment captured by a route pattern, e.g. :id in /user/:id or * for a wildcard.
  • req.query reads a ?key=value parameter and URL-decodes it for you; absent keys return "".
  • Header lookups are case-insensitive (req.header("x-test") matches X-Test). req.header returns "" for an absent header; use req.hasHeader to distinguish absent from empty. req.headerAll collects every value of a repeated header (e.g. Set-Cookie).
// route: /user/:id   requested as  /user/42?tab=billing&q=hello%20world
server.get("/user/:id", fn(HttpRequest req): HttpResponse {
    string id  = req.param("id")           // "42"
    string tab = req.query("tab")          // "billing"
    string q   = req.query("q")            // "hello world" (decoded)
    string auth = req.header("Authorization")
    return HttpResponse(200, "user " + id, "text/plain")
})

File uploads (multipart/form-data)

multipart.parse(req) splits a multipart/form-data request body into its parts. It reads the raw body binary-safely (by the exact byte count, so file uploads with embedded NULs survive) and takes the boundary from the request’s Content-Type header. It returns a FormPart[]:

FieldTypeMeaning
namestringThe form field name
filenamestringThe uploaded file’s name ("" for a plain field)
contentTypestringThe part’s own Content-Type ("" if absent)
databyte[]The raw part body
server.post("/upload", fn(HttpRequest req): HttpResponse {
    FormPart[] parts = multipart.parse(req)
    for (FormPart p in parts) {
        if (p.filename != "") {
            // A file part: persist the raw bytes to disk.
            fs.writeBytesSync("uploads/" + p.filename, p.data)
        } else {
            // A plain text field: decode the bytes to a string.
            string value = p.data.toString()
        }
    }
    return HttpResponse(200, "ok")
})

A part with an empty filename is an ordinary form field; a non-empty filename marks a file upload. Use p.data.length() for the byte size and p.data.toString() to read a text field’s value.

Redirects

An HttpResponse has a redirect helper that produces a 302 pointing at another URL:

server.get("/old", fn(HttpRequest req): HttpResponse {
    HttpResponse res = HttpResponse(200, "", "text/plain")
    return res.redirect("/new")
})

Custom response headers

Set response headers with res.header (append) or res.setHeader (replace). Both return the response so calls chain:

res.header(name: string, value: string)    : HttpResponse  // append (allows duplicates)
res.setHeader(name: string, value: string) : HttpResponse  // replace any existing same-named header
server.get("/data", fn(HttpRequest req): HttpResponse {
    return HttpResponse(200, "[]", "application/json")
        .header("X-Total-Count", "0")
        .setHeader("Cache-Control", "no-store")
})

Use header when you deliberately want repeated headers (e.g. multiple Set-Cookie); use setHeader when a header should appear once. The same two methods are available on the live res handle inside middleware. CR/LF characters in a header name or value are stripped automatically, so a user-supplied value can’t inject extra headers (HTTP response splitting).

Custom 404

server.notFound(handler)
server.notFound(fn(HttpRequest req): HttpResponse {
    return HttpResponse(404, "Nothing here", "text/plain")
})

Static assets

server.useStatic()        // serve files from cfg.staticDir (default "public")
server.useStatic("dist")  // serve from a different directory instead

useStatic() serves files at the URL root: a GET/HEAD request for /app.css returns <staticDir>/app.css, and / or any path ending in / returns index.html from that directory. The Content-Type is inferred from the file extension (html, css, js/mjs, json, svg, png, jpg, gif, webp, ico, txt, xml, pdf, wasm, woff/woff2, ttf, mp4, webm, mp3 — anything else is served as application/octet-stream).

fn main() {
    HttpServer server = httpserver.serve(8080)
    server.get("/api/users", handleUsers)
    server.useStatic()        // everything else tries public/ then notFound
    server.listen()
}

The lookup order is dynamic routes first, then a static file, then the 404 handler — a matching get/post/… route always wins over a file of the same path, and a static miss falls through to notFound. Middleware registered with use() does not run for static hits; they are streamed straight from disk.

Static assets are served with ETag, Last-Modified, and Cache-Control validators, and revalidating clients get a 304 Not Modified — see Caching. Tune the policy with server.cache(seconds).

The directory comes from paths.static in config.yaml (default public) and is resolved relative to the process’s working directory at run time. Request paths are percent-decoded and any .. traversal is rejected. It is also readable in code as cfg.staticDir.

# config.yaml
paths:
  static: "public"

Server-side data (server.ssr)

server.ssr hands server-computed data to a client framework in the first byte of HTML, so the page hydrates without a client fetch round-trip. It is framework-agnostic: the handler returns any JSON-serializable value and the server injects it as a global, which React, Vue, Svelte, or vanilla JS all read the same way.

server.ssr("/", fn(HttpRequest req): DashData {
    return DashData(currentUser(req), recentStats())   // any struct/value
})

The handler’s return value is the props — any JSON-serializable value (struct, list, map, primitive). The HttpRequest parameter is optional: take it to personalize the first paint (a route param, cookie, or query), or omit it when every visitor gets the same data.

server.ssr("/stats", fn(): SiteStats {          // no request needed
    return SiteStats(userCount(), uptime())
})

The returned value is JSON-serialized and spliced into a static HTML template as:

<script>window.__RAD_PROPS__=<your data as JSON></script>

The client reads that global on boot instead of fetching:

const props = window.__RAD_PROPS__;   // present in the first paint
hydrateRoot(document.getElementById("root"), <App {...props} />);

A typed, framework-agnostic accessor for the client side ships in ts/rad-ssr.interface.ts (getRadProps<T>() / requireRadProps<T>()).

Template. By default the template is <cfg.staticDir>/index.html. Pass an explicit template as a string literal to override it:

server.ssr("/", "dist/index.html", fn(HttpRequest req): DashData { ... })

The injection point is chosen in this order: the <!--rad-ssr--> marker if the template contains one (replaced in place), else immediately before </head>, else before </body>, else appended. Place the marker to control exactly where the props land:

<head>
  <title>Dashboard</title>
  <!--rad-ssr-->
</head>

One-call SPA + data. useStatic takes an optional trailing props handler, folding “serve the bundle and inject props into the index shell” into a single registration:

server.useStatic("dist", fn(HttpRequest req): DashData {
    return DashData(currentUser(req), recentStats())
})

This serves dist/ statically and injects props into index.html at /. Non-index assets (/bundle.js, /app.css, …) stay on the pure static path and are streamed untouched. useStatic(fn) uses cfg.staticDir as the directory. When a handler is present the directory must be a string literal (the template path is resolved at compile time).

Escaping. Prop values are HTML-escaped inside the script tag (<, >, & become their \uXXXX forms, plus the U+2028/U+2029 line separators), so a value containing </script> cannot break out of the tag.

Registration. An ssr route is a GET handler and follows the same precedence as any dynamic route: it wins over a static file of the same path. If the template file is missing at request time the server logs a warning and serves a minimal props-only shell (status 200) so hydration data still reaches the client.

What this is not. server.ssr injects data into the first paint; it does not render your components to HTML on the server (no renderToString, no SEO benefit from server-rendered markup). The page still hydrates from client JS. True server-side rendering (a Node sidecar streaming rendered HTML) is a separate future mode; the server.ssr(path, fn) surface is shaped so it can slot in behind a config flag without changing this handler signature.

Cookies

Cookies are handled through two types:

  • Cookie — a single cookie with all standard fields. Build one with Cookie(name, value) and set attributes with fluent, chainable setters.
  • CookieJar — a collection of cookies. Read one off a request, or build a fresh one to attach to a response.
Cookie(name: string, value: string) : Cookie   // constructor

// fluent setters (each returns the Cookie)
cookie.path(value: string)      : Cookie
cookie.domain(value: string)    : Cookie
cookie.maxAge(seconds: int)     : Cookie
cookie.expires(value: string)   : Cookie
cookie.httpOnly(value: bool)    : Cookie
cookie.secure(value: bool)      : Cookie
cookie.sameSite(value: string)  : Cookie        // "Strict" | "Lax" | "None"

// readable fields
cookie.name  cookie.value  cookie.path  cookie.domain
cookie.maxAge  cookie.expires  cookie.httpOnly  cookie.secure  cookie.sameSite
CookieJar()                     : CookieJar     // new empty jar
jar.get(name: string)           : Cookie        // empty Cookie (name "") if absent
jar.add(cookie: Cookie)         : void           // add / replace by name
jar.remove(name: string)        : void           // drop from the jar

Reading request cookiesreq.cookies() parses the incoming Cookie: header into a jar (name/value pairs only; requests carry no attributes):

server.get("/me", fn(HttpRequest req): HttpResponse {
    Cookie session = req.cookies().get("session")
    if (session.value == "") {
        return HttpResponse(401, "no session", "text/plain")
    }
    return HttpResponse(200, "hi " + session.value, "text/plain")
})

Setting response cookies — build a jar and attach it with res.cookies(jar), which emits a Set-Cookie: header per cookie:

server.get("/login", fn(HttpRequest req): HttpResponse {
    CookieJar jar = CookieJar()
    jar.add(Cookie("session", "abc123")
        .path("/")
        .maxAge(3600)
        .httpOnly(true)
        .secure(true)
        .sameSite("Lax"))
    return HttpResponse(200, "ok", "text/plain").cookies(jar)
})

Read-modify-writeres.cookies() (no argument) returns a fresh, modifiable copy of the cookies already set on a response. Modify it and put it back with res.cookies(jar); the setter replaces the response’s cookies, so a get → modify → put round-trip is idempotent:

CookieJar jar = res.cookies()
jar.add(Cookie("theme", "dark"))
res = res.cookies(jar)

To expire a cookie in the browser, set one with maxAge(0); jar.remove(name) only drops it from the jar (it won’t be sent).

Sessions (session, stdlib)

The session stdlib namespace (import { session } from stdlib) provides stateless, signed session cookies. A session is an HS256 JWT (interop-grade, verifiable by the jwt namespace and any standard library) carried in an httpOnly, SameSite=Lax cookie. Because the whole session lives in the signed cookie there is no server-side store to synchronise: it survives restarts and scales across processes. Tampering, a wrong secret, or a passed exp all make the session invalid.

session.issue(secret, payloadJson, ttlSeconds) : string        // a raw signed token
session.save(res, secret, payloadJson, ttlSeconds) : HttpResponse   // res + session cookie
session.load(req, secret)          : string         // verified payload JSON, or ""
session.active(req, secret)        : bool           // valid, unexpired session present?
session.value(req, secret, key)    : string         // one payload field, or ""
session.clear(res)                 : HttpResponse    // expire the cookie (log out)
session.cookieName()               : string         // the cookie name ("rad_session")

Build the payload with json.from. save stamps an exp claim ttlSeconds out and matches the cookie Max-Age, so the token and browser cookie expire together. save/clear are fluent — return the response they hand back.

// Log in: sign a session and set the cookie.
server.post("/login", fn(HttpRequest req): HttpResponse {
    // ... verify credentials ...
    return session.save(HttpResponse(200, "ok"),
        secret, json.from({ userId: "u1", role: "admin" }), 3600)
})

// Read the session on a protected route.
server.get("/me", fn(HttpRequest req): HttpResponse {
    if (!session.active(req, secret)) {
        return HttpResponse(401, "unauthorized")
    }
    string uid = session.value(req, secret, "userId")
    return HttpResponse(200, "hi " + uid)
})

// Log out.
server.post("/logout", fn(HttpRequest req): HttpResponse {
    return session.clear(HttpResponse(200, "bye"))
})

issue returns the raw token for when the client carries the session itself (e.g. an Authorization: Bearer header) rather than a cookie.


Middleware

server.use(handler)               // runs for every request
server.use(pathPrefix, handler)   // runs for requests matching the prefix

Middleware is Express-style: a handler receives the request and/or a live response handle plus a next callback, and returns void. It comes in three overloaded shapes — pick whichever arguments you need:

SignatureUse
fn(HttpRequest, NextFn): voidinspect the request
fn(HttpResponse, NextFn): voidmutate the response
fn(HttpRequest, HttpResponse, NextFn): voidboth

Call next() (no arguments) to run the rest of the chain — downstream middleware and the matched route handler. Skip next() to short-circuit: the current response is sent and no downstream handler runs. This is how you reject a request (auth, rate-limiting) without letting it reach the route.

The res handle is live — its setters change the response in place, so they persist regardless of whether you call them before or after next(). The 0-arg getters read the response as it stands right now, so calling them after next() returns the downstream handler’s response (wrap/append/logging):

MethodEffect
res.status(int)set the status code
res.body(string)set the response body
res.header(name, value)add a response header
res.setCookie(Cookie)add a Set-Cookie header (full Cookie builder)
res.status()read the current status code (int)
res.body()read the current body (string)
res.contentType()read the current content type (string)

Ordering follows the onion model: the first-registered middleware is outermost, so its code after next() runs last (and wins on any field it overwrites). A route handler stays the return-based fn(HttpRequest): HttpResponse.

// Wrap the downstream response by reading it back after next().
server.use("/api", fn(HttpResponse res, NextFn next): void {
    next()
    if (res.status() == 200) {
        res.body("[" + res.body() + "]")
    }
})
fn main() {
    HttpServer server = httpserver.serve(8080)

    // (res, next): attach a session cookie + header, then continue.
    server.use(fn(HttpResponse res, NextFn next): void {
        res.setCookie(Cookie("sid", "abc").path("/").httpOnly(true))
        res.header("X-Powered-By", "radlang")
        next()
    })

    // (req, res, next) scoped to /api/*: reject unauthenticated requests by
    // short-circuiting — no next(), so the route handler never runs.
    server.use("/api/*", fn(HttpRequest req, HttpResponse res, NextFn next): void {
        if (req.header("Authorization") == "") {
            res.status(401)
            res.body("unauthorized")
            return
        }
        next()
    })

    server.get("/api/data", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "payload", "text/plain")
    })

    server.listen()
}

Note: req is read-only in middleware — a middleware inspects the request but cannot rewrite it for downstream handlers.

Rate limiting (ratelimit)

ratelimit.check(key, limit, windowSeconds) is a fixed-window throttle. It counts hits per key and returns 0 while the key is under limit within the window (recording the hit); once at the limit it returns the whole seconds until the window resets — send that as Retry-After. The counter store is process-global and thread-safe, so it works across the server’s connection coroutines.

ratelimit.check(key: string, limit: int, windowSeconds: int) : int   // 0 = allowed, else Retry-After
ratelimit.reset(key: string) : void                                  // forget a key's counter

Key on req.ip() for a per-client limit, or a route name (or both) to scope it. Install it as a server.use middleware to guard every route:

// 60 requests per minute per client IP.
server.use(fn(HttpRequest req, HttpResponse res, NextFn next): void {
    int retry = ratelimit.check(req.ip(), 60, 60)
    if (retry > 0) {
        res.status(429)
        res.header("Retry-After", retry.toString())
        res.body("Too Many Requests")
    } else {
        next()
    }
})

reset(key) forgets a counter — for example, clear an IP’s login throttle after a successful sign-in.


Observability

Production servers expose request tracing, structured logs, and Prometheus metrics. Each is opt-in and independent.

server.jsonLogs() : void                       // structured JSON access logs
server.metrics(path: string = "/metrics") : void   // Prometheus endpoint
server.healthCheck(path: string = "/health") : void
req.requestId() : string                       // per-request correlation ID

Request IDs

Every request is stamped with a correlation ID. If the client sends an X-Request-Id header it is honoured; otherwise the server generates one. The ID is readable in handlers via req.requestId() and echoed back on the response as X-Request-Id, so a trace can be followed across a call chain.

server.get("/order", fn(HttpRequest req): HttpResponse {
    string rid = req.requestId()
    // include rid in downstream calls / logs
    return HttpResponse(200, "traced " + rid)
})

Structured logs

server.jsonLogs() switches the access log from the default plaintext line to a one-object-per-request JSON line (method, path, status, latency, request ID, client IP) suited to log aggregation.

Prometheus metrics

server.metrics() serves a Prometheus exposition endpoint (default /metrics). It is a built-in route intercept that bypasses the middleware chain. Exposed series include http_requests_total, http_requests_by_status, http_requests_in_flight, a http_request_duration_seconds histogram, and process_resident_memory_bytes.

server.metrics()          // /metrics
server.metrics("/_stats") // custom path

Health checks

server.healthCheck() serves a liveness endpoint (default /health) that returns 200 ok while the server is running and 503 shutting down once a graceful shutdown has begun — so a load balancer stops routing to a draining instance.

Graceful shutdown

On SIGTERM the server stops accepting new connections and lets in-flight requests finish within a drain window before exiting, so a rolling restart is lossless. Bound the window with:

server.drainTimeout(seconds: int) : void   // default 5s

During draining the health-check endpoint reports 503, and a stalled connection is additionally bounded by its own request/keepalive timeout.

CORS

server.enableCORS()
server.allowOrigins(origins: string[])
server.allowHeaders(headers: string[])
server.allowMethods(methods: string[])

Enable CORS and configure the allowed origins, headers, and methods. Preflight OPTIONS requests are handled automatically once enabled.

fn main() {
    HttpServer server = httpserver.serve(8080)

    server.enableCORS()
    server.allowOrigins(["https://example.com"])
    server.allowMethods(["GET", "POST", "OPTIONS"])
    server.allowHeaders(["Content-Type", "Authorization"])

    server.get("/data", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "ok")     // text/plain is the default
    })

    server.listen()
}

WebSockets and Server-Sent Events

The same server also serves WebSocket and SSE endpoints alongside your normal routes, so one process can handle pages, a REST API, a WebSocket, and an event stream together.

server.ws(path, handler)    // handler: fn(WebSocket): void  (Socket is an alias)
server.sse(path, handler)   // handler: fn(HttpRequest, SseStream): void

A ws route upgrades a matching Upgrade: websocket request (radlang does the RFC 6455 handshake) and hands your handler a live WebSocket. An sse route sends the text/event-stream headers and hands your handler a writable SseStream. Both handlers run for the lifetime of the connection.

server.ws("/chat", fn(WebSocket ws): void {
    while (ws.isOpen()) {
        string msg = ws.receive()
        if (msg != "") {
            ws.send("echo: " + msg)
        }
    }
})

server.sse("/events", fn(HttpRequest req, SseStream stream): void {
    int i = 0
    while (stream.isOpen() && i < 10) {
        stream.send("tick " + i.toString())
        sys.sleep(1000)
        i++
    }
})

Instead of the manual receive loop, a WebSocket handler can attach event callbacks and let ws.listen() pump the connection (the handler argument may be spelled Socket, an alias for WebSocket). This is the leak-resistant path: listen() reclaims each message’s memory after the "message" handler returns, so a long-lived connection stays flat. See net.md for the full event API and the handler memory contract.

server.ws("/chat", fn(Socket ws): void {
    ws.on("connect",    fn(): void { sys.output("connected") })
    ws.on("message",    fn(string msg): void { ws.send("echo: " + msg) })
    ws.on("disconnect", fn(): void { sys.output("gone") })
    ws.listen()
})

For the full WebSocket / SSE API (including the websocket.connect and sse.connect clients), plus raw TCP and the Channel long-polling primitive, see net.md.

Chunked streaming responses

When you need to stream an arbitrary response body — a large download, a report built progressively, tokens from a model — server.stream opens an HTTP/1.1 chunked response you write to directly:

server.stream(path, handler)   // handler: fn(HttpRequest, HttpStream): void

Unlike SSE (which fixes the text/event-stream framing), a stream sends raw bytes with a status, content type, and headers you choose. The response head is flushed lazily on the first write, so configure it before then:

HttpStream:
  s.setStatus(code: int)               : HttpStream   // before first write
  s.setContentType(ct: string)         : HttpStream   // before first write
  s.setHeader(name: string, v: string) : HttpStream   // before first write
  s.write(chunk: string)               : HttpStream   // sends one chunk (flushes head on first call)
  s.isOpen()                           : bool         // has the client hung up?
  s.close()                            : void         // send the terminating chunk + close
server.stream("/report.csv", fn(HttpRequest req, HttpStream s): void {
    s.setContentType("text/csv")
    s.setHeader("Content-Disposition", "attachment; filename=report.csv")
    s.write("id,name\n")
    for (int i in [1..1000]) {
        if (!s.isOpen()) {
            return               // client disconnected — stop producing
        }
        s.write(i.toString() + ",row-" + i.toString() + "\n")
    }
    s.close()
})

The setters and write return the stream, so they chain. A client reads the result like any other response — the transport de-chunks it, so res.body is the full concatenation of every chunk. CR/LF is stripped from setHeader names and values, so a stream header can’t inject extra response headers.

Long-polling

To hold a normal HTTP request open until data is available, park the handler on a Channel (see net.md). Because the handler yields while parked, one server thread can keep many long-poll requests open at once.

Channel updates = Channel()

server.get("/poll", fn(HttpRequest req): HttpResponse {
    string msg = updates.receiveTimeout(30000)
    if (msg == "") {
        return HttpResponse(204, "", "text/plain")   // nothing yet; client retries
    }
    return HttpResponse(200, msg, "text/plain")
})

server.post("/publish", fn(HttpRequest req): HttpResponse {
    updates.send(req.body)
    return HttpResponse(200, "ok", "text/plain")
})

Running in production

The server is built to be pointed at real traffic. A few things to know before you deploy.

TLS — put it behind a proxy

The listener speaks plain HTTP only. For public traffic, terminate TLS at a reverse proxy (nginx, Caddy, or your cloud load balancer) and forward to the radlang server on localhost. Do not expose the raw port to the internet.

Multi-core scaling

A single server process runs on one CPU core (radlang serves each connection on its own coroutine over one cooperative event loop). To use the whole machine, server.listen() preforks: it becomes a supervisor and starts one worker process per CPU core, all sharing the same listening port. The kernel hands each incoming connection to whichever worker is free, spreading load across cores. This is on by default — no code change.

Control the worker count with the RAD_HTTP_WORKERS environment variable:

./myserver                     # one worker per CPU core (default)
RAD_HTTP_WORKERS=4 ./myserver  # exactly 4 workers
RAD_HTTP_WORKERS=1 ./myserver  # single process, no prefork

CPU-bound handlers scale nearly linearly with the core count. A trivial handler returning a static string may not — at that point the bottleneck is the network, not your code.

Workers don’t share memory. Each worker is a separate process with its own copy of every global. An in-memory counter, cache, or session map lives in one worker only and is invisible to the others — a client’s next request may land on a different worker. Keep shared state in an external store (a database, Redis, etc.).

Don’t open shared resources before listen(). Anything you open at startup — most importantly a database connection — is duplicated into every worker by the fork, and those copies will race on the same socket. Open per-request or lazily inside your handlers instead, so each worker gets its own. (Read-only setup like route registration and config is fine; it’s shared connections that bite.)

server.start() (the background-thread mode) does not prefork — running on a thread makes the process multi-threaded, where fork() is unsafe, so that path stays single-process. Prefork is a listen()-only feature.

Built-in request limits

These guard against slow or oversized clients and are always on:

LimitValueBehavior
Request read timeout30 sA client that stalls mid-request is dropped.
Max request body8 MBA larger Content-Length gets 413 Payload Too Large.
Max concurrent connections1024Connections past the cap are shed.

One slow connection never blocks the others — each is served on its own coroutine.

Access logging

Off by default. Set the RADLANG_HTTP_LOG environment variable (to any value other than 0) to emit one line per request on stderr:

RADLANG_HTTP_LOG=1 ./myserver
# GET /user/42 200 3ms

Graceful shutdown

On SIGTERM (what systemd, Kubernetes, and docker stop send), the server stops accepting new connections, lets in-flight requests finish (up to a 10 s grace window), then exits cleanly — so a rolling restart doesn’t drop responses mid-flight. An idle server exits immediately. SIGINT (Ctrl-C) is left as an immediate hard-kill for development.

When scaled across cores (the default), the supervisor forwards the shutdown to every worker, waits out the grace window, and force-kills any straggler before exiting. No worker ever outlives the main process — if the supervisor itself is killed by any means, including an uncatchable SIGKILL, the workers detect it and exit on their own. Killing the process you started always tears the whole tree down.

Startup failures

If the port can’t be bound (already in use, or a privileged port without permission), the process prints the reason and exits non-zero. Run it under a supervisor (systemd, a container orchestrator) that restarts it.


A full example

fn main() {
    HttpServer server = httpserver.serve(8080)

    server.use(fn(HttpResponse res, NextFn next): void {
        res.header("X-Powered-By", "radlang")
        next()
    })

    server.get("/", fn(HttpRequest req): HttpResponse {
        return HttpResponse(200, "welcome")
    })

    server.notFound(fn(HttpRequest req): HttpResponse {
        return HttpResponse(404, "not found")
    })

    // Start in the background so we can hit it from the same program.
    server.start()
    sys.sleep(150)

    HttpResponse r = http.getSync("http://localhost:8080/")
    sys.output(r.body)          // welcome

    server.close()
}

Quick reference

Client (http)

CallReturnsPurpose
http.get(url) / http.delete(url)Future<HttpResponse>Bodyless request (twins getSync/deleteSync)
http.post/put/patch(url, body, headers?)Future<HttpResponse>Request with a body (twins postSync/…)
http.request(method, url, headers, body)Future<HttpResponse>Full control incl. headers (twin requestSync)
http.encode(s) / http.decode(s)stringURL percent-encode / decode (pure, sync)
http.timeout(seconds)voidSet the client connect + transfer timeout (default 30s)

A transport failure (refused/DNS/timeout/TLS) raises a catchable Error on the …Sync path; the async path resolves with status == 0 and the message in body. See Timeouts and transport errors.

Server (httpserver)

CallPurpose
httpserver.serve(port)Create a server (HttpServer)
server.get/post/put/delete/patch(path, handler)Register a route (handler is fn(HttpRequest): HttpResponse, sync or a named async fn)
server.ws(path, handler)Register a WebSocket route (see net.md)
server.sse(path, handler)Register a Server-Sent Events route (see net.md)
server.stream(path, handler)Register a chunked-streaming route (handler is fn(HttpRequest, HttpStream))
server.useStatic([dir])Serve static files from dir (default cfg.staticDir)
server.ssr(path, [template], handler)Inject handler’s value into a template’s first paint (handler is fn(HttpRequest): T)
server.useStatic(dir, handler)Serve dir and inject props into its index shell at /
server.cache(maxAgeSeconds)Set the static-asset Cache-Control max-age (0 = always revalidate)
server.use([prefix,] handler)Register middleware
server.notFound(handler)Custom 404 handler
server.tls(certPath, keyPath)Enable HTTPS from a runtime-loaded PEM cert + key (BearSSL, TLS 1.2)
server.listen() / start() / close()Run (blocking) / run (background) / stop
server.enableCORS() + allowOrigins/Headers/Methods(list)Configure CORS
req.param(name) / req.query(name) / req.header(name)Read a route param / query param / header
res.header(name, value)Add a custom response header (chainable)
res.redirect(url)Build a 302 redirect response

Environment variables

VariableEffect
RAD_HTTP_WORKERSPrefork worker count. Unset = one per CPU core; 1 = single process.
RADLANG_HTTP_LOGAny value but 0 enables one-line-per-request access logging.

Picking the right tool

  • Calling an external API → http.get/post/… , or http.request when you need custom headers.
  • Building a service → httpserver.serve + route handlers.
  • Cross-cutting logic (auth, logging) → server.use middleware with next.
  • Browser clients → server.enableCORS() and the allow* configuration.
  • Real-time push → server.ws (bidirectional) or server.sse (server → client); hold a plain request open with a Channel for long-polling. See net.md.