hcs — HTTP server & client
hcs is the transport layer of an araara application: an HTTP/1.1 and HTTP/2 server and client on Eio, with WebSocket, Server-Sent Events, a radix-trie router, composable plugs and pipelines, sessions and auth, static files, multipart uploads, pub/sub and a typed request context. Every snippet below is cut from a complete program under the site repository's examples/ directory, and the runnable ones execute in the test suite.
The shape of an hcs application
Everything in hcs is an ordinary value. A handler is a function from a request to a response. A plug wraps a handler to add behavior. A pipeline is a list of plugs. A router maps paths to handlers. An endpoint glues router and global plugs into one handler, and Hcs.Server.run serves that handler. There is no inversion of control: your main function builds these values and hands them to the server.
hcs runs on Eio, OCaml 5's effect-based I/O runtime. Capabilities — the network, the clock — are values you take from the environment and pass explicitly, which is why every program here starts with Eio_main.run and a switch. The modules below — Server, Client, Router, Plug, Pipeline, Endpoint, Request, Response, Websocket, Sse, Pubsub, Channel, Multipart, Assigns, Log, Tls_config — are all reachable under Hcs.
Your first server
Three pieces: a view, a handler, and a boot function. Each is a value you could test on its own.
The view
let hello_page =
let open Pure_html in
let open HTML in
html
[ lang "en" ]
[
head [] [ title [] "Hello from araara" ];
body [] [ h1 [] [ txt "Hello, araara!" ] ];
]The handler
let hello _params _req =
Hcs.Server.respond_html (Pure_html.to_string hello_page)A handler takes the router's captured parameters and the request, and returns a response. respond_html sets the content type and renders the node tree to a string.
Boot: switch, router, endpoint, server
let () =
Eio_main.run @@ fun env ->
Eio.Switch.run @@ fun sw ->
let routes =
Hcs.Router.(compile_scopes [ scope "/" [ Route.get "/" hello ] ])
in
let handler =
Hcs.Endpoint.(
create default_config |> Fun.flip router routes |> to_handler)
in
Hcs.Server.run ~sw
~net:(Eio.Stdenv.net env)
~clock:(Eio.Stdenv.clock env)
handlerEio_main.run starts the runtime; Eio.Switch.run opens the scope that owns every fiber the server spawns. compile_scopes builds the router, the endpoint turns it into a plain handler, and Server.run serves it: port 8080, HTTP/1.1, and a built-in health check at /_health by default.
$ dune exec examples/hcs_hello/main.exe$ curl localhost:8080/Routing
The router is a radix trie: lookup cost does not grow with the number of routes. Routes are declared in scopes, scopes run pipelines, and captures are typed at the edge. Route constructors exist for every method — get, post, put, patch, delete, head, options — plus any for a catch-all, and a route can carry its own per-route plug.
Typed, bidirectional routes
A route is one typed value, built from combinators: s "items" is a literal segment, int and str capture one segment each, and / composes them. That single value does two jobs. Register it with Route.get/Route.post and the handler receives the captures as typed arguments — no params lookup, no parsing, and a capture that does not parse (/items/abc) is a 404 the handler never sees. Build a URL with Route.link and the compiler checks the arity and types of the captures against the same route, so a link can never drift from the path it points at. Routes compile down to the radix trie, so scopes and pipelines are unchanged.
(* A route is one typed value. [int] and [str] capture path segments, and
the handler receives them as typed arguments — no [params] lookup, no
parsing, and a bad capture (/items/abc) is a 404 the handler never sees.
The same value builds URLs through [Route.link], so a link can't drift
from its route. Routes are thunks because each is polymorphic in its
result — a response when registered, a string when linked. *)
let hello () = Route.(s "hello" / str)
let item () = Route.(s "items" / int)
let greet name _req = Hcs.Server.respond (Printf.sprintf "Hello, %s!\n" name)
let item_view id _req = Hcs.Server.respond (Printf.sprintf "item #%d\n" id)
(* link is the reverse direction, type-checked against the same route: *)
let _self = Route.link (item ()) 42 (* "/items/42" *)A route value is polymorphic in its result — a response when registered, a string when linked — so each is written as a thunk and called where it is used.
Pipelines and scopes
(* Pipelines: cross-cutting concerns compose as plugs. A scope routes
every request in it through its pipeline — handlers stay clean. *)
let browser =
Hcs.Pipeline.create
[
Hcs.Plug.Logger.create ~clock (Hcs.Log.stdout ());
Hcs.Plug.Recover.create ();
]
in
let api =
Hcs.Pipeline.compose browser
(Hcs.Pipeline.create
[ Hcs.Plug.Request_id.create (); Hcs.Plug.Cors.create () ])
in(* Route.get/post take a typed route and a handler expecting its captures.
The string router underneath is unchanged — scopes and pipelines work
exactly as before. *)
let routes =
Hcs.Router.compile_scopes
[
Hcs.Router.scope "/" ~through:browser
[
Route.get (Route.s "") home;
Route.get (hello ()) greet;
Route.get (item ()) item_view;
];
Hcs.Router.scope "/api" ~through:api
[ Route.get (Route.s "status") api_status ];
]
inA pipeline composes plugs in order; a scope prefixes its routes and runs them through a pipeline, so the security posture of every URL is readable in one place. A literal segment beats a :param, which beats a * wildcard — handy for a styled catch-all 404.
Server configuration
(* Server settings are a plain record — override what you need. *)
let port =
Option.value ~default:8080
(Option.bind (Sys.getenv_opt "PORT") int_of_string_opt)
in
let config = { Hcs.Server.default_config with port } in
Hcs.Server.run ~sw ~net:(Eio.Stdenv.net env) ~clock ~config handlerThe config record covers binding (host, port, backlog), protocol selection, timeouts (read, write, idle, request), body-size limits, socket options, GC tuning, and domain_count for multi-core serving with Server.run_parallel.
$ dune exec examples/hcs_routing/main.exe$ curl localhost:8080/hello/ada$ curl -i localhost:8080/api/statusReading the request
Hcs.Request parses lazily: the method, target and version are read up front; everything else — query string, headers, cookies, body, form fields — is read on demand through accessor functions.
Query parameters and headers
(* Query parameters are read by key; typed and defaulting variants save
the caller from juggling [option]s. *)
let page = Hcs.Request.query_int req "page" in
let sort = Hcs.Request.query_or ~default:"asc" req "sort" in
let raw = Hcs.Request.query req "q" in(* [header] is case-insensitive and returns an [option]. *)
let agent =
match Hcs.Request.header req "x-demo" with
| Some v -> v
| None -> "(none)"
inquery returns a string option; query_int parses; query_or supplies a default; query_all returns every value for a repeated key. Headers come through header / header_multi / has_header, with named shortcuts like content_type, host, authorization, accept, and predicates accepts_json / accepts_html. Method predicates (is_get, is_post, is_safe, is_idempotent) round out the request surface.
Body and form fields
(* [body] materializes the request body; [form_data]/[form_field] decode
an [application/x-www-form-urlencoded] payload into an assoc list. *)
let raw = Hcs.Request.body req in
let email =
match Hcs.Request.form_field req "email" with Some e -> e | None -> "(none)"
in
let fields = Hcs.Request.form_data req inbody reads the whole body as a string (or stream it with body_stream for large uploads). form_data decodes an application/x-www-form-urlencoded body into an assoc list; form_field, form_int and friends pull a single field. For multipart bodies, see the uploads section below.
$ dune exec examples/hcs_request/main.exeBuilding the response
Hcs.Response has a builder per content type and a status helper per common code, plus combinators that layer on headers, cookies, caching and CORS.
Content and status
(* The content-type helpers set the right header and wrap the body. Each
returns a [Hcs.Response.t] that a route hands straight back. *)
let as_text _params _req = Hcs.Response.text "plain words\n"
let as_html _params _req = Hcs.Response.html "<h1>hi</h1>\n"
let as_json _params _req = Hcs.Response.json {|{"ok":true}|}text, html, json, xml, stream and bigstring set the content type for you; each takes an optional ?status. Named status helpers — ok, created, no_content, bad_request, unauthorized, forbidden, not_found, unprocessable_entity, too_many_requests, internal_error and the rest — read better than raw codes at the call site.
Redirects
(* A redirect carries the Location header and an empty body; [found] is a
302, [redirect ~permanent:true] a 301. *)
let go_home _params _req = Hcs.Response.found "/"redirect, found, see_other, moved_permanently and temporary_redirect set the Location header and the right 3xx status.
Headers, cookies and caching
(* Combinators thread modifications through a base response. [with_header]
adds an arbitrary header; [with_cookie] appends a Set-Cookie. *)
let session _params _req =
Hcs.Response.text "session set\n"
|> Hcs.Response.with_header "X-Request-Source" "example"
|> Hcs.Response.with_cookie ~max_age:3600 "sid" "abc123"Responses are immutable values you refine with with_header, with_cookie (path, max_age, http_only, secure, same_site), clear_cookie, with_cache_control, with_no_cache and with_cors. Each returns a new response, so they chain.
$ dune exec examples/hcs_response/main.exePlugs and pipelines
A plug has type Hcs.Plug.t = (request -> response) -> request -> response: it can rewrite the request before calling the inner handler, rewrite the response after, or halt the chain by returning a response without calling the handler at all. Pipelines compose plugs; this is how araara keeps cross-cutting concerns out of handlers.
Writing a custom plug
(* A plug has type [(request -> response) -> request -> response]: it wraps
the inner handler. To pass the request through, call [handler req]. To
halt, return a response without calling [handler] — the inner handler
(and everything after this plug) never runs. *)
let require_header name : Hcs.Plug.t =
fun handler req ->
match Hcs.Request.header req name with
| Some _ -> handler req
| None ->
Hcs.Server.respond ~status:`Forbidden
(Printf.sprintf "missing %s header\n" name)The plug receives the downstream handler and the request and returns a response — do work before, after, or both.
Halting the chain
Hcs.Server.respond ~status:`Forbidden
(Printf.sprintf "missing %s header\n" name)Returning a response without calling the handler stops the request there — exactly how auth, rate limiting and CSRF reject a request before it reaches your logic.
Composing a pipeline
(* Built-in plugs and our custom plug compose left-to-right into a
pipeline; the scope runs every request through it before the handler. *)
let pipeline =
Hcs.Pipeline.create
[
Hcs.Plug.Logger.create ~clock (Hcs.Log.stdout ());
Hcs.Plug.Recover.create ();
require_header "x-api-key";
]
in
let routes =
Hcs.Router.(
compile_scopes [ scope "/" ~through:pipeline [ Route.get "/hello" hello ] ])
inPipeline.create takes plugs in order (outermost first); Pipeline.compose layers one pipeline on another; Plug.(@>) composes inline. A scope runs its routes through the pipeline you give it.
$ dune exec examples/hcs_plug/main.exeThe built-in plug catalog
hcs ships the cross-cutting plugs a production service needs, all under Hcs.Plug. You rarely write your own.
Observability and safety: Logger.create ~clock (a Log.logger) logs each request/response; Request_id.create stamps a correlation id; Recover.create ?on_error turns an uncaught exception into a 500 instead of a dropped connection; Timeout.create ~clock seconds bounds handler time; Head.create answers HEAD by running the GET and dropping the body.
Traffic control: Cors.create ~config adds CORS headers and answers preflight; Rate_limit.create ~clock ~key ~requests ~per is a token bucket keyed by any function of the request (client IP, API key…); Circuit_breaker.create and Retry.create ~clock add resilience around flaky downstreams.
Representation: Compress.create gzips eligible responses; Etag.create adds ETags and answers conditional requests with 304; Cache_control.create sets Cache-Control; Negotiate.create ~formats picks a representation from the Accept header. Sessions, CSRF and auth have their own section next.
Sessions, CSRF and authentication
Authentication and session state are pipeline plugs, never handler code — the araara convention is that a route's security is declared by the pipeline it runs through.
HTTP Basic auth
(* A pipeline fronted by Basic auth: requests without valid credentials
get an automatic 401 before reaching the handler. *)
let admin =
Hcs.Pipeline.create
[
Hcs.Plug.Basic_auth.create_static ~realm:"admin" ~username:"root"
~password:"hunter2";
]
inBasic_auth.create_static guards a scope with a fixed credential; create ~validate checks against your own function, and create_with_map against a table. Unauthorized requests get a 401 with the realm automatically. Token-based API keys use Hcs.Plug.Token, which signs and verifies HMAC tokens.
Sessions
(* An encrypted cookie store keeps session state on the client; the plug
decodes it on the way in and re-encodes it on the way out. [~secure:false]
lets the cookie travel over plain HTTP for this local demo (use the
default in production). CSRF protection would be added with
[Hcs.Plug.Csrf.create ()] — safe methods (GET/HEAD) bypass it. *)
let store =
Hcs.Plug.Session.Cookie_store.create ~secret:"a-32-byte-demo-secret-value!!" ()
in
let sessions =
Hcs.Pipeline.create [ Hcs.Plug.Session.create ~store ~secure:false () ]
in(* Inside a handler running under the Session plug, the session is read and
written with plain string keys/values. [put] marks it modified, so the
plug serializes it into a Set-Cookie on the way out. *)
let login _params _req =
Hcs.Plug.Session.put "user" "ada";
Hcs.Server.respond "logged in\n"
let whoami _params _req =
match Hcs.Plug.Session.get "user" with
| Some user -> Hcs.Server.respond (Printf.sprintf "you are %s\n" user)
| None -> Hcs.Server.respond ~status:`Unauthorized "no session\n"A session store is either Cookie_store.create ~secret (signed, stateless, in the cookie) or Memory_store.create (server-side). The Session.create plug then makes Session.get / Session.put / Session.delete available inside handlers. CSRF protection is the companion Csrf.create plug: safe methods pass through, unsafe ones must present a matching token.
$ dune exec examples/hcs_auth/main.exeTyped request context (Assigns)
When a plug computes something a handler needs — the authenticated account, a parsed locale, a request-scoped id — it stashes it in the request's typed context. Hcs.Assigns is a heterogeneous map keyed by typed keys, so the handler reads the value back at its real type, not as a string.
A typed key
(* A key carries the type of the value it stores. [create_key] mints a fresh
one; the phantom type parameter ties [add] and [find] together so reads
come back typed — no casting at the use site. *)
let current_user : string Hcs.Assigns.key = Hcs.Assigns.create_key ()A plug that assigns
(* This plug derives a user id from a header and attaches it to the request's
assigns. Assigns are immutable, so we build a new request carrying the
extended map and pass that to the handler. *)
let identify : Hcs.Plug.t =
fun handler req ->
let user =
match Hcs.Request.header req "x-api-key" with
| Some "secret" -> "ada"
| _ -> "anonymous"
in
let assigns = Hcs.Assigns.add current_user user (Hcs.Request.assigns req) in
handler { req with Hcs.Server.assigns }The plug adds a binding to Request.assigns and puts the updated request back on the chain.
A handler that reads it
(* The handler recovers the value by key; [find] returns it at the key's
type ([string] here), so no downcast is needed. *)
let greet _params req =
match Hcs.Assigns.find current_user (Hcs.Request.assigns req) with
| Some user -> Hcs.Server.respond (Printf.sprintf "hello, %s\n" user)
| None -> Hcs.Server.respond ~status:`Internal_server_error "no user\n"Assigns.find returns an option at the key's type. A missing key is None, never a wrong-typed value — the phantom type on the key guarantees it.
$ dune exec examples/hcs_assigns/main.exeServing static files
Plug.Static serves a directory: index files, ETags and the right content types for free. Mount it on the endpoint, ahead of the router, and it answers asset requests while passing everything else through.
Mounting the plug
(* [Plug.Static.create] takes an Eio directory capability; it serves
regular files under that root and falls through to the next handler
(here the endpoint's 404) when nothing matches. *)
let root = Eio.Path.(fs / dir) in
let static = Hcs.Plug.Static.create ~with_etag:true root in
let handler =
Hcs.Endpoint.(create default_config |> with_plug static |> to_handler)
inThe root is an Eio.Path.t, so the plug only ever reads inside it — path traversal and symlink escapes are refused. ?index sets the directory index; ?with_etag toggles conditional requests.
Fetching an asset
match Hcs.Client.get client url with
| Ok { Hcs.Client.status; body; _ } ->
Printf.printf "%s -> %d %S\n" path status (String.trim body)
| Error _ ->
prerr_endline "request failed";
exit 1$ dune exec examples/hcs_static/main.exeThe HTTP client
The same library speaks both sides of HTTP. The example starts a server and queries it from the same process, so the whole exchange runs in the test suite.
A server fiber
let echo params _req =
match Hcs.Router.param "word" params with
| Some word -> Hcs.Server.respond (String.uppercase_ascii word)
| None -> Hcs.Server.respond ~status:`Bad_request "missing word"
let start_server ~sw ~net ~clock =
let routes =
Hcs.Router.(compile_scopes [ scope "/" [ Route.get "/echo/:word" echo ] ])
in
let handler =
Hcs.Endpoint.(
create default_config |> Fun.flip router routes |> to_handler)
in
let config = { Hcs.Server.default_config with port; host = "127.0.0.1" } in
Eio.Fiber.fork ~sw (fun () -> Hcs.Server.run ~sw ~net ~clock ~config handler)The client value
(* A client is a value: connection pooling and protocol selection live
inside it. [get] returns a result — errors are data, not exceptions. *)
let fetch ~sw ~net ~clock url =
let client = Hcs.Client.create ~sw ~net ~clock () in
match Hcs.Client.get client url with
| Ok { Hcs.Client.status; body; _ } ->
Printf.printf "GET %s -> %d %s\n" url status body
| Error _ ->
prerr_endline "request failed";
exit 1Client.create gives a value holding a connection pool and protocol negotiation. get, post and request return a result — connection failures, timeouts and oversized bodies are variants to match on, not exceptions. Configuration is functional: with_timeout, with_redirects / without_redirects, with_http2, with_tls, with_default_header each return an updated config. Methods are the Hcs.Http constructors (Hcs.Http.GET, POST, …).
$ dune exec examples/hcs_client/main.exeWebSocket
hcs implements WebSocket (RFC 6455) on both ends. The server runs in Auto_websocket mode and hands each upgraded connection to a handler; the client connects and exchanges frames. The example does both in one process.
The connection handler
(* A WebSocket handler is [Websocket.t -> unit]: it owns the connection for its
lifetime. This one echoes text frames back until the peer closes (at which
point [recv_message] returns an error and the loop ends). [recv_message]
reassembles fragmented frames and returns the opcode plus the payload. *)
let echo_handler ws =
let rec loop () =
match Hcs.Websocket.recv_message ws with
| Ok (Hcs.Websocket.Opcode.Text, msg) -> (
match Hcs.Websocket.send_text ws msg with
| Ok () -> loop ()
| Error _ -> ())
| Ok _ -> loop () (* ignore non-text messages *)
| Error _ -> () (* connection closed or errored: stop *)
in
loop ()A ws_handler is Websocket.t -> unit. recv_message returns the opcode and payload; send_text / send_binary write frames; send_ping / send_pong and close handle control frames; is_open tests the connection.
Wiring the server
(* WebSocket support is opt-in: run the server in [Auto_websocket] protocol mode
and register the handler on the endpoint. [Endpoint.ws_handler] then supplies
it to [Server.run] via [?ws_handler]. *)
let start_server ~sw ~net ~clock =
let ep =
Hcs.Endpoint.(create default_config |> Fun.flip websocket echo_handler)
in
let config =
{
Hcs.Server.default_config with
port;
host = "127.0.0.1";
protocol = Hcs.Server.Auto_websocket;
}
in
Eio.Fiber.fork ~sw (fun () ->
Hcs.Server.run ~sw ~net ~clock ~config
?ws_handler:(Hcs.Endpoint.ws_handler ep)
(Hcs.Endpoint.to_handler ep))Endpoint.websocket attaches the handler; Server.run takes it via ?ws_handler with protocol Auto_websocket, so the same port serves HTTP and upgrades WebSocket requests.
The client
(* [connect] performs the HTTP/1.1 upgrade handshake and returns a connection
(or an error). Send a text message, read the echo, then close. *)
let exchange ~sw ~net url =
match Hcs.Websocket.connect ~sw ~net url with
| Error _ ->
prerr_endline "connect failed";
1
| Ok ws -> (
match Hcs.Websocket.send_text ws "hello" with
| Error _ ->
prerr_endline "send failed";
1
| Ok () -> (
match Hcs.Websocket.recv_message ws with
| Ok (_, reply) ->
Printf.printf "echo: %s\n" reply;
Hcs.Websocket.close ws;
if reply = "hello" then 0 else 1
| Error _ ->
prerr_endline "recv failed";
1))Websocket.connect performs the handshake and returns a Websocket.t you drive with the same send/recv functions. For topic fan-out across many connections — broadcasting one message to every subscribed socket or SSE stream — reach for hive's pubsub, documented on the hive page; hcs itself stays a transport.
$ dune exec examples/hcs_websocket/main.exeServer-Sent Events
SSE is the push half of araara's frontend story: the server streams HTML fragments and htmx swaps them in. The fan-out underneath — one broadcast reaching every subscribed connection — comes from hive's pubsub (see the hive page); an SSE handler subscribes on connect and pushes each message it receives.
An SSE stream
(* An SSE response is driven by a generator: hcs calls it each time it
wants the next event; returning None closes the stream. *)
let ticks ~clock _params _req =
let count = Atomic.make 0 in
Hcs.Sse.respond (fun () ->
Eio.Time.sleep clock 1.0;
let n = Atomic.fetch_and_add count 1 in
if n >= 5 then None
else
Some
(Hcs.Sse.make ~event_type:"tick" ~id:(string_of_int n)
(Printf.sprintf "tick %d at %.0f" n (Eio.Time.now clock))))let routes =
Hcs.Router.(
compile_scopes
[
scope "/"
[ Route.get "/" home; Route.get "/events" (ticks ~clock) ];
])
inAn SSE response is a pull: hcs calls your generator when the connection can take the next event; Some sends, None closes. Events carry an optional type and id — the id lets a reconnecting client resume. respond_with_heartbeat keeps idle connections alive through proxies; Sse_client and Event_source (auto-reconnecting) cover the consuming side.
$ dune exec examples/hcs_sse/main.exe$ curl -N localhost:8080/eventsMultipart uploads
File uploads arrive as multipart/form-data. Hcs.Multipart parses the body into parts — text fields and files — either all at once or streamed for large payloads.
Parsing on the server
(* On the server, [Multipart.parse] turns the request body into parts.
[find_part] looks up a field by name; [find_file] finds a file part (one
that carries a filename). Errors are data — answer 400 on a bad body. *)
let upload _params req =
match Hcs.Multipart.parse req with
| Error e ->
Hcs.Server.respond ~status:`Bad_request (Hcs.Multipart.error_to_string e)
| Ok parts -> (
let caption =
match Hcs.Multipart.find_part "caption" parts with
| Some p -> p.Hcs.Multipart.data
| None -> "(none)"
in
match Hcs.Multipart.find_file "upload" parts with
| None -> Hcs.Server.respond ~status:`Bad_request "no file part"
| Some file ->
let name = Option.value ~default:"(unnamed)" file.filename in
Hcs.Server.respond
(Printf.sprintf "caption=%s file=%s size=%d" caption name
(String.length file.data)))is_multipart guards the request; parse returns the parts as a result; find_part pulls a text field and find_file a file (name, filename, content_type, data). For large uploads, create_parser plus iter_parts / next_part stream parts without buffering the whole body.
Posting a file
(* The client posts the assembled body with a multipart Content-Type whose
[boundary] parameter matches the one used to frame the parts. *)
let post_upload ~sw ~net ~clock url =
let body =
build_multipart_body ~field:"a tiny upload" ~filename:"hello.txt"
~file_contents:"file body bytes"
in
let headers =
[ ("Content-Type", Printf.sprintf "multipart/form-data; boundary=%s" boundary) ]
in
let client = Hcs.Client.create ~sw ~net ~clock () in
match Hcs.Client.post client url ~headers ~body with
| Ok { Hcs.Client.status; body; _ } ->
Printf.printf "POST %s -> %d %s\n" url status body;
if status = 200 then 0 else 1
| Error _ ->
prerr_endline "request failed";
1$ dune exec examples/hcs_multipart/main.exeAssembling the endpoint
The endpoint is the single value that ties a server together: the global plugs every request passes through, the router, and an optional WebSocket handler. You saw the minimal form in the first-server example (create |> router |> to_handler); the full builder is worth knowing.
The builder
(* An endpoint is built by a chain: start from a config, attach
cross-cutting plugs and pipelines, mount a router, then close it
into a handler. [health_check = true] makes the endpoint answer
GET /_health and /health on its own. *)
let config =
{ Hcs.Endpoint.secret_key_base = "change-me-in-production";
health_check = true }
in
let pipeline =
Hcs.Pipeline.create
[
Hcs.Plug.Logger.create ~clock (Hcs.Log.stdout ());
Hcs.Plug.Recover.create ();
]
in
let routes =
Hcs.Router.(compile_scopes [ scope "/" [ Route.get "/" home ] ])
in
let handler =
Hcs.Endpoint.create config
|> Hcs.Endpoint.with_plug security_headers
|> Hcs.Endpoint.with_pipeline pipeline
|> Fun.flip Hcs.Endpoint.router routes
|> Hcs.Endpoint.to_handler
inEndpoint.create takes a config record { secret_key_base; health_check }. secret_key_base is the signing key behind cookie sessions and CSRF tokens — set it from configuration in production, never commit it. From there the builder is a pipeline of refinements: with_plug adds one global plug (static files, security headers), with_pipeline (or the @>> operator) composes a whole pipeline of them, router attaches the compiled routes, websocket attaches a Websocket.t -> unit handler, and to_handler produces the plain request -> response function that Server.run serves.
Order matters: the global plugs wrap everything, including the static plug, the health check and the not-found handler — so a logger or security-headers plug at the endpoint level genuinely sees every response, not just routed ones. Per-scope pipelines (from the routing section) then layer on top for the routes inside them.
The health check
(* The endpoint answers its own health check with 200 "ok". *)
fetch ~sw ~net ~clock ~label:"health"
(Printf.sprintf "http://127.0.0.1:%d/_health" port);With health_check enabled (the default), the endpoint answers GET /_health and GET /health with a 200 and a plain "ok", short-circuiting before the router. That gives load balancers and container orchestrators a liveness probe without a route of your own — this site's Docker image uses it. Turn it off in the config if you would rather own those paths.
$ dune exec examples/hcs_endpoint/main.exeProtocols, HTTP/2 and multiple cores
The protocol field of the server config selects how connections are handled:
(* The server config selects the protocol. Http1_only is the default
and fastest; Http2_only, Auto and Auto_websocket are the others. *)
let config =
{
Hcs.Server.default_config with
port;
host = "127.0.0.1";
protocol = Hcs.Server.Http1_only;
}
inHttp1_only — HTTP/1.1 only, the default and the right choice behind a proxy that terminates HTTP/2.
Http2_only — HTTP/2 cleartext (h2c), for an internal hop that always speaks HTTP/2.
Auto — serve both: with TLS, ALPN negotiates h2 or http/1.1 per connection; without TLS, HTTP/1.1 with h2c upgrade.
Auto_websocket — Auto plus the WebSocket upgrade path, which is the mode the WebSocket section uses.
Server.run uses a single accept loop. For multi-core serving, Server.run_parallel takes a domain manager and spreads accept loops across domain_count domains (set it in the config), so throughput scales with cores while your handlers stay ordinary direct-style Eio code. The same config also tunes backlog, max_connections, the read / write / idle / request timeouts, max_header_size, max_body_size, TCP_NODELAY / SO_REUSEPORT, and GC parameters.
TLS
TLS is configured through Hcs.Tls_config, purely in OCaml via tls-eio — no OpenSSL. The example generates a self-signed certificate at runtime and does a complete HTTPS round-trip in one process; a real deployment loads a certificate instead with Tls_config.Server.of_pem.
A certificate
(* Build a key and a self-signed certificate entirely in memory. Both the
key generation and the signing draw on the process-wide RNG, so this
must run after the RNG is seeded. Each step returns a result; we thread
them with [let*] so any failure surfaces as a clear message. *)
let ( let* ) = Result.bind
let self_signed () =
let key = X509.Private_key.generate `ED25519 in
let dn = [ Dn.Relative_distinguished_name.singleton (Dn.CN "localhost") ] in
let* csr =
Result.map_error
(fun (`Msg m) -> "csr: " ^ m)
(X509.Signing_request.create dn key)
in
(* Validity window: now .. now + 1 day, expressed as Ptime.t. *)
let now = Ptime_clock.now () in
let valid_until =
match Ptime.add_span now (Ptime.Span.of_int_s 86_400) with
| Some t -> t
| None -> now
in
let* cert =
Result.map_error
(fun e -> Format.asprintf "sign: %a" X509.Validation.pp_signature_error e)
(X509.Signing_request.sign csr ~valid_from:now ~valid_until key dn)
in
Ok (cert, key)Here X509 mints an ED25519 key and self-signs a certificate for development. In production you would not generate one — Tls_config.Server.of_pem ~cert_file ~key_file loads the chain and key issued by your CA.
The server
(* The server TLS config is a plain record: a [`Single] own-cert pairing the
certificate chain with its private key, plus the ALPN protocols offered.
We advertise only HTTP/1.1 so the in-process client negotiates cleanly. *)
let start_server ~sw ~net ~clock cert key =
let server_tls =
{
Hcs.Tls_config.Server.certificate = `Single ([ cert ], key);
alpn_protocols = Some [ Hcs.Tls_config.alpn_http11 ];
}
in
let hello _params _req = Hcs.Server.respond "secure hello\n" in
let routes = Hcs.Router.(compile_scopes [ scope "/" [ Route.get "/" hello ] ]) in
let handler =
Hcs.Endpoint.(create default_config |> Fun.flip router routes |> to_handler)
in
let config =
{
Hcs.Server.default_config with
port;
host = "127.0.0.1";
protocol = Hcs.Server.Auto;
tls = Some server_tls;
}
in
Eio.Fiber.fork ~sw (fun () -> Hcs.Server.run ~sw ~net ~clock ~config handler)A Tls_config.Server.t pairs the certificate with the ALPN protocols to advertise; put it in the server config's tls field. In Auto mode ALPN offers both h2 and http/1.1, so HTTP/2 is negotiated automatically when the client supports it (the helpers h1_only / h2_only / h2_or_http11 set the list).
The client
(* A self-signed cert won't chain to any system CA, so the client uses
[with_insecure_tls], which skips verification — right for a dev cert,
never for production. The URL scheme is https; everything else is the
ordinary client API. *)
let fetch ~sw ~net ~clock url =
let config = Hcs.Client.with_insecure_tls Hcs.Client.default_config in
let client = Hcs.Client.create ~sw ~net ~clock ~config () in
match Hcs.Client.get client url with
| Ok { Hcs.Client.status; body; _ } ->
Printf.printf "GET %s -> %d %s" url status body
| Error _ ->
prerr_endline "request failed";
exit 1Tls_config.Client has a default verifying configuration and an insecure variant for local self-signed certificates; the client takes it with with_insecure_tls / with_tls, and Tls_config exposes the negotiated protocol after the handshake. In production araara apps usually terminate TLS at a reverse proxy and run Http1_only behind it; built-in TLS is there for when you don't.
$ dune exec examples/hcs_tls/main.exeLogging
Request logging is the Plug.Logger.create ~clock plug from the catalog, and it takes a Hcs.Log.logger — a sink for structured request/response events.
A log sink
(* A logger is [level -> event -> unit]. [Hcs.Log.custom] hands us each
event pre-formatted as a string, so here we just collect the lines.
(Swap in [Hcs.Log.stdout ~json:true ()] to emit JSON to stdout
instead — same plug, different sink.) *)
let captured : string list ref = ref []
let capture_sink : Hcs.Log.logger =
Hcs.Log.custom (fun level msg ->
let line =
Printf.sprintf "[%s] %s" (Hcs.Log.level_to_string level) msg
in
captured := line :: !captured)The Log module builds sinks: stdout and stderr (each with ?min_level and a ?json toggle for line-delimited JSON), custom and custom_json to route formatted lines into your own logging stack, combine to fan one event stream into several sinks, with_min_level to filter, and null to discard.
Installing it
(* Plug.Logger turns request lifecycle events into log events and feeds
them to whichever sink we pass it. *)
let pipeline =
Hcs.Pipeline.create
[
Hcs.Plug.Logger.create ~clock capture_sink;
Hcs.Plug.Recover.create ();
]
inBecause the logger is just a value you pass to the plug, swapping human-readable logs in development for Hcs.Log.stdout ~json:true () in production is a one-line change, and the same events carry the request id from Plug.Request_id for correlation.
$ dune exec examples/hcs_logging/main.exeStreaming bodies
Not every body fits in memory. Hcs.Stream provides sync and async streams, and both the request and response sides use them.
Streaming a response
(* [Response.stream] takes a generator [unit -> Cstruct.t option]: each call
yields the next chunk, [None] ends the body. The chunks are sent as they
are produced rather than buffered into one string. *)
let counter _params _req =
let remaining = ref [ "one "; "two "; "three" ] in
let next () =
match !remaining with
| [] -> None
| chunk :: rest ->
remaining := rest;
Some (Cstruct.of_string chunk)
in
Hcs.Response.stream nextResponse.stream (and bigstring / cstruct) sends a body produced incrementally — the generator returns the next chunk until None — which is exactly what the SSE generator and the multipart streaming parser are built on.
Consuming a request body in chunks
(* On the request side, [Request.body_stream] pulls the incoming body in
chunks the same way — useful for large uploads you do not want to hold
in memory all at once. Here we just count the bytes as they arrive. *)
let upload _params req =
let stream = Hcs.Request.body_stream req in
let total = ref 0 in
let rec drain () =
match stream () with
| None -> ()
| Some chunk ->
total := !total + Cstruct.length chunk;
drain ()
in
drain ();
Hcs.Server.respond (Printf.sprintf "received %d bytes\n" !total)Request.body_stream hands you the body as chunks instead of one string, and the server's request_body_buffer_limit caps how much is ever held in memory — an endpoint that only counts or forwards a large upload need not buffer it.
$ dune exec examples/hcs_streaming/main.exeIn a full application
Where this fits in a full application: the endpoint and pipelines live in lib/
_web, handlers are thin controllers calling contexts, and the conventions page describes the layering around them. The API reference linked above documents every module and function in full.