ocaml http/1, http/2 and websocket client and server library
OCaml 90%
Shell 3%
Go 2%
Rust 2%
1%
Python 1%
Dockerfile <1%
Dune <1%
C <1%

README.md

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_http2 when you want true concurrent streams on one HTTP/2 connection.
  • Keep requests in a batch on the same origin for multiplexing.
  • max_concurrent bounds 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.

Dependencies #

  • eio - Structured concurrency
  • http-proto - HTTP/1.1 and HTTP/2 parsing
  • ssl - OpenSSL bindings (TLS)
  • hacl-star - Hashing and HMAC primitives
  • uri - URI parsing
  • climate - CLI parsing