std.net.http_server

Create a server, register routes, and listen. Each route maps a path to a handler(req) function. The engine calls handlers on background fibers when requests arrive.

Minimal REST API

var srv = std.net.http_server()
srv.routes([{
  path: "/greet", handler: fn(req) {
    var who = "world"
    if (req.query.name != nil) { who = req.query.name }   # /greet?name=Bob
    return "hello, " + who                                # plain string → 200 text/plain
  }
}, {
  path: "/json", handler: fn(req) {
    return {
      status: std.net.http_codes.ok,
      content_type: "application/json",
      body: std.json.stringify({result: 42})
    }
  }
}, {
  path: "*", handler: fn(req) {                           # catch-all
    return {
      status: std.net.http_codes.not_found,
      content_type: "application/json",
      body: std.json.stringify({error: "nope"})
    }
  }
}])

srv.listen(8200)
print("listening on", srv.port())     # actual port (useful when 0)
std.async.sleep(5000)                 # keep serving: without this the script exits and the server dies with it
srv.stop()

Handler input — the req object

Field Description
req.method "GET", "POST", …
req.path "/greet" (no query string)
req.query Parsed query object, e.g. {name: "Bob"} for ?name=Bob
req.headers Request headers object
req.raw_headers Raw header block string
req.body Request body string
req.upgrade Lowercased Upgrade header ("websocket" for WS handshakes, "" otherwise)

Handler output — string or response object

Return a plain string for an HTTP 200 with text/plain:

handler: fn(req) { return "hello" } # 200 text/plain

Or return a response object for full control:

Key Purpose Default
status HTTP status code 200
content_type Content-Type header text/plain or guessed for file
headers Extra response headers object {}
body Response body string ""
file Path to a file to serve —

File serving example:

# inside a handler:
return {file: "./public/index.html"} # content-type guessed from extension

When a response object carries both file and body, file wins and
body is ignored — never set both. (status, headers, and
content_type still apply to file responses.)

Routing

  • First matching route wins — order your routes([...]) array from specific to generic.
  • path: "*" is the catch-all; place it last.
  • Registering routes replaces the previous table — call it once with the full array.
  • route_add({path, handler}) adds one route at runtime (replaces any route with the same path; returns the route count). route_remove(path) removes every route with that exact path (returns true when anything was removed). Both are safe to call while serving.

Persistent connections (keep-alive)

By default the server closes each connection after one request. set_keep_alive enables HTTP/1.1 persistence — several requests per connection:

srv.set_keep_alive({idle_timeout_ms: 30000, max_requests: 100})
# or srv.set_keep_alive(false) to go back to close-per-request
  • idle_timeout_ms (default 30000) — quiet connections are reaped after this gap between requests (slow-loris protection stays on either way).
  • max_requests (default 100) — cap per connection, then close.
  • ws_idle_timeout_ms (default 0 = never) — reap idle upgraded websockets; see below.

on_disconnect(fn({method, path, status})) fires once per HTTP connection close with the last completed exchange — an access-log hook (status 0 means the transport died mid-request). Upgraded websocket connections report via their on_close instead:

srv.on_disconnect(fn(info) {
  print(info.method, info.path, "->", info.status)
})

Websocket upgrade

Any route can become a websocket endpoint by returning {websocket: true} from its handler. The request must actually be a websocket handshake (Upgrade: websocket + Sec-WebSocket-Key); otherwise the server answers 400:

srv.routes([{path: "/ws", handler: fn(req) {
  if (req.upgrade != "websocket") { return {status: 400, body: "ws only"} }
  return {websocket: true,
    on_message: fn(id, data, binary) { srv.ws_send(id, "echo:" + data) },
    on_close: fn(id) { print("ws", id, "closed") }}
}}])
  • on_message(id, data, is_binary) fires per message; ping frames are answered internally.
  • srv.ws_send(id, text | {data, binary}) sends a frame (returns false for unknown/gone ids); srv.ws_close(id) closes one session.
  • Unmasked client frames violate RFC 6455 — the server answers with a close frame and drops the connection (it stays up for everyone else).
  • Works over TLS (wss://) when the server listens with {cert, key}.

No trailing commas in object literals — after a member whose value is a
multi-line fn body, write the closing } of the handler directly followed
by the object’s }, as in the example above. A trailing comma fails with
EXPECTED_TOKEN: Expected object key but got: CLOSE_CURLY.

Lifecycle and errors

srv.listen(8200, "127.0.0.1") # port [, host] — a {cert, key} third argument serves TLS
print(srv.is_running())       # true
print(srv.port())             # actual port
srv.stop()                    # halt and close

print(srv.last_error())       # most recent background error or nil
  • Handlers run on background fibers — the main script must sleep/await or the server exits with the script.
  • Handler exceptions and TLS handshake failures have no caller to throw to — they are stored per-server and readable via server.last_error() (also echoed to stderr). See Concurrency — Errors.
  • TLS: pass a {cert, key} third argument to listen() (PEM file paths) to serve https:// — requires an OpenSSL build, else UNSUPPORTED (see Overview).
  • Use port: 0 to let the OS pick a free port, then share srv.port() with clients.

See also