hcs #
HTTP library for OCaml 5+ built on Eio. Supports HTTP/1.1, HTTP/2, and WebSocket.
Modules #
| Module | Description |
|---|---|
Client |
HTTP client with auto-protocol selection and connection pooling |
Server |
HTTP server with HTTP/1.1, HTTP/2, TLS, streaming, and WebSocket support |
Router |
Router with path parameters, wildcards, scopes, and per-route plugs |
Plug |
Middleware composition and built-in plugs |
Pipeline |
Reusable ordered plug collections for route scopes |
Endpoint |
Global plug entry point; builds a server handler from plugs + router |
Request |
Lazy request accessors for headers, query strings, forms, body, and assigns |
Response |
Response constructors, modifiers, streaming, binary, and prebuilt bodies |
Http |
Client request builder DSL |
Method |
HTTP method type and conversion helpers |
Status |
HTTP status type and conversion helpers |
Assigns |
Type-safe per-request storage for plugs and handlers |
Websocket |
WebSocket client and server frame I/O (RFC 6455) |
Sse |
Server-Sent Events response helpers |
Sse_client |
Low-level SSE client with W3C-compliant parsing |
Event_source |
High-level SSE client with auto-reconnect |
Pool |
Generic connection pool data structure |
Stream |
Sync and Eio-oriented streaming helpers |
Codec |
Codec signature and helpers for typed request/response bodies |
Tls_config |
Client and server TLS configuration |
Log |
Structured logging helpers |
Multipart |
Multipart form-data parsing |
H1_client |
HTTP/1.1-specific client |
H2_client |
HTTP/2-specific client |
For a function-by-function map with examples, see docs/reference/public-api.md.
Plug System #
Plug provides middleware composition for request/response pipelines.
Available Plugs #
| Plug | Description |
|---|---|
Plug.Logger |
Request/response logging |
Plug.Request_id |
Add unique request ID header |
Plug.Head |
Convert HEAD to GET, strip body |
Plug.Timeout |
Request timeout with cancellation |
Plug.Recover |
Exception recovery with custom handler |
Plug.Cors |
CORS headers |
Plug.Rate_limit |
Token bucket rate limiting |
Plug.Etag |
ETag generation and validation |
Plug.Cache_control |
Cache-Control headers |
Plug.Static |
Static file serving |
Plug.Compress |
Gzip/zstd response compression |
Plug.Circuit_breaker |
Circuit breaker pattern |
Plug.Retry |
Retry with backoff |
Plug.Basic_auth |
HTTP Basic authentication |
Plug.Csrf |
CSRF token validation |
Plug.Negotiate |
Content negotiation (Accept header parsing) |
Plug.Token |
Signed/encrypted token generation and validation |
Plug.Session |
Cookie-based session management |
Usage #
open Hcs
let pipeline =
Plug.Logger.create ~clock logger
@> Plug.Compress.create ()
@> Plug.Timeout.create ~clock 30.0
@> Plug.Cors.create ()
@> Plug.identity
let handler = Plug.apply pipeline my_handler
Plugs compose left-to-right with @>. The pipeline wraps my_handler, executing plugs in order for requests and reverse order for responses.
Client HTTP/2 Batching #
Hcs.Client.execute_many sends a list of Http.request values concurrently
when the client is configured for HTTP/2. Results preserve input order, while
HTTP/2 streams may complete out of order internally.
let config = Hcs.Client.default_config |> Hcs.Client.with_http2 in
let client = Hcs.Client.create ~sw ~net ~clock ~config () in
let requests =
[
Hcs.Http.get "http://127.0.0.1:8080/slow" |> Hcs.Http.build;
Hcs.Http.get "http://127.0.0.1:8080/fast" |> Hcs.Http.build;
]
let responses = Hcs.Client.execute_many ~max_concurrent:100 client requests
Guidance:
- Use
with_http2when you want true concurrent streams on one HTTP/2 connection. - Keep requests in a batch on the same origin for multiplexing.
max_concurrentbounds streams opened at once; larger lists are processed in ordered chunks.- Non-HTTP/2 client configurations fall back to sequential execution.
CLI Tools #
hc - HTTP Client #
hc [OPTIONS] <URL>
Options:
| Flag | Description |
|---|---|
-X, --request METHOD |
HTTP method (GET, POST, PUT, DELETE, etc.) |
-H, --header "Name: Value" |
Add header (can be repeated) |
-d, --data DATA |
Request body |
-2, --http2 |
Force HTTP/2 |
-1, --http1 |
Force HTTP/1.1 |
-k, --insecure |
Skip TLS verification |
-v, --verbose |
Show headers |
-I, --head |
HEAD request only |
-L, --location |
Follow redirects (default) |
--no-location |
Don't follow redirects |
-o, --output FILE |
Write to file |
-w, --websocket |
WebSocket mode |
--ws-message MSG |
Message to send in WebSocket mode |
Examples:
# GET request
hc https://httpbin.org/get
# POST with data
hc -d '{"key":"value"}' -H "Content-Type: application/json" https://httpbin.org/post
# Force HTTP/2
hc -2 https://nghttp2.org/httpbin/get
# WebSocket
hc -w wss://echo.websocket.org --ws-message "hello"
# Download file
hc -o image.png https://example.com/image.png
hs - HTTP File Server #
hs [OPTIONS] [DIRECTORY]
Options:
| Flag | Description |
|---|---|
-p, --port PORT |
Port (default: 8080) |
-b, --bind ADDRESS |
Bind address (default: 0.0.0.0) |
-d, --domains N |
Worker domains (default: CPU count) |
-1, --http1 |
HTTP/1.1 only |
-2, --http2 |
HTTP/2 only |
--index FILE |
Index file (default: index.html) |
--no-index |
Disable index file |
--list |
Enable directory listing |
--cors |
Enable CORS headers |
-v, --verbose |
Log requests |
Examples:
# Serve current directory
hs
# Serve specific directory on port 3000
hs -p 3000 ./public
# With directory listing and CORS
hs --list --cors ./dist
# Verbose logging
hs -v -p 8000 .
hs is secure by default for static roots: it prepares an Eio directory capability for the selected directory and Plug.Static refuses symlink paths, including symlinked index files and directory-listing entries. Library users should follow the same pattern by passing a pre-rooted directory capability to Plug.Static.create or Plug.Static.server instead of passing the process-wide filesystem capability through application code.
Building #
opam install . --deps-only
dune build
Running Tests #
dune test
TechEmpower Benchmarks #
The bench/ directory contains TechEmpower-style benchmark servers for HCS,
Go net/http, and Rust Hyper. Each server implements:
/plaintext, /slow?ms=N, /json, /db, /queries?queries=N,
/updates?queries=N, /fortunes, and /cached-queries?count=N.
The HCS implementation uses Repodb with SQLite. The Go and Rust benchmark
servers use SQLite directly so they remain benchmark-focused comparison targets.
Build #
dune build bench/hcs/bench_server_techempower.exe bench/client/bench_client.exe
(cd bench/nethttp && go build -o bench-nethttp .)
(cd bench/hyper && mise exec rust -- cargo build --release)
Initialize Databases #
_build/default/bench/hcs/bench_server_techempower.exe --init-only --db /tmp/hcs_te.sqlite3
bench/nethttp/bench-nethttp -init-only -db /tmp/go_te.sqlite3
mise exec rust -- bench/hyper/target/release/bench-hyper --init-only --db /tmp/rust_te.sqlite3
Run Servers #
_build/default/bench/hcs/bench_server_techempower.exe -p 18101 --cpus 4 --db /tmp/hcs_te.sqlite3 --pool-size 32 --pipeline-depth 1
bench/nethttp/bench-nethttp -port 18102 -cpus 4 -db /tmp/go_te.sqlite3 -pool-size 32
mise exec rust -- bench/hyper/target/release/bench-hyper -p 18103 --cpus 4 --db /tmp/rust_te.sqlite3 --pool-size 32
Run HTTP/1.1 Benchmarks #
Use the benchmark client against each endpoint:
_build/default/bench/client/bench_client.exe -p h1 -u http://127.0.0.1:18101/plaintext -c 50 -d 10 --json
_build/default/bench/client/bench_client.exe -p h1 -u 'http://127.0.0.1:18101/queries?queries=5' -c 50 -d 10 --json
Replace the port and endpoint for each server. The benchmark report below used these endpoints:
/plaintext
/json
/db
/queries?queries=5
/queries?queries=20
/updates?queries=5
/fortunes
/cached-queries?count=20
The benchmark runner can exercise the same endpoint set for HCS, Go, and Rust:
bench/scripts/run_benchmark.sh --suite techempower --cpus 4 --protocol h1 --base-port 18101
bench/scripts/run_benchmark.sh --suite techempower --cpus 4 --protocol h2 --base-port 18101
Run HTTP/2 h2c Benchmarks #
h2load -n 200000 -c 50 -t 4 http://127.0.0.1:18101/plaintext
h2load -n 200000 -c 50 -t 4 'http://127.0.0.1:18101/queries?queries=5'
Run Mixed HTTP/2 Pipelining Benchmark #
Use bench_h2_mixed to measure whether fast requests are blocked behind slow
requests on the same HTTP/2 connection. It sends /slow?ms=N and /plaintext
pairs over each connection and reports latency for the fast /plaintext
responses.
dune build bench/client/bench_h2_mixed.exe bench/hcs/bench_server_techempower.exe
_build/default/bench/hcs/bench_server_techempower.exe \
-p 18101 --cpus 4 --db /tmp/hcs_te.sqlite3 --pool-size 32 \
--pipeline-depth 256
_build/default/bench/client/bench_h2_mixed.exe \
-u http://127.0.0.1:18101 -c 50 --pairs 100 --slow-ms 20
On 2026-05-09, the mixed benchmark showed the intended tail-latency effect:
| HCS mode | Fast requests | Failed | Duration | Fast avg | Fast p50 | Fast p90 | Fast p99 | Fast p99.9 |
|---|---|---|---|---|---|---|---|---|
--pipeline-depth 1 |
5,000 | 0 | 6.081s | 58.806ms | 61.024ms | 61.986ms | 62.587ms | 62.887ms |
--pipeline-depth 256 |
5,000 | 0 | 2.032s | 0.171ms | 0.043ms | 0.209ms | 1.025ms | 13.576ms |
This benchmark is more representative of the feature than homogeneous
/plaintext throughput: pipelining is meant to reduce head-of-line blocking
when slow and fast streams share a connection.
Previous Results #
Historical run from 2026-05-09 on AMD Ryzen 9 9950X, Linux, SQLite WAL mode,
pool size 32, local loopback client. This run is kept for comparison with the
new explicit --cpus runs below.
HTTP/1.1 used bench_client with concurrency 50 and 10 second duration.
| Endpoint | HCS req/s | Go req/s | Rust req/s |
|---|---|---|---|
/plaintext |
327,104 | 439,698 | 453,032 |
/json |
368,606 | 447,050 | 449,606 |
/db |
343,363 | 249,577 | 485,808 |
/queries?queries=5 |
333,745 | 246,739 | 477,882 |
/queries?queries=20 |
328,970 | 244,514 | 494,467 |
/updates?queries=5 |
138,816 | 79,406 | 279,977 |
/fortunes |
263,761 | 150,446 | 460,623 |
/cached-queries?count=20 |
362,191 | 421,367 | 443,835 |
HTTP/2 h2c used h2load with 200,000 requests, concurrency 50, and 4 client
threads.
| Endpoint | HCS req/s | Go req/s | Rust req/s |
|---|---|---|---|
/plaintext |
476,466 | 255,920 | 712,972 |
/json |
451,184 | 250,608 | 700,378 |
/db |
346,794 | 152,067 | 466,579 |
/queries?queries=5 |
156,986 | 81,681 | 170,403 |
/cached-queries?count=20 |
394,058 | 192,234 | 611,395 |
Summary from this run:
HCS was ahead of Go on every database-backed HTTP/1.1 endpoint except cached queries, and ahead of Go on every measured HTTP/2 endpoint.
Rust Hyper was fastest overall in this run. HCS was closest to Rust on HTTP/2
/queries?queries=5, where Rust was about 8.5% faster.
4 CPU Results #
Last run: 2026-05-09 with --cpus 4 / -cpus 4, SQLite WAL mode, pool size
32, and local loopback client. All runs completed with 100% successful
responses and no SQLite lock/database errors in server logs.
HTTP/1.1 used bench_client with concurrency 50 and 10 second duration.
| Endpoint | HCS req/s | Go req/s | Rust req/s |
|---|---|---|---|
/plaintext |
377,658 | 410,048 | 403,171 |
/json |
389,042 | 456,870 | 413,299 |
/db |
361,132 | 260,268 | 489,162 |
/queries?queries=5 |
352,613 | 262,285 | 489,917 |
/queries?queries=20 |
347,337 | 257,133 | 518,952 |
/updates?queries=5 |
148,197 | 74,916 | 287,993 |
/fortunes |
288,152 | 148,668 | 472,293 |
/cached-queries?count=20 |
377,796 | 438,392 | 483,261 |
HTTP/2 h2c used h2load with 200,000 requests, concurrency 50, and 4 client
threads.
| Endpoint | HCS req/s | Go req/s | Rust req/s |
|---|---|---|---|
/plaintext |
479,513 | 222,466 | 580,602 |
/json |
471,289 | 250,311 | 860,911 |
/db |
322,530 | 164,741 | 351,833 |
/queries?queries=5 |
179,486 | 81,475 | 148,300 |
/cached-queries?count=20 |
439,909 | 195,976 | 476,971 |
In this 4 CPU run, HCS was faster than Go on all database-backed HTTP/1.1
endpoints except cached queries, and faster than Go on every measured HTTP/2
endpoint. Rust was fastest overall, except HCS led Rust on HTTP/2
/queries?queries=5.