Skip to content

serve — programmable HTTP/1.1 + HTTP/2 server

The top of the aoughwl networking stack. You write a handler proc(req: Request): Response and return whatever Response you like; serve owns the accept/read/write loop, request framing, keep-alive, and hardening. It sits above tcp/net (transport), tls (HTTPS), and http (the transport-free request/response helpers, all re-exported), and speaks HTTP/2 through an opt-in nghttp2 binding.

Status — Production-ready for HTTP/1.1 and HTTP/2. HTTP/1.1 + HTTPS + a bounded worker-thread pool + h2c/h2-over-TLS all ship and are covered by e2e tests. An opt-in router + middleware layer (serve/router) now provides method+path routing with :id params, a trailing * wildcard, and a middleware chain. The remaining gap is HTTP/3 serving, which is infeasible (no QUIC library); the requests client already speaks h3.

Quickstart

nim
import serve

# A programmable handler decides every response. Handlers are `.closure`
# (nimony requires the pragma; they may capture state).
proc app(req: Request): Response {.closure.} =
  if req.path == "/":
    response(200, "text/html", "<h1>hi</h1>")
  else:
    response(404, "text/plain", "not found\n")

serve(8080, app)                                  # HTTP/1.1, loop forever
# serveTls(8443, "cert.pem", "key.pem", app)      # HTTPS, same handler
# serve("/var/www", 8080)                         # built-in static-file server

API

Handler types

symbolsignaturewhat it does
Handlerproc(req: Request): Response {.closure.}The request handler for the single-threaded loops. .closure so it can capture state (e.g. a root directory).
NimcallHandlerproc(req: Request): Response {.nimcall.}A handler as a bare function pointer (no captured env). Required by the worker pool: a {.nimcall.} proc crosses module/thread boundaries cleanly where a closure in another module's global does not. Per-request state must come from thread-safe globals.
ServerConnobject (isTls: bool; fd: TcpHandle; tls: TlsSocket)The transport under one served connection — plaintext TcpHandle or TlsSocket. fd is always the underlying descriptor (so socket options like the read timeout apply to both). Lets the request/response core run byte-for-byte identically over HTTP and HTTPS.

Serving (single-threaded)

symbolsignaturewhat it does
serveproc(port: int; handler: Handler; maxRequests = 0)Run a programmable plaintext server. Loops forever unless maxRequests > 0, then exits after that many connections (used by tests).
serveproc(root: string; port: int; maxRequests = 0)Static-file server under root, built on staticHandler over the same loop. Backwards-compatible API.
serveTlsproc(port: int; certFile, keyFile: string; handler: Handler; maxRequests = 0)HTTPS. Loads a PEM cert chain + key into a shared TlsContext; each accepted socket gets its own server-side TLS session, then runs the same handler loop.
serveConnectionproc(fd: TcpHandle; handler: Handler)Serve one already-accepted plaintext socket to completion. Exposed so tests/drivers can hand it a socket.
serveConnectionTlsproc(fd: TcpHandle; ctx: TlsContext; handler: Handler)Wrap one accepted socket in a server-side TLS session and serve it. Drops the connection silently if the handshake fails.
serveConnectionNimcallproc(fd: TcpHandle; handler: NimcallHandler)Serve one plaintext socket with a {.nimcall.} handler — the thread-safe entry the worker pool uses.
serveConnectionTlsNimcallproc(fd: TcpHandle; ctx: TlsContext; handler: NimcallHandler)TLS variant of serveConnectionNimcall.

Serving (concurrent — serve/pool)

A bounded pool of worker threads that all accept() on one shared listening socket; the kernel hands each incoming connection to one waiting worker. No per-connection thread churn, no unbounded growth. The pool is process-global (one server per process).

symbolsignaturewhat it does
serveConcurrentproc(port: int; handler: NimcallHandler; workers = 4)Concurrent plaintext server: workers threads share one listener. Loops forever (workers never return).
serveTlsConcurrentproc(port: int; certFile, keyFile: string; handler: NimcallHandler; workers = 4)Concurrent HTTPS server: workers threads share one listener and a single TlsContext.
configurePoolproc(listenFd: TcpHandle; handler: NimcallHandler; useTls: bool; ctx: TlsContext)Install the shared listening socket + handler that workers serve. Lower-level; tests drive it directly.
spawnWorkerproc(t: var RawThread)Create one worker thread into caller-owned storage t. t must outlive the thread (create passes addr t to the OS; a by-value RawThread would dangle). Keep it in a long-lived array/global.
MaxWorkersconst = 256Upper bound; runPool clamps the requested workers into 1..MaxWorkers.

Routing + middleware (serve/router)

Opt-in import serve/router. A Router dispatches on method + path: :id segments are path params and a trailing * captures the remainder of the path, both read back off the request with param / wildcard. A middleware chain wraps the matched handler (and the 404/405 responses). No path match → 404 (or your notFound handler); path matches but wrong method → 405 with an Allow header; HEAD falls back to a GET route.

Handlers and middleware are {.nimcall.} function pointers, not closures — a hard requirement: the current nimony C backend miscompiles a closure stored in a seq/object field, and cannot pass a closure as a parameter of another closure (the classic next: Handler middleware shape). Storing bare function pointers sidesteps both, and as a bonus makes the router worker-pool compatible. The middleware continuation is passed as a plain Chain value and advanced with proceed(nxt, req) — the idiomatic stand-in for next(req).

nim
import serve
import serve/router

proc getUser(req: Request): Response {.nimcall.} =
  response(200, "text/plain", "user " & param(req, "id") & "\n")

proc logging(req: Request; nxt: Chain): Response {.nimcall.} =
  let resp = proceed(nxt, req)          # run the rest of the chain + handler
  echo req.meth, " ", req.path, " -> ", resp.status
  return resp

var r = newRouter()
r.use(logging)
r.get("/users/:id", getUser)
r.get("/static/*", serveAsset)          # wildcard(req) == the rest of the path
r.post("/users", createUser)
serveRouter(8080, r)
symbolsignaturewhat it does
Routerobject (id: int)A handle to a registered router (its route/middleware tables live in a module-global registry, since function pointers can't be captured).
RouteHandlerproc(req: Request): Response {.nimcall.}A route handler — same shape as NimcallHandler. Reads the request (incl. captured params) and returns the response.
Middlewareproc(req: Request; nxt: Chain): Response {.nimcall.}Wraps the downstream chain; run logic before/after proceed(nxt, req), short-circuit by not calling it, or rewrite the response.
Chainobject (rid, idx: int)Opaque continuation handed to a middleware; advance with proceed.
newRouterproc(): RouterCreate an empty router (no routes, no middleware, default 404).
get / post / put / patch / delete / head / optionsproc(r: Router; pattern: string; h: RouteHandler)Register h for that method + pattern. :name = path param, trailing * = wildcard.
useproc(r: Router; mw: Middleware)Append a middleware (runs in registration order; first is outermost).
notFoundproc(r: Router; h: RouteHandler)Custom handler for unmatched paths (replaces the default 404).
paramproc(req: Request; name: string): stringCaptured value of path param name (e.g. :id), or "".
hasParamproc(req: Request; name: string): boolWhether param name was captured (distinguishes empty from absent).
wildcardproc(req: Request): stringThe remainder captured by a trailing * (e.g. /static/* on /static/js/app.jsjs/app.js).
proceedproc(nxt: Chain; req: Request): ResponseFrom a middleware, run the rest of the chain and the matched handler — the idiomatic next(req).
dispatchproc(r: Router; req: Request): ResponseRoute one request directly (middleware included), bypassing the socket loop — handy for unit tests.
toHandlerproc(r: Router): NimcallHandlerInstall r as the active router and return the {.nimcall.} handler that serveConnectionNimcall / the pool accept.
serveRouterproc(port: int; r: Router; maxRequests = 0)Run r on port over the single-threaded plaintext loop.

Params ride as reserved pseudo-headers under a control-character prefix that a conforming HTTP client can't inject (and spoofed copies are stripped before dispatch), so param only ever returns router-populated values. Because the {.nimcall.} dispatcher carries no state, the most recently installed router is the active one — fine for the usual single-server process.

Static files (serve/static)

Maps a URL path onto a file under a served root: //index.html, query stripped, percent-escapes decoded, ..403, missing → 404, Content-Type by extension. Standard-library only.

symbolsignaturewhat it does
staticHandlerproc(root: string): HandlerBuild a Handler that serves static files under root. This is how serve(root, port) is expressed on the handler API.
staticRouteproc(root: string; req: Request): ResponseRoute one request against a static root: validate, handle OPTIONS/HEAD/GET, 405 other methods, map path to a file. Top-level proc, reusable without nesting closures.
staticResponseObjproc(root: string; urlPath: string): ResponseRoute a URL path to a file under root and return the in-memory Response model (status, headers, full body, plus X-Content-Type-Options: nosniff).
staticResponseproc(root: string; urlPath: string; includeBody = true): stringThin wrapper: serialize staticResponseObj to a full HTTP response string.
serveFileproc(root: string; urlPath: string): stringBackwards-compatible static-GET helper (staticResponse with body).
contentTypeForproc(path: string): stringPick a Content-Type from the file extension (html/js/css/json/svg/png/jpg/gif/webp/ico/wasm/pdf/woff/woff2/…; else application/octet-stream).
normalizeUrlPathproc(urlPath: string): stringStrip query/fragment, percent-decode, force a leading slash, map //index.html. Does not join the filesystem root.
relativePathproc(urlPath: string): stringConvert a normalized URL path into a root-relative filesystem path.
endsWithSuffixproc(s, suffix: string): boolNon-allocating "does s end with suffix?" (used by the extension match).

Compression (serve/encoding)

Opt-in: wrap a Response before returning it. Kept out of the core loop so plain servers pay no CPU for compression they didn't ask for. (Named encoding rather than compress to avoid a build-key collision with http/compress, whose codecs it wraps and re-exports.)

symbolsignaturewhat it does
compressResponseproc(req: Request; resp: Response): ResponseReturn resp with its body compressed for the client's Accept-Encoding (best supported codec — br > gzip), setting Content-Encoding + Vary. Unchanged when it doesn't apply: body < 64 bytes, already Content-Encoding'd, client accepts none, or compression didn't shrink it.
(re-exported)pickEncoding, encodeFor, decodeFromThe underlying http/contentcoding codec picker/encoder/decoder.

HTTP/2 (serve/http2)

Opt-in import serve/http2 so plain serve users don't pull the nghttp2 (libnghttp2.so.14) dependency. Speaks h2c (cleartext, prior-knowledge); the same session driver runs over TLS once ALPN negotiates "h2" — the path real browsers use. Handlers are {.nimcall.} (called from C callbacks).

symbolsignaturewhat it does
H2Handlerproc(req: Request): Response {.nimcall.}HTTP/2 request handler — a bare function pointer (not a closure, since it's invoked from nghttp2's C callbacks).
serveHttp2proc(port: int; handler: H2Handler; maxRequests = 0)Run an h2c (cleartext) server. Test with curl --http2-prior-knowledge.
serveHttp2Tlsproc(port: int; certFile, keyFile: string; handler: H2Handler; maxRequests = 0)HTTP/2 over TLS, advertising ALPN ["h2", "http/1.1"]. Connections that negotiate "h2" are driven by nghttp2; others are dropped (this entry point is h2-only).
serveHttp2Connectionproc(fd: TcpHandle; handler: H2Handler)Drive one already-accepted h2c connection to completion.
serveHttp2ConnectionTlsproc(tlsSock: TlsSocket; handler: H2Handler)Drive one HTTP/2-over-TLS connection (ALPN "h2" already negotiated).

Tuning constants (serve/loop)

symbolvaluewhat it does
MaxRequestBytes8 * 1024 * 1024Reject requests larger than this → 413 Payload Too Large.
ReadTimeoutMillis15000Per-socket blocking read timeout — the slowloris guard.
MaxKeepAliveRequests100Max requests served on one kept-alive connection before it's closed.

Re-exported http helpers (umbrella)

import serve re-exports the transport-free http pack (headers, url, request, response) plus tcp and tls, so a handler needs no extra imports. The essentials a handler touches:

symbolsignaturewhat it does
Requestobject (meth, path, version: string; headers: seq[Header]; body: string)The parsed request handed to your handler.
Responseobject (status: int; headers: seq[Header]; body: string)The value your handler returns.
responseproc(status: int; contentType, body: string): ResponseBuild a Response with a Content-Type.
withHeaderproc(res: var Response; name, value: string)Append a header.
isMethodproc(req: Request; meth: string): boolCase-checked method test (e.g. isMethod(req, "GET")).
isValidRequestproc(req: Request): boolSanity-check a parsed request.
headerValue / hasHeaderproc(req: Request; name): string / boolLook up a request header (also seq[Header] overloads).
pathOnly / queryString / queryParam / queryParams / formParamproc(target …): …URL parsing: split path from query, pull query/form params.
percentDecode / percentEncode / encodeQueryproc(s …): stringPercent-coding and query-string building.
HttpCode, code, is2xxis5xx, reasonPhraseStatus-code helpers.
redirect / optionsResponse / httpResponseproc(…): stringReady-made response-string builders.
encodeChunked / decodeChunkedproc(body: string): stringChunked transfer coding (the loop de-chunks inbound bodies in place).

Design notes

  • One transport-independent core. serveConnCore reads a complete request, calls the handler, and streams the response over a ServerConn that is either a raw TcpHandle or a TlsSocket. HTTP and HTTPS share the framing, keep-alive, HEAD, size-cap, and timeout logic byte-for-byte.
  • Complete-request framing. The reader accumulates the header block, then frames the body by Content-Length or Transfer-Encoding: chunked. A chunked body is de-chunked in place, so the handler always sees a plain body. Expect: 100-continue gets an interim 100 Continue before the body is read.
  • Streamed responses, no buffer cap. The body is written through a stack chunk buffer rather than concatenating a second whole-response copy or a fixed 1 MB buffer, so response size is unbounded.
  • Closures vs. {.nimcall.}. The single-threaded loops take .closure handlers (capture state). The worker pool and HTTP/2 take bare function pointers: nimony's lambda lifter can't lift a closure that captures a proc-typed variable across threads, and C callbacks can't receive a closure — hence the duplicated …Nimcall cores and NimcallHandler/H2Handler types.
  • Hardening by default. 8 MB request cap → 413, 15 s slowloris read timeout, .. path segments → 403, static responses carry X-Content-Type-Options: nosniff, and keep-alive is capped at 100 requests per connection.
  • Routing is opt-in. The core loop still dispatches through one proc(req): Response; import serve/router layers method+path routing, :id/* capture, and a middleware chain on top (all {.nimcall.}). Without it you dispatch on req.path/req.meth yourself, or compose with staticRoute / compressResponse.
  • No HTTP/3 serving. Serving h3 needs a QUIC stack, and none is installed; this is a hard gap, not a tuning item. (The client side already speaks h3 via requests.)

Requirements

  • nimony toolchain (aowl/nimony), --threads:on for the concurrent pool.
  • Dependency repos: aoughwl/http (request/response/url/headers/compress helpers), aoughwl/tcp + aoughwl/net (transport), aoughwl/tls (OpenSSL 3, for HTTPS and ALPN).
  • C libraries it FFIs to (transitively / opt-in): OpenSSL 3 (libssl / libcrypto, via tls) for HTTPS; libnghttp2.so.14 for serve/http2 (structs are hand-laid to the C ABI — no nghttp2 headers required).

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.