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:
| Type | Role |
|---|---|
HttpResponse | Result of a client request / value a handler returns |
HttpRequest | The incoming request passed to a server handler |
HttpServer | An opaque server handle from httpserver.serve |
NextFn | Arg-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 twin — getSync, 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:
| Field | Type | Meaning |
|---|---|---|
status | int | HTTP status code (e.g. 200) |
body | string | Response body |
contentType | string | The 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 catchableErrorwhosemessagedescribes the failure and whosecodeis the underlying transport error code. Wrap the call intry/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 anHttpResponsewithstatus == 0and the failure message inbody(throwing from the scheduler’s completion tick would unwind the wrong frame, so the failure is delivered as a value). Checkstatus != 0before using it, or use the…Synctwin inside atry/catchwhen 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 tocountextra times with exponential backoff (backoffMs * 2^(attempt-1)). A request is retried on a transport error or a5xxresponse;4xxand2xxare returned immediately. With the defaultcountof 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 backoffcircuitBreaker— per-host failure tracking. Afterthresholdconsecutive failures (transport error or5xx) to a host, the circuit opens and further requests to that host fail fast with acircuit breaker openerror forcooldownMs. It then half-opens to allow one probe; a success closes it, a failure re-opens it. Off unlessthreshold > 0.http.circuitBreaker(5, 10000) // open after 5 fails, 10s cooldownproxy— 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 manualserver.accept()/server.respond()loop is also plaintext-only; uselisten()/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:
| Field | Type | Meaning |
|---|---|---|
method | string | HTTP verb (GET, POST, …) |
path | string | Request path |
query | string | Raw query string |
body | string | Request body |
headers | string | Raw 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.paramreads a segment captured by a route pattern, e.g.:idin/user/:idor*for a wildcard.req.queryreads a?key=valueparameter and URL-decodes it for you; absent keys return"".- Header lookups are case-insensitive (
req.header("x-test")matchesX-Test).req.headerreturns""for an absent header; usereq.hasHeaderto distinguish absent from empty.req.headerAllcollects 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[]:
| Field | Type | Meaning |
|---|---|---|
name | string | The form field name |
filename | string | The uploaded file’s name ("" for a plain field) |
contentType | string | The part’s own Content-Type ("" if absent) |
data | byte[] | 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 withCookie(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 cookies — req.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-write — res.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:
| Signature | Use |
|---|---|
fn(HttpRequest, NextFn): void | inspect the request |
fn(HttpResponse, NextFn): void | mutate the response |
fn(HttpRequest, HttpResponse, NextFn): void | both |
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):
| Method | Effect |
|---|---|
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:
reqis 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:
| Limit | Value | Behavior |
|---|---|---|
| Request read timeout | 30 s | A client that stalls mid-request is dropped. |
| Max request body | 8 MB | A larger Content-Length gets 413 Payload Too Large. |
| Max concurrent connections | 1024 | Connections 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)
| Call | Returns | Purpose |
|---|---|---|
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) | string | URL percent-encode / decode (pure, sync) |
http.timeout(seconds) | void | Set 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)
| Call | Purpose |
|---|---|
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
| Variable | Effect |
|---|---|
RAD_HTTP_WORKERS | Prefork worker count. Unset = one per CPU core; 1 = single process. |
RADLANG_HTTP_LOG | Any value but 0 enables one-line-per-request access logging. |
Picking the right tool
- Calling an external API →
http.get/post/… , orhttp.requestwhen you need custom headers. - Building a service →
httpserver.serve+ route handlers. - Cross-cutting logic (auth, logging) →
server.usemiddleware withnext. - Browser clients →
server.enableCORS()and theallow*configuration. - Real-time push →
server.ws(bidirectional) orserver.sse(server → client); hold a plain request open with aChannelfor long-polling. Seenet.md.