···11+# Beads - AI-Native Issue Tracking
22+33+Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
44+55+## What is Beads?
66+77+Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
88+99+**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
1010+1111+## Quick Start
1212+1313+### Essential Commands
1414+1515+```bash
1616+# Create new issues
1717+bd create "Add user authentication"
1818+1919+# View all issues
2020+bd list
2121+2222+# View issue details
2323+bd show <issue-id>
2424+2525+# Update issue status
2626+bd update <issue-id> --status in_progress
2727+bd update <issue-id> --status done
2828+2929+# Sync with git remote
3030+bd sync
3131+```
3232+3333+### Working with Issues
3434+3535+Issues in Beads are:
3636+- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
3737+- **AI-friendly**: CLI-first design works perfectly with AI coding agents
3838+- **Branch-aware**: Issues can follow your branch workflow
3939+- **Always in sync**: Auto-syncs with your commits
4040+4141+## Why Beads?
4242+4343+✨ **AI-Native Design**
4444+- Built specifically for AI-assisted development workflows
4545+- CLI-first interface works seamlessly with AI coding agents
4646+- No context switching to web UIs
4747+4848+🚀 **Developer Focused**
4949+- Issues live in your repo, right next to your code
5050+- Works offline, syncs when you push
5151+- Fast, lightweight, and stays out of your way
5252+5353+🔧 **Git Integration**
5454+- Automatic sync with git commits
5555+- Branch-aware issue tracking
5656+- Intelligent JSONL merge resolution
5757+5858+## Get Started with Beads
5959+6060+Try Beads in your own projects:
6161+6262+```bash
6363+# Install Beads
6464+curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
6565+6666+# Initialize in your repo
6767+bd init
6868+6969+# Create your first issue
7070+bd create "Try out Beads"
7171+```
7272+7373+## Learn More
7474+7575+- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
7676+- **Quick Start Guide**: Run `bd quickstart`
7777+- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
7878+7979+---
8080+8181+*Beads: Issue tracking that moves at the speed of thought* ⚡
+62
.beads/config.yaml
···11+# Beads Configuration File
22+# This file configures default behavior for all bd commands in this repository
33+# All settings can also be set via environment variables (BD_* prefix)
44+# or overridden with command-line flags
55+66+# Issue prefix for this repository (used by bd init)
77+# If not set, bd init will auto-detect from directory name
88+# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
99+# issue-prefix: ""
1010+1111+# Use no-db mode: load from JSONL, no SQLite, write back after each command
1212+# When true, bd will use .beads/issues.jsonl as the source of truth
1313+# instead of SQLite database
1414+# no-db: false
1515+1616+# Disable daemon for RPC communication (forces direct database access)
1717+# no-daemon: false
1818+1919+# Disable auto-flush of database to JSONL after mutations
2020+# no-auto-flush: false
2121+2222+# Disable auto-import from JSONL when it's newer than database
2323+# no-auto-import: false
2424+2525+# Enable JSON output by default
2626+# json: false
2727+2828+# Default actor for audit trails (overridden by BD_ACTOR or --actor)
2929+# actor: ""
3030+3131+# Path to database (overridden by BEADS_DB or --db)
3232+# db: ""
3333+3434+# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
3535+# auto-start-daemon: true
3636+3737+# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
3838+# flush-debounce: "5s"
3939+4040+# Git branch for beads commits (bd sync will commit to this branch)
4141+# IMPORTANT: Set this for team projects so all clones use the same sync branch.
4242+# This setting persists across clones (unlike database config which is gitignored).
4343+# Can also use BEADS_SYNC_BRANCH env var for local override.
4444+# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
4545+# sync-branch: "beads-sync"
4646+4747+# Multi-repo configuration (experimental - bd-307)
4848+# Allows hydrating from multiple repositories and routing writes to the correct JSONL
4949+# repos:
5050+# primary: "." # Primary repo (where this database lives)
5151+# additional: # Additional repos to hydrate from (read-only)
5252+# - ~/beads-planning # Personal planning repo
5353+# - ~/work-planning # Work planning repo
5454+5555+# Integration settings (access with 'bd config get/set')
5656+# These are stored in the database, not in this file:
5757+# - jira.url
5858+# - jira.project
5959+# - linear.url
6060+# - linear.api-key
6161+# - github.org
6262+# - github.repo
+108
.beads/issues.jsonl
···11+{"id":"hcs-0ro","title":"Implement WebSocket frame types and parsing","description":"Implement WebSocket wire format in hcs-core/ws_frame.ml:\n\n```ocaml\ntype frame =\n | Text of string\n | Binary of Cstruct.t\n | Ping of Cstruct.t\n | Pong of Cstruct.t\n | Close of int option * string option\n\ntype parse_result =\n | Complete of frame * int (* frame and bytes consumed *)\n | Incomplete of int (* need more bytes *)\n | Error of string\n\nval parse_frame : Cstruct.t -\u003e parse_result\nval serialize_frame : ?mask:bool -\u003e frame -\u003e Cstruct.t\n\n(* Handshake *)\nval make_handshake_request : Uri.t -\u003e ?protocols:string list -\u003e Headers.t -\u003e request\nval validate_handshake_response : response -\u003e (string option, string) result (* selected protocol *)\nval make_handshake_response : request -\u003e ?protocol:string -\u003e (response, string) result\n```\n\nPure parsing/serialization.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:34:32.864373772+01:00","updated_at":"2025-12-29T16:00:43.877842667+01:00","closed_at":"2025-12-29T16:00:43.877842667+01:00","dependencies":[{"issue_id":"hcs-0ro","depends_on_id":"hcs-lhr","type":"parent-child","created_at":"2025-12-29T14:34:52.111489961+01:00","created_by":"gdiazlo"}]}
22+{"id":"hcs-0zq","title":"Testing Infrastructure and Compliance","description":"Set up testing infrastructure including unit tests, integration tests, and HTTP compliance test suites for both client and server implementations.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:35:43.905080385+01:00","updated_at":"2025-12-29T17:57:31.987947689+01:00","closed_at":"2025-12-29T17:57:31.987947689+01:00","dependencies":[{"issue_id":"hcs-0zq","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:35:55.080432801+01:00","created_by":"gdiazlo"}]}
33+{"id":"hcs-1h7","title":"Create Go WebSocket benchmark server","description":"Go server using gorilla/websocket or nhooyr.io/websocket. Same interface as HCS: accept connections, keep alive, report count.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T09:59:59.144195314+01:00","updated_at":"2025-12-30T10:08:50.856707182+01:00","closed_at":"2025-12-30T10:08:50.856707182+01:00","dependencies":[{"issue_id":"hcs-1h7","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:30.601603816+01:00","created_by":"gdiazlo"}]}
44+{"id":"hcs-1uy","title":"HTTP/2 Specific Features","description":"Implement H2 module with server push, stream priority, and HTTP/2 detection.","status":"closed","priority":3,"issue_type":"epic","created_at":"2025-12-29T14:25:37.55760677+01:00","updated_at":"2025-12-29T17:40:54.000014455+01:00","closed_at":"2025-12-29T17:40:54.000014455+01:00","dependencies":[{"issue_id":"hcs-1uy","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:20.914923161+01:00","created_by":"gdiazlo"}]}
55+{"id":"hcs-1vt","title":"Implement HTTP/2 server (Eio)","description":"Implement HTTP/2 server for Eio in hcs-eio/h2_server.ml:\n\n```ocaml\nval handle_connection :\n flow:Eio.Flow.two_way -\u003e\n clock:Eio.Time.clock -\u003e\n config:Server.config -\u003e\n handler:(request -\u003e (response, error) result) -\u003e\n unit\n```\n\nFeatures:\n- HPACK header compression\n- Stream multiplexing (concurrent requests)\n- Flow control\n- Server push support\n- GOAWAY for graceful shutdown\n- Priority handling\n\nDepends on hpack package.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:33:25.282940788+01:00","updated_at":"2025-12-29T16:00:42.340368477+01:00","closed_at":"2025-12-29T16:00:42.340368477+01:00","dependencies":[{"issue_id":"hcs-1vt","depends_on_id":"hcs-rw6","type":"parent-child","created_at":"2025-12-29T14:33:42.729783837+01:00","created_by":"gdiazlo"}]}
66+{"id":"hcs-23f","title":"Implement synchronous Stream module (core)","description":"Implement the synchronous Stream module in hcs-core/stream.ml:\n\nProducers:\n- empty, singleton, of_list, of_seq\n- unfold : ('s -\u003e ('a * 's) option) -\u003e 's -\u003e 'a t\n\nTransformers:\n- map, filter, filter_map\n- take, drop, chunks\n\nConsumers:\n- fold, iter, drain\n- to_string (for Cstruct.t streams)\n\nCombinators:\n- concat, zip\n\nThis is the pure, synchronous implementation. Runtime-specific async streams will wrap this.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:28:01.435958367+01:00","updated_at":"2025-12-29T14:51:05.189865738+01:00","closed_at":"2025-12-29T14:51:05.189865738+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-23f","depends_on_id":"hcs-czm","type":"parent-child","created_at":"2025-12-29T14:28:16.036236675+01:00","created_by":"gdiazlo"}]}
77+{"id":"hcs-2ca","title":"Implement Eio TLS integration","description":"Implement TLS context creation for Eio runtime in hcs-eio/tls.ml:\n\n- Convert Tls_config.client to Tls.Config.client\n- Convert Tls_config.server to Tls.Config.server \n- Load system certificates using ca-certs\n- Handle ALPN negotiation for HTTP/2\n- Wrap Eio flows with TLS\n\nDepends on tls-eio, ca-certs, x509 packages.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:30:34.350512358+01:00","updated_at":"2025-12-29T15:24:43.224163622+01:00","closed_at":"2025-12-29T15:24:43.224163622+01:00","dependencies":[{"issue_id":"hcs-2ca","depends_on_id":"hcs-y9w","type":"parent-child","created_at":"2025-12-29T14:30:47.016262637+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-2ca","depends_on_id":"hcs-cyb","type":"blocks","created_at":"2025-12-29T14:30:47.870432453+01:00","created_by":"gdiazlo"}]}
88+{"id":"hcs-2ie","title":"Router Implementation","description":"Implement Path DSL for type-safe extraction and Router module with trie-based route lookup, scoping, and middleware support.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:30.896190732+01:00","updated_at":"2025-12-29T15:41:48.160193387+01:00","closed_at":"2025-12-29T15:41:48.160193387+01:00","dependencies":[{"issue_id":"hcs-2ie","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:15.462426699+01:00","created_by":"gdiazlo"}]}
99+{"id":"hcs-320","title":"Create benchmark client for load generation","description":"Create a benchmark client (bin/hcs_bench_client.ml) that generates load and measures performance:\n- Configurable concurrency (number of parallel connections)\n- Configurable duration or request count\n- Support for HTTP/1.1 and HTTP/2\n- Measures: requests/second, latency (min/max/avg/p50/p99), errors\n- Reports results in human-readable and JSON formats\n\nShould use Eio fibers for concurrent requests.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T18:03:53.28432088+01:00","updated_at":"2025-12-29T18:15:36.842283087+01:00","closed_at":"2025-12-29T18:15:36.842283087+01:00","dependencies":[{"issue_id":"hcs-320","depends_on_id":"hcs-jtz","type":"parent-child","created_at":"2025-12-29T18:04:20.175343889+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-320","depends_on_id":"hcs-40d","type":"blocks","created_at":"2025-12-29T18:04:35.617524274+01:00","created_by":"gdiazlo"}]}
1010+{"id":"hcs-3dn","title":"Create HCS-based benchmark client for testing all servers","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T22:21:41.677601767+01:00","updated_at":"2025-12-30T22:31:12.664814017+01:00","closed_at":"2025-12-30T22:31:12.664814017+01:00"}
1111+{"id":"hcs-3ww","title":"Middleware System","description":"Implement Middleware module with composition, logging, security (CORS, auth), rate limiting, compression, caching, and static files.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:34.907454455+01:00","updated_at":"2025-12-29T17:40:52.182994384+01:00","closed_at":"2025-12-29T17:40:52.182994384+01:00","dependencies":[{"issue_id":"hcs-3ww","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:19.080036903+01:00","created_by":"gdiazlo"}]}
1212+{"id":"hcs-40d","title":"Implement latency histogram and percentile calculations","description":"Create a statistics module (lib/bench_stats.ml or in the benchmark binary) for accurate latency measurements:\n- HDR histogram or simple sorted array for percentiles\n- Calculate min, max, mean, stddev\n- Calculate p50, p90, p95, p99, p99.9 percentiles\n- Track request count, error count, bytes transferred\n- Support for merging stats from multiple workers","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T18:03:57.696657065+01:00","updated_at":"2025-12-29T18:15:29.902324683+01:00","closed_at":"2025-12-29T18:15:29.902324683+01:00","dependencies":[{"issue_id":"hcs-40d","depends_on_id":"hcs-jtz","type":"parent-child","created_at":"2025-12-29T18:04:22.619809769+01:00","created_by":"gdiazlo"}]}
1313+{"id":"hcs-42q","title":"Implement runtime-parameterized async Stream","description":"Design async stream interface that works with both Eio and Lwt:\n\n```ocaml\nmodule type ASYNC_STREAM = sig\n type 'a io\n type 'a t\n \n val from_flow : ... -\u003e Cstruct.t t\n val from_file : ... -\u003e Cstruct.t t\n val to_sink : ... -\u003e Cstruct.t t -\u003e unit io\nend\n\nmodule Make_stream (R : RUNTIME) : ASYNC_STREAM with type 'a io = 'a R.t\n```\n\nThe Eio implementation uses Eio.Flow, the future Lwt version will use Lwt_io.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:28:05.317935403+01:00","updated_at":"2025-12-29T17:08:13.483830721+01:00","closed_at":"2025-12-29T17:08:13.483830721+01:00","labels":["architecture","core"],"dependencies":[{"issue_id":"hcs-42q","depends_on_id":"hcs-czm","type":"parent-child","created_at":"2025-12-29T14:28:16.763534695+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-42q","depends_on_id":"hcs-23f","type":"blocks","created_at":"2025-12-29T14:28:17.658557754+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-42q","depends_on_id":"hcs-6bi","type":"blocks","created_at":"2025-12-29T14:28:18.52508237+01:00","created_by":"gdiazlo"}]}
1414+{"id":"hcs-4w8","title":"Create benchmark server with configurable endpoints","description":"Create a dedicated benchmark server (bin/hcs_bench_server.ml) with endpoints optimized for benchmarking:\n- GET /ping - minimal response (measures raw throughput)\n- GET /bytes/:n - returns n bytes (measures payload handling)\n- POST /echo - echoes request body (measures request body parsing)\n- GET /delay/:ms - adds artificial delay (measures concurrency)\n- GET /headers/:n - returns n headers (measures header handling)\n\nServer should support both HTTP/1.1 and HTTP/2, with configurable port and worker count.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T18:03:49.541780606+01:00","updated_at":"2025-12-29T18:15:27.763639409+01:00","closed_at":"2025-12-29T18:15:27.763639409+01:00","dependencies":[{"issue_id":"hcs-4w8","depends_on_id":"hcs-jtz","type":"parent-child","created_at":"2025-12-29T18:04:17.563056726+01:00","created_by":"gdiazlo"}]}
1515+{"id":"hcs-505","title":"Add HTTP/2 benchmark endpoints and comparison scripts","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T00:14:16.705365323+01:00","updated_at":"2025-12-30T00:28:18.304877441+01:00","closed_at":"2025-12-30T00:28:18.304877441+01:00"}
1616+{"id":"hcs-56z","title":"Implement HTTP/1.1 server parser/serializer","description":"Implement HTTP/1.1 server wire format in hcs-core/h1.ml (extend existing):\n\n```ocaml\n(* Request parsing *)\ntype request_parse_result =\n | Complete of request * int (* request and bytes consumed *)\n | Incomplete of int (* need more bytes *)\n | Error of string\n\nval parse_request_head : Cstruct.t -\u003e request_parse_result\n\n(* Response serialization *)\nval serialize_response : response -\u003e Cstruct.t\nval serialize_response_head : response -\u003e Cstruct.t\n```\n\nPure parsing, zero-copy with Cstruct views.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:33:19.050407222+01:00","updated_at":"2025-12-29T14:56:39.076056919+01:00","closed_at":"2025-12-29T14:56:39.076056919+01:00","dependencies":[{"issue_id":"hcs-56z","depends_on_id":"hcs-rw6","type":"parent-child","created_at":"2025-12-29T14:33:41.058840522+01:00","created_by":"gdiazlo"}]}
1717+{"id":"hcs-5eu","title":"Implement Log module","description":"Implement logging in hcs-core/log.ml:\n\n```ocaml\ntype level = Debug | Info | Warn | Error\n\ntype event =\n | Request_start of { id: string; meth: method_; uri: Uri.t }\n | Request_end of { id: string; status: status; duration_ms: float }\n | Connection_open of { host: string; port: int }\n | Connection_close of { host: string; port: int; reason: string }\n | Connection_reuse of { host: string; port: int }\n | Tls_handshake of { host: string; protocol: string }\n | Retry of { id: string; attempt: int; reason: error }\n | Error of { id: string; error: error }\n\ntype logger = level -\u003e event -\u003e unit\n\n(* Built-in loggers *)\nval null : logger\nval stderr : ?min_level:level -\u003e unit -\u003e logger\nval custom : (level -\u003e string -\u003e unit) -\u003e logger\n\n(* Format event as string *)\nval event_to_string : event -\u003e string\nval level_to_string : level -\u003e string\n```\n\nPure OCaml, no runtime dependency.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:30:26.138649492+01:00","updated_at":"2025-12-29T17:06:37.134220488+01:00","closed_at":"2025-12-29T17:06:37.134220488+01:00","dependencies":[{"issue_id":"hcs-5eu","depends_on_id":"hcs-fgd","type":"parent-child","created_at":"2025-12-29T14:30:45.51204607+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-5eu","depends_on_id":"hcs-gmb","type":"blocks","created_at":"2025-12-29T14:30:48.61530863+01:00","created_by":"gdiazlo"}]}
1818+{"id":"hcs-5wp","title":"Epic: Unified multi-protocol server + comprehensive benchmark suite","description":"Create unified servers (HCS, Hyper, Go) supporting HTTP/1.1, HTTP/2 h2c upgrade, and WebSocket. Build comprehensive benchmark client and automated benchmark runner.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-31T14:13:14.360303024+01:00","updated_at":"2025-12-31T14:47:54.828065477+01:00","closed_at":"2025-12-31T14:47:54.828065477+01:00"}
1919+{"id":"hcs-6bi","title":"Design Runtime Abstraction Layer","description":"Design and implement a runtime abstraction layer that allows the library to work with both Eio and Lwt (future). This should include:\n\n1. Define a RUNTIME module signature abstracting:\n - Promise/fiber types ('a t)\n - Concurrency primitives (bind, return, map, both, all)\n - Cancellation tokens\n - Clock/time operations\n - Network operations (connect, listen, read, write)\n - Flow/stream abstractions\n\n2. Structure the library as:\n - `hcs-core`: Pure types, parsers, router trie, codec signatures (no IO)\n - `hcs-eio`: Eio runtime implementation\n - `hcs-lwt`: (future) Lwt runtime implementation\n\n3. Use functors where IO is needed:\n ```ocaml\n module type RUNTIME = sig\n type +'a t\n val return : 'a -\u003e 'a t\n val bind : 'a t -\u003e ('a -\u003e 'b t) -\u003e 'b t\n val both : 'a t -\u003e 'b t -\u003e ('a * 'b) t\n \n module Net : sig ... end\n module Time : sig ... end\n module Cancel : sig ... end\n end\n \n module Make_client (R : RUNTIME) : CLIENT with type 'a io = 'a R.t\n module Make_server (R : RUNTIME) : SERVER with type 'a io = 'a R.t\n ```\n\n4. Keep the core types (request, response, headers, body) runtime-agnostic where possible.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:26:44.115541536+01:00","updated_at":"2025-12-29T15:18:00.901135582+01:00","closed_at":"2025-12-29T15:18:00.901135582+01:00","labels":["architecture","design"],"dependencies":[{"issue_id":"hcs-6bi","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:55.24314139+01:00","created_by":"gdiazlo"}]}
2020+{"id":"hcs-6hx","title":"Create WebSocket benchmark client","description":"Tool to open N concurrent WebSocket connections. Ramp up gradually, keep alive, report success/failure rates. Can target any server.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T10:00:01.720658703+01:00","updated_at":"2025-12-30T10:13:10.676804856+01:00","closed_at":"2025-12-30T10:13:10.676804856+01:00","dependencies":[{"issue_id":"hcs-6hx","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:40.686385073+01:00","created_by":"gdiazlo"}]}
2121+{"id":"hcs-6ki","title":"Create Rust WebSocket benchmark server","description":"Rust server using tokio-tungstenite. Same interface as HCS: accept connections, keep alive, report count.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T09:59:59.970998267+01:00","updated_at":"2025-12-30T10:08:50.863660424+01:00","closed_at":"2025-12-30T10:08:50.863660424+01:00","dependencies":[{"issue_id":"hcs-6ki","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:35.642901603+01:00","created_by":"gdiazlo"}]}
2222+{"id":"hcs-6yl","title":"Implement method_ type and helpers","description":"Implement the HTTP method type in types.ml:\n- GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE variants\n- to_string/of_string functions\n- Comparison and equality\n\nThis is pure OCaml with no runtime dependency.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:08.798345263+01:00","updated_at":"2025-12-29T14:50:42.057204391+01:00","closed_at":"2025-12-29T14:50:42.057204391+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-6yl","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:38.28650235+01:00","created_by":"gdiazlo"}]}
2323+{"id":"hcs-763","title":"Create HTTP/1.1 server compliance tests","description":"Create comprehensive HTTP/1.1 server tests using curl and custom test client:\n\nTest categories (based on RFC 7230-7235):\n\n1. **Request parsing:**\n - Valid/invalid request lines\n - Header field parsing (folding, whitespace)\n - Host header requirement\n - Content-Length handling\n - Transfer-Encoding: chunked\n\n2. **Response generation:**\n - Status line format\n - Required headers (Date, Content-Length/Transfer-Encoding)\n - Connection: close/keep-alive\n\n3. **Methods:**\n - HEAD returns no body\n - OPTIONS with Allow header\n - TRACE (if supported)\n - Unknown methods\n\n4. **Connection management:**\n - Keep-alive (HTTP/1.1 default)\n - Pipelining support\n - Connection: close handling\n - Timeout behavior\n\n5. **Error handling:**\n - 400 Bad Request (malformed)\n - 405 Method Not Allowed\n - 411 Length Required\n - 413 Payload Too Large\n - 414 URI Too Long\n - 431 Request Header Fields Too Large\n - 501 Not Implemented\n\n6. **Edge cases:**\n - Empty body vs no body\n - Zero Content-Length\n - Multiple Content-Length headers (reject)\n - LF vs CRLF tolerance","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:36:24.121658164+01:00","updated_at":"2025-12-29T17:52:19.19217344+01:00","closed_at":"2025-12-29T17:52:19.19217344+01:00","dependencies":[{"issue_id":"hcs-763","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:50.138423904+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-763","depends_on_id":"hcs-cyk","type":"blocks","created_at":"2025-12-29T14:36:59.919859607+01:00","created_by":"gdiazlo"}]}
2424+{"id":"hcs-7dw","title":"Run benchmarks after optimizations","description":"Re-run single-CPU benchmarks with run_h2_comparison.sh --single-cpu to measure improvement.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T08:57:27.925408893+01:00","updated_at":"2025-12-30T09:01:48.794721842+01:00","closed_at":"2025-12-30T09:01:48.794721842+01:00","dependencies":[{"issue_id":"hcs-7dw","depends_on_id":"hcs-cq4","type":"parent-child","created_at":"2025-12-30T08:57:54.882328415+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-7dw","depends_on_id":"hcs-f2r","type":"blocks","created_at":"2025-12-30T08:57:59.924301728+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-7dw","depends_on_id":"hcs-jqx","type":"blocks","created_at":"2025-12-30T08:58:04.960054323+01:00","created_by":"gdiazlo"}]}
2525+{"id":"hcs-7my","title":"Implement zero-copy optimizations in H2_server (Body_bigstring, Body_prebuilt)","description":"","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-30T00:14:14.612100017+01:00","updated_at":"2025-12-30T00:19:21.483563727+01:00","closed_at":"2025-12-30T00:19:21.483563727+01:00"}
2626+{"id":"hcs-7n9","title":"Implement HTTP/1.1 client (Eio)","description":"Implement HTTP/1.1 client for Eio in hcs-eio/h1_client.ml:\n\n```ocaml\nval request :\n flow:Eio.Flow.two_way -\u003e\n clock:Eio.Time.clock -\u003e\n config:Client.config -\u003e\n ?cancel:Cancel.t -\u003e\n request -\u003e\n (response, error) result\n```\n\nFeatures:\n- Send request, read response using h1 parser\n- Handle Content-Length and chunked bodies\n- Support streaming response body\n- Respect timeouts from config\n- Handle keep-alive\n- Integrate with logging","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:31:20.182355085+01:00","updated_at":"2025-12-29T15:03:26.826860172+01:00","closed_at":"2025-12-29T15:03:26.826860172+01:00","dependencies":[{"issue_id":"hcs-7n9","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:48.505518121+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-7n9","depends_on_id":"hcs-8zr","type":"blocks","created_at":"2025-12-29T14:31:52.981022505+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-7n9","depends_on_id":"hcs-kg1","type":"blocks","created_at":"2025-12-29T14:31:53.49623941+01:00","created_by":"gdiazlo"}]}
2727+{"id":"hcs-82y","title":"HTTP/2 Performance Comparison: HCS vs Rust vs Go","description":"Compare HTTP/2 server performance across HCS (OCaml), Rust (hyper/axum), and Go (net/http). Focus on throughput, latency, and resource usage under various workloads.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-30T08:21:37.583365059+01:00","updated_at":"2025-12-30T08:49:54.887556286+01:00","closed_at":"2025-12-30T08:49:54.887556286+01:00"}
2828+{"id":"hcs-8br","title":"Implement Cancel module","description":"Implement cooperative cancellation in hcs-core/cancel.ml:\n\n```ocaml\nmodule Cancel : sig\n type t\n \n val create : unit -\u003e t\n val cancel : t -\u003e unit\n val is_cancelled : t -\u003e bool\n val check : t -\u003e (unit, error) result (* Returns Error Cancelled if cancelled *)\n \n (* Combine multiple tokens - cancelled if any is cancelled *)\n val any : t list -\u003e t\nend\n```\n\nImplementation: Use an Atomic.t bool internally for thread-safety. The `any` combinator creates a new token that polls children.\n\nThis is the core, runtime-agnostic cancellation. Runtime-specific implementations (Eio.Cancel, Lwt.cancel) will wrap this or provide their own.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:29:20.005246967+01:00","updated_at":"2025-12-29T14:56:31.063102088+01:00","closed_at":"2025-12-29T14:56:31.063102088+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-8br","depends_on_id":"hcs-pnc","type":"parent-child","created_at":"2025-12-29T14:30:09.074639524+01:00","created_by":"gdiazlo"}]}
2929+{"id":"hcs-8zr","title":"Implement HTTP/1.1 client parser/serializer","description":"Implement HTTP/1.1 wire format in hcs-core/h1.ml:\n\n```ocaml\n(* Request serialization *)\nval serialize_request : request -\u003e Cstruct.t\nval serialize_request_head : request -\u003e Cstruct.t (* without body *)\n\n(* Response parsing *)\ntype parse_result = \n | Complete of response * int (* response and bytes consumed *)\n | Incomplete of int (* need more bytes, minimum *)\n | Error of string\n\nval parse_response_head : Cstruct.t -\u003e parse_result\n\n(* Chunked transfer encoding *)\nval parse_chunk_header : Cstruct.t -\u003e (int * int, string) result (* size, header_len *)\nval serialize_chunk : Cstruct.t -\u003e Cstruct.t\nval serialize_last_chunk : Cstruct.t\n```\n\nPure parsing/serialization, no IO. Use zero-copy where possible with Cstruct views.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:31:17.431899471+01:00","updated_at":"2025-12-29T14:56:37.246977449+01:00","closed_at":"2025-12-29T14:56:37.246977449+01:00","dependencies":[{"issue_id":"hcs-8zr","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:46.61571522+01:00","created_by":"gdiazlo"}]}
3030+{"id":"hcs-9dz","title":"Clean up duplicate implementations: remove non-optimized functions and rename optimized ones","description":"","status":"closed","priority":1,"issue_type":"chore","created_at":"2025-12-30T21:00:09.361966265+01:00","updated_at":"2025-12-30T21:08:48.343581414+01:00","closed_at":"2025-12-30T21:08:48.343581414+01:00"}
3131+{"id":"hcs-9y1","title":"Implement Client.config type","description":"Implement client configuration in hcs-core/client_config.ml:\n\n```ocaml\ntype config = {\n (* Connection pooling *)\n max_connections_per_host : int; (* default: 100 *)\n max_total_connections : int; (* default: 1000 *)\n idle_timeout : float; (* seconds, default: 60.0 *)\n\n (* Timeouts *)\n connect_timeout : float; (* default: 30.0 *)\n read_timeout : float; (* default: 30.0 *)\n write_timeout : float; (* default: 30.0 *)\n\n (* Behavior *)\n follow_redirects : int option; (* None = don't follow, default: Some 10 *)\n http2_prior_knowledge : bool; (* default: false *)\n\n (* Buffers *)\n buffer_size : int; (* default: 16384 *)\n max_response_body : int64 option; (* None = unlimited *)\n\n (* TLS *)\n tls : Tls_config.client option;\n\n (* Compression *)\n accept_compression : bool; (* default: true *)\n decompress_response : bool; (* default: true *)\n\n (* Logging *)\n logger : Log.logger; (* default: Log.null *)\n}\n\nval default : config\nval with_timeout : float -\u003e config -\u003e config\nval with_max_connections : int -\u003e config -\u003e config\n(* ... other builders ... *)\n```\n\nPure configuration, no IO.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:31:05.431230385+01:00","updated_at":"2025-12-29T15:40:22.434194947+01:00","closed_at":"2025-12-29T15:40:22.434194947+01:00","dependencies":[{"issue_id":"hcs-9y1","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:42.307867928+01:00","created_by":"gdiazlo"}]}
3232+{"id":"hcs-9yc","title":"Go: Unified server with HTTP/1.1 + h2c + WebSocket","description":"Update Go net/http server to handle HTTP/1.1, h2c, and WebSocket on single port.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-31T14:13:36.193925748+01:00","updated_at":"2025-12-31T14:26:13.33110701+01:00","closed_at":"2025-12-31T14:26:13.33110701+01:00","dependencies":[{"issue_id":"hcs-9yc","depends_on_id":"hcs-5wp","type":"parent-child","created_at":"2025-12-31T14:14:30.935375958+01:00","created_by":"gdiazlo"}]}
3333+{"id":"hcs-ag6","title":"Implement H2 module (HTTP/2 specific features)","description":"Implement HTTP/2 specific features in hcs-eio/h2.ml:\n\n```ocaml\n(* Server push *)\nval push : request -\u003e Uri.t -\u003e (unit, error) result\n\n(* Stream priority *)\ntype priority = {\n dependency : int32;\n weight : int; (* 1-256 *)\n exclusive : bool;\n}\n\nval set_priority : priority -\u003e (unit, error) result\n\n(* Check protocol *)\nval is_h2 : request -\u003e bool\n```\n\nLower priority - can be added after basic HTTP/2 works.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-29T14:34:36.59579057+01:00","updated_at":"2025-12-29T17:39:24.986755909+01:00","closed_at":"2025-12-29T17:39:24.986755909+01:00","dependencies":[{"issue_id":"hcs-ag6","depends_on_id":"hcs-1uy","type":"parent-child","created_at":"2025-12-29T14:34:53.890566238+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ag6","depends_on_id":"hcs-d5s","type":"blocks","created_at":"2025-12-29T14:34:58.997623679+01:00","created_by":"gdiazlo"}]}
3434+{"id":"hcs-ajr","title":"Compare HCS performance against external tools (wrk, hey)","description":"Validate HCS benchmark results by comparing against established tools:\n- Use wrk or hey to benchmark HCS server\n- Compare results with HCS benchmark client\n- Document any discrepancies\n- This validates both our server performance and benchmark accuracy\n\nOptional: Add script to run comparison benchmarks.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-29T18:04:05.806924896+01:00","updated_at":"2025-12-29T18:18:13.778810608+01:00","closed_at":"2025-12-29T18:18:13.778810608+01:00","dependencies":[{"issue_id":"hcs-ajr","depends_on_id":"hcs-jtz","type":"parent-child","created_at":"2025-12-29T18:04:25.893845517+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ajr","depends_on_id":"hcs-4w8","type":"blocks","created_at":"2025-12-29T18:04:39.385790421+01:00","created_by":"gdiazlo"}]}
3535+{"id":"hcs-ax6","title":"Implement With_codec functor","description":"Implement the With_codec functor in hcs-core/codec.ml:\n\n```ocaml\nmodule With_codec (C : CODEC) : sig\n val encode_body : 'a C.encoder -\u003e 'a -\u003e (body, error) result\n val decode_body : 'a C.decoder -\u003e body -\u003e ('a, error) result\n\n (* Request helpers *)\n val set_body : 'a C.encoder -\u003e 'a -\u003e request -\u003e (request, error) result\n\n (* Response helpers *) \n val read_body : 'a C.decoder -\u003e response -\u003e ('a, error) result\n val make_response : ?status:status -\u003e 'a C.encoder -\u003e 'a -\u003e (response, error) result\nend\n```\n\nThe functor should:\n- Handle body type variants (Empty, Fixed, Stream, File)\n- Set appropriate Content-Type header from C.content_type\n- Convert between Cstruct.t and body types\n- Propagate codec errors as Codec_error\n\nPure OCaml, depends on types.ml and error.ml.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:28:47.94509919+01:00","updated_at":"2025-12-29T17:04:35.621002337+01:00","closed_at":"2025-12-29T17:04:35.621002337+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-ax6","depends_on_id":"hcs-m4r","type":"parent-child","created_at":"2025-12-29T14:29:00.595372107+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ax6","depends_on_id":"hcs-pwc","type":"blocks","created_at":"2025-12-29T14:29:02.656963585+01:00","created_by":"gdiazlo"}]}
3636+{"id":"hcs-bea","title":"Implement core middleware combinators","description":"Implement middleware composition in hcs-core/middleware.ml:\n\n```ocaml\ntype middleware = (request -\u003e (response, error) result) -\u003e request -\u003e (response, error) result\n\n(* Composition *)\nval ( @\u003e ) : middleware -\u003e middleware -\u003e middleware\nval compose : middleware list -\u003e middleware\nval identity : middleware\n\n(* Pure middleware (no IO needed) *)\nval default_headers : (string * string) list -\u003e middleware\nval catch_errors : (error -\u003e response) -\u003e middleware\nval body_limit : int64 -\u003e middleware\nval request_id : ?header:string -\u003e ?generator:(unit -\u003e string) -\u003e unit -\u003e middleware\n\n(* Security headers *)\nval security_headers : middleware\nval cors :\n ?origins:[ `All | `List of string list ] -\u003e\n ?methods:method_ list -\u003e\n ?headers:string list -\u003e\n ?max_age:int -\u003e\n ?credentials:bool -\u003e\n unit -\u003e\n middleware\n\n(* Auth - validation functions are pure *)\nval basic_auth : realm:string -\u003e validate:(user:string -\u003e pass:string -\u003e bool) -\u003e middleware\nval bearer_auth : validate:(token:string -\u003e bool) -\u003e middleware\n```\n\nPure OCaml.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:34:27.663177003+01:00","updated_at":"2025-12-29T15:18:06.262737908+01:00","closed_at":"2025-12-29T15:18:06.262737908+01:00","dependencies":[{"issue_id":"hcs-bea","depends_on_id":"hcs-3ww","type":"parent-child","created_at":"2025-12-29T14:34:50.365242865+01:00","created_by":"gdiazlo"}]}
3737+{"id":"hcs-chm","title":"Implement body type","description":"Implement the body type in types.ml:\n- Empty variant\n- Fixed of string variant\n- Stream of (unit -\u003e Cstruct.t option) - pull-based streaming\n- File of string * int64 * int64 (path, offset, length)\n\nNote: The Stream variant uses a function type that is runtime-agnostic. For async streaming, we'll need a runtime-parameterized body type in the IO layer.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:16.560667848+01:00","updated_at":"2025-12-29T14:50:45.579783806+01:00","closed_at":"2025-12-29T14:50:45.579783806+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-chm","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:40.664331169+01:00","created_by":"gdiazlo"}]}
3838+{"id":"hcs-cks","title":"Implement Headers module","description":"Implement case-insensitive header map in headers.ml:\n- type t (backed by Map with lowercase keys)\n- empty, singleton, add, add_list\n- find, find_all, remove, mem\n- fold, to_list, of_list\n- Consider using a more efficient representation (e.g., sorted list for small headers)\n\nPure OCaml, no runtime dependency.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:12.768404369+01:00","updated_at":"2025-12-29T14:50:44.414829218+01:00","closed_at":"2025-12-29T14:50:44.414829218+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-cks","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:39.839372964+01:00","created_by":"gdiazlo"}]}
3939+{"id":"hcs-cmg","title":"Implement version type","description":"Implement the HTTP version type:\n- HTTP_1_1, HTTP_2 variants\n- to_string/of_string functions\n\nPure OCaml, no runtime dependency.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:10.081239709+01:00","updated_at":"2025-12-29T14:50:42.943845373+01:00","closed_at":"2025-12-29T14:50:42.943845373+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-cmg","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:39.140549859+01:00","created_by":"gdiazlo"}]}
4040+{"id":"hcs-cq4","title":"HTTP/2 Server Performance Optimizations","description":"Implement optimizations identified from benchmarking: skip body reading for GET, optimize path extraction, reduce allocations","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-30T08:57:10.907204503+01:00","updated_at":"2025-12-30T09:02:03.611661211+01:00","closed_at":"2025-12-30T09:02:03.611661211+01:00"}
4141+{"id":"hcs-cwz","title":"Set up httpbin for client compliance testing","description":"Set up httpbin (or go-httpbin) for testing the HTTP client:\n\n1. Add Docker Compose file with go-httpbin service\n2. Create test suite covering:\n - All HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)\n - Request headers sent correctly\n - Query parameters encoding\n - Request body (form, JSON-like, binary)\n - Response status codes (1xx-5xx)\n - Redirects (301, 302, 303, 307, 308) with redirect limits\n - Basic auth, Bearer auth\n - Cookies (send and receive)\n - Compression (gzip, deflate)\n - Chunked transfer encoding\n - Connection keep-alive\n - Timeouts (delayed responses)\n - Large responses / streaming\n\n3. Integrate into `dune runtest` or separate compliance target","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:36:08.700591408+01:00","updated_at":"2025-12-29T17:56:36.02148206+01:00","closed_at":"2025-12-29T17:56:36.02148206+01:00","dependencies":[{"issue_id":"hcs-cwz","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:44.727305222+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-cwz","depends_on_id":"hcs-j2z","type":"blocks","created_at":"2025-12-29T14:36:54.844910505+01:00","created_by":"gdiazlo"}]}
4242+{"id":"hcs-cxj","title":"Add domain_count to server config","description":"Add domain_count field to H1_server.config and H2_server to specify max CPUs. Default to 1 for backward compatibility.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T09:05:52.544967921+01:00","updated_at":"2025-12-30T09:10:47.510063908+01:00","closed_at":"2025-12-30T09:10:47.510063908+01:00","dependencies":[{"issue_id":"hcs-cxj","depends_on_id":"hcs-zq3","type":"parent-child","created_at":"2025-12-30T09:06:14.106250303+01:00","created_by":"gdiazlo"}]}
4343+{"id":"hcs-cyb","title":"Implement Tls_config module","description":"Implement TLS configuration in hcs-core/tls_config.ml:\n\n```ocaml\ntype client\ntype server\n\ntype verification =\n | System_certificates\n | Custom_certificates of string list (* PEM file paths *)\n | Fingerprint of string\n | Insecure_no_verify\n\n(* Client config builder - returns config, actual TLS context created by runtime *)\nval client :\n ?verification:verification -\u003e\n ?alpn_protocols:string list -\u003e\n ?hostname:string -\u003e\n unit -\u003e\n client\n\n(* Server config builder *)\nval server :\n cert_file:string -\u003e\n key_file:string -\u003e\n ?alpn_protocols:string list -\u003e\n ?client_auth:[ `None | `Optional | `Required ] -\u003e\n ?ca_file:string -\u003e\n unit -\u003e\n server\n```\n\nThe actual TLS context creation (using tls-eio or tls-lwt) happens in the runtime layer. This module just holds configuration.\n\nPure OCaml, configuration types only.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:30:31.480806804+01:00","updated_at":"2025-12-29T15:24:40.571579433+01:00","closed_at":"2025-12-29T15:24:40.571579433+01:00","dependencies":[{"issue_id":"hcs-cyb","depends_on_id":"hcs-y9w","type":"parent-child","created_at":"2025-12-29T14:30:46.125231092+01:00","created_by":"gdiazlo"}]}
4444+{"id":"hcs-cyk","title":"Implement unified Server module (Eio)","description":"Implement unified server API in hcs-eio/server.ml:\n\n```ocaml\ntype t\n\nval create :\n sw:Eio.Switch.t -\u003e\n net:Eio.Net.t -\u003e\n clock:Eio.Time.clock -\u003e\n ?config:config -\u003e\n Router.compiled -\u003e\n t\n\nval run : t -\u003e unit (* Blocks until shutdown *)\nval shutdown : ?timeout:float -\u003e t -\u003e unit\nval listening_on : t -\u003e (string * int)\nval connection_count : t -\u003e int\n```\n\nFeatures:\n- Accept connections, spawn fibers\n- Protocol detection (ALPN for TLS, prior knowledge)\n- Dispatch to H1 or H2 handler\n- Connection limiting\n- Graceful shutdown with drain timeout\n- TLS support","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:33:29.304626659+01:00","updated_at":"2025-12-29T17:00:22.652965066+01:00","closed_at":"2025-12-29T17:00:22.652965066+01:00","dependencies":[{"issue_id":"hcs-cyk","depends_on_id":"hcs-rw6","type":"parent-child","created_at":"2025-12-29T14:33:43.591242696+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-cyk","depends_on_id":"hcs-lqi","type":"blocks","created_at":"2025-12-29T14:33:45.174337222+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-cyk","depends_on_id":"hcs-1vt","type":"blocks","created_at":"2025-12-29T14:33:46.071365575+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-cyk","depends_on_id":"hcs-sny","type":"blocks","created_at":"2025-12-29T14:33:46.914541815+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-cyk","depends_on_id":"hcs-2ca","type":"blocks","created_at":"2025-12-29T14:33:47.760104216+01:00","created_by":"gdiazlo"}]}
4545+{"id":"hcs-czm","title":"Streaming Abstraction","description":"Implement the Stream module with producers, transformers, consumers, and combinators for lazy, backpressure-aware streaming.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:20.968837055+01:00","updated_at":"2025-12-29T17:41:32.374046501+01:00","closed_at":"2025-12-29T17:41:32.374046501+01:00","dependencies":[{"issue_id":"hcs-czm","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:25:57.77315075+01:00","created_by":"gdiazlo"}]}
4646+{"id":"hcs-d3q","title":"Set up Autobahn for WebSocket compliance testing","description":"Integrate Autobahn|Testsuite for WebSocket compliance:\n\n1. Add Autobahn to CI via Docker (crossbario/autobahn-testsuite)\n2. Test both client and server modes:\n\n**Server testing** (Autobahn as client):\n```\ndocker run -it --rm \\\n -v \"${PWD}/reports:/reports\" \\\n crossbario/autobahn-testsuite \\\n wstest -m fuzzingclient -s /config/fuzzingclient.json\n```\n\n**Client testing** (Autobahn as server):\n```\ndocker run -it --rm \\\n -v \"${PWD}/reports:/reports\" \\\n crossbario/autobahn-testsuite \\\n wstest -m fuzzingserver -s /config/fuzzingserver.json\n```\n\nTest cases cover:\n- Framing (text, binary, fragmentation)\n- Ping/Pong\n- Close handshake\n- Reserved bits\n- Opcodes\n- UTF-8 validation\n- Compression (permessage-deflate)\n- Limits and performance\n\nTarget: Pass all non-optional Autobahn test cases","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:36:18.251422099+01:00","updated_at":"2025-12-29T17:57:13.521624023+01:00","closed_at":"2025-12-29T17:57:13.521624023+01:00","dependencies":[{"issue_id":"hcs-d3q","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:48.059495592+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-d3q","depends_on_id":"hcs-nad","type":"blocks","created_at":"2025-12-29T14:36:58.909180795+01:00","created_by":"gdiazlo"}]}
4747+{"id":"hcs-d5s","title":"Implement HTTP/2 client (Eio)","description":"Implement HTTP/2 client for Eio in hcs-eio/h2_client.ml:\n\n```ocaml\ntype t (* HTTP/2 connection with multiplexed streams *)\n\nval create : flow:Eio.Flow.two_way -\u003e clock:Eio.Time.clock -\u003e config:Client.config -\u003e t\nval request : t -\u003e ?cancel:Cancel.t -\u003e request -\u003e (response, error) result\nval close : t -\u003e unit\n```\n\nFeatures:\n- HPACK header compression\n- Stream multiplexing\n- Flow control per stream and connection\n- SETTINGS frame handling\n- Priority hints (optional)\n- GOAWAY handling\n\nDepends on hpack package.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:31:23.240487001+01:00","updated_at":"2025-12-29T16:00:40.891022027+01:00","closed_at":"2025-12-29T16:00:40.891022027+01:00","dependencies":[{"issue_id":"hcs-d5s","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:50.019661778+01:00","created_by":"gdiazlo"}]}
4848+{"id":"hcs-dd4","title":"Set up h2spec for HTTP/2 compliance testing","description":"Integrate h2spec for HTTP/2 server compliance:\n\n1. Add h2spec to CI (available as binary or Docker)\n2. Create test harness that:\n - Starts hcs HTTP/2 server on test port\n - Runs h2spec against it\n - Parses results\n\nh2spec tests:\n- HPACK header compression\n- Stream states and transitions\n- Flow control (window updates)\n- Error handling (RST_STREAM, GOAWAY)\n- SETTINGS frames\n- PRIORITY frames\n- CONTINUATION frames\n- Frame size limits\n- Connection preface\n\nTarget: Pass all h2spec generic tests\n\nCommand: `h2spec -h localhost -p 8080 --strict`","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:36:13.071332042+01:00","updated_at":"2025-12-29T17:57:10.493059681+01:00","closed_at":"2025-12-29T17:57:10.493059681+01:00","dependencies":[{"issue_id":"hcs-dd4","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:46.491278695+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-dd4","depends_on_id":"hcs-cyk","type":"blocks","created_at":"2025-12-29T14:36:57.517342388+01:00","created_by":"gdiazlo"}]}
4949+{"id":"hcs-ddd","title":"Create HTTP/2 cross-language benchmark script","description":"Create run_h2_comparison.sh that starts all three servers (HCS, Rust, Go) and runs h2load benchmarks against each. Test /ping, /bytes/1024, /bytes/10240 endpoints. Output structured results for comparison.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T08:21:57.169092148+01:00","updated_at":"2025-12-30T08:48:51.028339202+01:00","closed_at":"2025-12-30T08:48:51.028339202+01:00","dependencies":[{"issue_id":"hcs-ddd","depends_on_id":"hcs-osg","type":"blocks","created_at":"2025-12-30T08:22:14.06532517+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ddd","depends_on_id":"hcs-l9p","type":"blocks","created_at":"2025-12-30T08:22:16.71293984+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ddd","depends_on_id":"hcs-82y","type":"parent-child","created_at":"2025-12-30T08:22:25.823611037+01:00","created_by":"gdiazlo"}]}
5050+{"id":"hcs-dle","title":"Request/Response Helpers","description":"Implement Request and Response helper modules with body handling, status shortcuts, and codec integration.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:33.481216228+01:00","updated_at":"2025-12-29T15:40:53.163093449+01:00","closed_at":"2025-12-29T15:40:53.163093449+01:00","dependencies":[{"issue_id":"hcs-dle","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:17.959259297+01:00","created_by":"gdiazlo"}]}
5151+{"id":"hcs-dsf","title":"Set up project structure and dune build","description":"Set up the OCaml project structure with dune:\n\n```\nhcs/\n├── dune-project\n├── hcs-core/ # Pure, runtime-agnostic\n│ ├── dune\n│ ├── types.ml\n│ ├── error.ml\n│ ├── headers.ml\n│ ├── stream.ml # Synchronous stream operations\n│ ├── codec.ml\n│ └── hcs_core.ml # Public API re-exports\n├── hcs-eio/ # Eio runtime\n│ ├── dune\n│ ├── runtime.ml # RUNTIME implementation for Eio\n│ ├── client.ml\n│ ├── server.ml\n│ └── hcs_eio.ml\n└── hcs/ # Convenience package (re-exports hcs-eio)\n ├── dune\n └── hcs.ml\n```\n\nConfigure opam dependencies.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:26.717322855+01:00","updated_at":"2025-12-29T14:50:39.789609025+01:00","closed_at":"2025-12-29T14:50:39.789609025+01:00","labels":["infrastructure"],"dependencies":[{"issue_id":"hcs-dsf","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:43.02614445+01:00","created_by":"gdiazlo"}]}
5252+{"id":"hcs-dzr","title":"Create TechEmpower-style benchmark suite with hyper, fasthttp, and HCS servers","description":"","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-30T22:21:05.455078159+01:00","updated_at":"2025-12-30T22:33:15.399922114+01:00","closed_at":"2025-12-30T22:33:15.399922114+01:00"}
5353+{"id":"hcs-f2r","title":"Skip body reading for bodiless methods (GET/HEAD/DELETE)","description":"In h2_server.ml, skip Buffer.create, Promise.create, and body reading for GET/HEAD/DELETE methods. These have no body but currently allocate a buffer and promise.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T08:57:23.954431824+01:00","updated_at":"2025-12-30T09:01:38.714453571+01:00","closed_at":"2025-12-30T09:01:38.714453571+01:00","dependencies":[{"issue_id":"hcs-f2r","depends_on_id":"hcs-cq4","type":"parent-child","created_at":"2025-12-30T08:57:44.799141738+01:00","created_by":"gdiazlo"}]}
5454+{"id":"hcs-fgd","title":"Logging System","description":"Implement Log module with level, event types, built-in loggers (null, stderr, custom), and event formatting.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:26.517303879+01:00","updated_at":"2025-12-29T17:40:48.765669075+01:00","closed_at":"2025-12-29T17:40:48.765669075+01:00","dependencies":[{"issue_id":"hcs-fgd","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:08.827592455+01:00","created_by":"gdiazlo"}]}
5555+{"id":"hcs-fsc","title":"Implement Request helper module","description":"Implement request helpers in hcs-core/request.ml:\n\n```ocaml\nval path : request -\u003e string\nval query : string -\u003e request -\u003e string option\nval query_all : string -\u003e request -\u003e string list\nval header : string -\u003e request -\u003e string option\nval header_all : string -\u003e request -\u003e string list\nval content_type : request -\u003e string option\nval content_length : request -\u003e int64 option\nval is_keep_alive : request -\u003e bool\n\n(* Body consumption - sync versions *)\nval body_string : request -\u003e (string, error) result\nval body_to_cstruct : request -\u003e (Cstruct.t, error) result\n\n(* Form data parsing *)\nval form : request -\u003e ((string * string) list, error) result\n```\n\nPure OCaml + uri package.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:34:09.842512086+01:00","updated_at":"2025-12-29T15:40:29.136273489+01:00","closed_at":"2025-12-29T15:40:29.136273489+01:00","dependencies":[{"issue_id":"hcs-fsc","depends_on_id":"hcs-dle","type":"parent-child","created_at":"2025-12-29T14:34:47.838799048+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-fsc","depends_on_id":"hcs-lpr","type":"blocks","created_at":"2025-12-29T14:34:54.851316989+01:00","created_by":"gdiazlo"}]}
5656+{"id":"hcs-g2y","title":"Research HTTP compliance test suites","description":"Research and document available HTTP compliance testing tools and servers:\n\n**For Client Testing (test servers):**\n\n1. **httpbin** (https://httpbin.org / kennethreitz/httpbin)\n - Returns request info as JSON\n - Tests: methods, headers, redirects, auth, cookies, response codes\n - Can run locally via Docker\n\n2. **go-httpbin** (mccutchen/go-httpbin)\n - Go reimplementation, faster and more features\n - Better for local testing\n\n3. **h2spec** (summerwind/h2spec)\n - HTTP/2 conformance testing tool\n - Tests HPACK, streams, flow control, error handling\n - Essential for HTTP/2 compliance\n\n4. **curl test suite**\n - curl's own test servers have extensive edge cases\n\n**For Server Testing (test clients):**\n\n1. **h2load** (nghttp2)\n - HTTP/2 benchmarking and testing\n - Tests multiplexing, flow control\n\n2. **curl** with verbose options\n - Good for basic HTTP/1.1 compliance\n\n3. **nghttp** (nghttp2)\n - HTTP/2 client for testing server responses\n\n4. **Autobahn|Testsuite** (for WebSocket)\n - Comprehensive WebSocket protocol compliance\n - https://github.com/crossbario/autobahn-testsuite\n\n**RFC Compliance:**\n- RFC 7230-7235 (HTTP/1.1)\n- RFC 7540 (HTTP/2)\n- RFC 6455 (WebSocket)\n- RFC 7541 (HPACK)\n\nEvaluate which tools to integrate into CI.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:36:03.601972706+01:00","updated_at":"2025-12-29T17:31:46.947022974+01:00","closed_at":"2025-12-29T17:31:46.947022974+01:00","dependencies":[{"issue_id":"hcs-g2y","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:42.697870585+01:00","created_by":"gdiazlo"}]}
5757+{"id":"hcs-gmb","title":"Implement error type","description":"Implement comprehensive error type in error.ml:\n- Connection_failed, Connection_closed\n- Timeout variants (Connect, Read, Write, Total)\n- Cancelled\n- Invalid_url, Invalid_response\n- Too_many_redirects\n- Protocol_error, Tls_error, Codec_error\n- Body_too_large, IO_error\n\nInclude to_string for debugging. Pure OCaml.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:22.136715155+01:00","updated_at":"2025-12-29T14:50:40.75088559+01:00","closed_at":"2025-12-29T14:50:40.75088559+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-gmb","depends_on_id":"hcs-ugs","type":"parent-child","created_at":"2025-12-29T14:27:42.239579889+01:00","created_by":"gdiazlo"}]}
5858+{"id":"hcs-h2a","title":"Benchmark runner script with memory profiling","description":"Create benchmark runner that tests all servers across all protocols, measures req/s, msg/s, memory/connection, and produces summary report.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-31T14:13:51.31059033+01:00","updated_at":"2025-12-31T14:47:32.444742919+01:00","closed_at":"2025-12-31T14:47:32.444742919+01:00","dependencies":[{"issue_id":"hcs-h2a","depends_on_id":"hcs-rzc","type":"blocks","created_at":"2025-12-31T14:14:05.717334469+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-h2a","depends_on_id":"hcs-9yc","type":"blocks","created_at":"2025-12-31T14:14:10.761462149+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-h2a","depends_on_id":"hcs-s94","type":"blocks","created_at":"2025-12-31T14:14:15.805316293+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-h2a","depends_on_id":"hcs-peu","type":"blocks","created_at":"2025-12-31T14:14:20.849391195+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-h2a","depends_on_id":"hcs-5wp","type":"parent-child","created_at":"2025-12-31T14:14:46.066127034+01:00","created_by":"gdiazlo"}]}
5959+{"id":"hcs-hkf","title":"Add benchmark runner script with standard scenarios","description":"Create a benchmark runner script (bench/run_benchmarks.sh) that runs standard benchmark scenarios:\n1. Minimal GET (/ping) - pure overhead measurement\n2. Small payload (1KB) - typical API response\n3. Medium payload (10KB) - larger JSON responses \n4. Large payload (100KB) - file downloads\n5. POST with body - request body handling\n6. Varying concurrency (1, 10, 50, 100, 200 connections)\n7. HTTP/1.1 vs HTTP/2 comparison\n\nOutput results to bench/results/ with timestamps for tracking over time.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T18:04:02.294539956+01:00","updated_at":"2025-12-29T18:17:10.742622288+01:00","closed_at":"2025-12-29T18:17:10.742622288+01:00","dependencies":[{"issue_id":"hcs-hkf","depends_on_id":"hcs-jtz","type":"parent-child","created_at":"2025-12-29T18:04:24.660841757+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-hkf","depends_on_id":"hcs-4w8","type":"blocks","created_at":"2025-12-29T18:04:36.967445707+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-hkf","depends_on_id":"hcs-320","type":"blocks","created_at":"2025-12-29T18:04:38.425355179+01:00","created_by":"gdiazlo"}]}
6060+{"id":"hcs-i4f","title":"Setup bench/ directory structure with .gitignore","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T22:21:21.51511238+01:00","updated_at":"2025-12-30T22:23:03.029513364+01:00","closed_at":"2025-12-30T22:23:03.029513364+01:00"}
6161+{"id":"hcs-i8j","title":"Implement OCaml/HCS benchmark server (plaintext + json, HTTP/1+2, CPU scaling)","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T22:21:36.638072425+01:00","updated_at":"2025-12-30T22:28:28.339092129+01:00","closed_at":"2025-12-30T22:28:28.339092129+01:00"}
6262+{"id":"hcs-j2z","title":"Implement unified Client module (Eio)","description":"Implement unified client API in hcs-eio/client.ml:\n\n```ocaml\ntype t\n\nval create :\n sw:Eio.Switch.t -\u003e\n net:Eio.Net.t -\u003e\n clock:Eio.Time.clock -\u003e\n ?config:config -\u003e\n unit -\u003e\n t\n\nval request : ?cancel:Cancel.t -\u003e t -\u003e request -\u003e (response, error) result\nval fetch : ?cancel:Cancel.t -\u003e t -\u003e request -\u003e (status * Headers.t * string, error) result\nval stream : ?cancel:Cancel.t -\u003e t -\u003e request -\u003e (status * Headers.t * Cstruct.t Stream.t, error) result\n\nval close_idle : t -\u003e unit\nval pool_stats : t -\u003e { active: int; idle: int; total: int }\n```\n\nFeatures:\n- Protocol selection (HTTP/1.1 vs HTTP/2) via ALPN or config\n- Connection pooling\n- Automatic redirect following\n- Compression handling\n- TLS with system certs by default","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:31:27.67982735+01:00","updated_at":"2025-12-29T17:00:20.730823454+01:00","closed_at":"2025-12-29T17:00:20.730823454+01:00","dependencies":[{"issue_id":"hcs-j2z","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:51.749045471+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-j2z","depends_on_id":"hcs-7n9","type":"blocks","created_at":"2025-12-29T14:31:54.365652586+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-j2z","depends_on_id":"hcs-d5s","type":"blocks","created_at":"2025-12-29T14:31:55.246269371+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-j2z","depends_on_id":"hcs-2ca","type":"blocks","created_at":"2025-12-29T14:31:55.835151512+01:00","created_by":"gdiazlo"}]}
6363+{"id":"hcs-j5q","title":"Move Go benchmark server to dedicated folder (bench/comparison/go_fasthttp/)","description":"","status":"closed","priority":2,"issue_type":"chore","created_at":"2025-12-30T00:14:15.760072799+01:00","updated_at":"2025-12-30T00:17:18.571463212+01:00","closed_at":"2025-12-30T00:17:18.571463212+01:00"}
6464+{"id":"hcs-j7j","title":"Implement Go/fasthttp benchmark server (plaintext + json, HTTP/1+2, CPU scaling)","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T22:21:31.597229058+01:00","updated_at":"2025-12-30T22:28:28.338758567+01:00","closed_at":"2025-12-30T22:28:28.338758567+01:00"}
6565+{"id":"hcs-jk8","title":"WebSocket Benchmark: Memory and Connection Scaling","description":"Compare HCS (OCaml) vs Go vs Rust WebSocket implementations. Metrics: memory per connection, max connections per CPU.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-30T09:59:44.349580383+01:00","updated_at":"2025-12-30T10:23:00.707208406+01:00","closed_at":"2025-12-30T10:23:00.707208406+01:00"}
6666+{"id":"hcs-jqx","title":"Cache :path header extraction in request_handler","description":"H2.Headers.get is O(n). For the :path pseudo-header which is always present and accessed for every request, consider direct access or caching.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T08:57:26.215703677+01:00","updated_at":"2025-12-30T09:01:43.754321197+01:00","closed_at":"2025-12-30T09:01:43.754321197+01:00","dependencies":[{"issue_id":"hcs-jqx","depends_on_id":"hcs-cq4","type":"parent-child","created_at":"2025-12-30T08:57:49.840282561+01:00","created_by":"gdiazlo"}]}
6767+{"id":"hcs-jsl","title":"Implement Path DSL","description":"Implement type-safe path DSL in hcs-core/path.ml:\n\n```ocaml\ntype 'a t\n\n(* Combinators *)\nval root : unit t (* / *)\nval const : string -\u003e unit t (* /literal *)\nval str : string t (* /:param - captures string *)\nval int : int t (* /:param - captures int *)\nval int32 : int32 t\nval int64 : int64 t\nval uuid : string t (* validates UUID format *)\nval rest : string list t (* /** - captures remaining *)\n\nval ( / ) : 'a t -\u003e 'b t -\u003e ('a * 'b) t\nval ( /: ) : unit t -\u003e 'a t -\u003e 'a t (* const / capture shorthand *)\n\nval trailing_slash : 'a t -\u003e 'a t\n\n(* For router compilation *)\ntype segment =\n | Literal of string\n | Param_string\n | Param_int\n | Param_int32\n | Param_int64 \n | Param_uuid\n | Wildcard\n\nval to_segments : 'a t -\u003e segment list\nval parse : 'a t -\u003e string list -\u003e ('a, string) result\n```\n\nPure OCaml, uses GADTs for type safety.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:32:24.842727479+01:00","updated_at":"2025-12-29T14:56:33.544162475+01:00","closed_at":"2025-12-29T14:56:33.544162475+01:00","dependencies":[{"issue_id":"hcs-jsl","depends_on_id":"hcs-2ie","type":"parent-child","created_at":"2025-12-29T14:32:55.556666992+01:00","created_by":"gdiazlo"}]}
6868+{"id":"hcs-jtz","title":"Benchmark Suite for HCS HTTP Library","description":"Create a comprehensive benchmark suite to measure HCS HTTP library performance. Focus on requests/second for both HTTP/1.1 and HTTP/2, comparing client and server performance under various conditions.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T18:03:37.371206713+01:00","updated_at":"2025-12-29T18:18:17.12058364+01:00","closed_at":"2025-12-29T18:18:17.12058364+01:00"}
6969+{"id":"hcs-k8f","title":"Implement multi-domain server run functions","description":"Create run_parallel and run_parallel_opt functions that spawn N domains, each with their own accept loop using SO_REUSEPORT.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T09:05:54.711085855+01:00","updated_at":"2025-12-30T09:10:52.550223218+01:00","closed_at":"2025-12-30T09:10:52.550223218+01:00","dependencies":[{"issue_id":"hcs-k8f","depends_on_id":"hcs-zq3","type":"parent-child","created_at":"2025-12-30T09:06:19.149511092+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-k8f","depends_on_id":"hcs-cxj","type":"blocks","created_at":"2025-12-30T09:06:34.264044099+01:00","created_by":"gdiazlo"}]}
7070+{"id":"hcs-kfm","title":"Set up property-based testing for parsers","description":"Use property-based testing (QCheck or Crowbar) for parsers and router:\n\n1. **HTTP/1.1 parser properties:**\n - parse(serialize(request)) = request (roundtrip)\n - parse partial input = Incomplete\n - parse garbage = Error (no crashes)\n - parse valid + garbage = Complete with correct consumed bytes\n\n2. **Router properties:**\n - All registered routes are matchable\n - More specific routes match before less specific\n - No path matches multiple routes (deterministic)\n - Captured params have correct types\n\n3. **Headers properties:**\n - Case-insensitive lookup\n - add then find = Some value\n - remove then find = None\n - of_list(to_list(h)) preserves all values\n\n4. **WebSocket frame properties:**\n - parse(serialize(frame)) = frame\n - Masked frames unmask correctly\n - Fragmented messages reassemble correctly\n\nAdd to dune test configuration with reasonable iteration counts.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:36:30.160273757+01:00","updated_at":"2025-12-29T17:52:17.694190669+01:00","closed_at":"2025-12-29T17:52:17.694190669+01:00","dependencies":[{"issue_id":"hcs-kfm","depends_on_id":"hcs-0zq","type":"parent-child","created_at":"2025-12-29T14:36:52.71433591+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-kfm","depends_on_id":"hcs-8zr","type":"blocks","created_at":"2025-12-29T14:37:00.796778902+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-kfm","depends_on_id":"hcs-sny","type":"blocks","created_at":"2025-12-29T14:37:01.737277561+01:00","created_by":"gdiazlo"}]}
7171+{"id":"hcs-kg1","title":"Implement connection pool","description":"Implement connection pooling with runtime abstraction:\n\nhcs-core/pool.ml (data structures):\n```ocaml\ntype key = { host: string; port: int; is_tls: bool }\ntype 'conn entry = { conn: 'conn; last_used: float; created: float }\ntype 'conn t\n\nval create : max_per_host:int -\u003e max_total:int -\u003e 'conn t\nval get : 'conn t -\u003e key -\u003e 'conn entry option\nval put : 'conn t -\u003e key -\u003e 'conn -\u003e now:float -\u003e unit\nval remove : 'conn t -\u003e key -\u003e 'conn -\u003e unit\nval close_idle : 'conn t -\u003e older_than:float -\u003e 'conn list\nval stats : 'conn t -\u003e { active: int; idle: int; total: int }\n```\n\nhcs-eio/pool.ml (Eio-specific):\n```ocaml\nmodule Eio_pool : sig\n type t\n val create : config:Client.config -\u003e t\n val acquire : t -\u003e key -\u003e (Eio.Flow.two_way, error) result\n val release : t -\u003e key -\u003e Eio.Flow.two_way -\u003e unit\n val with_connection : t -\u003e key -\u003e (Eio.Flow.two_way -\u003e 'a) -\u003e ('a, error) result\nend\n```\n\nUse LRU eviction, health checks on reuse.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:31:11.052080452+01:00","updated_at":"2025-12-29T15:18:04.471112737+01:00","closed_at":"2025-12-29T15:18:04.471112737+01:00","dependencies":[{"issue_id":"hcs-kg1","depends_on_id":"hcs-qnb","type":"parent-child","created_at":"2025-12-29T14:31:44.252516545+01:00","created_by":"gdiazlo"}]}
7272+{"id":"hcs-l23","title":"HTTP Client DSL","description":"Implement the Http module with request builder DSL for fluent API including headers, query params, body, and codec integration.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:29.533679497+01:00","updated_at":"2025-12-29T17:40:50.224704889+01:00","closed_at":"2025-12-29T17:40:50.224704889+01:00","dependencies":[{"issue_id":"hcs-l23","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:13.135112824+01:00","created_by":"gdiazlo"}]}
7373+{"id":"hcs-l9p","title":"Create Go HTTP/2 benchmark server (net/http)","description":"Implement a Go HTTP/2 server using net/http with h2c support. Match endpoints: /ping, /bytes/:n, /json. Use golang.org/x/net/http2/h2c for cleartext HTTP/2. Pre-allocate response buffers.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T08:21:53.967258404+01:00","updated_at":"2025-12-30T08:48:32.173036222+01:00","closed_at":"2025-12-30T08:48:32.173036222+01:00","dependencies":[{"issue_id":"hcs-l9p","depends_on_id":"hcs-82y","type":"parent-child","created_at":"2025-12-30T08:22:23.072848194+01:00","created_by":"gdiazlo"}]}
7474+{"id":"hcs-lhr","title":"WebSocket Support","description":"Implement Ws module with frame types, connection management, send/recv operations, server upgrade handler, and client connect.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:36.29066838+01:00","updated_at":"2025-12-29T16:01:05.272490486+01:00","closed_at":"2025-12-29T16:01:05.272490486+01:00","dependencies":[{"issue_id":"hcs-lhr","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:20.036599039+01:00","created_by":"gdiazlo"}]}
7575+{"id":"hcs-llr","title":"Implement Rust/hyper benchmark server (plaintext + json, HTTP/1+2, CPU scaling)","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T22:21:26.556966252+01:00","updated_at":"2025-12-30T22:28:28.337697653+01:00","closed_at":"2025-12-30T22:28:28.337697653+01:00"}
7676+{"id":"hcs-lpr","title":"Implement request and response record types","description":"Implement request and response records in types.ml:\n\n```ocaml\ntype request = {\n meth : method_;\n uri : Uri.t;\n version : version;\n headers : Headers.t;\n body : body;\n}\n\ntype response = {\n status : status;\n version : version;\n headers : Headers.t;\n body : body;\n}\n```\n\nInclude smart constructors and accessors. Pure OCaml + uri package.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:27:19.295375903+01:00","updated_at":"2025-12-29T14:50:46.801981182+01:00","closed_at":"2025-12-29T14:50:46.801981182+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-lpr","depends_on_id":"hcs-ugs","type":"blocks","created_at":"2025-12-29T14:27:19.300960037+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-lpr","depends_on_id":"hcs-6yl","type":"blocks","created_at":"2025-12-29T14:27:43.920564401+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-lpr","depends_on_id":"hcs-cmg","type":"blocks","created_at":"2025-12-29T14:27:44.815114113+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-lpr","depends_on_id":"hcs-cks","type":"blocks","created_at":"2025-12-29T14:27:45.656483969+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-lpr","depends_on_id":"hcs-chm","type":"blocks","created_at":"2025-12-29T14:27:46.481924426+01:00","created_by":"gdiazlo"}]}
7777+{"id":"hcs-lqi","title":"Implement HTTP/1.1 server (Eio)","description":"Implement HTTP/1.1 server for Eio in hcs-eio/h1_server.ml:\n\n```ocaml\nval handle_connection :\n flow:Eio.Flow.two_way -\u003e\n clock:Eio.Time.clock -\u003e\n config:Server.config -\u003e\n handler:(request -\u003e (response, error) result) -\u003e\n unit\n```\n\nFeatures:\n- Parse requests using h1 parser\n- Handle keep-alive connections\n- Support chunked and fixed-length bodies\n- Respect timeouts\n- Handle pipelining (optional)\n- Integrate with logging","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:33:22.560229088+01:00","updated_at":"2025-12-29T15:03:24.765221644+01:00","closed_at":"2025-12-29T15:03:24.765221644+01:00","dependencies":[{"issue_id":"hcs-lqi","depends_on_id":"hcs-rw6","type":"parent-child","created_at":"2025-12-29T14:33:42.102983428+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-lqi","depends_on_id":"hcs-56z","type":"blocks","created_at":"2025-12-29T14:33:44.325287786+01:00","created_by":"gdiazlo"}]}
7878+{"id":"hcs-m4r","title":"Codec System","description":"Implement the functor-based CODEC signature and With_codec functor for type-safe serialization/deserialization. Use Cstruct.t (buffers) instead of strings to properly support binary formats like MessagePack, Protobuf, and CBOR.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:22.203715886+01:00","updated_at":"2025-12-29T17:40:46.982752973+01:00","closed_at":"2025-12-29T17:40:46.982752973+01:00","dependencies":[{"issue_id":"hcs-m4r","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:00.496780385+01:00","created_by":"gdiazlo"}]}
7979+{"id":"hcs-mip","title":"Implement Http request builder DSL","description":"Implement high-level client DSL in hcs-core/http.ml:\n\n```ocaml\ntype builder\n\nval get : string -\u003e (builder, error) result\nval post : string -\u003e (builder, error) result\nval put : string -\u003e (builder, error) result\nval delete : string -\u003e (builder, error) result\n(* ... other methods ... *)\n\nval of_uri : method_ -\u003e Uri.t -\u003e builder\n\n(* Headers *)\nval header : string -\u003e string -\u003e builder -\u003e builder\nval headers : (string * string) list -\u003e builder -\u003e builder\nval content_type : string -\u003e builder -\u003e builder\nval accept : string -\u003e builder -\u003e builder\nval bearer : string -\u003e builder -\u003e builder\nval basic_auth : user:string -\u003e pass:string -\u003e builder -\u003e builder\nval user_agent : string -\u003e builder -\u003e builder\n\n(* Query parameters *)\nval query : string -\u003e string -\u003e builder -\u003e builder\nval queries : (string * string) list -\u003e builder -\u003e builder\n\n(* Body *)\nval body : body -\u003e builder -\u003e builder\nval body_string : ?content_type:string -\u003e string -\u003e builder -\u003e builder\nval form : (string * string) list -\u003e builder -\u003e builder\n\n(* Build final request *)\nval build : builder -\u003e request\n```\n\nPure OCaml, no IO.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:34:22.698743046+01:00","updated_at":"2025-12-29T17:10:04.149286655+01:00","closed_at":"2025-12-29T17:10:04.149286655+01:00","dependencies":[{"issue_id":"hcs-mip","depends_on_id":"hcs-l23","type":"parent-child","created_at":"2025-12-29T14:34:49.5612417+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-mip","depends_on_id":"hcs-lpr","type":"blocks","created_at":"2025-12-29T14:34:56.47577088+01:00","created_by":"gdiazlo"}]}
8080+{"id":"hcs-mli","title":"Run multi-CPU benchmarks (4, 8, 16 CPUs)","description":"Run benchmarks with 4, 8, and 16 domains for HCS, compare with Rust and Go multi-threaded.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T09:05:59.450548339+01:00","updated_at":"2025-12-30T09:16:49.37792589+01:00","closed_at":"2025-12-30T09:16:49.37792589+01:00","dependencies":[{"issue_id":"hcs-mli","depends_on_id":"hcs-zq3","type":"parent-child","created_at":"2025-12-30T09:06:29.226366436+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-mli","depends_on_id":"hcs-zln","type":"blocks","created_at":"2025-12-30T09:06:44.348550935+01:00","created_by":"gdiazlo"}]}
8181+{"id":"hcs-nad","title":"Implement WebSocket connection (Eio)","description":"Implement WebSocket for Eio in hcs-eio/websocket.ml:\n\n```ocaml\ntype conn\n\nval is_open : conn -\u003e bool\n\n(* Receive *)\nval recv : conn -\u003e (frame option, error) result\nval recv_timeout : Eio.Time.clock -\u003e float -\u003e conn -\u003e (frame option, error) result\n\n(* Send *)\nval send : conn -\u003e frame -\u003e (unit, error) result\nval close : ?code:int -\u003e ?reason:string -\u003e conn -\u003e (unit, error) result\n\n(* Stream interface *)\nval recv_stream : conn -\u003e frame Stream.t\nval send_stream : conn -\u003e frame Stream.t -\u003e (unit, error) result\n\n(* Server: upgrade handler *)\nval upgrade :\n ?protocols:string list -\u003e\n ?on_close:(int option -\u003e string option -\u003e unit) -\u003e\n (conn -\u003e (unit, error) result) -\u003e\n unit handler\n\n(* Client: connect *)\nval connect :\n sw:Eio.Switch.t -\u003e\n net:Eio.Net.t -\u003e\n clock:Eio.Time.clock -\u003e\n ?tls:Tls_config.client -\u003e\n ?headers:Headers.t -\u003e\n ?protocols:string list -\u003e\n string -\u003e\n (conn, error) result\n```","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:34:33.686889707+01:00","updated_at":"2025-12-29T16:00:45.086724651+01:00","closed_at":"2025-12-29T16:00:45.086724651+01:00","dependencies":[{"issue_id":"hcs-nad","depends_on_id":"hcs-lhr","type":"parent-child","created_at":"2025-12-29T14:34:52.929848888+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-nad","depends_on_id":"hcs-0ro","type":"blocks","created_at":"2025-12-29T14:34:58.053806421+01:00","created_by":"gdiazlo"}]}
8282+{"id":"hcs-njk","title":"Run HTTP/2 comparison benchmarks and analyze results","description":"Execute the HTTP/2 benchmarks across all three implementations. Collect req/s, latency (p50/p99), memory usage. Document results in BENCHMARKS.md with analysis of performance characteristics.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T08:21:59.417171367+01:00","updated_at":"2025-12-30T08:48:51.035410704+01:00","closed_at":"2025-12-30T08:48:51.035410704+01:00","dependencies":[{"issue_id":"hcs-njk","depends_on_id":"hcs-ddd","type":"blocks","created_at":"2025-12-30T08:22:17.716250574+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-njk","depends_on_id":"hcs-82y","type":"parent-child","created_at":"2025-12-30T08:22:28.307104582+01:00","created_by":"gdiazlo"}]}
8383+{"id":"hcs-oo5","title":"Benchmark suite: single-CPU comparison of HCS, hyper, fasthttp","description":"","notes":"## Benchmark Results (Dec 31, 2025)\n\n### HCS vs Eio Native (c=1000, 100k requests)\n\n| Domains | HCS run_parallel | Eio run_server | Winner |\n|---------|------------------|----------------|--------|\n| 1 | 142k | 136k | HCS +4% |\n| 2 | 196k | 200k | Eio +2% |\n| 4 | 264k | 259k | HCS +2% |\n| 8 | 259k | 267k | Eio +3% |\n\n**Key Finding**: Both approaches perform similarly. The 2-domain regression previously observed was a measurement artifact.\n\n### HCS vs Fasthttp (c=1000, 100k requests)\n\n| Domains | HCS | Fasthttp | Winner |\n|---------|-----|----------|--------|\n| 1 | 137k | 181k | Fasthttp +32% |\n| 4 | 242k | 186k | **HCS +30%** |\n| 8 | 228k | 204k | **HCS +12%** |\n\n**Key Finding**: HCS scales BETTER than Fasthttp and beats it at 4+ domains!\n\n### Conclusions\n\n1. The nested `Eio_main.run` is NOT a problem - Eio's Domain_manager.run does the same thing internally\n2. SO_REUSEPORT (HCS) vs shared socket (Eio native) perform similarly - no clear winner\n3. HCS multi-core scaling is actually quite good - beats Fasthttp at higher core counts\n4. Earlier \"2-domain regression\" was likely a measurement artifact (port reuse, warmup, etc.)\n\n### Remaining Optimization Opportunities\n\n1. Single-core performance still lags Fasthttp by ~32% - room for improvement in request parsing/response writing\n2. 8-domain shows slight regression from 4-domain for HCS - could investigate GC tuning\n3. Hyper benchmark failed to run - need to fix for comparison","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-30T22:41:13.187711389+01:00","updated_at":"2025-12-31T09:33:51.051037492+01:00","closed_at":"2025-12-31T09:33:51.051037492+01:00"}
8484+{"id":"hcs-osg","title":"Create Rust HTTP/2 benchmark server (hyper)","description":"Implement a Rust HTTP/2 server using hyper with h2c (cleartext HTTP/2) support. Match endpoints: /ping, /bytes/:n, /json. Use tokio runtime, pre-allocate response buffers for zero-copy.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T08:21:52.194368992+01:00","updated_at":"2025-12-30T08:48:32.166538953+01:00","closed_at":"2025-12-30T08:48:32.166538953+01:00","dependencies":[{"issue_id":"hcs-osg","depends_on_id":"hcs-82y","type":"parent-child","created_at":"2025-12-30T08:22:20.416305094+01:00","created_by":"gdiazlo"}]}
8585+{"id":"hcs-peu","title":"HCS benchmark client: HTTP/1.1 + HTTP/2 + WebSocket support","description":"Create comprehensive benchmark client supporting all protocols with connection reuse, multiplexing, and WebSocket message throughput.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-31T14:13:46.273123556+01:00","updated_at":"2025-12-31T14:37:50.862757453+01:00","closed_at":"2025-12-31T14:37:50.862757453+01:00","dependencies":[{"issue_id":"hcs-peu","depends_on_id":"hcs-5wp","type":"parent-child","created_at":"2025-12-31T14:14:41.0222415+01:00","created_by":"gdiazlo"}]}
8686+{"id":"hcs-pnc","title":"Control Flow and Cancellation","description":"Implement Cancel module for cooperative cancellation and Control module for timeout, retry, deadline, and circuit breaker patterns.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:23.840150584+01:00","updated_at":"2025-12-29T15:41:49.225247023+01:00","closed_at":"2025-12-29T15:41:49.225247023+01:00","dependencies":[{"issue_id":"hcs-pnc","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:03.266144948+01:00","created_by":"gdiazlo"}]}
8787+{"id":"hcs-pwc","title":"Implement CODEC module signature","description":"Implement the CODEC module signature in hcs-core/codec.ml using buffers:\n\n```ocaml\nmodule type CODEC = sig\n type 'a encoder\n type 'a decoder\n\n val content_type : string (* e.g., \"application/json\", \"application/msgpack\" *)\n\n (* Use Cstruct.t for buffer-based encoding/decoding *)\n val encode : 'a encoder -\u003e 'a -\u003e (Cstruct.t, string) result\n val decode : 'a decoder -\u003e Cstruct.t -\u003e ('a, string) result\n \n (* Optional: streaming encode/decode for large payloads *)\n val encode_stream : 'a encoder -\u003e 'a -\u003e Cstruct.t Stream.t option\n val decode_stream : 'a decoder -\u003e Cstruct.t Stream.t -\u003e ('a, string) result option\nend\n```\n\nBenefits of buffer-based approach:\n- Zero-copy for binary formats (msgpack, protobuf, cbor)\n- Efficient for large payloads\n- Can still handle text formats (JSON) by converting at boundaries\n- Consistent with body type which uses Cstruct.t for streaming\n\nPure OCaml + cstruct, no runtime dependency.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:28:42.177976816+01:00","updated_at":"2025-12-29T17:04:22.669690697+01:00","closed_at":"2025-12-29T17:04:22.669690697+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-pwc","depends_on_id":"hcs-m4r","type":"parent-child","created_at":"2025-12-29T14:28:59.747767828+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-pwc","depends_on_id":"hcs-23f","type":"blocks","created_at":"2025-12-29T14:29:04.083986956+01:00","created_by":"gdiazlo"}]}
8888+{"id":"hcs-qf7","title":"Implement radix trie for routing","description":"Implement radix trie in hcs-core/trie.ml:\n\n```ocaml\ntype 'a t\n\nval empty : 'a t\nval insert : Path.segment list -\u003e method_ -\u003e 'a -\u003e 'a t -\u003e 'a t\nval lookup : string list -\u003e method_ -\u003e 'a t -\u003e ('a * string list, string) result\n (* Returns handler and captured params *)\n\nval compile : 'a t -\u003e 'a t (* Optimize: compress edges, sort by priority *)\n```\n\nImplementation notes:\n- Radix trie with compressed edges for static segments\n- Parameter nodes stored separately for O(1) lookup\n- Method dispatch at leaf nodes (small array)\n- Priority: static \u003e typed param \u003e string param \u003e wildcard\n- Pre-compile regex for uuid validation\n\nPure OCaml, O(path_length) lookup.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:32:37.659665672+01:00","updated_at":"2025-12-29T15:18:02.267050922+01:00","closed_at":"2025-12-29T15:18:02.267050922+01:00","dependencies":[{"issue_id":"hcs-qf7","depends_on_id":"hcs-2ie","type":"parent-child","created_at":"2025-12-29T14:32:56.840281975+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-qf7","depends_on_id":"hcs-jsl","type":"blocks","created_at":"2025-12-29T14:32:57.708007508+01:00","created_by":"gdiazlo"}]}
8989+{"id":"hcs-qnb","title":"HTTP Client Implementation","description":"Implement the Client module with connection pooling, HTTP/1.1 and HTTP/2 support, request/fetch/stream methods, and configuration.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:28.006219271+01:00","updated_at":"2025-12-29T17:41:34.620441464+01:00","closed_at":"2025-12-29T17:41:34.620441464+01:00","dependencies":[{"issue_id":"hcs-qnb","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:11.475056322+01:00","created_by":"gdiazlo"}]}
9090+{"id":"hcs-rw6","title":"HTTP Server Implementation","description":"Implement Server module with HTTP/1.1 and HTTP/2 support, configuration, graceful shutdown, and connection management.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:32.173329865+01:00","updated_at":"2025-12-29T17:41:35.435456001+01:00","closed_at":"2025-12-29T17:41:35.435456001+01:00","dependencies":[{"issue_id":"hcs-rw6","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:17.069517446+01:00","created_by":"gdiazlo"}]}
9191+{"id":"hcs-rzc","title":"HCS: Unified server with HTTP/1.1 + h2c upgrade + WebSocket","description":"Create single HCS server that detects protocol and routes to H1/H2/WS handlers. Support h2c upgrade from HTTP/1.1.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-31T14:13:31.15150333+01:00","updated_at":"2025-12-31T14:24:25.878899607+01:00","closed_at":"2025-12-31T14:24:25.878899607+01:00","dependencies":[{"issue_id":"hcs-rzc","depends_on_id":"hcs-5wp","type":"parent-child","created_at":"2025-12-31T14:14:25.89281292+01:00","created_by":"gdiazlo"}]}
9292+{"id":"hcs-s3a","title":"Implement runtime-specific middleware (Eio)","description":"Implement Eio-specific middleware in hcs-eio/middleware.ml:\n\n```ocaml\n(* Logging - needs clock for timing *)\nval logging : Eio.Time.clock -\u003e Log.logger -\u003e middleware\n\n(* Timeout *)\nval timeout : Eio.Time.clock -\u003e float -\u003e middleware\n\n(* Rate limiting - needs clock and mutable state *)\nval rate_limit :\n clock:Eio.Time.clock -\u003e\n key:(request -\u003e string) -\u003e\n requests:int -\u003e\n per:float -\u003e\n middleware\n\n(* Compression - CPU bound but may benefit from async *)\nval compress : ?level:int -\u003e ?min_size:int -\u003e unit -\u003e middleware\nval decompress : middleware\n\n(* Static files - needs filesystem *)\nval static :\n fs:Eio.Fs.dir_ty Eio.Path.t -\u003e\n ?index:string list -\u003e\n ?etag:bool -\u003e\n string -\u003e\n middleware\n\n(* ETag generation *)\nval etag : middleware\nval cache_control : string -\u003e middleware\n```","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T14:34:31.955254986+01:00","updated_at":"2025-12-29T17:14:15.252061602+01:00","closed_at":"2025-12-29T17:14:15.252061602+01:00","dependencies":[{"issue_id":"hcs-s3a","depends_on_id":"hcs-3ww","type":"parent-child","created_at":"2025-12-29T14:34:51.261233247+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-s3a","depends_on_id":"hcs-bea","type":"blocks","created_at":"2025-12-29T14:34:57.261423006+01:00","created_by":"gdiazlo"}]}
9393+{"id":"hcs-s94","title":"Rust: Unified Hyper server with HTTP/1.1 + h2c + WebSocket","description":"Update Hyper server to handle HTTP/1.1, h2c, and WebSocket on single port.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-31T14:13:41.231763861+01:00","updated_at":"2025-12-31T14:31:18.83450995+01:00","closed_at":"2025-12-31T14:31:18.83450995+01:00","dependencies":[{"issue_id":"hcs-s94","depends_on_id":"hcs-5wp","type":"parent-child","created_at":"2025-12-31T14:14:35.978086083+01:00","created_by":"gdiazlo"}]}
9494+{"id":"hcs-sny","title":"Implement Router module","description":"Implement router API in hcs-core/router.ml:\n\n```ocaml\ntype 'a handler = 'a -\u003e request -\u003e (response, error) result\ntype middleware = (request -\u003e (response, error) result) -\u003e request -\u003e (response, error) result\n\ntype t\n\n(* Route registration *)\nval get : 'a Path.t -\u003e 'a handler -\u003e t\nval post : 'a Path.t -\u003e 'a handler -\u003e t\nval put : 'a Path.t -\u003e 'a handler -\u003e t\nval delete : 'a Path.t -\u003e 'a handler -\u003e t\nval patch : 'a Path.t -\u003e 'a handler -\u003e t\nval head : 'a Path.t -\u003e 'a handler -\u003e t\nval options : 'a Path.t -\u003e 'a handler -\u003e t\nval any : method_ list -\u003e 'a Path.t -\u003e 'a handler -\u003e t\n\n(* Composition *)\nval routes : t list -\u003e t\nval scope : string -\u003e t list -\u003e t\nval scope_with : string -\u003e middleware list -\u003e t list -\u003e t\nval with_middleware : middleware list -\u003e t -\u003e t\n\n(* Compilation *)\ntype compiled\nval compile : t -\u003e compiled\nval match_ : compiled -\u003e request -\u003e (response, error) result\n```\n\nPure OCaml, builds on Path and Trie.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:32:43.527248631+01:00","updated_at":"2025-12-29T15:18:03.419090442+01:00","closed_at":"2025-12-29T15:18:03.419090442+01:00","dependencies":[{"issue_id":"hcs-sny","depends_on_id":"hcs-2ie","type":"parent-child","created_at":"2025-12-29T14:32:57.378211329+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-sny","depends_on_id":"hcs-qf7","type":"blocks","created_at":"2025-12-29T14:32:58.327421433+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-sny","depends_on_id":"hcs-lpr","type":"blocks","created_at":"2025-12-29T14:32:59.27491603+01:00","created_by":"gdiazlo"}]}
9595+{"id":"hcs-tn3","title":"Run WebSocket benchmarks and collect results","description":"Execute the benchmark suite, collect memory and connection data, document findings.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T10:00:04.76879529+01:00","updated_at":"2025-12-30T10:22:46.346637622+01:00","closed_at":"2025-12-30T10:22:46.346637622+01:00","dependencies":[{"issue_id":"hcs-tn3","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:50.758778716+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-tn3","depends_on_id":"hcs-wkv","type":"blocks","created_at":"2025-12-30T10:01:15.964007648+01:00","created_by":"gdiazlo"}]}
9696+{"id":"hcs-tzc","title":"Implement Server.config type","description":"Implement server configuration in hcs-core/server_config.ml:\n\n```ocaml\ntype config = {\n host : string; (* default: \"0.0.0.0\" *)\n port : int; (* default: 8080 *)\n backlog : int; (* default: 2048 *)\n max_connections : int; (* default: 10000 *)\n\n (* Timeouts *)\n read_timeout : float; (* default: 60.0 *)\n write_timeout : float; (* default: 60.0 *)\n idle_timeout : float; (* default: 120.0 *)\n request_timeout : float; (* default: 30.0 *)\n\n (* Limits *)\n max_header_size : int; (* default: 8192 *)\n max_body_size : int64 option; (* None = unlimited *)\n\n (* Protocol *)\n http2 : bool; (* default: true *)\n buffer_size : int; (* default: 16384 *)\n\n (* TLS *)\n tls : Tls_config.server option;\n\n (* Compression *)\n compress_response : bool; (* default: true *)\n compression_min_size : int; (* default: 1024 *)\n compression_level : int; (* default: 6 *)\n\n (* Logging *)\n logger : Log.logger;\n}\n\nval default : config\n```\n\nPure configuration, no IO.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:33:15.561446445+01:00","updated_at":"2025-12-29T15:40:25.769211668+01:00","closed_at":"2025-12-29T15:40:25.769211668+01:00","dependencies":[{"issue_id":"hcs-tzc","depends_on_id":"hcs-rw6","type":"parent-child","created_at":"2025-12-29T14:33:40.219574299+01:00","created_by":"gdiazlo"}]}
9797+{"id":"hcs-ucw","title":"Implement Response helper module","description":"Implement response helpers in hcs-core/response.ml:\n\n```ocaml\nval make : ?version:version -\u003e ?headers:Headers.t -\u003e ?body:body -\u003e status -\u003e response\n\n(* Status shortcuts *)\nval ok : ?headers:Headers.t -\u003e body -\u003e response\nval created : ?headers:Headers.t -\u003e ?location:string -\u003e body -\u003e response\nval no_content : unit -\u003e response\nval bad_request : ?body:body -\u003e unit -\u003e response\nval unauthorized : ?www_authenticate:string -\u003e unit -\u003e response\nval forbidden : ?body:body -\u003e unit -\u003e response\nval not_found : ?body:body -\u003e unit -\u003e response\nval method_not_allowed : allowed:method_ list -\u003e unit -\u003e response\nval internal_error : ?body:body -\u003e unit -\u003e response\n(* ... all status helpers from spec ... *)\n\n(* Body helpers *)\nval text : string -\u003e response\nval html : string -\u003e response\nval redirect : ?permanent:bool -\u003e string -\u003e response\n\n(* Modify response *)\nval with_header : string -\u003e string -\u003e response -\u003e response\nval with_headers : (string * string) list -\u003e response -\u003e response\nval with_body : body -\u003e response -\u003e response\n```\n\nPure OCaml.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:34:17.870074295+01:00","updated_at":"2025-12-29T15:40:30.579234447+01:00","closed_at":"2025-12-29T15:40:30.579234447+01:00","dependencies":[{"issue_id":"hcs-ucw","depends_on_id":"hcs-dle","type":"parent-child","created_at":"2025-12-29T14:34:48.628725792+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-ucw","depends_on_id":"hcs-lpr","type":"blocks","created_at":"2025-12-29T14:34:55.629240942+01:00","created_by":"gdiazlo"}]}
9898+{"id":"hcs-ugs","title":"Core Types and Foundations","description":"Implement core type foundations: method_, version, status, Headers module, body type, request/response records, and error types.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:19.301411166+01:00","updated_at":"2025-12-29T15:42:11.485073934+01:00","closed_at":"2025-12-29T15:42:11.485073934+01:00","dependencies":[{"issue_id":"hcs-ugs","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:25:54.989012647+01:00","created_by":"gdiazlo"}]}
9999+{"id":"hcs-w1c","title":"Create HCS WebSocket benchmark server","description":"OCaml server using lib/websocket.ml. Accept connections, keep alive with ping/pong, report connection count. Port configurable via CLI.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T09:59:57.95619465+01:00","updated_at":"2025-12-30T10:03:58.03114584+01:00","closed_at":"2025-12-30T10:03:58.03114584+01:00","dependencies":[{"issue_id":"hcs-w1c","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:25.560665813+01:00","created_by":"gdiazlo"}]}
100100+{"id":"hcs-wkv","title":"Create WebSocket benchmark runner script","description":"Script like run_h2_comparison.sh. Start servers, run tests at 1k/10k/50k/100k connections, measure memory via /proc/[pid]/status VmRSS, report results.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T10:00:03.612055281+01:00","updated_at":"2025-12-30T10:14:42.952379356+01:00","closed_at":"2025-12-30T10:14:42.952379356+01:00","dependencies":[{"issue_id":"hcs-wkv","depends_on_id":"hcs-jk8","type":"parent-child","created_at":"2025-12-30T10:00:45.722495633+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-wkv","depends_on_id":"hcs-w1c","type":"blocks","created_at":"2025-12-30T10:00:55.80122372+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-wkv","depends_on_id":"hcs-1h7","type":"blocks","created_at":"2025-12-30T10:01:00.843226486+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-wkv","depends_on_id":"hcs-6ki","type":"blocks","created_at":"2025-12-30T10:01:05.885715216+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-wkv","depends_on_id":"hcs-6hx","type":"blocks","created_at":"2025-12-30T10:01:10.922318474+01:00","created_by":"gdiazlo"}]}
101101+{"id":"hcs-x1l","title":"Create benchmark runner script for standardized testing","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T22:21:46.718118939+01:00","updated_at":"2025-12-30T22:33:10.362379965+01:00","closed_at":"2025-12-30T22:33:10.362379965+01:00"}
102102+{"id":"hcs-y9w","title":"TLS Configuration","description":"Implement Tls_config module for client and server TLS configuration with verification modes and system certificate loading.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-29T14:25:24.911800471+01:00","updated_at":"2025-12-29T15:25:06.384359847+01:00","closed_at":"2025-12-29T15:25:06.384359847+01:00","dependencies":[{"issue_id":"hcs-y9w","depends_on_id":"hcs-zba","type":"parent-child","created_at":"2025-12-29T14:26:05.906211432+01:00","created_by":"gdiazlo"}]}
103103+{"id":"hcs-zba","title":"HCS HTTP Library Implementation","description":"Implement the hcs HTTP library for OCaml 5+ supporting HTTP/1.1, HTTP/2, and WebSockets. Built with a runtime-agnostic core to support Eio initially with Lwt support planned for the future. Features zero-copy streaming and minimal allocations.\n\nDesign Principle: The library should have a pure functional core with IO effects abstracted behind a runtime interface, allowing future Lwt integration without major rewrites.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-29T14:25:07.01892354+01:00","updated_at":"2025-12-29T17:57:35.070502613+01:00","closed_at":"2025-12-29T17:57:35.070502613+01:00"}
104104+{"id":"hcs-zft","title":"Document CODEC implementation examples","description":"Document how users can implement their own CODEC for various formats. Include examples in documentation/comments showing the pattern:\n\n```ocaml\n(* Example: User implements JSON codec with their preferred library *)\nmodule My_json_codec : Hcs.CODEC = struct\n type 'a encoder = 'a -\u003e Yojson.Safe.t (* or Jsonm, Jsoo, etc. *)\n type 'a decoder = Yojson.Safe.t -\u003e ('a, string) result\n \n let content_type = \"application/json\"\n \n let encode enc value =\n try Ok (Cstruct.of_string (Yojson.Safe.to_string (enc value)))\n with exn -\u003e Error (Printexc.to_string exn)\n \n let decode dec buf =\n try \n let json = Yojson.Safe.from_string (Cstruct.to_string buf) in\n dec json\n with exn -\u003e Error (Printexc.to_string exn)\n \n let encode_stream _ _ = None (* Optional streaming *)\n let decode_stream _ _ = None\nend\n\n(* Example: MessagePack codec *)\nmodule My_msgpack_codec : Hcs.CODEC = struct\n (* Similar pattern with msgpck library *)\nend\n```\n\nThis is documentation only - no actual JSON library dependency in hcs. Users choose their own serialization libraries.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-29T14:28:49.108715641+01:00","updated_at":"2025-12-29T17:32:45.942298931+01:00","closed_at":"2025-12-29T17:32:45.942298931+01:00","labels":["codec","optional"],"dependencies":[{"issue_id":"hcs-zft","depends_on_id":"hcs-m4r","type":"parent-child","created_at":"2025-12-29T14:29:01.540146453+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-zft","depends_on_id":"hcs-pwc","type":"blocks","created_at":"2025-12-29T14:29:03.003001157+01:00","created_by":"gdiazlo"}]}
105105+{"id":"hcs-zln","title":"Update benchmark server with --domains flag","description":"Add --domains N flag to bench_server_h2.exe to test with configurable domain count.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T09:05:56.907974106+01:00","updated_at":"2025-12-30T09:16:33.434469675+01:00","closed_at":"2025-12-30T09:16:33.434469675+01:00","dependencies":[{"issue_id":"hcs-zln","depends_on_id":"hcs-zq3","type":"parent-child","created_at":"2025-12-30T09:06:24.184829345+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-zln","depends_on_id":"hcs-k8f","type":"blocks","created_at":"2025-12-30T09:06:39.306344162+01:00","created_by":"gdiazlo"}]}
106106+{"id":"hcs-zq3","title":"Multi-CPU Support for HTTP/2 Server","description":"Add configurable multi-CPU support using Eio domain pools. Allow users to specify max CPU count.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-30T09:04:59.162003477+01:00","updated_at":"2025-12-30T09:16:54.412371508+01:00","closed_at":"2025-12-30T09:16:54.412371508+01:00"}
107107+{"id":"hcs-zum","title":"Make zero-copy responses the default in high-level Server API","description":"","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-30T00:14:13.733230476+01:00","updated_at":"2025-12-30T00:21:07.346540362+01:00","closed_at":"2025-12-30T00:21:07.346540362+01:00"}
108108+{"id":"hcs-zya","title":"Implement Control module","description":"Implement control flow combinators. Split into core (pure) and runtime-specific parts:\n\nhcs-core/control.ml (pure combinators):\n```ocaml\n(* Retry logic - pure, takes a \"sleep\" function *)\nval with_retry :\n sleep:(float -\u003e unit) -\u003e\n max_attempts:int -\u003e\n backoff:(int -\u003e float) -\u003e\n should_retry:(error -\u003e bool) -\u003e\n (unit -\u003e ('a, error) result) -\u003e\n ('a, error) result\n\n(* Circuit breaker state machine - pure *)\ntype circuit_state = Closed | Open of float | HalfOpen\ntype circuit_breaker\nval create_breaker : failure_threshold:int -\u003e reset_timeout:float -\u003e circuit_breaker\nval breaker_allow : circuit_breaker -\u003e now:float -\u003e bool\nval breaker_record_success : circuit_breaker -\u003e unit\nval breaker_record_failure : circuit_breaker -\u003e now:float -\u003e unit\n```\n\nRuntime-specific (hcs-eio/control.ml):\n```ocaml\nval with_timeout : Eio.Time.clock -\u003e float -\u003e (unit -\u003e ('a, error) result) -\u003e ('a, error) result\nval with_cancel : Cancel.t -\u003e (unit -\u003e ('a, error) result) -\u003e ('a, error) result\nval with_deadline : Eio.Time.clock -\u003e float -\u003e (unit -\u003e ('a, error) result) -\u003e ('a, error) result\n```","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-29T14:29:29.643574962+01:00","updated_at":"2025-12-29T15:18:05.295780311+01:00","closed_at":"2025-12-29T15:18:05.295780311+01:00","labels":["core"],"dependencies":[{"issue_id":"hcs-zya","depends_on_id":"hcs-pnc","type":"parent-child","created_at":"2025-12-29T14:30:10.213773459+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-zya","depends_on_id":"hcs-8br","type":"blocks","created_at":"2025-12-29T14:30:10.494720544+01:00","created_by":"gdiazlo"},{"issue_id":"hcs-zya","depends_on_id":"hcs-gmb","type":"blocks","created_at":"2025-12-29T14:30:10.704956596+01:00","created_by":"gdiazlo"}]}
···11+ISC License
22+33+Copyright (c) 2026 Gabriel Díaz López de la Llave
44+55+Permission to use, copy, modify, and/or distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
1010+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1111+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1212+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1313+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1414+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1515+PERFORMANCE OF THIS SOFTWARE.
+133
README.md
···11+# hcs
22+33+HTTP library for OCaml 5+ built on [Eio](https://github.com/ocaml-multicore/eio). Supports HTTP/1.1, HTTP/2, and WebSocket.
44+55+## Modules
66+77+| Module | Description |
88+|--------|-------------|
99+| [Client](lib/client.ml) | HTTP client with auto-protocol selection |
1010+| [Server](lib/server.ml) | HTTP server with multi-domain parallelism |
1111+| [Router](lib/router.ml) | Radix trie router with path parameters |
1212+| [Middleware](lib/middleware.ml) | Composable middleware |
1313+| [Middleware_eio](lib/middleware_eio.ml) | Eio-specific middleware (logging, timeout, rate limiting, static files) |
1414+| [Websocket](lib/websocket.ml) | WebSocket client and server (RFC 6455) |
1515+| [Pool](lib/pool.ml) | Connection pooling |
1616+| [Pooled_client](lib/pooled_client.ml) | Client with connection pooling |
1717+| [Control](lib/control.ml) | Retry, circuit breaker, backoff strategies |
1818+| [Stream](lib/stream.ml) | Sync and async streaming |
1919+| [Codec](lib/codec.ml) | Codec signature for serialization |
2020+| [Tls_config](lib/tls_config.ml) | TLS configuration |
2121+| [Log](lib/log.ml) | Structured logging |
2222+| [Request](lib/request.ml) | Request helpers |
2323+| [Response](lib/response.ml) | Response helpers |
2424+| [Http](lib/http.ml) | Request builder DSL |
2525+| [H1_client](lib/h1_client.ml) | HTTP/1.1 client |
2626+| [H2_client](lib/h2_client.ml) | HTTP/2 client |
2727+| [H1_server](lib/h1_server.ml) | HTTP/1.1 server |
2828+| [H2_server](lib/h2_server.ml) | HTTP/2 server |
2929+3030+## CLI Tools
3131+3232+### hc - HTTP Client
3333+3434+```
3535+hc [OPTIONS] <URL>
3636+```
3737+3838+**Options:**
3939+4040+| Flag | Description |
4141+|------|-------------|
4242+| `-X, --request METHOD` | HTTP method (GET, POST, PUT, DELETE, etc.) |
4343+| `-H, --header "Name: Value"` | Add header (can be repeated) |
4444+| `-d, --data DATA` | Request body |
4545+| `-2, --http2` | Force HTTP/2 |
4646+| `-1, --http1` | Force HTTP/1.1 |
4747+| `-k, --insecure` | Skip TLS verification |
4848+| `-v, --verbose` | Show headers |
4949+| `-I, --head` | HEAD request only |
5050+| `-L, --location` | Follow redirects (default) |
5151+| `--no-location` | Don't follow redirects |
5252+| `-o, --output FILE` | Write to file |
5353+| `-w, --websocket` | WebSocket mode |
5454+| `--ws-message MSG` | Message to send in WebSocket mode |
5555+5656+**Examples:**
5757+5858+```bash
5959+# GET request
6060+hc https://httpbin.org/get
6161+6262+# POST with data
6363+hc -d '{"key":"value"}' -H "Content-Type: application/json" https://httpbin.org/post
6464+6565+# Force HTTP/2
6666+hc -2 https://nghttp2.org/httpbin/get
6767+6868+# WebSocket
6969+hc -w wss://echo.websocket.org --ws-message "hello"
7070+7171+# Download file
7272+hc -o image.png https://example.com/image.png
7373+```
7474+7575+### hs - HTTP File Server
7676+7777+```
7878+hs [OPTIONS] [DIRECTORY]
7979+```
8080+8181+**Options:**
8282+8383+| Flag | Description |
8484+|------|-------------|
8585+| `-p, --port PORT` | Port (default: 8080) |
8686+| `-b, --bind ADDRESS` | Bind address (default: 0.0.0.0) |
8787+| `-d, --domains N` | Worker domains (default: CPU count) |
8888+| `-1, --http1` | HTTP/1.1 only |
8989+| `-2, --http2` | HTTP/2 only |
9090+| `--index FILE` | Index file (default: index.html) |
9191+| `--no-index` | Disable index file |
9292+| `--list` | Enable directory listing |
9393+| `--cors` | Enable CORS headers |
9494+| `-v, --verbose` | Log requests |
9595+9696+**Examples:**
9797+9898+```bash
9999+# Serve current directory
100100+hs
101101+102102+# Serve specific directory on port 3000
103103+hs -p 3000 ./public
104104+105105+# With directory listing and CORS
106106+hs --list --cors ./dist
107107+108108+# Verbose logging
109109+hs -v -p 8000 .
110110+```
111111+112112+## Building
113113+114114+```bash
115115+opam install . --deps-only
116116+dune build
117117+```
118118+119119+## Running Tests
120120+121121+```bash
122122+dune test
123123+```
124124+125125+## Dependencies
126126+127127+- [eio](https://github.com/ocaml-multicore/eio) - Structured concurrency
128128+- [h1](https://github.com/anmonteiro/http-protocols) - HTTP/1.1 parsing
129129+- [h2](https://github.com/anmonteiro/http-protocols) - HTTP/2 parsing
130130+- [tls-eio](https://github.com/mirleft/ocaml-tls) - TLS with Eio
131131+- [ca-certs](https://github.com/mirage/ca-certs) - System CA certificates
132132+- [uri](https://github.com/mirage/ocaml-uri) - URI parsing
133133+- [climate](https://github.com/gridbugs/climate) - CLI parsing
+20
bench/.gitignore
···11+# Rust build artifacts
22+hyper/target/
33+44+# Go build artifacts
55+fasthttp/bench-server
66+77+# Results and logs
88+results/
99+*.log
1010+1111+# Editor/IDE
1212+.idea/
1313+.vscode/
1414+*.swp
1515+*.swo
1616+*~
1717+1818+# OS files
1919+.DS_Store
2020+Thumbs.db
+232
bench/client/bench_client.ml
···11+module Stats = struct
22+ type t = {
33+ mutable total_requests : int;
44+ mutable successful : int;
55+ mutable failed : int;
66+ latencies : float Queue.t;
77+ start_time : float;
88+ mutable end_time : float;
99+ }
1010+1111+ let create () =
1212+ {
1313+ total_requests = 0;
1414+ successful = 0;
1515+ failed = 0;
1616+ latencies = Queue.create ();
1717+ start_time = Unix.gettimeofday ();
1818+ end_time = 0.0;
1919+ }
2020+2121+ let record_success stats latency_ms =
2222+ stats.total_requests <- stats.total_requests + 1;
2323+ stats.successful <- stats.successful + 1;
2424+ Queue.push latency_ms stats.latencies
2525+2626+ let record_failure stats =
2727+ stats.total_requests <- stats.total_requests + 1;
2828+ stats.failed <- stats.failed + 1
2929+3030+ let finish stats = stats.end_time <- Unix.gettimeofday ()
3131+3232+ let percentile arr p =
3333+ let n = Array.length arr in
3434+ if n = 0 then 0.0
3535+ else
3636+ let idx = int_of_float (float_of_int (n - 1) *. p) in
3737+ arr.(idx)
3838+3939+ let report stats ~json ~protocol =
4040+ finish stats;
4141+ let duration = stats.end_time -. stats.start_time in
4242+ let rps = float_of_int stats.successful /. duration in
4343+4444+ let latencies = Queue.to_seq stats.latencies |> Array.of_seq in
4545+ Array.sort compare latencies;
4646+4747+ let p50 = percentile latencies 0.50 in
4848+ let p90 = percentile latencies 0.90 in
4949+ let p99 = percentile latencies 0.99 in
5050+ let p999 = percentile latencies 0.999 in
5151+ let avg =
5252+ if Array.length latencies > 0 then
5353+ Array.fold_left ( +. ) 0.0 latencies
5454+ /. float_of_int (Array.length latencies)
5555+ else 0.0
5656+ in
5757+5858+ if json then
5959+ Printf.printf
6060+ {|{"protocol":"%s","duration":%.2f,"total":%d,"successful":%d,"failed":%d,"rps":%.2f,"latency_avg":%.3f,"latency_p50":%.3f,"latency_p90":%.3f,"latency_p99":%.3f,"latency_p999":%.3f}
6161+|}
6262+ protocol duration stats.total_requests stats.successful stats.failed rps
6363+ avg p50 p90 p99 p999
6464+ else begin
6565+ Printf.printf "\n=== Benchmark Results (%s) ===\n" protocol;
6666+ Printf.printf "Duration: %.2f seconds\n" duration;
6767+ Printf.printf "Requests: %d total, %d successful, %d failed\n"
6868+ stats.total_requests stats.successful stats.failed;
6969+ Printf.printf "Throughput: %.2f req/s\n" rps;
7070+ Printf.printf "\nLatency (ms):\n";
7171+ Printf.printf " avg: %.3f\n" avg;
7272+ Printf.printf " p50: %.3f\n" p50;
7373+ Printf.printf " p90: %.3f\n" p90;
7474+ Printf.printf " p99: %.3f\n" p99;
7575+ Printf.printf " p99.9: %.3f\n" p999
7676+ end
7777+end
7878+7979+module H1_benchmark = struct
8080+ let worker ~sw ~net ~url ~stats ~stop_flag =
8181+ let uri = Uri.of_string url in
8282+ let host = Uri.host uri |> Option.value ~default:"localhost" in
8383+ let port = Uri.port uri |> Option.value ~default:8080 in
8484+ let path = Uri.path uri in
8585+ let path = if path = "" then "/" else path in
8686+8787+ let request_line =
8888+ Printf.sprintf
8989+ "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n" path
9090+ host
9191+ in
9292+ let request_bytes = Bytes.of_string request_line in
9393+ let buf = Cstruct.create 4096 in
9494+ let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in
9595+9696+ try
9797+ let flow = Eio.Net.connect ~sw net addr in
9898+9999+ while not !stop_flag do
100100+ let start = Unix.gettimeofday () in
101101+ try
102102+ Eio.Flow.write flow [ Cstruct.of_bytes request_bytes ];
103103+ let _ = Eio.Flow.single_read flow buf in
104104+ let elapsed = (Unix.gettimeofday () -. start) *. 1000.0 in
105105+ Stats.record_success stats elapsed
106106+ with _ -> Stats.record_failure stats
107107+ done;
108108+109109+ Eio.Flow.close flow
110110+ with _ -> ()
111111+112112+ let run ~net ~clock ~sw ~url ~concurrency ~duration ~json =
113113+ if not json then begin
114114+ Printf.printf "Benchmarking %s (HTTP/1.1)\n" url;
115115+ Printf.printf "Concurrency: %d, Duration: %d seconds\n%!" concurrency
116116+ duration
117117+ end;
118118+119119+ let stats = Stats.create () in
120120+ let stop_flag = ref false in
121121+122122+ for _ = 1 to concurrency do
123123+ Eio.Fiber.fork ~sw (fun () -> worker ~sw ~net ~url ~stats ~stop_flag)
124124+ done;
125125+126126+ Eio.Time.sleep clock (float_of_int duration);
127127+ stop_flag := true;
128128+ Eio.Time.sleep clock 0.1;
129129+130130+ Stats.report stats ~json ~protocol:"HTTP/1.1"
131131+end
132132+133133+module Ws_benchmark = struct
134134+ let worker ~sw ~net ~url ~stats ~stop_flag ~msg_size =
135135+ let message = String.make msg_size 'x' in
136136+137137+ match Hcs.Websocket.connect ~sw ~net url with
138138+ | Error e ->
139139+ let err_msg =
140140+ match e with
141141+ | Hcs.Websocket.Connection_closed -> "Connection closed"
142142+ | Hcs.Websocket.Protocol_error s -> "Protocol error: " ^ s
143143+ | Hcs.Websocket.Io_error s -> "IO error: " ^ s
144144+ in
145145+ Printf.eprintf "WebSocket connect error: %s\n%!" err_msg
146146+ | Ok ws ->
147147+ while not !stop_flag do
148148+ let start = Unix.gettimeofday () in
149149+ match Hcs.Websocket.send_text ws message with
150150+ | Error _ -> Stats.record_failure stats
151151+ | Ok () -> (
152152+ match Hcs.Websocket.recv_message ws with
153153+ | Error _ -> Stats.record_failure stats
154154+ | Ok (_, _response) ->
155155+ let elapsed = (Unix.gettimeofday () -. start) *. 1000.0 in
156156+ Stats.record_success stats elapsed)
157157+ done;
158158+ Hcs.Websocket.close ws
159159+160160+ let run ~net ~clock ~sw ~url ~concurrency ~duration ~json ~msg_size =
161161+ if not json then begin
162162+ Printf.printf "Benchmarking %s (WebSocket)\n" url;
163163+ Printf.printf
164164+ "Concurrency: %d, Duration: %d seconds, Message size: %d\n%!"
165165+ concurrency duration msg_size
166166+ end;
167167+168168+ let stats = Stats.create () in
169169+ let stop_flag = ref false in
170170+171171+ for _ = 1 to concurrency do
172172+ Eio.Fiber.fork ~sw (fun () ->
173173+ worker ~sw ~net ~url ~stats ~stop_flag ~msg_size)
174174+ done;
175175+176176+ Eio.Time.sleep clock (float_of_int duration);
177177+ stop_flag := true;
178178+ Eio.Time.sleep clock 0.1;
179179+180180+ Stats.report stats ~json ~protocol:"WebSocket"
181181+end
182182+183183+let run_benchmark protocol url concurrency duration json msg_size =
184184+ Eio_main.run @@ fun env ->
185185+ Eio.Switch.run @@ fun sw ->
186186+ let net = Eio.Stdenv.net env in
187187+ let clock = Eio.Stdenv.clock env in
188188+189189+ match protocol with
190190+ | "h1" | "http1" | "http/1.1" ->
191191+ H1_benchmark.run ~net ~clock ~sw ~url ~concurrency ~duration ~json
192192+ | "h2" | "http2" | "http/2" | "h2c" ->
193193+ Printf.eprintf
194194+ "HTTP/2 benchmarks: use h2load from nghttp2\n\
195195+ Example: h2load -n 100000 -c %d -t 1 %s\n"
196196+ concurrency url;
197197+ exit 0
198198+ | "ws" | "websocket" ->
199199+ Ws_benchmark.run ~net ~clock ~sw ~url ~concurrency ~duration ~json
200200+ ~msg_size
201201+ | _ ->
202202+ Printf.eprintf "Unknown protocol: %s (use h1, h2, or ws)\n" protocol;
203203+ exit 1
204204+205205+let command =
206206+ Climate.Command.singleton
207207+ ~doc:
208208+ "HCS multi-protocol benchmark client (HTTP/1.1 native, WebSocket native, \
209209+ HTTP/2 via h2load)"
210210+ @@
211211+ let open Climate.Arg_parser in
212212+ let+ protocol =
213213+ named_with_default [ "p"; "protocol" ] string ~default:"h1"
214214+ ~doc:
215215+ "Protocol to use: h1 (HTTP/1.1), h2 (HTTP/2 - prints h2load command), \
216216+ ws (WebSocket)"
217217+ and+ url = named_req [ "u"; "url" ] string ~doc:"Target URL to benchmark"
218218+ and+ concurrency =
219219+ named_with_default [ "c"; "concurrency" ] int ~default:10
220220+ ~doc:"Number of concurrent connections"
221221+ and+ duration =
222222+ named_with_default [ "d"; "duration" ] int ~default:10
223223+ ~doc:"Test duration in seconds"
224224+ and+ json = flag [ "json" ] ~doc:"Output results as JSON"
225225+ and+ msg_size =
226226+ named_with_default [ "m"; "msg-size" ] int ~default:32
227227+ ~doc:"WebSocket message size in bytes"
228228+ in
229229+230230+ run_benchmark protocol url concurrency duration json msg_size
231231+232232+let () = Climate.Command.run command
···11+(** Unified HTTP Client supporting HTTP/1.1 and HTTP/2.
22+33+ This module provides a high-level HTTP client that automatically selects the
44+ appropriate protocol based on ALPN negotiation or configuration. *)
55+66+(** {1 Types} *)
77+88+(** Protocol version *)
99+type protocol = HTTP_1_1 | HTTP_2
1010+1111+type config = {
1212+ (* Timeouts *)
1313+ connect_timeout : float; (** Connection timeout in seconds. Default: 30.0 *)
1414+ read_timeout : float; (** Read timeout in seconds. Default: 30.0 *)
1515+ write_timeout : float; (** Write timeout in seconds. Default: 30.0 *)
1616+ (* Behavior *)
1717+ follow_redirects : int option;
1818+ (** Max redirects to follow. None = don't follow. Default: Some 10 *)
1919+ preferred_protocol : protocol option;
2020+ (** Preferred protocol. None = auto-detect via ALPN. Default: None *)
2121+ (* Buffers *)
2222+ buffer_size : int; (** Read buffer size. Default: 16384 *)
2323+ max_response_body : int64 option;
2424+ (** Max response body size. None = unlimited. Default: None *)
2525+ (* TLS *)
2626+ tls : Tls_config.Client.t; (** TLS configuration for HTTP/1.1 *)
2727+ (* Headers *)
2828+ default_headers : (string * string) list;
2929+ (** Headers to add to every request *)
3030+}
3131+(** Client configuration *)
3232+3333+let default_config =
3434+ {
3535+ connect_timeout = 30.0;
3636+ read_timeout = 30.0;
3737+ write_timeout = 30.0;
3838+ follow_redirects = Some 10;
3939+ preferred_protocol = Some HTTP_1_1;
4040+ (* Default to HTTP/1.1 for broader compatibility *)
4141+ buffer_size = 16384;
4242+ max_response_body = None;
4343+ tls = Tls_config.Client.default;
4444+ (* HTTP/1.1 ALPN *)
4545+ default_headers = [ ("User-Agent", "hcs/0.1.0") ];
4646+ }
4747+4848+(** {2 Config builders} *)
4949+5050+let with_timeout timeout config =
5151+ { config with connect_timeout = timeout; read_timeout = timeout }
5252+5353+let with_connect_timeout timeout config =
5454+ { config with connect_timeout = timeout }
5555+5656+let with_read_timeout timeout config = { config with read_timeout = timeout }
5757+let with_write_timeout timeout config = { config with write_timeout = timeout }
5858+5959+let with_redirects max_redirects config =
6060+ { config with follow_redirects = Some max_redirects }
6161+6262+let without_redirects config = { config with follow_redirects = None }
6363+let with_buffer_size size config = { config with buffer_size = size }
6464+6565+let with_max_response_body max_size config =
6666+ { config with max_response_body = Some max_size }
6767+6868+let with_tls tls config = { config with tls }
6969+let with_insecure_tls config = { config with tls = Tls_config.Client.insecure }
7070+7171+let with_http2 config =
7272+ { config with preferred_protocol = Some HTTP_2; tls = Tls_config.Client.h2 }
7373+7474+let with_http11 config =
7575+ {
7676+ config with
7777+ preferred_protocol = Some HTTP_1_1;
7878+ tls = Tls_config.Client.default;
7979+ }
8080+8181+let with_default_header name value config =
8282+ { config with default_headers = (name, value) :: config.default_headers }
8383+8484+let with_default_headers headers config =
8585+ { config with default_headers = headers @ config.default_headers }
8686+8787+(** Error type for client operations *)
8888+type error =
8989+ | Connection_failed of string
9090+ | Tls_error of string
9191+ | Protocol_error of string
9292+ | Timeout
9393+ | Invalid_response of string
9494+ | Too_many_redirects
9595+9696+type response = {
9797+ status : int;
9898+ headers : (string * string) list;
9999+ body : string;
100100+ protocol : protocol;
101101+}
102102+(** Response type - unified across protocols *)
103103+104104+(** {1 Internal helpers} *)
105105+106106+let h1_status_to_int status = H1.Status.to_code status
107107+let h2_status_to_int status = H2.Status.to_code status
108108+109109+let h1_headers_to_list headers =
110110+ let result = ref [] in
111111+ H1.Headers.iter
112112+ ~f:(fun name value -> result := (name, value) :: !result)
113113+ headers;
114114+ List.rev !result
115115+116116+let h2_headers_to_list headers =
117117+ let result = ref [] in
118118+ H2.Headers.iter
119119+ ~f:(fun name value -> result := (name, value) :: !result)
120120+ headers;
121121+ List.rev !result
122122+123123+(** {1 Public API} *)
124124+125125+(** Perform an HTTP GET request. The protocol is selected based on: 1.
126126+ config.preferred_protocol if set 2. ALPN negotiation if using HTTPS 3.
127127+ HTTP/1.1 for plain HTTP *)
128128+let get ~sw ~net ~clock ?(config = default_config) url =
129129+ let uri = Uri.of_string url in
130130+ let scheme = Uri.scheme uri |> Option.value ~default:"http" in
131131+ let is_https = String.equal scheme "https" in
132132+133133+ let use_h2 =
134134+ match config.preferred_protocol with
135135+ | Some HTTP_2 -> true
136136+ | Some HTTP_1_1 -> false
137137+ | None -> (
138138+ is_https
139139+ &&
140140+ match config.tls.alpn_protocols with
141141+ | Some protos -> List.mem Tls_config.alpn_h2 protos
142142+ | None -> false)
143143+ in
144144+145145+ if use_h2 then
146146+ (* Use HTTP/2 *)
147147+ match H2_client.get ~sw ~net ~clock url with
148148+ | Ok resp ->
149149+ Ok
150150+ {
151151+ status = h2_status_to_int resp.H2_client.status;
152152+ headers = h2_headers_to_list resp.headers;
153153+ body = resp.body;
154154+ protocol = HTTP_2;
155155+ }
156156+ | Error (H2_client.Connection_failed msg) -> Error (Connection_failed msg)
157157+ | Error (H2_client.Tls_error msg) -> Error (Tls_error msg)
158158+ | Error (H2_client.Protocol_error msg) -> Error (Protocol_error msg)
159159+ | Error H2_client.Timeout -> Error Timeout
160160+ | Error (H2_client.Invalid_response msg) -> Error (Invalid_response msg)
161161+ else
162162+ (* Use HTTP/1.1 *)
163163+ let h1_config : H1_client.config =
164164+ {
165165+ connect_timeout = config.connect_timeout;
166166+ read_timeout = config.read_timeout;
167167+ write_timeout = config.write_timeout;
168168+ follow_redirects = config.follow_redirects;
169169+ buffer_size = config.buffer_size;
170170+ max_response_body = config.max_response_body;
171171+ tls = config.tls;
172172+ default_headers = config.default_headers;
173173+ }
174174+ in
175175+ match H1_client.get ~sw ~net ~clock ~config:h1_config url with
176176+ | Ok resp ->
177177+ Ok
178178+ {
179179+ status = h1_status_to_int resp.H1_client.status;
180180+ headers = h1_headers_to_list resp.headers;
181181+ body = resp.body;
182182+ protocol = HTTP_1_1;
183183+ }
184184+ | Error (H1_client.Connection_failed msg) -> Error (Connection_failed msg)
185185+ | Error (H1_client.Tls_error msg) -> Error (Tls_error msg)
186186+ | Error H1_client.Timeout -> Error Timeout
187187+ | Error (H1_client.Invalid_response msg) -> Error (Invalid_response msg)
188188+ | Error H1_client.Too_many_redirects -> Error Too_many_redirects
189189+190190+(** Perform an HTTP POST request *)
191191+let post ~sw ~net ~clock ?(config = default_config) url ~body:request_body =
192192+ (* For now, POST only uses HTTP/1.1 - H2 POST can be added later *)
193193+ let h1_config : H1_client.config =
194194+ {
195195+ connect_timeout = config.connect_timeout;
196196+ read_timeout = config.read_timeout;
197197+ write_timeout = config.write_timeout;
198198+ follow_redirects = config.follow_redirects;
199199+ buffer_size = config.buffer_size;
200200+ max_response_body = config.max_response_body;
201201+ tls = config.tls;
202202+ default_headers = config.default_headers;
203203+ }
204204+ in
205205+ match
206206+ H1_client.post ~sw ~net ~clock ~config:h1_config url ~body:request_body
207207+ with
208208+ | Ok resp ->
209209+ Ok
210210+ {
211211+ status = h1_status_to_int resp.H1_client.status;
212212+ headers = h1_headers_to_list resp.headers;
213213+ body = resp.body;
214214+ protocol = HTTP_1_1;
215215+ }
216216+ | Error (H1_client.Connection_failed msg) -> Error (Connection_failed msg)
217217+ | Error (H1_client.Tls_error msg) -> Error (Tls_error msg)
218218+ | Error H1_client.Timeout -> Error Timeout
219219+ | Error (H1_client.Invalid_response msg) -> Error (Invalid_response msg)
220220+ | Error H1_client.Too_many_redirects -> Error Too_many_redirects
+206
lib/codec.ml
···11+(** CODEC module for type-safe serialization/deserialization.
22+33+ This module provides a signature for codecs that can encode and decode
44+ values to/from binary buffers. Users implement this signature with their
55+ preferred serialization library (yojson, jsonm, msgpck, etc.).
66+77+ The library itself does not depend on any specific serialization library. *)
88+99+(** {1 CODEC Signature} *)
1010+1111+(** Module signature for codecs.
1212+1313+ Codecs provide encoding/decoding between OCaml values and binary buffers.
1414+ The Cstruct.t type is used for efficient zero-copy binary handling. *)
1515+module type CODEC = sig
1616+ type 'a encoder
1717+ (** Encoder for type 'a - converts values to binary format *)
1818+1919+ type 'a decoder
2020+ (** Decoder for type 'a - parses values from binary format *)
2121+2222+ val content_type : string
2323+ (** Content-Type header value for this codec. Examples: "application/json",
2424+ "application/msgpack", "application/cbor" *)
2525+2626+ val encode : 'a encoder -> 'a -> (Cstruct.t, string) result
2727+ (** Encode a value to a buffer. Returns Error with message on encoding
2828+ failure. *)
2929+3030+ val decode : 'a decoder -> Cstruct.t -> ('a, string) result
3131+ (** Decode a value from a buffer. Returns Error with message on decoding
3232+ failure. *)
3333+3434+ val encode_stream : 'a encoder -> 'a -> Cstruct.t Seq.t option
3535+ (** Optional: Streaming encode for large payloads. Returns None if streaming
3636+ is not supported. *)
3737+3838+ val decode_stream :
3939+ 'a decoder -> Cstruct.t Seq.t -> ('a, string) result option
4040+ (** Optional: Streaming decode for large payloads. Returns None if streaming
4141+ is not supported. *)
4242+end
4343+4444+(** {1 With_codec Functor} *)
4545+4646+(** Error type for codec operations *)
4747+type codec_error =
4848+ | Encode_error of string
4949+ | Decode_error of string
5050+ | Unsupported_body_type
5151+5252+let codec_error_to_string = function
5353+ | Encode_error msg -> "Encode error: " ^ msg
5454+ | Decode_error msg -> "Decode error: " ^ msg
5555+ | Unsupported_body_type -> "Unsupported body type for codec operation"
5656+5757+(** Functor that provides helpers for working with a specific codec.
5858+5959+ This functor generates request/response helpers that automatically set
6060+ Content-Type headers and handle encoding/decoding. *)
6161+module With_codec (C : CODEC) = struct
6262+ (** Encode a value to a string body *)
6363+ let encode_body encoder value =
6464+ match C.encode encoder value with
6565+ | Ok buf -> Ok (Cstruct.to_string buf)
6666+ | Error msg -> Error (Encode_error msg)
6767+6868+ (** Decode a string body to a value *)
6969+ let decode_body decoder body_str =
7070+ let buf = Cstruct.of_string body_str in
7171+ match C.decode decoder buf with
7272+ | Ok value -> Ok value
7373+ | Error msg -> Error (Decode_error msg)
7474+7575+ (** Encode a value directly to Cstruct *)
7676+ let encode encoder value =
7777+ match C.encode encoder value with
7878+ | Ok buf -> Ok buf
7979+ | Error msg -> Error (Encode_error msg)
8080+8181+ (** Decode a Cstruct to a value *)
8282+ let decode decoder buf =
8383+ match C.decode decoder buf with
8484+ | Ok value -> Ok value
8585+ | Error msg -> Error (Decode_error msg)
8686+8787+ (** Get the content type for this codec *)
8888+ let content_type = C.content_type
8989+end
9090+9191+(** {1 Example Implementations}
9292+9393+ These are examples showing how to implement codecs. Users should implement
9494+ their own codecs with their preferred libraries.
9595+9696+ {2 JSON Example}
9797+9898+ {[
9999+ module Json_codec : Hcs.Codec.CODEC = struct
100100+ (* Using yojson as an example *)
101101+ type 'a encoder = 'a -> Yojson.Safe.t
102102+ type 'a decoder = Yojson.Safe.t -> ('a, string) result
103103+104104+ let content_type = "application/json"
105105+106106+ let encode enc value =
107107+ try Ok (Cstruct.of_string (Yojson.Safe.to_string (enc value)))
108108+ with exn -> Error (Printexc.to_string exn)
109109+110110+ let decode dec buf =
111111+ try
112112+ let json = Yojson.Safe.from_string (Cstruct.to_string buf) in
113113+ dec json
114114+ with exn -> Error (Printexc.to_string exn)
115115+116116+ let encode_stream _ _ = None
117117+ let decode_stream _ _ = None
118118+ end
119119+ ]}
120120+121121+ {2 MessagePack Example}
122122+123123+ {[
124124+ module Msgpack_codec : Hcs.Codec.CODEC = struct
125125+ type 'a encoder = 'a -> Msgpck.t
126126+ type 'a decoder = Msgpck.t -> ('a, string) result
127127+128128+ let content_type = "application/msgpack"
129129+130130+ let encode enc value =
131131+ try
132132+ let packed =
133133+ Msgpck.Bytes.to_string (Msgpck.Bytes.of_msgpck (enc value))
134134+ in
135135+ Ok (Cstruct.of_string packed)
136136+ with exn -> Error (Printexc.to_string exn)
137137+138138+ let decode dec buf =
139139+ try
140140+ match
141141+ Msgpck.Bytes.read (Bytes.of_string (Cstruct.to_string buf))
142142+ with
143143+ | Some (msgpack, _) -> dec msgpack
144144+ | None -> Error "Failed to parse msgpack"
145145+ with exn -> Error (Printexc.to_string exn)
146146+147147+ let encode_stream _ _ = None
148148+ let decode_stream _ _ = None
149149+ end
150150+ ]}
151151+152152+ {2 Plain Text Codec}
153153+154154+ {[
155155+ module Text_codec : Hcs.Codec.CODEC = struct
156156+ type 'a encoder = 'a -> string
157157+ type 'a decoder = string -> ('a, string) result
158158+159159+ let content_type = "text/plain; charset=utf-8"
160160+ let encode enc value = Ok (Cstruct.of_string (enc value))
161161+ let decode dec buf = dec (Cstruct.to_string buf)
162162+ let encode_stream _ _ = None
163163+ let decode_stream _ _ = None
164164+ end
165165+ ]} *)
166166+167167+(** {1 Built-in Identity Codec}
168168+169169+ A simple pass-through codec for raw binary data. *)
170170+module Identity_codec :
171171+ CODEC
172172+ with type 'a encoder = 'a -> Cstruct.t
173173+ and type 'a decoder = Cstruct.t -> ('a, string) result = struct
174174+ type 'a encoder = 'a -> Cstruct.t
175175+ type 'a decoder = Cstruct.t -> ('a, string) result
176176+177177+ let content_type = "application/octet-stream"
178178+179179+ let encode enc value =
180180+ try Ok (enc value) with exn -> Error (Printexc.to_string exn)
181181+182182+ let decode dec buf = dec buf
183183+ let encode_stream _ _ = None
184184+ let decode_stream _ _ = None
185185+end
186186+187187+(** {1 Built-in String Codec}
188188+189189+ A simple codec for UTF-8 text. *)
190190+module String_codec :
191191+ CODEC
192192+ with type 'a encoder = 'a -> string
193193+ and type 'a decoder = string -> ('a, string) result = struct
194194+ type 'a encoder = 'a -> string
195195+ type 'a decoder = string -> ('a, string) result
196196+197197+ let content_type = "text/plain; charset=utf-8"
198198+199199+ let encode enc value =
200200+ try Ok (Cstruct.of_string (enc value))
201201+ with exn -> Error (Printexc.to_string exn)
202202+203203+ let decode dec buf = dec (Cstruct.to_string buf)
204204+ let encode_stream _ _ = None
205205+ let decode_stream _ _ = None
206206+end
+166
lib/control.ml
···11+(** Control flow combinators for HCS.
22+33+ This module provides runtime-agnostic control flow patterns:
44+ - Retry with backoff
55+ - Circuit breaker
66+ - Rate limiting state
77+88+ Time and sleep operations are passed as parameters to keep this module pure
99+ and compatible with any runtime (Eio, Lwt). *)
1010+1111+(** Backoff strategies *)
1212+module Backoff = struct
1313+ (** Constant delay between retries *)
1414+ let constant delay _attempt = delay
1515+1616+ (** Exponential backoff: base * 2^attempt *)
1717+ let exponential ~base attempt = base *. Float.pow 2.0 (Float.of_int attempt)
1818+1919+ (** Exponential backoff with jitter *)
2020+ let exponential_jitter ~base ~jitter attempt =
2121+ let delay = exponential ~base attempt in
2222+ let jitter_amount = delay *. jitter *. (Random.float 2.0 -. 1.0) in
2323+ max 0.0 (delay +. jitter_amount)
2424+2525+ (** Linear backoff: base * attempt *)
2626+ let linear ~base attempt = base *. Float.of_int (attempt + 1)
2727+2828+ (** Capped backoff: applies cap to any strategy *)
2929+ let capped ~max_delay strategy attempt = min max_delay (strategy attempt)
3030+end
3131+3232+type retry_config = {
3333+ max_attempts : int;
3434+ backoff : int -> float; (** attempt -> delay in seconds *)
3535+ should_retry : exn -> bool; (** Which exceptions to retry *)
3636+}
3737+(** Retry configuration *)
3838+3939+let default_retry_config =
4040+ {
4141+ max_attempts = 3;
4242+ backoff = Backoff.exponential ~base:1.0;
4343+ should_retry = (fun _ -> true);
4444+ }
4545+4646+(** Retry with backoff. [sleep] is provided by the runtime (e.g.,
4747+ Eio.Time.sleep) *)
4848+let with_retry ~sleep ~config f =
4949+ let rec loop attempt =
5050+ match f () with
5151+ | result -> Ok result
5252+ | exception exn
5353+ when config.should_retry exn && attempt < config.max_attempts ->
5454+ let delay = config.backoff attempt in
5555+ sleep delay;
5656+ loop (attempt + 1)
5757+ | exception exn -> Error exn
5858+ in
5959+ loop 0
6060+6161+(** Circuit breaker states *)
6262+type circuit_state =
6363+ | Closed (** Normal operation *)
6464+ | Open of float (** Failing, timestamp when opened *)
6565+ | Half_open (** Testing if recovered *)
6666+6767+type circuit_breaker = {
6868+ mutable state : circuit_state;
6969+ mutable failure_count : int;
7070+ failure_threshold : int;
7171+ reset_timeout : float; (** Seconds before trying again *)
7272+ mutable success_count : int; (** Successes needed to close from half-open *)
7373+ success_threshold : int;
7474+}
7575+(** Circuit breaker *)
7676+7777+(** Create a circuit breaker *)
7878+let create_breaker ?(failure_threshold = 5) ?(reset_timeout = 30.0)
7979+ ?(success_threshold = 2) () =
8080+ {
8181+ state = Closed;
8282+ failure_count = 0;
8383+ failure_threshold;
8484+ reset_timeout;
8585+ success_count = 0;
8686+ success_threshold;
8787+ }
8888+8989+(** Check if circuit allows requests. [now] is current time in seconds (provided
9090+ by runtime) *)
9191+let circuit_allow breaker ~now =
9292+ match breaker.state with
9393+ | Closed -> true
9494+ | Open opened_at ->
9595+ if now -. opened_at >= breaker.reset_timeout then begin
9696+ breaker.state <- Half_open;
9797+ breaker.success_count <- 0;
9898+ true
9999+ end
100100+ else false
101101+ | Half_open -> true
102102+103103+(** Record a successful call *)
104104+let circuit_success breaker =
105105+ match breaker.state with
106106+ | Closed -> breaker.failure_count <- 0
107107+ | Half_open ->
108108+ breaker.success_count <- breaker.success_count + 1;
109109+ if breaker.success_count >= breaker.success_threshold then begin
110110+ breaker.state <- Closed;
111111+ breaker.failure_count <- 0
112112+ end
113113+ | Open _ -> ()
114114+115115+(** Record a failed call. [now] is current time (provided by runtime) *)
116116+let circuit_failure breaker ~now =
117117+ match breaker.state with
118118+ | Closed ->
119119+ breaker.failure_count <- breaker.failure_count + 1;
120120+ if breaker.failure_count >= breaker.failure_threshold then
121121+ breaker.state <- Open now
122122+ | Half_open -> breaker.state <- Open now
123123+ | Open _ -> ()
124124+125125+(** Execute with circuit breaker. [now] provides current time, [on_open] called
126126+ when circuit is open *)
127127+let with_circuit_breaker ~now ~on_open breaker f =
128128+ if not (circuit_allow breaker ~now) then on_open ()
129129+ else
130130+ match f () with
131131+ | result ->
132132+ circuit_success breaker;
133133+ Ok result
134134+ | exception exn ->
135135+ circuit_failure breaker ~now;
136136+ Error exn
137137+138138+type rate_limiter = {
139139+ mutable tokens : float;
140140+ mutable last_update : float;
141141+ rate : float; (** Tokens per second *)
142142+ capacity : float; (** Max tokens *)
143143+}
144144+(** Rate limiter state (token bucket) *)
145145+146146+(** Create a rate limiter *)
147147+let create_rate_limiter ~rate ~capacity =
148148+ { tokens = capacity; last_update = 0.0; rate; capacity }
149149+150150+(** Try to acquire a token. [now] is current time in seconds *)
151151+let rate_limit_acquire limiter ~now =
152152+ (* Refill tokens based on elapsed time *)
153153+ let elapsed = now -. limiter.last_update in
154154+ limiter.tokens <-
155155+ min limiter.capacity (limiter.tokens +. (elapsed *. limiter.rate));
156156+ limiter.last_update <- now;
157157+158158+ if limiter.tokens >= 1.0 then begin
159159+ limiter.tokens <- limiter.tokens -. 1.0;
160160+ true
161161+ end
162162+ else false
163163+164164+(** Calculate wait time until a token is available *)
165165+let rate_limit_wait_time limiter =
166166+ if limiter.tokens >= 1.0 then 0.0 else (1.0 -. limiter.tokens) /. limiter.rate
···11+(** HTTP/1.1 Client implementation using h1.
22+33+ This module provides HTTP/1.1 client functionality built on Eio. *)
44+55+(** {1 Configuration} *)
66+77+type config = {
88+ (* Timeouts *)
99+ connect_timeout : float; (** Connection timeout in seconds. Default: 30.0 *)
1010+ read_timeout : float; (** Read timeout in seconds. Default: 30.0 *)
1111+ write_timeout : float; (** Write timeout in seconds. Default: 30.0 *)
1212+ (* Behavior *)
1313+ follow_redirects : int option;
1414+ (** Max redirects to follow. None = don't follow. Default: Some 10 *)
1515+ (* Buffers *)
1616+ buffer_size : int; (** Read buffer size. Default: 16384 *)
1717+ max_response_body : int64 option;
1818+ (** Max response body size. None = unlimited. Default: None *)
1919+ (* TLS *)
2020+ tls : Tls_config.Client.t; (** TLS configuration *)
2121+ (* Headers *)
2222+ default_headers : (string * string) list;
2323+ (** Headers to add to every request *)
2424+}
2525+(** Client configuration *)
2626+2727+let default_config =
2828+ {
2929+ connect_timeout = 30.0;
3030+ read_timeout = 30.0;
3131+ write_timeout = 30.0;
3232+ follow_redirects = Some 10;
3333+ buffer_size = 16384;
3434+ max_response_body = None;
3535+ tls = Tls_config.Client.default;
3636+ default_headers = [ ("User-Agent", "hcs/0.1.0") ];
3737+ }
3838+3939+(** {2 Config builders} *)
4040+4141+let with_timeout timeout config =
4242+ { config with connect_timeout = timeout; read_timeout = timeout }
4343+4444+let with_connect_timeout timeout config =
4545+ { config with connect_timeout = timeout }
4646+4747+let with_read_timeout timeout config = { config with read_timeout = timeout }
4848+let with_write_timeout timeout config = { config with write_timeout = timeout }
4949+5050+let with_redirects max_redirects config =
5151+ { config with follow_redirects = Some max_redirects }
5252+5353+let without_redirects config = { config with follow_redirects = None }
5454+let with_buffer_size size config = { config with buffer_size = size }
5555+5656+let with_max_response_body max_size config =
5757+ { config with max_response_body = Some max_size }
5858+5959+let with_tls tls config = { config with tls }
6060+let with_insecure_tls config = { config with tls = Tls_config.Client.insecure }
6161+6262+let with_default_header name value config =
6363+ { config with default_headers = (name, value) :: config.default_headers }
6464+6565+let with_default_headers headers config =
6666+ { config with default_headers = headers @ config.default_headers }
6767+6868+(** Error type for client operations *)
6969+type error =
7070+ | Connection_failed of string
7171+ | Tls_error of string
7272+ | Timeout
7373+ | Invalid_response of string
7474+ | Too_many_redirects
7575+7676+type response = { status : H1.Status.t; headers : H1.Headers.t; body : string }
7777+(** Response type *)
7878+7979+(** Write all IOVecs to the flow *)
8080+let write_iovecs flow iovecs =
8181+ let cstructs =
8282+ List.map
8383+ (fun iov ->
8484+ Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off
8585+ ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer)
8686+ iovecs
8787+ in
8888+ Eio.Flow.write flow cstructs
8989+9090+(** Read from flow into bigstring buffer *)
9191+let read_into_bigstring flow buf ~off ~len =
9292+ let cs = Cstruct.of_bigarray ~off ~len buf in
9393+ try
9494+ let n = Eio.Flow.single_read flow cs in
9595+ `Ok n
9696+ with End_of_file -> `Eof
9797+9898+(** Resolve hostname to IP address *)
9999+let resolve_host net host =
100100+ let addrs = Eio.Net.getaddrinfo_stream net host in
101101+ match addrs with addr :: _ -> Some addr | [] -> None
102102+103103+(** Perform an HTTP/1.1 request on a connected flow *)
104104+let do_request ?(request_body = "") flow req =
105105+ (* Set up response handling *)
106106+ let response_received = Eio.Promise.create () in
107107+ let body_buffer = Buffer.create 4096 in
108108+ let resolved = ref false in
109109+110110+ let resolve_once result =
111111+ if not !resolved then begin
112112+ resolved := true;
113113+ Eio.Promise.resolve (snd response_received) result
114114+ end
115115+ in
116116+117117+ let response_handler resp body_reader =
118118+ let rec read_body () =
119119+ H1.Body.Reader.schedule_read body_reader
120120+ ~on_eof:(fun () ->
121121+ let body = Buffer.contents body_buffer in
122122+ resolve_once
123123+ (Ok
124124+ {
125125+ status = resp.H1.Response.status;
126126+ headers = resp.headers;
127127+ body;
128128+ }))
129129+ ~on_read:(fun buf ~off ~len ->
130130+ Buffer.add_string body_buffer (Bigstringaf.substring buf ~off ~len);
131131+ read_body ())
132132+ in
133133+ read_body ()
134134+ in
135135+136136+ let error_handler err =
137137+ let msg =
138138+ match err with
139139+ | `Malformed_response s -> s
140140+ | `Invalid_response_body_length _ -> "Invalid response body length"
141141+ | `Exn exn -> Printexc.to_string exn
142142+ in
143143+ resolve_once (Error (Invalid_response msg))
144144+ in
145145+146146+ (* Create the client connection *)
147147+ let body_writer, conn =
148148+ H1.Client_connection.request req ~error_handler ~response_handler
149149+ in
150150+151151+ (* Write request body if provided, then close *)
152152+ if String.length request_body > 0 then begin
153153+ H1.Body.Writer.write_string body_writer request_body;
154154+ H1.Body.Writer.flush body_writer (fun () -> ())
155155+ end;
156156+ H1.Body.Writer.close body_writer;
157157+158158+ (* Buffer for reading - track unconsumed bytes between reads *)
159159+ let read_buffer_size = 0x4000 in
160160+ let read_buffer = Bigstringaf.create read_buffer_size in
161161+ let unconsumed = ref 0 in
162162+163163+ (* Connection loop - handle both read and write operations *)
164164+ let rec loop () =
165165+ (* First, handle any pending writes *)
166166+ let write_done =
167167+ match H1.Client_connection.next_write_operation conn with
168168+ | `Write iovecs ->
169169+ write_iovecs flow iovecs;
170170+ let len =
171171+ List.fold_left
172172+ (fun acc iov -> acc + iov.Httpun_types.IOVec.len)
173173+ 0 iovecs
174174+ in
175175+ H1.Client_connection.report_write_result conn (`Ok len);
176176+ false
177177+ | `Yield -> true
178178+ | `Close _ -> true
179179+ in
180180+181181+ (* Then handle reads *)
182182+ let read_done =
183183+ match H1.Client_connection.next_read_operation conn with
184184+ | `Read -> (
185185+ let available = read_buffer_size - !unconsumed in
186186+ match
187187+ read_into_bigstring flow read_buffer ~off:!unconsumed ~len:available
188188+ with
189189+ | `Ok n ->
190190+ let total = !unconsumed + n in
191191+ let consumed =
192192+ H1.Client_connection.read conn read_buffer ~off:0 ~len:total
193193+ in
194194+ (* Shift unconsumed bytes to start of buffer *)
195195+ let remaining = total - consumed in
196196+ if remaining > 0 && consumed > 0 then
197197+ Bigstringaf.blit read_buffer ~src_off:consumed read_buffer
198198+ ~dst_off:0 ~len:remaining;
199199+ unconsumed := remaining;
200200+ false
201201+ | `Eof ->
202202+ let _ =
203203+ H1.Client_connection.read_eof conn read_buffer ~off:0
204204+ ~len:!unconsumed
205205+ in
206206+ true)
207207+ | `Close -> true
208208+ in
209209+210210+ (* Continue until both read and write are done *)
211211+ if not (write_done && read_done) then loop ()
212212+ in
213213+214214+ loop ();
215215+ Eio.Promise.await (fst response_received)
216216+217217+(** Wrap flow with TLS if needed, returns the flow to use *)
218218+let maybe_wrap_tls ~config ~host ~is_https flow =
219219+ if is_https then
220220+ match Tls_config.Client.to_tls_config config.tls ~host with
221221+ | Error msg -> Error (Tls_error msg)
222222+ | Ok tls_config -> (
223223+ try
224224+ (* Parse host as domain name for SNI *)
225225+ let host_domain =
226226+ match Domain_name.of_string host with
227227+ | Ok dn -> (
228228+ match Domain_name.host dn with
229229+ | Ok h -> Some h
230230+ | Error _ -> None)
231231+ | Error _ -> None
232232+ in
233233+ let tls_flow =
234234+ Tls_eio.client_of_flow tls_config ?host:host_domain flow
235235+ in
236236+ Ok (tls_flow :> Eio.Flow.two_way_ty Eio.Std.r)
237237+ with
238238+ | Tls_eio.Tls_failure failure ->
239239+ Error (Tls_error (Tls_config.failure_to_string failure))
240240+ | exn -> Error (Tls_error (Printexc.to_string exn)))
241241+ else Ok (flow :> Eio.Flow.two_way_ty Eio.Std.r)
242242+243243+(** Perform a GET request *)
244244+let get ~sw ~net ~clock ?(config = default_config) url =
245245+ let uri = Uri.of_string url in
246246+ let scheme = Uri.scheme uri |> Option.value ~default:"http" in
247247+ let is_https = String.equal scheme "https" in
248248+ let host = Uri.host uri |> Option.value ~default:"localhost" in
249249+ let default_port = if is_https then 443 else 80 in
250250+ let port = Uri.port uri |> Option.value ~default:default_port in
251251+ let path = Uri.path_and_query uri in
252252+ let path = if path = "" then "/" else path in
253253+254254+ (* Wrap in timeout - use with_timeout_exn and catch the exception *)
255255+ let total_timeout = config.connect_timeout +. config.read_timeout in
256256+ try
257257+ Eio.Time.with_timeout_exn clock total_timeout @@ fun () ->
258258+ match resolve_host net host with
259259+ | None -> Error (Connection_failed ("Cannot resolve host: " ^ host))
260260+ | Some addr_info -> (
261261+ let addr =
262262+ match addr_info with
263263+ | `Tcp (ip, _) -> `Tcp (ip, port)
264264+ | `Unix _ -> failwith "Unix sockets not supported"
265265+ in
266266+ let tcp_flow = Eio.Net.connect ~sw net addr in
267267+268268+ (* Wrap with TLS if HTTPS *)
269269+ match maybe_wrap_tls ~config ~host ~is_https tcp_flow with
270270+ | Error e -> Error e
271271+ | Ok flow ->
272272+ let req =
273273+ H1.Request.create
274274+ ~headers:
275275+ (H1.Headers.of_list
276276+ [ ("Host", host); ("Connection", "keep-alive") ])
277277+ `GET path
278278+ in
279279+ do_request flow req)
280280+ with Eio.Time.Timeout -> Error Timeout
281281+282282+(** Perform a POST request with body *)
283283+let post ~sw ~net ~clock ?(config = default_config) url ~body:request_body =
284284+ let uri = Uri.of_string url in
285285+ let scheme = Uri.scheme uri |> Option.value ~default:"http" in
286286+ let is_https = String.equal scheme "https" in
287287+ let host = Uri.host uri |> Option.value ~default:"localhost" in
288288+ let default_port = if is_https then 443 else 80 in
289289+ let port = Uri.port uri |> Option.value ~default:default_port in
290290+ let path = Uri.path_and_query uri in
291291+ let path = if path = "" then "/" else path in
292292+293293+ (* Wrap in timeout *)
294294+ let total_timeout = config.connect_timeout +. config.read_timeout in
295295+ try
296296+ Eio.Time.with_timeout_exn clock total_timeout @@ fun () ->
297297+ match resolve_host net host with
298298+ | None -> Error (Connection_failed ("Cannot resolve host: " ^ host))
299299+ | Some addr_info -> (
300300+ let addr =
301301+ match addr_info with
302302+ | `Tcp (ip, _) -> `Tcp (ip, port)
303303+ | `Unix _ -> failwith "Unix sockets not supported"
304304+ in
305305+ let tcp_flow = Eio.Net.connect ~sw net addr in
306306+307307+ (* Wrap with TLS if HTTPS *)
308308+ match maybe_wrap_tls ~config ~host ~is_https tcp_flow with
309309+ | Error e -> Error e
310310+ | Ok flow ->
311311+ let content_length = String.length request_body in
312312+ let req =
313313+ H1.Request.create
314314+ ~headers:
315315+ (H1.Headers.of_list
316316+ [
317317+ ("Host", host);
318318+ ("Connection", "keep-alive");
319319+ ("Content-Length", string_of_int content_length);
320320+ ])
321321+ `POST path
322322+ in
323323+ do_request ~request_body flow req)
324324+ with Eio.Time.Timeout -> Error Timeout
+588
lib/h1_server.ml
···11+(** HTTP/1.1 Server implementation using h1.
22+33+ This module provides HTTP/1.1 server functionality built on Eio.
44+55+ Features:
66+ - Lazy body reading: request body is not read until accessed
77+ - Zero-copy responses: bigstring bodies avoid copying
88+ - Streaming responses: write large responses without buffering
99+ - Connection pooling via buffer reuse *)
1010+1111+open Eio.Std
1212+1313+(** {1 Read Buffer Pool} *)
1414+1515+module Read_buffer_pool : sig
1616+ val acquire : unit -> Bigstringaf.t * Cstruct.t
1717+ val release : Bigstringaf.t -> unit
1818+end = struct
1919+ let buffer_size = 0x4000
2020+2121+ let acquire () =
2222+ let buf = Bigstringaf.create buffer_size in
2323+ (buf, Cstruct.of_bigarray buf ~off:0 ~len:buffer_size)
2424+2525+ let release _ = ()
2626+end
2727+2828+(** {1 Configuration} *)
2929+3030+type config = {
3131+ (* Network *)
3232+ host : string; (** Bind address. Default: "0.0.0.0" *)
3333+ port : int; (** Listen port. Default: 8080 *)
3434+ backlog : int; (** Listen backlog. Default: 2048 *)
3535+ max_connections : int; (** Max concurrent connections. Default: 10000 *)
3636+ (* Parallelism *)
3737+ domain_count : int; (** Number of domains (CPUs) to use. Default: 1 *)
3838+ (* Timeouts *)
3939+ read_timeout : float; (** Read timeout in seconds. Default: 60.0 *)
4040+ write_timeout : float; (** Write timeout in seconds. Default: 60.0 *)
4141+ idle_timeout : float; (** Idle connection timeout. Default: 120.0 *)
4242+ request_timeout : float; (** Request processing timeout. Default: 30.0 *)
4343+ (* Limits *)
4444+ max_header_size : int; (** Max header size in bytes. Default: 8192 *)
4545+ max_body_size : int64 option;
4646+ (** Max body size. None = unlimited. Default: None *)
4747+ (* Buffers *)
4848+ buffer_size : int; (** Read buffer size. Default: 16384 *)
4949+ (* TLS *)
5050+ tls : Tls_config.Server.t option; (** TLS config. None = plain HTTP *)
5151+ (* Socket options *)
5252+ tcp_nodelay : bool; (** Enable TCP_NODELAY (disable Nagle). Default: true *)
5353+ reuse_addr : bool; (** Enable SO_REUSEADDR. Default: true *)
5454+ reuse_port : bool;
5555+ (** Enable SO_REUSEPORT for multi-process scaling. Default: true *)
5656+}
5757+(** Server configuration *)
5858+5959+let default_config =
6060+ {
6161+ host = "0.0.0.0";
6262+ port = 8080;
6363+ backlog = 2048;
6464+ max_connections = 10000;
6565+ domain_count = 1;
6666+ read_timeout = 60.0;
6767+ write_timeout = 60.0;
6868+ idle_timeout = 120.0;
6969+ request_timeout = 30.0;
7070+ max_header_size = 8192;
7171+ max_body_size = None;
7272+ buffer_size = 16384;
7373+ tls = None;
7474+ tcp_nodelay = true;
7575+ reuse_addr = true;
7676+ reuse_port = true;
7777+ }
7878+7979+(** {2 Config builders} *)
8080+8181+let with_port port config = { config with port }
8282+let with_host host config = { config with host }
8383+let with_backlog backlog config = { config with backlog }
8484+let with_max_connections max config = { config with max_connections = max }
8585+let with_read_timeout timeout config = { config with read_timeout = timeout }
8686+let with_write_timeout timeout config = { config with write_timeout = timeout }
8787+let with_idle_timeout timeout config = { config with idle_timeout = timeout }
8888+8989+let with_request_timeout timeout config =
9090+ { config with request_timeout = timeout }
9191+9292+let with_domain_count count config = { config with domain_count = count }
9393+let with_max_header_size size config = { config with max_header_size = size }
9494+let with_max_body_size size config = { config with max_body_size = Some size }
9595+let with_buffer_size size config = { config with buffer_size = size }
9696+let with_tls tls config = { config with tls = Some tls }
9797+let with_tcp_nodelay enabled config = { config with tcp_nodelay = enabled }
9898+let with_reuse_addr enabled config = { config with reuse_addr = enabled }
9999+let with_reuse_port enabled config = { config with reuse_port = enabled }
100100+101101+(** {1 GC Tuning} *)
102102+103103+type gc_config = {
104104+ minor_heap_size : int;
105105+ major_heap_increment : int;
106106+ space_overhead : int;
107107+ max_overhead : int;
108108+}
109109+110110+let default_gc_config =
111111+ {
112112+ minor_heap_size = 64 * 1024 * 1024;
113113+ major_heap_increment = 16 * 1024 * 1024;
114114+ space_overhead = 120;
115115+ max_overhead = 500;
116116+ }
117117+118118+let tune_gc ?(config = default_gc_config) () =
119119+ let ctrl = Gc.get () in
120120+ Gc.set
121121+ {
122122+ ctrl with
123123+ minor_heap_size = config.minor_heap_size / (Sys.word_size / 8);
124124+ major_heap_increment = config.major_heap_increment / (Sys.word_size / 8);
125125+ space_overhead = config.space_overhead;
126126+ max_overhead = config.max_overhead;
127127+ }
128128+129129+let gc_tuned = ref false
130130+131131+let ensure_gc_tuned () =
132132+ if not !gc_tuned then begin
133133+ tune_gc ();
134134+ gc_tuned := true
135135+ end
136136+137137+(** {1 Cached Date Header} *)
138138+139139+module Date_cache : sig
140140+ val get : unit -> string
141141+end = struct
142142+ let day_names = [| "Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat" |]
143143+144144+ let month_names =
145145+ [|
146146+ "Jan";
147147+ "Feb";
148148+ "Mar";
149149+ "Apr";
150150+ "May";
151151+ "Jun";
152152+ "Jul";
153153+ "Aug";
154154+ "Sep";
155155+ "Oct";
156156+ "Nov";
157157+ "Dec";
158158+ |]
159159+160160+ let cached_date = Atomic.make ""
161161+ let cached_time = Atomic.make 0.
162162+163163+ let format_date () =
164164+ let t = Unix.gettimeofday () in
165165+ let tm = Unix.gmtime t in
166166+ Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT"
167167+ day_names.(tm.Unix.tm_wday)
168168+ tm.Unix.tm_mday
169169+ month_names.(tm.Unix.tm_mon)
170170+ (1900 + tm.Unix.tm_year) tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
171171+172172+ let get () =
173173+ let now = Unix.gettimeofday () in
174174+ let last = Atomic.get cached_time in
175175+ if now -. last >= 1.0 then begin
176176+ let date = format_date () in
177177+ Atomic.set cached_date date;
178178+ Atomic.set cached_time now;
179179+ date
180180+ end
181181+ else Atomic.get cached_date
182182+end
183183+184184+(** {1 Cached Prebuilt Response} *)
185185+186186+type cached_prebuilt = {
187187+ base_response : H1.Response.t;
188188+ body : Bigstringaf.t;
189189+ cached_response : H1.Response.t Atomic.t;
190190+ cached_second : int Atomic.t;
191191+}
192192+193193+let make_cached_prebuilt h1_response body =
194194+ let now = int_of_float (Unix.gettimeofday ()) in
195195+ let headers =
196196+ H1.Headers.add h1_response.H1.Response.headers "Date" (Date_cache.get ())
197197+ in
198198+ let resp = H1.Response.create ~headers h1_response.H1.Response.status in
199199+ {
200200+ base_response = h1_response;
201201+ body;
202202+ cached_response = Atomic.make resp;
203203+ cached_second = Atomic.make now;
204204+ }
205205+206206+let[@inline] get_cached_response cached =
207207+ let now = int_of_float (Unix.gettimeofday ()) in
208208+ let last = Atomic.get cached.cached_second in
209209+ if now <> last then begin
210210+ let headers =
211211+ H1.Headers.add cached.base_response.H1.Response.headers "Date"
212212+ (Date_cache.get ())
213213+ in
214214+ let resp =
215215+ H1.Response.create ~headers cached.base_response.H1.Response.status
216216+ in
217217+ Atomic.set cached.cached_response resp;
218218+ Atomic.set cached.cached_second now
219219+ end;
220220+ Atomic.get cached.cached_response
221221+222222+(** {1 Socket Helpers} *)
223223+224224+let set_tcp_nodelay flow =
225225+ match Eio_unix.Resource.fd_opt flow with
226226+ | None -> ()
227227+ | Some fd ->
228228+ Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd ->
229229+ Unix.setsockopt unix_fd Unix.TCP_NODELAY true)
230230+231231+(** {1 Body Types} *)
232232+233233+type body_reader = {
234234+ read : unit -> string;
235235+ (** Read the entire body as a string. Can only be called once. *)
236236+ read_stream : unit -> Cstruct.t option;
237237+ (** Read body in chunks. Returns None when done. *)
238238+ close : unit -> unit; (** Close the body reader without reading. *)
239239+}
240240+(** Lazy body reader - body is only read when [read] is called *)
241241+242242+(** Response body - supports string, bigstring, streaming, and pre-built *)
243243+type response_body =
244244+ | Body_string of string
245245+ | Body_bigstring of Bigstringaf.t
246246+ | Body_prebuilt of { h1_response : H1.Response.t; body : Bigstringaf.t }
247247+ | Body_cached_prebuilt of cached_prebuilt
248248+ | Body_stream of {
249249+ content_length : int64 option;
250250+ next : unit -> Cstruct.t option;
251251+ }
252252+253253+(** {1 Request and Response Types} *)
254254+255255+type request = {
256256+ meth : H1.Method.t;
257257+ target : string;
258258+ headers : H1.Headers.t;
259259+ body_reader : body_reader;
260260+ (** Lazy body reader - call [body_reader.read ()] to get the body *)
261261+}
262262+(** Request type with lazy body reading *)
263263+264264+(** Read the request body as a string (convenience function) *)
265265+let read_body req = req.body_reader.read ()
266266+267267+(** Read the request body as a stream (for large bodies) *)
268268+let read_body_stream req = req.body_reader.read_stream
269269+270270+(** Close the request body without reading (for ignored bodies) *)
271271+let close_body req = req.body_reader.close ()
272272+273273+type response = {
274274+ status : H1.Status.t;
275275+ headers : (string * string) list;
276276+ response_body : response_body;
277277+}
278278+(** Response type with optimized body variants *)
279279+280280+type handler = request -> response
281281+(** Handler type *)
282282+283283+let respond ?(status = `OK) ?(headers = []) body =
284284+ { status; headers; response_body = Body_string body }
285285+286286+let respond_bigstring ?(status = `OK) ?(headers = []) bstr =
287287+ { status; headers; response_body = Body_bigstring bstr }
288288+289289+let respond_stream ?(status = `OK) ?(headers = []) ?content_length next =
290290+ { status; headers; response_body = Body_stream { content_length; next } }
291291+292292+let respond_prebuilt h1_response body =
293293+ {
294294+ status = `OK;
295295+ headers = [];
296296+ response_body = Body_prebuilt { h1_response; body };
297297+ }
298298+299299+let respond_cached_prebuilt cached =
300300+ { status = `OK; headers = []; response_body = Body_cached_prebuilt cached }
301301+302302+type static_response = response
303303+304304+let make_static_response cached : static_response =
305305+ { status = `OK; headers = []; response_body = Body_cached_prebuilt cached }
306306+307307+let[@inline always] respond_static (r : static_response) : response = r
308308+let make_h1_headers headers_list = H1.Headers.of_list headers_list
309309+310310+let make_h1_response ?(status = `OK) headers =
311311+ H1.Response.create ~headers status
312312+313313+(** {1 Internal Helpers} *)
314314+315315+(** Write all IOVecs to the flow - optimized version *)
316316+let write_iovecs flow iovecs =
317317+ match iovecs with
318318+ | [] -> ()
319319+ | [ iov ] ->
320320+ (* Fast path for single iovec - common case *)
321321+ let cs =
322322+ Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off
323323+ ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer
324324+ in
325325+ Eio.Flow.write flow [ cs ]
326326+ | _ ->
327327+ (* Multiple iovecs - build list directly *)
328328+ let cstructs =
329329+ List.map
330330+ (fun iov ->
331331+ Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off
332332+ ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer)
333333+ iovecs
334334+ in
335335+ Eio.Flow.write flow cstructs
336336+337337+(** Check if method typically has no body *)
338338+let[@inline] method_has_no_body = function
339339+ | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE -> true
340340+ | `POST | `PUT | `PATCH | `Other _ -> false
341341+342342+(** Create a lazy body reader from H1's body reader *)
343343+let make_body_reader (h1_body : H1.Body.Reader.t) : body_reader =
344344+ let read_called = ref false in
345345+ let closed = ref false in
346346+ let chunks = ref [] in
347347+ let done_promise, done_resolver = Eio.Promise.create () in
348348+349349+ (* Start reading in background - will block until first chunk or EOF *)
350350+ let rec schedule_read () =
351351+ if not !closed then
352352+ H1.Body.Reader.schedule_read h1_body
353353+ ~on_eof:(fun () -> Eio.Promise.resolve done_resolver ())
354354+ ~on_read:(fun buf ~off ~len ->
355355+ (* Store chunk as Cstruct to avoid copying bigstring *)
356356+ let chunk = Cstruct.of_bigarray ~off ~len buf |> Cstruct.to_string in
357357+ chunks := chunk :: !chunks;
358358+ schedule_read ())
359359+ in
360360+361361+ {
362362+ read =
363363+ (fun () ->
364364+ if !read_called then failwith "Body already read"
365365+ else begin
366366+ read_called := true;
367367+ if !closed then ""
368368+ else begin
369369+ schedule_read ();
370370+ Eio.Promise.await done_promise;
371371+ String.concat "" (List.rev !chunks)
372372+ end
373373+ end);
374374+ read_stream =
375375+ (fun () ->
376376+ (* For streaming, we read one chunk at a time *)
377377+ if !closed then None
378378+ else begin
379379+ let chunk_promise, chunk_resolver = Eio.Promise.create () in
380380+ let got_chunk = ref false in
381381+ H1.Body.Reader.schedule_read h1_body
382382+ ~on_eof:(fun () ->
383383+ if not !got_chunk then Eio.Promise.resolve chunk_resolver None)
384384+ ~on_read:(fun buf ~off ~len ->
385385+ got_chunk := true;
386386+ let cs = Cstruct.of_bigarray ~off ~len buf in
387387+ Eio.Promise.resolve chunk_resolver (Some cs));
388388+ Eio.Promise.await chunk_promise
389389+ end);
390390+ close =
391391+ (fun () ->
392392+ if not !closed then begin
393393+ closed := true;
394394+ H1.Body.Reader.close h1_body
395395+ end);
396396+ }
397397+398398+(** Create a no-op body reader for methods without bodies *)
399399+let empty_body_reader () : body_reader =
400400+ {
401401+ read = (fun () -> "");
402402+ read_stream = (fun () -> None);
403403+ close = (fun () -> ());
404404+ }
405405+406406+let handle_connection handler flow =
407407+ let read_buffer, read_cstruct = Read_buffer_pool.acquire () in
408408+ Fun.protect ~finally:(fun () -> Read_buffer_pool.release read_buffer)
409409+ @@ fun () ->
410410+ let request_handler reqd =
411411+ let req = H1.Reqd.request reqd in
412412+ let h1_body = H1.Reqd.request_body reqd in
413413+414414+ (* Create lazy body reader *)
415415+ let body_reader =
416416+ if method_has_no_body req.H1.Request.meth then begin
417417+ H1.Body.Reader.close h1_body;
418418+ empty_body_reader ()
419419+ end
420420+ else make_body_reader h1_body
421421+ in
422422+423423+ (* Build request with lazy body *)
424424+ let request =
425425+ {
426426+ meth = req.H1.Request.meth;
427427+ target = req.target;
428428+ headers = req.headers;
429429+ body_reader;
430430+ }
431431+ in
432432+433433+ (* Call user handler *)
434434+ let response = handler request in
435435+436436+ (* Ensure body is closed if not read *)
437437+ body_reader.close ();
438438+439439+ let date_header = ("Date", Date_cache.get ()) in
440440+ let filter_reserved headers =
441441+ List.filter
442442+ (fun (k, _) ->
443443+ let lk = String.lowercase_ascii k in
444444+ lk <> "content-length" && lk <> "date")
445445+ headers
446446+ in
447447+448448+ match response.response_body with
449449+ | Body_string body ->
450450+ let content_length = String.length body in
451451+ let headers =
452452+ H1.Headers.of_list
453453+ (date_header
454454+ :: ("Content-Length", string_of_int content_length)
455455+ :: filter_reserved response.headers)
456456+ in
457457+ let resp = H1.Response.create ~headers response.status in
458458+ H1.Reqd.respond_with_string reqd resp body
459459+ | Body_bigstring bstr ->
460460+ let content_length = Bigstringaf.length bstr in
461461+ let headers =
462462+ H1.Headers.of_list
463463+ (date_header
464464+ :: ("Content-Length", string_of_int content_length)
465465+ :: filter_reserved response.headers)
466466+ in
467467+ let resp = H1.Response.create ~headers response.status in
468468+ H1.Reqd.respond_with_bigstring reqd resp bstr
469469+ | Body_prebuilt { h1_response; body } ->
470470+ let headers =
471471+ H1.Headers.add h1_response.headers "Date" (Date_cache.get ())
472472+ in
473473+ let resp = { h1_response with H1.Response.headers } in
474474+ H1.Reqd.respond_with_bigstring reqd resp body
475475+ | Body_cached_prebuilt cached ->
476476+ let resp = get_cached_response cached in
477477+ H1.Reqd.respond_with_bigstring reqd resp cached.body
478478+ | Body_stream { content_length; next } ->
479479+ let headers =
480480+ match content_length with
481481+ | Some len ->
482482+ H1.Headers.of_list
483483+ (date_header
484484+ :: ("Content-Length", Int64.to_string len)
485485+ :: filter_reserved response.headers)
486486+ | None ->
487487+ H1.Headers.of_list
488488+ (date_header
489489+ :: ("Transfer-Encoding", "chunked")
490490+ :: filter_reserved response.headers)
491491+ in
492492+ let resp = H1.Response.create ~headers response.status in
493493+ let body_writer = H1.Reqd.respond_with_streaming reqd resp in
494494+ (* Write chunks *)
495495+ let rec write_chunks () =
496496+ match next () with
497497+ | None -> H1.Body.Writer.close body_writer
498498+ | Some cs ->
499499+ H1.Body.Writer.write_bigstring body_writer ~off:0
500500+ ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs);
501501+ write_chunks ()
502502+ in
503503+ write_chunks ()
504504+ in
505505+506506+ let error_handler ?request:_ _error start_response =
507507+ let resp_body = start_response H1.Headers.empty in
508508+ H1.Body.Writer.write_string resp_body "Internal Server Error";
509509+ H1.Body.Writer.close resp_body
510510+ in
511511+512512+ let conn = H1.Server_connection.create ~error_handler request_handler in
513513+514514+ let shutdown = ref false in
515515+516516+ let rec read_loop () =
517517+ if not !shutdown then
518518+ match H1.Server_connection.next_read_operation conn with
519519+ | `Read -> (
520520+ try
521521+ let n = Eio.Flow.single_read flow read_cstruct in
522522+ let _ = H1.Server_connection.read conn read_buffer ~off:0 ~len:n in
523523+ read_loop ()
524524+ with End_of_file ->
525525+ let _ =
526526+ H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0
527527+ in
528528+ shutdown := true)
529529+ | `Yield -> H1.Server_connection.yield_reader conn read_loop
530530+ | `Close | `Upgrade -> shutdown := true
531531+ in
532532+533533+ let rec write_loop () =
534534+ if not !shutdown then
535535+ match H1.Server_connection.next_write_operation conn with
536536+ | `Write iovecs ->
537537+ write_iovecs flow iovecs;
538538+ let len =
539539+ List.fold_left
540540+ (fun acc iov -> acc + iov.Httpun_types.IOVec.len)
541541+ 0 iovecs
542542+ in
543543+ H1.Server_connection.report_write_result conn (`Ok len);
544544+ write_loop ()
545545+ | `Yield -> H1.Server_connection.yield_writer conn write_loop
546546+ | `Upgrade -> shutdown := true
547547+ | `Close _ -> shutdown := true
548548+ in
549549+550550+ Fiber.both read_loop write_loop
551551+552552+let run ~sw ~net ?(config = default_config) handler =
553553+ ensure_gc_tuned ();
554554+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
555555+ let socket =
556556+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
557557+ ~reuse_port:config.reuse_port net addr
558558+ in
559559+ traceln "Server listening on port %d" config.port;
560560+ let connection_handler flow _addr =
561561+ if config.tcp_nodelay then set_tcp_nodelay flow;
562562+ handle_connection handler flow
563563+ in
564564+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
565565+ Eio.Net.run_server socket connection_handler
566566+ ~max_connections:config.max_connections ~on_error
567567+568568+let run_parallel ~sw ~net ~domain_mgr ?(config = default_config) handler =
569569+ ensure_gc_tuned ();
570570+ let domain_count = max 1 config.domain_count in
571571+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
572572+ let socket =
573573+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
574574+ ~reuse_port:config.reuse_port net addr
575575+ in
576576+ traceln "Server listening on port %d (%d domains)" config.port domain_count;
577577+ let connection_handler flow _addr =
578578+ if config.tcp_nodelay then set_tcp_nodelay flow;
579579+ handle_connection handler flow
580580+ in
581581+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
582582+ if domain_count <= 1 then
583583+ Eio.Net.run_server socket connection_handler
584584+ ~max_connections:config.max_connections ~on_error
585585+ else
586586+ Eio.Net.run_server socket connection_handler
587587+ ~max_connections:config.max_connections ~on_error
588588+ ~additional_domains:(domain_mgr, domain_count - 1)
+316
lib/h2_client.ml
···11+(** HTTP/2 Client implementation using h2.
22+33+ This module provides HTTP/2 client functionality built on the h2 library
44+ with Eio for structured concurrency. *)
55+66+open Eio.Std
77+88+(** {1 Types} *)
99+1010+type error =
1111+ | Connection_failed of string
1212+ | Tls_error of string
1313+ | Protocol_error of string
1414+ | Timeout
1515+ | Invalid_response of string
1616+1717+type response = { status : H2.Status.t; headers : H2.Headers.t; body : string }
1818+1919+(** {1 Internal helpers} *)
2020+2121+(** Write IOVecs to the flow, returns bytes written or `Closed *)
2222+let writev flow iovecs =
2323+ let lenv, cstructs =
2424+ List.fold_left_map
2525+ (fun acc iov ->
2626+ let len = iov.H2.IOVec.len in
2727+ let cs =
2828+ Cstruct.of_bigarray ~off:iov.H2.IOVec.off ~len iov.H2.IOVec.buffer
2929+ in
3030+ (acc + len, cs))
3131+ 0 iovecs
3232+ in
3333+ match Eio.Flow.write flow cstructs with
3434+ | () -> `Ok lenv
3535+ | exception End_of_file -> `Closed
3636+3737+(** Shutdown flow in specified direction *)
3838+let shutdown flow cmd =
3939+ try Eio.Flow.shutdown flow cmd with
4040+ | Unix.Unix_error (Unix.ENOTCONN, _, _) -> ()
4141+ | Eio.Io (Eio.Net.E (Eio.Net.Connection_reset _), _) -> ()
4242+4343+(** Simple read buffer that tracks unconsumed data. This is necessary because
4444+ angstrom (used by h2) tracks uncommitted bytes and will fail if the buffer
4545+ appears to shrink. Based on Gluten.Buffer pattern. *)
4646+module Read_buffer : sig
4747+ type t
4848+4949+ val create : int -> t
5050+5151+ val read : t -> _ Eio.Flow.source -> int
5252+ (** Read data from flow into the buffer. Returns bytes read. *)
5353+5454+ val get : t -> f:(Bigstringaf.t -> off:int -> len:int -> int) -> int
5555+ (** Feed buffered data to consumer. Returns bytes consumed. *)
5656+end = struct
5757+ type t = {
5858+ buffer : Bigstringaf.t;
5959+ mutable off : int; (* Start of unconsumed data *)
6060+ mutable len : int; (* Length of unconsumed data *)
6161+ cap : int;
6262+ }
6363+6464+ let create size =
6565+ { buffer = Bigstringaf.create size; off = 0; len = 0; cap = size }
6666+6767+ let compress t =
6868+ if t.len = 0 then begin
6969+ t.off <- 0;
7070+ t.len <- 0
7171+ end
7272+ else if t.off > 0 then begin
7373+ Bigstringaf.blit t.buffer ~src_off:t.off t.buffer ~dst_off:0 ~len:t.len;
7474+ t.off <- 0
7575+ end
7676+7777+ let read t flow =
7878+ compress t;
7979+ let off = t.off + t.len in
8080+ let available = t.cap - t.len - t.off in
8181+ if available > 0 then begin
8282+ let cs = Cstruct.of_bigarray t.buffer ~off ~len:available in
8383+ let n = Eio.Flow.single_read flow cs in
8484+ t.len <- t.len + n;
8585+ n
8686+ end
8787+ else 0
8888+8989+ let get t ~f =
9090+ let n = f t.buffer ~off:t.off ~len:t.len in
9191+ t.off <- t.off + n;
9292+ t.len <- t.len - n;
9393+ if t.len = 0 then t.off <- 0;
9494+ n
9595+end
9696+9797+(** {1 Connection handling} *)
9898+9999+(** Perform an HTTP/2 request on a connected flow.
100100+101101+ HTTP/2 requires interleaved read/write handling because: 1. The server may
102102+ send SETTINGS frames immediately after connection 2. We need to send the
103103+ client preface and request while receiving 3. Flow control (WINDOW_UPDATE)
104104+ must be processed promptly
105105+106106+ Note: We use a single-threaded interleaved approach rather than Fiber.both
107107+ because TLS flows are not safe for concurrent read/write operations. *)
108108+let do_request flow req =
109109+ let response_received = Eio.Promise.create () in
110110+ let body_buffer = Buffer.create 4096 in
111111+ let resolved = ref false in
112112+113113+ let resolve_once result =
114114+ if not !resolved then begin
115115+ resolved := true;
116116+ Eio.Promise.resolve (snd response_received) result
117117+ end
118118+ in
119119+120120+ let response_handler resp body_reader =
121121+ let rec read_body () =
122122+ H2.Body.Reader.schedule_read body_reader
123123+ ~on_eof:(fun () ->
124124+ let body = Buffer.contents body_buffer in
125125+ resolve_once
126126+ (Ok
127127+ {
128128+ status = resp.H2.Response.status;
129129+ headers = resp.headers;
130130+ body;
131131+ }))
132132+ ~on_read:(fun buf ~off ~len ->
133133+ Buffer.add_string body_buffer (Bigstringaf.substring buf ~off ~len);
134134+ read_body ())
135135+ in
136136+ read_body ()
137137+ in
138138+139139+ let error_handler err =
140140+ let msg =
141141+ match err with
142142+ | `Malformed_response s -> "Malformed response: " ^ s
143143+ | `Invalid_response_body_length _ -> "Invalid response body length"
144144+ | `Protocol_error (code, msg) ->
145145+ Printf.sprintf "Protocol error %s: %s"
146146+ (H2.Error_code.to_string code)
147147+ msg
148148+ | `Exn exn -> Printexc.to_string exn
149149+ in
150150+ resolve_once (Error (Invalid_response msg))
151151+ in
152152+153153+ (* Create the HTTP/2 client connection *)
154154+ let conn = H2.Client_connection.create ~error_handler () in
155155+156156+ (* We'll queue the request after writing the connection preface *)
157157+ let request_sent = ref false in
158158+ let send_request () =
159159+ if not !request_sent then begin
160160+ request_sent := true;
161161+ (* Send the request and immediately close the body to signal END_STREAM.
162162+ For GET requests, the body writer should be closed immediately which
163163+ sets the END_STREAM flag on the HEADERS frame. *)
164164+ let body_writer =
165165+ H2.Client_connection.request conn req
166166+ ~flush_headers_immediately:true (* Send HEADERS right away *)
167167+ ~error_handler ~response_handler
168168+ in
169169+ (* Close immediately for requests with no body *)
170170+ H2.Body.Writer.close body_writer
171171+ end
172172+ in
173173+174174+ (* Buffer for reading - tracks unconsumed data properly *)
175175+ let read_buffer = Read_buffer.create 0x4000 in
176176+177177+ (* Flag to track if we've sent the request *)
178178+ let request_queued = ref false in
179179+180180+ (* Read loop - runs concurrently with write loop.
181181+ Uses the gluten pattern: read from network first, then feed to h2. *)
182182+ let read_loop () =
183183+ let rec read_loop_step () =
184184+ match H2.Client_connection.next_read_operation conn with
185185+ | `Read -> (
186186+ match Read_buffer.read read_buffer flow with
187187+ | _n ->
188188+ (* Feed buffered data to h2 - separate from network read *)
189189+ let _consumed =
190190+ Read_buffer.get read_buffer ~f:(fun buf ~off ~len ->
191191+ H2.Client_connection.read conn buf ~off ~len)
192192+ in
193193+ read_loop_step ()
194194+ | exception End_of_file ->
195195+ (* Feed any remaining buffered data as EOF *)
196196+ let _ =
197197+ Read_buffer.get read_buffer ~f:(fun buf ~off ~len ->
198198+ H2.Client_connection.read_eof conn buf ~off ~len)
199199+ in
200200+ ())
201201+ | `Yield ->
202202+ let p, u = Eio.Promise.create () in
203203+ H2.Client_connection.yield_reader conn (fun () ->
204204+ Eio.Promise.resolve u ());
205205+ Eio.Promise.await p;
206206+ read_loop_step ()
207207+ | `Close -> shutdown flow `Receive
208208+ in
209209+ try read_loop_step () with exn -> H2.Client_connection.report_exn conn exn
210210+ in
211211+212212+ (* Write loop - runs concurrently with read loop *)
213213+ let write_loop () =
214214+ let rec loop () =
215215+ match H2.Client_connection.next_write_operation conn with
216216+ | `Write iovecs -> (
217217+ match writev flow iovecs with
218218+ | `Ok len ->
219219+ H2.Client_connection.report_write_result conn (`Ok len);
220220+ (* After first write (connection preface), queue the request *)
221221+ if not !request_queued then begin
222222+ request_queued := true;
223223+ send_request ()
224224+ end;
225225+ loop ()
226226+ | `Closed -> H2.Client_connection.report_write_result conn `Closed)
227227+ | `Yield ->
228228+ let p, u = Eio.Promise.create () in
229229+ H2.Client_connection.yield_writer conn (fun () ->
230230+ Eio.Promise.resolve u ());
231231+ Eio.Promise.await p;
232232+ loop ()
233233+ | `Close _ -> shutdown flow `Send
234234+ in
235235+ try loop () with exn -> H2.Client_connection.report_exn conn exn
236236+ in
237237+238238+ (* Run read and write loops concurrently until response is received.
239239+ For a single request, we use Fiber.any to exit when response arrives. *)
240240+ let io_loops () =
241241+ Eio.Fiber.both read_loop write_loop;
242242+ (* If loops exit naturally (connection closed), return error *)
243243+ Error (Protocol_error "Connection closed before response")
244244+ in
245245+246246+ let wait_for_response () =
247247+ let result = Eio.Promise.await (fst response_received) in
248248+ (* After getting response, shutdown the connection to exit loops *)
249249+ H2.Client_connection.shutdown conn;
250250+ result
251251+ in
252252+253253+ Eio.Fiber.any [ io_loops; wait_for_response ]
254254+255255+(** {1 Public API} *)
256256+257257+(** Perform an HTTP/2 GET request. Note: HTTP/2 requires TLS with ALPN
258258+ negotiation in most cases. *)
259259+let get ~sw ~net ~clock:_ ?config:_ url =
260260+ let uri = Uri.of_string url in
261261+ let scheme = Uri.scheme uri |> Option.value ~default:"https" in
262262+ let is_https = String.equal scheme "https" in
263263+ let host = Uri.host uri |> Option.value ~default:"localhost" in
264264+ let default_port = if is_https then 443 else 80 in
265265+ let port = Uri.port uri |> Option.value ~default:default_port in
266266+ let path = Uri.path_and_query uri in
267267+ let path = if path = "" then "/" else path in
268268+269269+ (* Resolve and connect *)
270270+ let addrs = Eio.Net.getaddrinfo_stream net host in
271271+ match addrs with
272272+ | [] -> Error (Connection_failed ("Cannot resolve host: " ^ host))
273273+ | addr_info :: _ ->
274274+ let addr =
275275+ match addr_info with
276276+ | `Tcp (ip, _) -> `Tcp (ip, port)
277277+ | `Unix _ -> failwith "Unix sockets not supported"
278278+ in
279279+ let tcp_flow = Eio.Net.connect ~sw net addr in
280280+281281+ (* HTTP/2 requires TLS with ALPN advertising h2 *)
282282+ if is_https then
283283+ (* Force h2 ALPN for HTTP/2 *)
284284+ let h2_tls = Tls_config.Client.h2 in
285285+ match Tls_config.Client.to_tls_config h2_tls ~host with
286286+ | Error msg -> Error (Tls_error msg)
287287+ | Ok tls_config -> (
288288+ try
289289+ let host_domain =
290290+ match Domain_name.of_string host with
291291+ | Ok dn -> (
292292+ match Domain_name.host dn with
293293+ | Ok h -> Some h
294294+ | Error _ -> None)
295295+ | Error _ -> None
296296+ in
297297+ let tls_flow =
298298+ Tls_eio.client_of_flow tls_config ?host:host_domain tcp_flow
299299+ in
300300+ let flow = (tls_flow :> Eio.Flow.two_way_ty r) in
301301+302302+ (* Build HTTP/2 request - h2 handles pseudo-headers automatically *)
303303+ let headers = H2.Headers.of_list [ (":authority", host) ] in
304304+ let req = H2.Request.create ~headers ~scheme `GET path in
305305+306306+ do_request flow req
307307+ with
308308+ | Tls_eio.Tls_failure failure ->
309309+ Error (Tls_error (Tls_config.failure_to_string failure))
310310+ | exn -> Error (Tls_error (Printexc.to_string exn)))
311311+ else
312312+ (* HTTP/2 over cleartext (h2c) - less common *)
313313+ let flow = (tcp_flow :> Eio.Flow.two_way_ty r) in
314314+ let headers = H2.Headers.of_list [ (":authority", host) ] in
315315+ let req = H2.Request.create ~headers ~scheme `GET path in
316316+ do_request flow req
+280
lib/h2_server.ml
···11+(** HTTP/2 Server implementation using h2.
22+33+ This module provides HTTP/2 server functionality built on the h2 library
44+ with Eio for structured concurrency. *)
55+66+open Eio.Std
77+88+(** {1 Types} *)
99+1010+type request = {
1111+ meth : H2.Method.t;
1212+ target : string;
1313+ headers : H2.Headers.t;
1414+ body : string;
1515+}
1616+(** Request type exposed to handlers - same as Server.request for compatibility
1717+*)
1818+1919+(** {1 Response Types} *)
2020+2121+(** Response body - supports string, bigstring, streaming, and pre-built *)
2222+type response_body =
2323+ | Body_string of string
2424+ | Body_bigstring of Bigstringaf.t
2525+ | Body_prebuilt of { h2_response : H2.Response.t; body : Bigstringaf.t }
2626+ | Body_stream of {
2727+ content_length : int64 option;
2828+ next : unit -> Cstruct.t option;
2929+ }
3030+3131+type response = {
3232+ status : H2.Status.t;
3333+ headers : (string * string) list;
3434+ response_body : response_body;
3535+}
3636+(** Response type with body variants *)
3737+3838+type handler = request -> response
3939+4040+(** {2 Optimized Response Constructors} *)
4141+4242+let respond_opt ?(status = `OK) ?(headers = []) body =
4343+ { status; headers; response_body = Body_string body }
4444+4545+let respond_bigstring ?(status = `OK) ?(headers = []) bstr =
4646+ { status; headers; response_body = Body_bigstring bstr }
4747+4848+let respond_stream ?(status = `OK) ?(headers = []) ?content_length next =
4949+ { status; headers; response_body = Body_stream { content_length; next } }
5050+5151+let respond_prebuilt h2_response body =
5252+ {
5353+ status = `OK;
5454+ headers = [];
5555+ response_body = Body_prebuilt { h2_response; body };
5656+ }
5757+5858+let make_h2_headers headers_list = H2.Headers.of_list headers_list
5959+6060+let make_h2_response ?(status = `OK) headers =
6161+ H2.Response.create ~headers status
6262+6363+(** {1 Internal helpers} *)
6464+6565+let set_tcp_nodelay flow =
6666+ match Eio_unix.Resource.fd_opt flow with
6767+ | None -> ()
6868+ | Some fd ->
6969+ Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd ->
7070+ Unix.setsockopt unix_fd Unix.TCP_NODELAY true)
7171+7272+let write_iovecs flow iovecs =
7373+ let cstructs =
7474+ List.map
7575+ (fun iov ->
7676+ Cstruct.of_bigarray ~off:iov.H2.IOVec.off ~len:iov.H2.IOVec.len
7777+ iov.H2.IOVec.buffer)
7878+ iovecs
7979+ in
8080+ Eio.Flow.write flow cstructs
8181+8282+(** {1 Connection handling} *)
8383+8484+let handle_connection handler flow =
8585+ let read_buffer_size = 0x4000 in
8686+ let read_buffer = Bigstringaf.create read_buffer_size in
8787+8888+ let request_handler reqd =
8989+ let req = H2.Reqd.request reqd in
9090+ let body_reader = H2.Reqd.request_body reqd in
9191+9292+ let body =
9393+ match req.meth with
9494+ | `GET | `HEAD ->
9595+ H2.Body.Reader.close body_reader;
9696+ ""
9797+ | `POST | `PUT | `DELETE | `CONNECT | `OPTIONS | `TRACE | `Other _ ->
9898+ let body_buffer = Buffer.create 4096 in
9999+ let body_done_promise, body_done_resolver = Eio.Promise.create () in
100100+ let rec read_body () =
101101+ H2.Body.Reader.schedule_read body_reader
102102+ ~on_eof:(fun () -> Eio.Promise.resolve body_done_resolver ())
103103+ ~on_read:(fun buf ~off ~len ->
104104+ Buffer.add_string body_buffer
105105+ (Bigstringaf.substring buf ~off ~len);
106106+ read_body ())
107107+ in
108108+ read_body ();
109109+ Eio.Promise.await body_done_promise;
110110+ Buffer.contents body_buffer
111111+ in
112112+113113+ let target =
114114+ match H2.Headers.get req.headers ":path" with Some p -> p | None -> "/"
115115+ in
116116+117117+ let request = { meth = req.meth; target; headers = req.headers; body } in
118118+ let response = handler request in
119119+120120+ match response.response_body with
121121+ | Body_string body ->
122122+ let headers =
123123+ H2.Headers.of_list
124124+ (("content-length", string_of_int (String.length body))
125125+ :: response.headers)
126126+ in
127127+ let resp = H2.Response.create ~headers response.status in
128128+ H2.Reqd.respond_with_string reqd resp body
129129+ | Body_bigstring bstr ->
130130+ let headers =
131131+ H2.Headers.of_list
132132+ (("content-length", string_of_int (Bigstringaf.length bstr))
133133+ :: response.headers)
134134+ in
135135+ let resp = H2.Response.create ~headers response.status in
136136+ H2.Reqd.respond_with_bigstring reqd resp bstr
137137+ | Body_prebuilt { h2_response; body } ->
138138+ H2.Reqd.respond_with_bigstring reqd h2_response body
139139+ | Body_stream { content_length; next } ->
140140+ let headers =
141141+ match content_length with
142142+ | Some len ->
143143+ H2.Headers.of_list
144144+ (("content-length", Int64.to_string len) :: response.headers)
145145+ | None -> H2.Headers.of_list response.headers
146146+ in
147147+ let resp = H2.Response.create ~headers response.status in
148148+ let body_writer = H2.Reqd.respond_with_streaming reqd resp in
149149+ let rec write_chunks () =
150150+ match next () with
151151+ | None -> H2.Body.Writer.close body_writer
152152+ | Some cs ->
153153+ H2.Body.Writer.write_bigstring body_writer ~off:0
154154+ ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs);
155155+ write_chunks ()
156156+ in
157157+ write_chunks ()
158158+ in
159159+160160+ let error_handler ?request:_ _error start_response =
161161+ let resp_body = start_response H2.Headers.empty in
162162+ H2.Body.Writer.write_string resp_body "Internal Server Error";
163163+ H2.Body.Writer.close resp_body
164164+ in
165165+166166+ let conn = H2.Server_connection.create ~error_handler request_handler in
167167+168168+ let shutdown = ref false in
169169+170170+ let read_loop () =
171171+ let rec loop () =
172172+ if not !shutdown then
173173+ match H2.Server_connection.next_read_operation conn with
174174+ | `Read -> (
175175+ let cs =
176176+ Cstruct.of_bigarray read_buffer ~off:0 ~len:read_buffer_size
177177+ in
178178+ try
179179+ let n = Eio.Flow.single_read flow cs in
180180+ let _ =
181181+ H2.Server_connection.read conn read_buffer ~off:0 ~len:n
182182+ in
183183+ loop ()
184184+ with End_of_file ->
185185+ let _ =
186186+ H2.Server_connection.read_eof conn read_buffer ~off:0 ~len:0
187187+ in
188188+ shutdown := true)
189189+ | `Close -> shutdown := true
190190+ in
191191+ loop ()
192192+ in
193193+194194+ let write_loop () =
195195+ let rec loop () =
196196+ if not !shutdown then
197197+ match H2.Server_connection.next_write_operation conn with
198198+ | `Write iovecs ->
199199+ write_iovecs flow iovecs;
200200+ let len =
201201+ List.fold_left (fun acc iov -> acc + iov.H2.IOVec.len) 0 iovecs
202202+ in
203203+ H2.Server_connection.report_write_result conn (`Ok len);
204204+ loop ()
205205+ | `Yield ->
206206+ let continue = Eio.Promise.create () in
207207+ H2.Server_connection.yield_writer conn (fun () ->
208208+ Eio.Promise.resolve (snd continue) ());
209209+ Eio.Promise.await (fst continue);
210210+ loop ()
211211+ | `Close _ -> shutdown := true
212212+ in
213213+ loop ()
214214+ in
215215+216216+ Fiber.both read_loop write_loop
217217+218218+(** {1 Public API} *)
219219+220220+let run ~sw ~net ?(config = H1_server.default_config) handler =
221221+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
222222+ let socket =
223223+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
224224+ ~reuse_port:config.reuse_port net addr
225225+ in
226226+ traceln "HTTP/2 Server listening on port %d" config.port;
227227+ let connection_handler flow _addr =
228228+ if config.tcp_nodelay then set_tcp_nodelay flow;
229229+ handle_connection handler flow
230230+ in
231231+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
232232+ Eio.Net.run_server socket connection_handler
233233+ ~max_connections:config.max_connections ~on_error
234234+235235+let run_tls ~sw ~net ?(config = H1_server.default_config) ~tls_config handler =
236236+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
237237+ let socket =
238238+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
239239+ ~reuse_port:config.reuse_port net addr
240240+ in
241241+ traceln "HTTP/2 Server (TLS) listening on port %d" config.port;
242242+ let connection_handler flow _addr =
243243+ if config.tcp_nodelay then set_tcp_nodelay flow;
244244+ match Tls_config.Server.to_tls_config tls_config with
245245+ | Error (`Msg msg) -> traceln "TLS config error: %s" msg
246246+ | Ok tls_cfg -> (
247247+ try
248248+ let tls_flow = Tls_eio.server_of_flow tls_cfg flow in
249249+ handle_connection handler tls_flow
250250+ with
251251+ | Tls_eio.Tls_failure failure ->
252252+ traceln "TLS error: %s" (Tls_config.failure_to_string failure)
253253+ | exn -> traceln "TLS error: %s" (Printexc.to_string exn))
254254+ in
255255+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
256256+ Eio.Net.run_server socket connection_handler
257257+ ~max_connections:config.max_connections ~on_error
258258+259259+let run_parallel ~sw ~net ~domain_mgr ?(config = H1_server.default_config)
260260+ handler =
261261+ let domain_count = max 1 config.domain_count in
262262+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
263263+ let socket =
264264+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
265265+ ~reuse_port:config.reuse_port net addr
266266+ in
267267+ traceln "HTTP/2 Server listening on port %d (%d domains)" config.port
268268+ domain_count;
269269+ let connection_handler flow _addr =
270270+ if config.tcp_nodelay then set_tcp_nodelay flow;
271271+ handle_connection handler flow
272272+ in
273273+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
274274+ if domain_count <= 1 then
275275+ Eio.Net.run_server socket connection_handler
276276+ ~max_connections:config.max_connections ~on_error
277277+ else
278278+ Eio.Net.run_server socket connection_handler
279279+ ~max_connections:config.max_connections ~on_error
280280+ ~additional_domains:(domain_mgr, domain_count - 1)
+73
lib/hcs.ml
···11+(** HCS - High-performance HTTP library for OCaml 5+.
22+33+ Built on Eio for structured concurrency, using h1 for HTTP/1.1 and h2 for
44+ HTTP/2 protocol handling.
55+66+ The library is designed with runtime abstraction in mind, making future Lwt
77+ support possible without major rewrites. *)
88+99+module Client = Client
1010+(** HTTP Client *)
1111+1212+module Server = Server
1313+(** HTTP Server *)
1414+1515+module Router = Router
1616+(** Router with radix trie *)
1717+1818+module Middleware = Middleware
1919+(** Middleware combinators *)
2020+2121+module Middleware_eio = Middleware_eio
2222+(** Eio-specific middleware (logging, timeout, rate limiting, static files) *)
2323+2424+module Control = Control
2525+(** Control flow: retry, circuit breaker, rate limiting *)
2626+2727+module Pool = Pool
2828+(** Connection pool *)
2929+3030+module Pooled_client = Pooled_client
3131+(** HTTP Client with connection pooling *)
3232+3333+module Tls_config = Tls_config
3434+(** TLS configuration *)
3535+3636+module Request = Request
3737+(** Request helpers *)
3838+3939+module Response = Response
4040+(** Response helpers *)
4141+4242+module H1_client = H1_client
4343+(** HTTP/1.1 Client *)
4444+4545+module H2_client = H2_client
4646+(** HTTP/2 Client *)
4747+4848+module H1_server = H1_server
4949+(** HTTP/1.1 Server (low-level, optimized) *)
5050+5151+module H2_server = H2_server
5252+(** HTTP/2 Server *)
5353+5454+module Websocket = Websocket
5555+(** WebSocket support *)
5656+5757+module Codec = Codec
5858+(** Codec system for serialization/deserialization *)
5959+6060+module Log = Log
6161+(** Logging system *)
6262+6363+module Stream = Stream
6464+(** Streaming abstractions *)
6565+6666+module Http = Http
6767+(** HTTP Request builder DSL *)
6868+6969+module Method = H1.Method
7070+(** Re-exports for convenience *)
7171+7272+module Status = H1.Status
7373+module Headers = H1.Headers
+261
lib/http.ml
···11+(** HTTP Request Builder DSL.
22+33+ This module provides a fluent API for building HTTP requests. It's designed
44+ to be used with the Client module for a high-level HTTP client experience.
55+66+ {1 Usage}
77+88+ {[
99+ open Hcs.Http
1010+1111+ (* Simple GET request *)
1212+ let req = get "https://api.example.com/users" |> build
1313+1414+ (* POST with JSON body *)
1515+ let req =
1616+ post "https://api.example.com/users"
1717+ |> content_type "application/json"
1818+ |> body_string {|{"name": "Alice"}|}
1919+ |> build
2020+2121+ (* GET with query params and auth *)
2222+ let req =
2323+ get "https://api.example.com/search"
2424+ |> query "q" "ocaml" |> query "limit" "10" |> bearer "my-token" |> build
2525+ ]} *)
2626+2727+(** {1 Types} *)
2828+2929+(** HTTP method *)
3030+type meth =
3131+ | GET
3232+ | POST
3333+ | PUT
3434+ | DELETE
3535+ | PATCH
3636+ | HEAD
3737+ | OPTIONS
3838+ | CONNECT
3939+ | TRACE
4040+ | Other of string
4141+4242+(** Body content *)
4343+type body = Empty | String of string | Form of (string * string) list
4444+4545+type builder = {
4646+ meth : meth;
4747+ uri : Uri.t;
4848+ headers : (string * string) list;
4949+ body : body;
5050+}
5151+(** Request builder - accumulates request parameters *)
5252+5353+type request = {
5454+ req_meth : meth;
5555+ req_uri : Uri.t;
5656+ req_headers : (string * string) list;
5757+ req_body : body;
5858+}
5959+(** Built request ready for execution *)
6060+6161+(** {1 Method to H1.Method conversion} *)
6262+6363+let meth_to_h1 = function
6464+ | GET -> `GET
6565+ | POST -> `POST
6666+ | PUT -> `PUT
6767+ | DELETE -> `DELETE
6868+ | PATCH -> `Other "PATCH"
6969+ | HEAD -> `HEAD
7070+ | OPTIONS -> `OPTIONS
7171+ | CONNECT -> `CONNECT
7272+ | TRACE -> `TRACE
7373+ | Other s -> `Other s
7474+7575+let meth_of_string = function
7676+ | "GET" -> GET
7777+ | "POST" -> POST
7878+ | "PUT" -> PUT
7979+ | "DELETE" -> DELETE
8080+ | "PATCH" -> PATCH
8181+ | "HEAD" -> HEAD
8282+ | "OPTIONS" -> OPTIONS
8383+ | "CONNECT" -> CONNECT
8484+ | "TRACE" -> TRACE
8585+ | s -> Other s
8686+8787+let meth_to_string = function
8888+ | GET -> "GET"
8989+ | POST -> "POST"
9090+ | PUT -> "PUT"
9191+ | DELETE -> "DELETE"
9292+ | PATCH -> "PATCH"
9393+ | HEAD -> "HEAD"
9494+ | OPTIONS -> "OPTIONS"
9595+ | CONNECT -> "CONNECT"
9696+ | TRACE -> "TRACE"
9797+ | Other s -> s
9898+9999+(** {1 Request Builders} *)
100100+101101+(** Create a builder with the given method and URL *)
102102+let create meth url =
103103+ let uri = Uri.of_string url in
104104+ { meth; uri; headers = []; body = Empty }
105105+106106+(** Create a GET request builder *)
107107+let get url = create GET url
108108+109109+(** Create a POST request builder *)
110110+let post url = create POST url
111111+112112+(** Create a PUT request builder *)
113113+let put url = create PUT url
114114+115115+(** Create a DELETE request builder *)
116116+let delete url = create DELETE url
117117+118118+(** Create a PATCH request builder *)
119119+let patch url = create PATCH url
120120+121121+(** Create a HEAD request builder *)
122122+let head url = create HEAD url
123123+124124+(** Create an OPTIONS request builder *)
125125+let options url = create OPTIONS url
126126+127127+(** Create a request builder from a Uri *)
128128+let of_uri meth uri = { meth; uri; headers = []; body = Empty }
129129+130130+(** {2 Headers} *)
131131+132132+(** Add a header to the request *)
133133+let header name value builder =
134134+ { builder with headers = (name, value) :: builder.headers }
135135+136136+(** Add multiple headers to the request *)
137137+let headers hdrs builder =
138138+ { builder with headers = List.rev_append hdrs builder.headers }
139139+140140+(** Set the Content-Type header *)
141141+let content_type ct builder = header "Content-Type" ct builder
142142+143143+(** Set the Accept header *)
144144+let accept ct builder = header "Accept" ct builder
145145+146146+(** Set the User-Agent header *)
147147+let user_agent ua builder = header "User-Agent" ua builder
148148+149149+(** Set Bearer authentication *)
150150+let bearer token builder = header "Authorization" ("Bearer " ^ token) builder
151151+152152+(** Set Basic authentication *)
153153+let basic_auth ~user ~pass builder =
154154+ let credentials = Base64.encode_string (user ^ ":" ^ pass) in
155155+ header "Authorization" ("Basic " ^ credentials) builder
156156+157157+(** Set a cookie header *)
158158+let cookie name value builder =
159159+ let existing =
160160+ List.find_opt (fun (n, _) -> String.equal n "Cookie") builder.headers
161161+ in
162162+ let new_cookie =
163163+ match existing with
164164+ | Some (_, v) -> v ^ "; " ^ name ^ "=" ^ value
165165+ | None -> name ^ "=" ^ value
166166+ in
167167+ let headers =
168168+ List.filter (fun (n, _) -> not (String.equal n "Cookie")) builder.headers
169169+ in
170170+ { builder with headers = ("Cookie", new_cookie) :: headers }
171171+172172+(** Set cookies from a list *)
173173+let cookies cs builder =
174174+ List.fold_left (fun b (n, v) -> cookie n v b) builder cs
175175+176176+(** {2 Query Parameters} *)
177177+178178+(** Add a query parameter *)
179179+let query name value builder =
180180+ let uri = Uri.add_query_param' builder.uri (name, value) in
181181+ { builder with uri }
182182+183183+(** Add multiple query parameters *)
184184+let queries qs builder =
185185+ let uri =
186186+ List.fold_left
187187+ (fun u (n, v) -> Uri.add_query_param' u (n, v))
188188+ builder.uri qs
189189+ in
190190+ { builder with uri }
191191+192192+(** {2 Body} *)
193193+194194+(** Set the request body *)
195195+let body b builder = { builder with body = b }
196196+197197+(** Set the body as a string with optional content type *)
198198+let body_string ?content_type:ct str builder =
199199+ let builder = { builder with body = String str } in
200200+ match ct with Some ct -> content_type ct builder | None -> builder
201201+202202+(** Set the body as a JSON string *)
203203+let body_json json builder =
204204+ builder |> body_string ~content_type:"application/json" json
205205+206206+(** Set the body as form data *)
207207+let form fields builder =
208208+ { builder with body = Form fields }
209209+ |> content_type "application/x-www-form-urlencoded"
210210+211211+(** {1 Building} *)
212212+213213+(** Build the final request *)
214214+let build builder =
215215+ {
216216+ req_meth = builder.meth;
217217+ req_uri = builder.uri;
218218+ req_headers = List.rev builder.headers;
219219+ req_body = builder.body;
220220+ }
221221+222222+(** Get the URL as a string *)
223223+let url request = Uri.to_string request.req_uri
224224+225225+(** Get the host from the request *)
226226+let host request = Uri.host request.req_uri |> Option.value ~default:"localhost"
227227+228228+(** Get the port from the request *)
229229+let port request =
230230+ match Uri.port request.req_uri with
231231+ | Some p -> p
232232+ | None -> (
233233+ match Uri.scheme request.req_uri with Some "https" -> 443 | _ -> 80)
234234+235235+(** Get the path from the request *)
236236+let path request =
237237+ let p = Uri.path request.req_uri in
238238+ if p = "" then "/" else p
239239+240240+(** Get the path and query from the request *)
241241+let path_and_query request = Uri.path_and_query request.req_uri
242242+243243+(** Check if the request is HTTPS *)
244244+let is_https request =
245245+ match Uri.scheme request.req_uri with Some "https" -> true | _ -> false
246246+247247+(** {1 Body Encoding} *)
248248+249249+(** Encode form data as URL-encoded string *)
250250+let encode_form fields =
251251+ String.concat "&"
252252+ (List.map (fun (k, v) -> Uri.pct_encode k ^ "=" ^ Uri.pct_encode v) fields)
253253+254254+(** Get the body as a string *)
255255+let body_to_string = function
256256+ | Empty -> ""
257257+ | String s -> s
258258+ | Form fields -> encode_form fields
259259+260260+(** Get the Content-Length for the body *)
261261+let body_length body = String.length (body_to_string body)
+311
lib/log.ml
···11+(** Logging module for HCS HTTP library.
22+33+ Provides structured logging for HTTP events including requests, responses,
44+ connections, and errors. The module is runtime-agnostic and uses a
55+ callback-based approach for flexibility.
66+77+ {1 Usage}
88+99+ {[
1010+ (* Use built-in stderr logger *)
1111+ let logger = Hcs.Log.stderr ()
1212+1313+ (* Use null logger (no output) *)
1414+ let logger = Hcs.Log.null
1515+1616+ (* Use custom logger *)
1717+ let logger =
1818+ Hcs.Log.custom (fun level msg ->
1919+ match level with
2020+ | Hcs.Log.Error -> Printf.eprintf "[ERROR] %s\n%!" msg
2121+ | _ ->
2222+ Printf.printf "[%s] %s\n%!" (Hcs.Log.level_to_string level) msg)
2323+ ]} *)
2424+2525+(** {1 Types} *)
2626+2727+(** Log levels *)
2828+type level =
2929+ | Debug (** Detailed debugging information *)
3030+ | Info (** General information about operations *)
3131+ | Warn (** Warning conditions *)
3232+ | Error (** Error conditions *)
3333+3434+(** HTTP method for logging (simplified) *)
3535+type http_method =
3636+ | GET
3737+ | POST
3838+ | PUT
3939+ | DELETE
4040+ | PATCH
4141+ | HEAD
4242+ | OPTIONS
4343+ | CONNECT
4444+ | TRACE
4545+ | Other of string
4646+4747+(** Log events - structured events that can be logged *)
4848+type event =
4949+ | Request_start of {
5050+ id : string;
5151+ meth : http_method;
5252+ uri : string;
5353+ headers : (string * string) list;
5454+ } (** Request started *)
5555+ | Request_end of {
5656+ id : string;
5757+ status : int;
5858+ duration_ms : float;
5959+ body_size : int option;
6060+ } (** Request completed *)
6161+ | Connection_open of {
6262+ host : string;
6363+ port : int;
6464+ protocol : string; (** "http/1.1" or "h2" *)
6565+ } (** Connection opened *)
6666+ | Connection_close of { host : string; port : int; reason : string }
6767+ (** Connection closed *)
6868+ | Connection_reuse of { host : string; port : int }
6969+ (** Connection reused from pool *)
7070+ | Tls_handshake of {
7171+ host : string;
7272+ protocol : string; (** TLS version *)
7373+ cipher : string option;
7474+ } (** TLS handshake completed *)
7575+ | Retry of {
7676+ id : string;
7777+ attempt : int;
7878+ reason : string;
7979+ delay_ms : float option;
8080+ } (** Request retry *)
8181+ | Redirect of {
8282+ id : string;
8383+ from_uri : string;
8484+ to_uri : string;
8585+ status : int;
8686+ } (** Following redirect *)
8787+ | Error of { id : string option; error : string; context : string option }
8888+ (** Error occurred *)
8989+ | Custom of { name : string; data : (string * string) list }
9090+ (** Custom event *)
9191+9292+type logger = level -> event -> unit
9393+(** Logger function type *)
9494+9595+(** {1 Level operations} *)
9696+9797+(** Convert level to string *)
9898+let level_to_string = function
9999+ | Debug -> "DEBUG"
100100+ | Info -> "INFO"
101101+ | Warn -> "WARN"
102102+ | Error -> "ERROR"
103103+104104+(** Parse level from string *)
105105+let level_of_string = function
106106+ | "DEBUG" | "debug" -> Some Debug
107107+ | "INFO" | "info" -> Some Info
108108+ | "WARN" | "warn" | "WARNING" | "warning" -> Some Warn
109109+ | "ERROR" | "error" -> Some Error
110110+ | _ -> None
111111+112112+(** Compare log levels (for filtering) *)
113113+let level_to_int = function Debug -> 0 | Info -> 1 | Warn -> 2 | Error -> 3
114114+115115+let level_gte l1 l2 = level_to_int l1 >= level_to_int l2
116116+117117+(** {1 HTTP method operations} *)
118118+119119+let method_to_string = function
120120+ | GET -> "GET"
121121+ | POST -> "POST"
122122+ | PUT -> "PUT"
123123+ | DELETE -> "DELETE"
124124+ | PATCH -> "PATCH"
125125+ | HEAD -> "HEAD"
126126+ | OPTIONS -> "OPTIONS"
127127+ | CONNECT -> "CONNECT"
128128+ | TRACE -> "TRACE"
129129+ | Other s -> s
130130+131131+let method_of_h1 (m : Httpun_types.Method.t) : http_method =
132132+ match m with
133133+ | `GET -> GET
134134+ | `POST -> POST
135135+ | `PUT -> PUT
136136+ | `DELETE -> DELETE
137137+ | `HEAD -> HEAD
138138+ | `OPTIONS -> OPTIONS
139139+ | `CONNECT -> CONNECT
140140+ | `TRACE -> TRACE
141141+ | `Other s -> Other s
142142+143143+(** {1 Event formatting} *)
144144+145145+(** Format event as a human-readable string *)
146146+let event_to_string = function
147147+ | Request_start { id; meth; uri; headers = _ } ->
148148+ Printf.sprintf "Request[%s] %s %s" id (method_to_string meth) uri
149149+ | Request_end { id; status; duration_ms; body_size } ->
150150+ let size_str =
151151+ match body_size with
152152+ | Some s -> Printf.sprintf ", %d bytes" s
153153+ | None -> ""
154154+ in
155155+ Printf.sprintf "Request[%s] completed: status=%d, duration=%.2fms%s" id
156156+ status duration_ms size_str
157157+ | Connection_open { host; port; protocol } ->
158158+ Printf.sprintf "Connection opened: %s:%d (%s)" host port protocol
159159+ | Connection_close { host; port; reason } ->
160160+ Printf.sprintf "Connection closed: %s:%d (%s)" host port reason
161161+ | Connection_reuse { host; port } ->
162162+ Printf.sprintf "Connection reused: %s:%d" host port
163163+ | Tls_handshake { host; protocol; cipher } ->
164164+ let cipher_str =
165165+ match cipher with Some c -> ", cipher=" ^ c | None -> ""
166166+ in
167167+ Printf.sprintf "TLS handshake: %s (%s%s)" host protocol cipher_str
168168+ | Retry { id; attempt; reason; delay_ms } ->
169169+ let delay_str =
170170+ match delay_ms with
171171+ | Some d -> Printf.sprintf ", delay=%.0fms" d
172172+ | None -> ""
173173+ in
174174+ Printf.sprintf "Request[%s] retry #%d: %s%s" id attempt reason delay_str
175175+ | Redirect { id; from_uri; to_uri; status } ->
176176+ Printf.sprintf "Request[%s] redirect %d: %s -> %s" id status from_uri
177177+ to_uri
178178+ | Error { id; error; context } ->
179179+ let id_str =
180180+ match id with Some i -> Printf.sprintf "[%s] " i | None -> ""
181181+ in
182182+ let ctx_str =
183183+ match context with Some c -> " (" ^ c ^ ")" | None -> ""
184184+ in
185185+ Printf.sprintf "Error%s: %s%s" id_str error ctx_str
186186+ | Custom { name; data } ->
187187+ let data_str =
188188+ String.concat ", "
189189+ (List.map (fun (k, v) -> Printf.sprintf "%s=%s" k v) data)
190190+ in
191191+ Printf.sprintf "Custom[%s]: %s" name data_str
192192+193193+(** Format event as JSON string *)
194194+let event_to_json = function
195195+ | Request_start { id; meth; uri; headers } ->
196196+ let headers_json =
197197+ String.concat ","
198198+ (List.map (fun (k, v) -> Printf.sprintf {|"%s":"%s"|} k v) headers)
199199+ in
200200+ Printf.sprintf
201201+ {|{"event":"request_start","id":"%s","method":"%s","uri":"%s","headers":{%s}}|}
202202+ id (method_to_string meth) uri headers_json
203203+ | Request_end { id; status; duration_ms; body_size } ->
204204+ let size_json =
205205+ match body_size with
206206+ | Some s -> Printf.sprintf {|,"body_size":%d|} s
207207+ | None -> ""
208208+ in
209209+ Printf.sprintf
210210+ {|{"event":"request_end","id":"%s","status":%d,"duration_ms":%.2f%s}|}
211211+ id status duration_ms size_json
212212+ | Connection_open { host; port; protocol } ->
213213+ Printf.sprintf
214214+ {|{"event":"connection_open","host":"%s","port":%d,"protocol":"%s"}|}
215215+ host port protocol
216216+ | Connection_close { host; port; reason } ->
217217+ Printf.sprintf
218218+ {|{"event":"connection_close","host":"%s","port":%d,"reason":"%s"}|}
219219+ host port reason
220220+ | Connection_reuse { host; port } ->
221221+ Printf.sprintf {|{"event":"connection_reuse","host":"%s","port":%d}|} host
222222+ port
223223+ | Tls_handshake { host; protocol; cipher } ->
224224+ let cipher_json =
225225+ match cipher with
226226+ | Some c -> Printf.sprintf {|,"cipher":"%s"|} c
227227+ | None -> ""
228228+ in
229229+ Printf.sprintf {|{"event":"tls_handshake","host":"%s","protocol":"%s"%s}|}
230230+ host protocol cipher_json
231231+ | Retry { id; attempt; reason; delay_ms } ->
232232+ let delay_json =
233233+ match delay_ms with
234234+ | Some d -> Printf.sprintf {|,"delay_ms":%.0f|} d
235235+ | None -> ""
236236+ in
237237+ Printf.sprintf
238238+ {|{"event":"retry","id":"%s","attempt":%d,"reason":"%s"%s}|} id attempt
239239+ reason delay_json
240240+ | Redirect { id; from_uri; to_uri; status } ->
241241+ Printf.sprintf
242242+ {|{"event":"redirect","id":"%s","from":"%s","to":"%s","status":%d}|} id
243243+ from_uri to_uri status
244244+ | Error { id; error; context } ->
245245+ let id_json =
246246+ match id with Some i -> Printf.sprintf {|"id":"%s",|} i | None -> ""
247247+ in
248248+ let ctx_json =
249249+ match context with
250250+ | Some c -> Printf.sprintf {|,"context":"%s"|} c
251251+ | None -> ""
252252+ in
253253+ Printf.sprintf {|{"event":"error",%s"error":"%s"%s}|} id_json error
254254+ ctx_json
255255+ | Custom { name; data } ->
256256+ let data_json =
257257+ String.concat ","
258258+ (List.map (fun (k, v) -> Printf.sprintf {|"%s":"%s"|} k v) data)
259259+ in
260260+ Printf.sprintf {|{"event":"custom","name":"%s","data":{%s}}|} name
261261+ data_json
262262+263263+(** {1 Built-in Loggers} *)
264264+265265+(** Null logger - discards all events *)
266266+let null : logger = fun _ _ -> ()
267267+268268+(** Stderr logger with optional minimum level filter *)
269269+let stderr ?(min_level = Debug) ?(json = false) () : logger =
270270+ fun level event ->
271271+ if level_gte level min_level then
272272+ let formatted =
273273+ if json then event_to_json event else event_to_string event
274274+ in
275275+ Printf.eprintf "[%s] %s\n%!" (level_to_string level) formatted
276276+277277+(** Stdout logger with optional minimum level filter *)
278278+let stdout ?(min_level = Debug) ?(json = false) () : logger =
279279+ fun level event ->
280280+ if level_gte level min_level then
281281+ let formatted =
282282+ if json then event_to_json event else event_to_string event
283283+ in
284284+ Printf.printf "[%s] %s\n%!" (level_to_string level) formatted
285285+286286+(** Custom logger from a simple callback *)
287287+let custom (f : level -> string -> unit) : logger =
288288+ fun level event -> f level (event_to_string event)
289289+290290+(** Custom logger with JSON output *)
291291+let custom_json (f : level -> string -> unit) : logger =
292292+ fun level event -> f level (event_to_json event)
293293+294294+(** Combine multiple loggers *)
295295+let combine (loggers : logger list) : logger =
296296+ fun level event -> List.iter (fun logger -> logger level event) loggers
297297+298298+(** Filter logger by minimum level *)
299299+let with_min_level (min_level : level) (logger : logger) : logger =
300300+ fun level event -> if level_gte level min_level then logger level event
301301+302302+(** {1 Request ID generation} *)
303303+304304+(** Counter for unique request IDs *)
305305+let request_id_counter = ref 0
306306+307307+(** Generate a unique request ID *)
308308+let generate_request_id () =
309309+ incr request_id_counter;
310310+ let random = Random.int 0xFFFF in
311311+ Printf.sprintf "req-%06d-%04x" !request_id_counter random
+53
lib/middleware.ml
···11+(** Middleware support for HCS HTTP server.
22+33+ Middleware wraps request handlers to add cross-cutting concerns like
44+ logging, authentication, CORS, etc. *)
55+66+type ('req, 'resp) t = ('req -> 'resp) -> 'req -> 'resp
77+(** Middleware type - wraps a handler *)
88+99+(** Identity middleware - does nothing *)
1010+let identity : ('req, 'resp) t = fun handler req -> handler req
1111+1212+(** Compose two middleware: m1 runs before m2 *)
1313+let compose (m1 : ('req, 'resp) t) (m2 : ('req, 'resp) t) : ('req, 'resp) t =
1414+ fun handler -> m1 (m2 handler)
1515+1616+(** Compose a list of middleware *)
1717+let compose_all middlewares = List.fold_right compose middlewares identity
1818+1919+(** Infix operator for middleware composition *)
2020+let ( @> ) = compose
2121+2222+(** Apply middleware to a handler *)
2323+let apply middleware handler = middleware handler
2424+2525+(** Logging middleware - logs requests (generic version) *)
2626+let logging ~log : ('req, 'resp) t =
2727+ fun handler req ->
2828+ log "Request received";
2929+ let resp = handler req in
3030+ log "Response sent";
3131+ resp
3232+3333+(** Timing middleware - measures request duration *)
3434+let timing ~on_complete : ('req, 'resp) t =
3535+ fun handler req ->
3636+ let start = Unix.gettimeofday () in
3737+ let resp = handler req in
3838+ let duration = Unix.gettimeofday () -. start in
3939+ on_complete duration;
4040+ resp
4141+4242+(** Exception recovery middleware *)
4343+let recover ~on_error : ('req, 'resp) t =
4444+ fun handler req -> try handler req with exn -> on_error exn
4545+4646+(** Conditional middleware - only applies if predicate is true *)
4747+let when_ predicate middleware : ('req, 'resp) t =
4848+ fun handler req ->
4949+ if predicate req then middleware handler req else handler req
5050+5151+(** Skip middleware for certain requests *)
5252+let unless predicate middleware =
5353+ when_ (fun req -> not (predicate req)) middleware
+349
lib/middleware_eio.ml
···11+(** Eio-specific middleware implementations.
22+33+ These middleware require Eio runtime features like clocks, filesystem
44+ access, and structured concurrency.
55+66+ {1 Usage}
77+88+ {[
99+ open Hcs.Middleware_eio
1010+1111+ (* Add logging with timing *)
1212+ let handler =
1313+ handler
1414+ |> Middleware.apply (logging ~clock (Log.stderr ()))
1515+ |> Middleware.apply (timeout ~clock 30.0)
1616+ ]} *)
1717+1818+(** {1 String Helpers} *)
1919+2020+(** Check if string starts with prefix *)
2121+let string_starts_with ~prefix s =
2222+ let plen = String.length prefix in
2323+ let slen = String.length s in
2424+ plen <= slen && String.sub s 0 plen = prefix
2525+2626+(** Check if substring exists in string *)
2727+let string_contains_substring ~substring s =
2828+ let rec check i =
2929+ if i + String.length substring > String.length s then false
3030+ else if String.sub s i (String.length substring) = substring then true
3131+ else check (i + 1)
3232+ in
3333+ check 0
3434+3535+(** {1 Types} *)
3636+3737+type request = Server.request
3838+(** Server request type (simplified for middleware) *)
3939+4040+type response = Server.response
4141+(** Server response type *)
4242+4343+type middleware = (request -> response) -> request -> response
4444+(** Middleware type matching Server.handler *)
4545+4646+(** {1 Response Helpers} *)
4747+4848+(** Get the body size from a response, if known *)
4949+let response_body_size (resp : response) : int option =
5050+ match resp.Server.body with
5151+ | Server.Body_empty -> Some 0
5252+ | Server.Body_string s -> Some (String.length s)
5353+ | Server.Body_bigstring b -> Some (Bigstringaf.length b)
5454+ | Server.Body_prebuilt p -> Some (Bigstringaf.length p.Server.Prebuilt.body)
5555+ | Server.Body_stream _ -> None
5656+5757+let response_body_string (resp : response) : string =
5858+ match resp.Server.body with
5959+ | Server.Body_empty -> ""
6060+ | Server.Body_string s -> s
6161+ | Server.Body_bigstring b -> Bigstringaf.to_string b
6262+ | Server.Body_prebuilt p -> Bigstringaf.to_string p.Server.Prebuilt.body
6363+ | Server.Body_stream _ -> ""
6464+6565+(** {1 Logging Middleware} *)
6666+6767+(** Logging middleware that records request timing and details.
6868+6969+ Uses the Log module for structured event logging. *)
7070+let logging ~(clock : _ Eio.Time.clock) (logger : Log.logger) : middleware =
7171+ fun handler req ->
7272+ let id = Log.generate_request_id () in
7373+ let start = Eio.Time.now clock in
7474+ let meth = Log.method_of_h1 req.Server.meth in
7575+ logger Log.Info
7676+ (Log.Request_start { id; meth; uri = req.target; headers = req.headers });
7777+ let resp = handler req in
7878+ let duration_ms = (Eio.Time.now clock -. start) *. 1000.0 in
7979+ let status = H1.Status.to_code resp.Server.status in
8080+ let body_size = response_body_size resp in
8181+ logger Log.Info (Log.Request_end { id; status; duration_ms; body_size });
8282+ resp
8383+8484+(** {1 Timeout Middleware} *)
8585+8686+(** Timeout middleware that cancels requests exceeding the time limit.
8787+8888+ Returns a 504 Gateway Timeout response if the handler takes too long. *)
8989+let timeout ~(clock : _ Eio.Time.clock) (seconds : float) : middleware =
9090+ fun handler req ->
9191+ let timeout_response () =
9292+ Eio.Time.sleep clock seconds;
9393+ Server.respond ~status:`Gateway_timeout "Request timed out"
9494+ in
9595+ Eio.Fiber.first (fun () -> handler req) timeout_response
9696+9797+(** {1 Rate Limiting} *)
9898+9999+(** Simple in-memory rate limiter state *)
100100+module Rate_limit_state = struct
101101+ type t = {
102102+ mutable requests : int;
103103+ mutable window_start : float;
104104+ window_seconds : float;
105105+ max_requests : int;
106106+ }
107107+108108+ let create ~max_requests ~window_seconds =
109109+ { requests = 0; window_start = 0.0; window_seconds; max_requests }
110110+111111+ let check_and_increment t now =
112112+ if now -. t.window_start >= t.window_seconds then begin
113113+ t.window_start <- now;
114114+ t.requests <- 1;
115115+ true
116116+ end
117117+ else if t.requests < t.max_requests then begin
118118+ t.requests <- t.requests + 1;
119119+ true
120120+ end
121121+ else false
122122+123123+ let remaining t = max 0 (t.max_requests - t.requests)
124124+ let reset_at t = t.window_start +. t.window_seconds
125125+end
126126+127127+(** Rate limiting middleware.
128128+129129+ Limits requests per time window, keyed by a function (e.g., by IP, by user).
130130+ Returns 429 Too Many Requests when the limit is exceeded. *)
131131+let rate_limit ~(clock : _ Eio.Time.clock) ~(key : request -> string)
132132+ ~(requests : int) ~(per : float) : middleware =
133133+ let states : (string, Rate_limit_state.t) Hashtbl.t = Hashtbl.create 256 in
134134+ let mutex = Eio.Mutex.create () in
135135+ fun handler req ->
136136+ let k = key req in
137137+ let now = Eio.Time.now clock in
138138+ let allowed, remaining, reset_at =
139139+ Eio.Mutex.use_rw ~protect:true mutex (fun () ->
140140+ let state =
141141+ match Hashtbl.find_opt states k with
142142+ | Some s -> s
143143+ | None ->
144144+ let s =
145145+ Rate_limit_state.create ~max_requests:requests
146146+ ~window_seconds:per
147147+ in
148148+ Hashtbl.add states k s;
149149+ s
150150+ in
151151+ let allowed = Rate_limit_state.check_and_increment state now in
152152+ ( allowed,
153153+ Rate_limit_state.remaining state,
154154+ Rate_limit_state.reset_at state ))
155155+ in
156156+ if allowed then handler req
157157+ else
158158+ let headers =
159159+ [
160160+ ("X-RateLimit-Limit", string_of_int requests);
161161+ ("X-RateLimit-Remaining", string_of_int remaining);
162162+ ("X-RateLimit-Reset", string_of_int (int_of_float reset_at));
163163+ ("Retry-After", string_of_int (int_of_float (reset_at -. now)));
164164+ ]
165165+ in
166166+ {
167167+ Server.status = `Code 429;
168168+ headers;
169169+ body = Server.Body_string "Too Many Requests";
170170+ }
171171+172172+(** {1 ETag and Caching} *)
173173+174174+(** Generate ETag from response body using MD5 hash *)
175175+let generate_etag body =
176176+ let hash = Digestif.MD5.digest_string body in
177177+ "\"" ^ Digestif.MD5.to_hex hash ^ "\""
178178+179179+let etag : middleware =
180180+ fun handler req ->
181181+ let resp = handler req in
182182+ let body_str = response_body_string resp in
183183+ let etag_value = generate_etag body_str in
184184+ let if_none_match =
185185+ List.find_opt
186186+ (fun (n, _) -> String.lowercase_ascii n = "if-none-match")
187187+ req.headers
188188+ |> Option.map snd
189189+ in
190190+ match if_none_match with
191191+ | Some client_etag when String.equal client_etag etag_value ->
192192+ {
193193+ status = `Code 304;
194194+ headers = [ ("ETag", etag_value) ];
195195+ body = Server.Body_empty;
196196+ }
197197+ | _ -> { resp with headers = ("ETag", etag_value) :: resp.headers }
198198+199199+(** Cache-Control middleware - adds Cache-Control header *)
200200+let cache_control (directive : string) : middleware =
201201+ fun handler req ->
202202+ let resp = handler req in
203203+ { resp with headers = ("Cache-Control", directive) :: resp.headers }
204204+205205+(** {1 Static Files} *)
206206+207207+(** MIME type mapping for common file extensions *)
208208+let mime_type_of_extension ext =
209209+ match String.lowercase_ascii ext with
210210+ | ".html" | ".htm" -> "text/html; charset=utf-8"
211211+ | ".css" -> "text/css; charset=utf-8"
212212+ | ".js" -> "application/javascript; charset=utf-8"
213213+ | ".json" -> "application/json"
214214+ | ".xml" -> "application/xml"
215215+ | ".txt" -> "text/plain; charset=utf-8"
216216+ | ".png" -> "image/png"
217217+ | ".jpg" | ".jpeg" -> "image/jpeg"
218218+ | ".gif" -> "image/gif"
219219+ | ".svg" -> "image/svg+xml"
220220+ | ".ico" -> "image/x-icon"
221221+ | ".webp" -> "image/webp"
222222+ | ".woff" -> "font/woff"
223223+ | ".woff2" -> "font/woff2"
224224+ | ".ttf" -> "font/ttf"
225225+ | ".otf" -> "font/otf"
226226+ | ".pdf" -> "application/pdf"
227227+ | ".zip" -> "application/zip"
228228+ | ".gz" -> "application/gzip"
229229+ | ".mp3" -> "audio/mpeg"
230230+ | ".mp4" -> "video/mp4"
231231+ | ".webm" -> "video/webm"
232232+ | _ -> "application/octet-stream"
233233+234234+(** Get file extension from path *)
235235+let extension path =
236236+ match String.rindex_opt path '.' with
237237+ | Some i -> String.sub path i (String.length path - i)
238238+ | None -> ""
239239+240240+(** Static file middleware - serves files from a directory.
241241+242242+ @param fs The Eio filesystem to use
243243+ @param root The root directory path for static files
244244+ @param index
245245+ Index files to try for directory requests (default: ["index.html"])
246246+ @param with_etag Whether to add ETag headers (default: true) *)
247247+let static ~(fs : _ Eio.Path.t) ?(index = [ "index.html" ]) ?(with_etag = true)
248248+ (root : string) : middleware =
249249+ fun handler req ->
250250+ (* Only handle GET and HEAD *)
251251+ match req.meth with
252252+ | `GET | `HEAD -> (
253253+ (* Normalize and validate path to prevent directory traversal *)
254254+ let path = req.target in
255255+ let path =
256256+ if String.length path > 0 && path.[0] = '/' then
257257+ String.sub path 1 (String.length path - 1)
258258+ else path
259259+ in
260260+ (* Remove query string *)
261261+ let path =
262262+ match String.index_opt path '?' with
263263+ | Some i -> String.sub path 0 i
264264+ | None -> path
265265+ in
266266+ (* Check for directory traversal *)
267267+ if String.contains path '\x00' || string_starts_with ~prefix:".." path
268268+ then handler req (* Pass to next handler *)
269269+ else
270270+ let full_path = Eio.Path.(fs / root / path) in
271271+ try
272272+ (* Try to read the file *)
273273+ let content = Eio.Path.load full_path in
274274+ let content_type = mime_type_of_extension (extension path) in
275275+ let headers =
276276+ [
277277+ ("Content-Type", content_type);
278278+ ("Content-Length", string_of_int (String.length content));
279279+ ]
280280+ in
281281+ let headers =
282282+ if with_etag then ("ETag", generate_etag content) :: headers
283283+ else headers
284284+ in
285285+ (* Handle If-None-Match *)
286286+ let if_none_match =
287287+ List.find_opt
288288+ (fun (n, _) -> String.lowercase_ascii n = "if-none-match")
289289+ req.headers
290290+ |> Option.map snd
291291+ in
292292+ let etag_value = generate_etag content in
293293+ match if_none_match with
294294+ | Some client_etag when String.equal client_etag etag_value ->
295295+ {
296296+ status = `Code 304;
297297+ headers = [ ("ETag", etag_value) ];
298298+ body = Server.Body_empty;
299299+ }
300300+ | _ ->
301301+ let body =
302302+ if req.meth = `HEAD then Server.Body_empty
303303+ else Server.Body_string content
304304+ in
305305+ { status = `OK; headers; body }
306306+ with
307307+ | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> (
308308+ (* Try index files if path looks like a directory *)
309309+ let try_index () =
310310+ List.find_map
311311+ (fun idx ->
312312+ try
313313+ let idx_path = Eio.Path.(fs / root / path / idx) in
314314+ let content = Eio.Path.load idx_path in
315315+ Some (idx, content)
316316+ with _ -> None)
317317+ index
318318+ in
319319+ match try_index () with
320320+ | Some (idx, content) ->
321321+ let content_type = mime_type_of_extension (extension idx) in
322322+ let headers =
323323+ [
324324+ ("Content-Type", content_type);
325325+ ("Content-Length", string_of_int (String.length content));
326326+ ]
327327+ in
328328+ let body =
329329+ if req.meth = `HEAD then Server.Body_empty
330330+ else Server.Body_string content
331331+ in
332332+ { status = `OK; headers; body }
333333+ | None -> handler req)
334334+ | _ -> handler req)
335335+ | _ -> handler req
336336+337337+(** {1 Compression} *)
338338+339339+(** Note: Compression middleware would require a compression library like
340340+ camlzip or decompress. For now, we provide a placeholder that can be
341341+ implemented when those dependencies are available. *)
342342+343343+(** Check if client accepts gzip encoding *)
344344+let accepts_gzip (req : request) =
345345+ List.exists
346346+ (fun (n, v) ->
347347+ String.lowercase_ascii n = "accept-encoding"
348348+ && string_contains_substring ~substring:"gzip" v)
349349+ req.headers
+159
lib/pool.ml
···11+(** Connection pool for HTTP client.
22+33+ This module provides a runtime-agnostic connection pool structure. The
44+ actual connection management is done by the runtime-specific code. *)
55+66+type config = {
77+ max_connections_per_host : int; (** Max connections per host:port *)
88+ max_total_connections : int; (** Max total connections *)
99+ idle_timeout : float; (** Seconds before closing idle connection *)
1010+ connection_timeout : float; (** Seconds to wait for connection *)
1111+}
1212+(** Pool configuration *)
1313+1414+let default_config =
1515+ {
1616+ max_connections_per_host = 10;
1717+ max_total_connections = 100;
1818+ idle_timeout = 60.0;
1919+ connection_timeout = 30.0;
2020+ }
2121+2222+type key = { host : string; port : int; tls : bool }
2323+(** Connection key - identifies a connection target *)
2424+2525+let make_key ~host ~port ~tls = { host; port; tls }
2626+2727+type 'conn entry = {
2828+ conn : 'conn;
2929+ created_at : float;
3030+ mutable last_used : float;
3131+ mutable in_use : bool;
3232+}
3333+(** Connection state *)
3434+3535+type 'conn t = {
3636+ config : config;
3737+ mutable connections : (key * 'conn entry list) list;
3838+ mutable total_count : int;
3939+}
4040+(** Connection pool - parameterized by connection type *)
4141+4242+(** Create a new pool *)
4343+let create ?(config = default_config) () =
4444+ { config; connections = []; total_count = 0 }
4545+4646+(** Get connections for a key *)
4747+let get_entries pool key =
4848+ List.assoc_opt key pool.connections |> Option.value ~default:[]
4949+5050+(** Update connections for a key *)
5151+let set_entries pool key entries =
5252+ let others = List.remove_assoc key pool.connections in
5353+ if entries = [] then pool.connections <- others
5454+ else pool.connections <- (key, entries) :: others
5555+5656+(** Count connections for a key *)
5757+let count_for_key pool key = get_entries pool key |> List.length
5858+5959+(** Try to acquire an idle connection. [now] is current time in seconds *)
6060+let acquire pool key ~now =
6161+ let entries = get_entries pool key in
6262+ let rec find_idle = function
6363+ | [] -> None
6464+ | entry :: rest ->
6565+ if
6666+ (not entry.in_use)
6767+ && now -. entry.last_used < pool.config.idle_timeout
6868+ then begin
6969+ entry.in_use <- true;
7070+ entry.last_used <- now;
7171+ Some entry.conn
7272+ end
7373+ else find_idle rest
7474+ in
7575+ find_idle entries
7676+7777+(** Release a connection back to the pool. [now] is current time *)
7878+let release pool key conn ~now =
7979+ let entries = get_entries pool key in
8080+ let entries =
8181+ List.map
8282+ (fun entry ->
8383+ if entry.conn == conn then
8484+ { entry with in_use = false; last_used = now }
8585+ else entry)
8686+ entries
8787+ in
8888+ set_entries pool key entries
8989+9090+(** Add a new connection to the pool. Returns false if pool is at capacity *)
9191+let add pool key conn ~now =
9292+ if pool.total_count >= pool.config.max_total_connections then false
9393+ else if count_for_key pool key >= pool.config.max_connections_per_host then
9494+ false
9595+ else begin
9696+ let entry = { conn; created_at = now; last_used = now; in_use = true } in
9797+ let entries = get_entries pool key in
9898+ set_entries pool key (entry :: entries);
9999+ pool.total_count <- pool.total_count + 1;
100100+ true
101101+ end
102102+103103+(** Remove a connection from the pool *)
104104+let remove pool key conn =
105105+ let entries = get_entries pool key in
106106+ let entries = List.filter (fun entry -> entry.conn != conn) entries in
107107+ let removed = List.length (get_entries pool key) - List.length entries in
108108+ set_entries pool key entries;
109109+ pool.total_count <- pool.total_count - removed
110110+111111+(** Close idle connections older than idle_timeout. [now] is current time,
112112+ [close] is the connection close function *)
113113+let evict_idle pool ~now ~close =
114114+ pool.connections <-
115115+ List.filter_map
116116+ (fun (key, entries) ->
117117+ let kept, evicted =
118118+ List.partition
119119+ (fun entry ->
120120+ entry.in_use || now -. entry.last_used < pool.config.idle_timeout)
121121+ entries
122122+ in
123123+ List.iter (fun entry -> close entry.conn) evicted;
124124+ pool.total_count <- pool.total_count - List.length evicted;
125125+ if kept = [] then None else Some (key, kept))
126126+ pool.connections
127127+128128+(** Close all connections *)
129129+let close_all pool ~close =
130130+ List.iter
131131+ (fun (_, entries) -> List.iter (fun entry -> close entry.conn) entries)
132132+ pool.connections;
133133+ pool.connections <- [];
134134+ pool.total_count <- 0
135135+136136+type stats = {
137137+ total_connections : int;
138138+ idle_connections : int;
139139+ in_use_connections : int;
140140+ hosts : int;
141141+}
142142+(** Get pool statistics *)
143143+144144+let stats pool =
145145+ let idle, in_use =
146146+ List.fold_left
147147+ (fun (idle, in_use) (_, entries) ->
148148+ List.fold_left
149149+ (fun (idle, in_use) entry ->
150150+ if entry.in_use then (idle, in_use + 1) else (idle + 1, in_use))
151151+ (idle, in_use) entries)
152152+ (0, 0) pool.connections
153153+ in
154154+ {
155155+ total_connections = pool.total_count;
156156+ idle_connections = idle;
157157+ in_use_connections = in_use;
158158+ hosts = List.length pool.connections;
159159+ }
+422
lib/pooled_client.ml
···11+(** High-performance HTTP client with connection pooling.
22+33+ This module provides an HTTP client optimized for making many requests to
44+ the same host. For best performance:
55+66+ 1. Parse the URI once and reuse it 2. Create a pool for the target host and
77+ pass it to requests
88+99+ Example:
1010+ {[
1111+ let uri = Uri.of_string "http://localhost:8080/api" in
1212+ let pool = Pooled_client.create_pool ~sw ~net ~clock uri in
1313+ (* All requests reuse the same connection *)
1414+ for _ = 1 to 1000 do
1515+ let _ = Pooled_client.get ~sw ~net ~clock ~pool uri in
1616+ ()
1717+ done
1818+ ]}
1919+2020+ For one-off requests, omit the pool:
2121+ {[
2222+ let uri = Uri.of_string "http://example.com/api" in
2323+ let _ = Pooled_client.get ~sw ~net ~clock uri in
2424+ ()
2525+ ]} *)
2626+2727+(** {1 Types} *)
2828+2929+(** Read buffer size for HTTP responses *)
3030+let read_buffer_size = 0x1000
3131+3232+type conn = {
3333+ flow : Eio.Flow.two_way_ty Eio.Std.r;
3434+ mutable alive : bool;
3535+ read_buffer : Bigstringaf.t;
3636+}
3737+(** Internal connection state *)
3838+3939+type pool = {
4040+ host : string;
4141+ port : int;
4242+ path : string; (* Pre-extracted path for fast access *)
4343+ is_https : bool;
4444+ tls_config : Tls.Config.client option;
4545+ addr : Eio.Net.Sockaddr.stream option; (* Cached resolved address *)
4646+ mutable conn : conn option;
4747+ mutex : Eio.Mutex.t;
4848+}
4949+(** Connection pool for a single host. Thread-safe via mutex. *)
5050+5151+type error = H1_client.error
5252+(** Error type alias *)
5353+5454+(** {1 Connection Management} *)
5555+5656+(** Close a connection safely *)
5757+let close_conn c =
5858+ if c.alive then begin
5959+ c.alive <- false;
6060+ try Eio.Resource.close (Obj.magic c.flow) with _ -> ()
6161+ end
6262+6363+(** Resolve hostname to IP address *)
6464+let resolve_host ~net host =
6565+ match Eio.Net.getaddrinfo_stream net host with
6666+ | addr :: _ -> Some addr
6767+ | [] -> None
6868+6969+(** Create a new TCP connection using cached or resolved address *)
7070+let connect_tcp pool ~sw ~net =
7171+ let addr =
7272+ match pool.addr with
7373+ | Some a -> Some a
7474+ | None -> (
7575+ (* Resolve if not cached *)
7676+ match resolve_host ~net pool.host with
7777+ | Some (`Tcp (ip, _)) -> Some (`Tcp (ip, pool.port))
7878+ | _ -> None)
7979+ in
8080+ match addr with
8181+ | None ->
8282+ Error (H1_client.Connection_failed ("Cannot resolve host: " ^ pool.host))
8383+ | Some a -> Ok (Eio.Net.connect ~sw net a)
8484+8585+(** Create a new connection to the pool's target *)
8686+let create_conn pool ~sw ~net =
8787+ match connect_tcp pool ~sw ~net with
8888+ | Error e -> Error e
8989+ | Ok tcp_flow ->
9090+ if pool.is_https then
9191+ match pool.tls_config with
9292+ | None -> Error (H1_client.Tls_error "TLS not configured")
9393+ | Some tls_config -> (
9494+ try
9595+ let host_domain =
9696+ match Domain_name.of_string pool.host with
9797+ | Ok dn -> (
9898+ match Domain_name.host dn with
9999+ | Ok h -> Some h
100100+ | Error _ -> None)
101101+ | Error _ -> None
102102+ in
103103+ let tls_flow =
104104+ Tls_eio.client_of_flow tls_config ?host:host_domain tcp_flow
105105+ in
106106+ Ok
107107+ {
108108+ flow = (tls_flow :> Eio.Flow.two_way_ty Eio.Std.r);
109109+ alive = true;
110110+ read_buffer = Bigstringaf.create read_buffer_size;
111111+ }
112112+ with
113113+ | Tls_eio.Tls_failure failure ->
114114+ Error
115115+ (H1_client.Tls_error (Tls_config.failure_to_string failure))
116116+ | exn -> Error (H1_client.Tls_error (Printexc.to_string exn)))
117117+ else
118118+ Ok
119119+ {
120120+ flow = (tcp_flow :> Eio.Flow.two_way_ty Eio.Std.r);
121121+ alive = true;
122122+ read_buffer = Bigstringaf.create read_buffer_size;
123123+ }
124124+125125+(** {1 Pool Creation} *)
126126+127127+(** Create a connection pool for a URI.
128128+129129+ The pool maintains a single persistent connection to the target host. Pass
130130+ this pool to [get] and [post] to reuse the connection.
131131+132132+ @param net
133133+ Network for DNS resolution (optional, resolves lazily if not provided)
134134+ @param tls
135135+ TLS configuration for HTTPS. Uses system defaults if not provided. *)
136136+let create_pool ?net ?(tls = Tls_config.Client.default) uri =
137137+ let scheme = Uri.scheme uri |> Option.value ~default:"http" in
138138+ let is_https = String.equal scheme "https" in
139139+ let host = Uri.host uri |> Option.value ~default:"localhost" in
140140+ let port =
141141+ match Uri.port uri with Some p -> p | None -> if is_https then 443 else 80
142142+ in
143143+ let path =
144144+ let p = Uri.path_and_query uri in
145145+ if p = "" then "/" else p
146146+ in
147147+ let tls_config =
148148+ if is_https then
149149+ match Tls_config.Client.to_tls_config tls ~host with
150150+ | Ok c -> Some c
151151+ | Error _ -> None
152152+ else None
153153+ in
154154+ (* Pre-resolve DNS if net is provided *)
155155+ let addr =
156156+ match net with
157157+ | Some n -> (
158158+ match Eio.Net.getaddrinfo_stream n host with
159159+ | addr_info :: _ -> (
160160+ match addr_info with
161161+ | `Tcp (ip, _) -> Some (`Tcp (ip, port))
162162+ | `Unix _ -> None)
163163+ | [] -> None)
164164+ | None -> None
165165+ in
166166+ {
167167+ host;
168168+ port;
169169+ path;
170170+ is_https;
171171+ tls_config;
172172+ addr;
173173+ conn = None;
174174+ mutex = Eio.Mutex.create ();
175175+ }
176176+177177+(** Close all connections in the pool *)
178178+let close_pool pool =
179179+ Eio.Mutex.use_rw ~protect:true pool.mutex (fun () ->
180180+ Option.iter close_conn pool.conn;
181181+ pool.conn <- None)
182182+183183+(** {1 Internal Request Handling} *)
184184+185185+(** Write IOVecs to flow *)
186186+let write_iovecs flow iovecs =
187187+ let cstructs =
188188+ List.map
189189+ (fun iov ->
190190+ Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off
191191+ ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer)
192192+ iovecs
193193+ in
194194+ Eio.Flow.write flow cstructs
195195+196196+(** Read into bigstring *)
197197+let read_into flow buf ~off ~len =
198198+ let cs = Cstruct.of_bigarray ~off ~len buf in
199199+ try `Ok (Eio.Flow.single_read flow cs) with End_of_file -> `Eof
200200+201201+(** Perform HTTP request on connection. Returns (response, keep_alive) *)
202202+let do_request ?(body = "") conn req =
203203+ let flow = conn.flow in
204204+ let read_buffer = conn.read_buffer in
205205+ let response_received = Eio.Promise.create () in
206206+ let body_chunks = ref [] in
207207+ (* Collect chunks, concat at end - faster than Buffer *)
208208+ let resolved = ref false in
209209+ let keep_alive = ref true in
210210+211211+ let resolve_once result =
212212+ if not !resolved then begin
213213+ resolved := true;
214214+ Eio.Promise.resolve (snd response_received) result
215215+ end
216216+ in
217217+218218+ let response_handler resp body_reader =
219219+ (* Check Connection header *)
220220+ (match H1.Headers.get resp.H1.Response.headers "connection" with
221221+ | Some v when String.lowercase_ascii v = "close" -> keep_alive := false
222222+ | _ -> ());
223223+224224+ let rec read_body () =
225225+ H1.Body.Reader.schedule_read body_reader
226226+ ~on_eof:(fun () ->
227227+ let response_body = String.concat "" (List.rev !body_chunks) in
228228+ resolve_once
229229+ (Ok
230230+ ( {
231231+ H1_client.status = resp.H1.Response.status;
232232+ headers = resp.headers;
233233+ body = response_body;
234234+ },
235235+ !keep_alive )))
236236+ ~on_read:(fun buf ~off ~len ->
237237+ body_chunks := Bigstringaf.substring buf ~off ~len :: !body_chunks;
238238+ read_body ())
239239+ in
240240+ read_body ()
241241+ in
242242+243243+ let error_handler err =
244244+ keep_alive := false;
245245+ conn.alive <- false;
246246+ let msg =
247247+ match err with
248248+ | `Malformed_response s -> s
249249+ | `Invalid_response_body_length _ -> "Invalid body length"
250250+ | `Exn exn -> Printexc.to_string exn
251251+ in
252252+ resolve_once (Error (H1_client.Invalid_response msg))
253253+ in
254254+255255+ let body_writer, h1_conn =
256256+ H1.Client_connection.request req ~error_handler ~response_handler
257257+ in
258258+259259+ if String.length body > 0 then begin
260260+ H1.Body.Writer.write_string body_writer body;
261261+ H1.Body.Writer.flush body_writer (fun () -> ())
262262+ end;
263263+ H1.Body.Writer.close body_writer;
264264+265265+ (* I/O loop *)
266266+ let rec loop () =
267267+ let write_done =
268268+ match H1.Client_connection.next_write_operation h1_conn with
269269+ | `Write iovecs ->
270270+ write_iovecs flow iovecs;
271271+ let len =
272272+ List.fold_left
273273+ (fun acc iov -> acc + iov.Httpun_types.IOVec.len)
274274+ 0 iovecs
275275+ in
276276+ H1.Client_connection.report_write_result h1_conn (`Ok len);
277277+ false
278278+ | `Yield -> true
279279+ | `Close _ -> true
280280+ in
281281+ let read_done =
282282+ match H1.Client_connection.next_read_operation h1_conn with
283283+ | `Read -> (
284284+ match read_into flow read_buffer ~off:0 ~len:read_buffer_size with
285285+ | `Ok n ->
286286+ let _ =
287287+ H1.Client_connection.read h1_conn read_buffer ~off:0 ~len:n
288288+ in
289289+ false
290290+ | `Eof ->
291291+ conn.alive <- false;
292292+ let _ =
293293+ H1.Client_connection.read_eof h1_conn read_buffer ~off:0 ~len:0
294294+ in
295295+ true)
296296+ | `Close -> true
297297+ in
298298+ if not (write_done && read_done) then loop ()
299299+ in
300300+301301+ (try loop () with _ -> conn.alive <- false);
302302+ Eio.Promise.await (fst response_received)
303303+304304+(** Acquire connection from pool or create new *)
305305+let acquire_conn pool ~sw ~net =
306306+ Eio.Mutex.use_rw ~protect:true pool.mutex (fun () ->
307307+ match pool.conn with
308308+ | Some c when c.alive -> Ok c
309309+ | _ -> (
310310+ (* Create new connection *)
311311+ match create_conn pool ~sw ~net with
312312+ | Ok c ->
313313+ pool.conn <- Some c;
314314+ Ok c
315315+ | Error e -> Error e))
316316+317317+(** Release connection back to pool *)
318318+let release_conn pool conn ~keep_alive =
319319+ if not keep_alive then
320320+ Eio.Mutex.use_rw ~protect:true pool.mutex (fun () ->
321321+ close_conn conn;
322322+ pool.conn <- None)
323323+324324+(** {1 HTTP Methods} *)
325325+326326+(** Perform a GET request.
327327+328328+ @param pool
329329+ Optional connection pool. If provided, reuses connections (fast path). If
330330+ omitted, creates a one-shot connection. *)
331331+let get ?pool ~sw ~net ~clock:_ uri =
332332+ match pool with
333333+ | Some p -> (
334334+ (* Fast path: use cached host/path from pool *)
335335+ let headers =
336336+ H1.Headers.of_list [ ("Host", p.host); ("Connection", "keep-alive") ]
337337+ in
338338+ let req = H1.Request.create ~headers `GET p.path in
339339+ match acquire_conn p ~sw ~net with
340340+ | Error e -> Error e
341341+ | Ok conn -> (
342342+ let result = do_request conn req in
343343+ match result with
344344+ | Ok (resp, keep_alive) ->
345345+ release_conn p conn ~keep_alive;
346346+ Ok resp
347347+ | Error e ->
348348+ release_conn p conn ~keep_alive:false;
349349+ Error e))
350350+ | None -> (
351351+ (* Slow path: parse URI for one-shot connection *)
352352+ let host = Uri.host uri |> Option.value ~default:"localhost" in
353353+ let path =
354354+ let p = Uri.path_and_query uri in
355355+ if p = "" then "/" else p
356356+ in
357357+ let headers =
358358+ H1.Headers.of_list [ ("Host", host); ("Connection", "close") ]
359359+ in
360360+ let req = H1.Request.create ~headers `GET path in
361361+ let temp_pool = create_pool uri in
362362+ match acquire_conn temp_pool ~sw ~net with
363363+ | Error e -> Error e
364364+ | Ok conn ->
365365+ let result = do_request conn req in
366366+ close_conn conn;
367367+ Result.map fst result)
368368+369369+(** Perform a POST request.
370370+371371+ @param pool
372372+ Optional connection pool. If provided, reuses connections (fast path). If
373373+ omitted, creates a one-shot connection.
374374+ @param body Request body *)
375375+let post ?pool ~sw ~net ~clock:_ uri ~body =
376376+ let content_length = String.length body in
377377+ match pool with
378378+ | Some p -> (
379379+ (* Fast path: use cached host/path from pool *)
380380+ let headers =
381381+ H1.Headers.of_list
382382+ [
383383+ ("Host", p.host);
384384+ ("Connection", "keep-alive");
385385+ ("Content-Length", string_of_int content_length);
386386+ ]
387387+ in
388388+ let req = H1.Request.create ~headers `POST p.path in
389389+ match acquire_conn p ~sw ~net with
390390+ | Error e -> Error e
391391+ | Ok conn -> (
392392+ let result = do_request ~body conn req in
393393+ match result with
394394+ | Ok (resp, keep_alive) ->
395395+ release_conn p conn ~keep_alive;
396396+ Ok resp
397397+ | Error e ->
398398+ release_conn p conn ~keep_alive:false;
399399+ Error e))
400400+ | None -> (
401401+ (* Slow path: parse URI for one-shot connection *)
402402+ let host = Uri.host uri |> Option.value ~default:"localhost" in
403403+ let path =
404404+ let p = Uri.path_and_query uri in
405405+ if p = "" then "/" else p
406406+ in
407407+ let headers =
408408+ H1.Headers.of_list
409409+ [
410410+ ("Host", host);
411411+ ("Connection", "close");
412412+ ("Content-Length", string_of_int content_length);
413413+ ]
414414+ in
415415+ let req = H1.Request.create ~headers `POST path in
416416+ let temp_pool = create_pool uri in
417417+ match acquire_conn temp_pool ~sw ~net with
418418+ | Error e -> Error e
419419+ | Ok conn ->
420420+ let result = do_request ~body conn req in
421421+ close_conn conn;
422422+ Result.map fst result)
+173
lib/request.ml
···11+(** Request helper functions.
22+33+ This module provides utility functions for working with HTTP requests,
44+ including header access, query parameter parsing, and body handling. *)
55+66+(** {1 Internal helpers} *)
77+88+let string_contains haystack pattern =
99+ let plen = String.length pattern in
1010+ let hlen = String.length haystack in
1111+ if plen > hlen then false
1212+ else
1313+ let rec check i =
1414+ if i > hlen - plen then false
1515+ else if String.sub haystack i plen = pattern then true
1616+ else check (i + 1)
1717+ in
1818+ check 0
1919+2020+(** {1 Types} *)
2121+2222+type t = H1_server.request
2323+(** Request type - re-exported from H1_server for convenience *)
2424+2525+(** {1 Basic accessors} *)
2626+2727+let meth (req : t) = req.meth
2828+let target (req : t) = req.target
2929+let headers (req : t) = req.headers
3030+3131+(** Read the request body as a string (reads lazily on first call) *)
3232+let body (req : t) = H1_server.read_body req
3333+3434+(** Get the body reader for streaming access *)
3535+let body_reader (req : t) = req.body_reader
3636+3737+(** {1 Path and Query} *)
3838+3939+(** Get the path component (without query string) *)
4040+let path (req : t) =
4141+ match String.index_opt req.target '?' with
4242+ | Some i -> String.sub req.target 0 i
4343+ | None -> req.target
4444+4545+(** Get the query string (without leading ?) *)
4646+let query_string (req : t) =
4747+ match String.index_opt req.target '?' with
4848+ | Some i ->
4949+ Some (String.sub req.target (i + 1) (String.length req.target - i - 1))
5050+ | None -> None
5151+5252+(** Parse query string into key-value pairs *)
5353+let query_params (req : t) =
5454+ match query_string req with
5555+ | None -> []
5656+ | Some qs ->
5757+ let pairs = String.split_on_char '&' qs in
5858+ List.filter_map
5959+ (fun pair ->
6060+ match String.index_opt pair '=' with
6161+ | Some i ->
6262+ let key = String.sub pair 0 i in
6363+ let value =
6464+ String.sub pair (i + 1) (String.length pair - i - 1)
6565+ in
6666+ (* URL decode *)
6767+ Some (Uri.pct_decode key, Uri.pct_decode value)
6868+ | None -> Some (Uri.pct_decode pair, ""))
6969+ pairs
7070+7171+(** Get a single query parameter value *)
7272+let query key req = List.assoc_opt key (query_params req)
7373+7474+(** Get all values for a query parameter *)
7575+let query_all key req =
7676+ List.filter_map
7777+ (fun (k, v) -> if String.equal k key then Some v else None)
7878+ (query_params req)
7979+8080+(** {1 Header accessors} *)
8181+8282+(** Get a header value (case-insensitive) *)
8383+let header name (req : t) = H1.Headers.get req.headers name
8484+8585+(** Get all values for a header *)
8686+let header_all name (req : t) = H1.Headers.get_multi req.headers name
8787+8888+(** Check if header exists *)
8989+let has_header name (req : t) =
9090+ match H1.Headers.get req.headers name with Some _ -> true | None -> false
9191+9292+(** {1 Common header helpers} *)
9393+9494+(** Get Content-Type header *)
9595+let content_type req = header "content-type" req
9696+9797+(** Get Content-Length header as int64 *)
9898+let content_length req =
9999+ match header "content-length" req with
100100+ | Some s -> Int64.of_string_opt s
101101+ | None -> None
102102+103103+(** Check if request is keep-alive *)
104104+let is_keep_alive (req : t) =
105105+ match header "connection" req with
106106+ | Some v -> String.lowercase_ascii v = "keep-alive"
107107+ | None -> true (* HTTP/1.1 default is keep-alive *)
108108+109109+(** Get Host header *)
110110+let host req = header "host" req
111111+112112+(** Get Accept header *)
113113+let accept req = header "accept" req
114114+115115+(** Get Authorization header *)
116116+let authorization req = header "authorization" req
117117+118118+(** Check if request accepts JSON *)
119119+let accepts_json req =
120120+ match accept req with
121121+ | Some v -> string_contains v "application/json" || string_contains v "*/*"
122122+ | None -> false
123123+124124+(** Check if request accepts HTML *)
125125+let accepts_html req =
126126+ match accept req with
127127+ | Some v -> string_contains v "text/html" || string_contains v "*/*"
128128+ | None -> false
129129+130130+(** {1 Method helpers} *)
131131+132132+let is_get (req : t) = req.meth = `GET
133133+let is_post (req : t) = req.meth = `POST
134134+let is_put (req : t) = req.meth = `PUT
135135+let is_delete (req : t) = req.meth = `DELETE
136136+let is_patch (req : t) = req.meth = `Other "PATCH"
137137+let is_head (req : t) = req.meth = `HEAD
138138+let is_options (req : t) = req.meth = `OPTIONS
139139+140140+(** Check if method is safe (GET, HEAD, OPTIONS) *)
141141+let is_safe (req : t) =
142142+ match req.meth with `GET | `HEAD | `OPTIONS -> true | _ -> false
143143+144144+(** Check if method is idempotent (GET, HEAD, PUT, DELETE, OPTIONS) *)
145145+let is_idempotent (req : t) =
146146+ match req.meth with
147147+ | `GET | `HEAD | `PUT | `DELETE | `OPTIONS -> true
148148+ | _ -> false
149149+150150+(** {1 Body helpers} *)
151151+152152+let body_string (req : t) = body req
153153+let body_length (req : t) = String.length (body req)
154154+let has_body (req : t) = String.length (body req) > 0
155155+156156+(** {1 Form data parsing} *)
157157+158158+let form_data (req : t) =
159159+ let b = body req in
160160+ if String.length b = 0 then []
161161+ else
162162+ let pairs = String.split_on_char '&' b in
163163+ List.filter_map
164164+ (fun pair ->
165165+ match String.index_opt pair '=' with
166166+ | Some i ->
167167+ let key = String.sub pair 0 i in
168168+ let value = String.sub pair (i + 1) (String.length pair - i - 1) in
169169+ Some (Uri.pct_decode key, Uri.pct_decode value)
170170+ | None -> Some (Uri.pct_decode pair, ""))
171171+ pairs
172172+173173+let form_field key req = List.assoc_opt key (form_data req)
+249
lib/response.ml
···11+(** Response helper functions.
22+33+ This module provides utility functions for creating and modifying HTTP
44+ responses, including status shortcuts, body helpers, and header
55+ manipulation. *)
66+77+(** {1 Types} *)
88+99+type t = H1_server.response
1010+1111+let make ?(status = `OK) ?(headers = []) body : t =
1212+ { status; headers; response_body = H1_server.Body_string body }
1313+1414+let empty ?(status = `OK) ?(headers = []) () : t =
1515+ { status; headers; response_body = H1_server.Body_string "" }
1616+1717+(** {1 Status shortcuts - 2xx Success} *)
1818+1919+let ok ?(headers = []) body : t =
2020+ { status = `OK; headers; response_body = H1_server.Body_string body }
2121+2222+let created ?(headers = []) ?location body : t =
2323+ let headers =
2424+ match location with
2525+ | Some loc -> ("Location", loc) :: headers
2626+ | None -> headers
2727+ in
2828+ { status = `Created; headers; response_body = H1_server.Body_string body }
2929+3030+let accepted ?(headers = []) body : t =
3131+ { status = `Accepted; headers; response_body = H1_server.Body_string body }
3232+3333+let no_content ?(headers = []) () : t =
3434+ { status = `No_content; headers; response_body = H1_server.Body_string "" }
3535+3636+(** {1 Status shortcuts - 3xx Redirection} *)
3737+3838+let redirect ?(permanent = false) ?(headers = []) location : t =
3939+ let status = if permanent then `Moved_permanently else `Found in
4040+ {
4141+ status;
4242+ headers = ("Location", location) :: headers;
4343+ response_body = H1_server.Body_string "";
4444+ }
4545+4646+let moved_permanently ?(headers = []) location : t =
4747+ redirect ~permanent:true ~headers location
4848+4949+let found ?(headers = []) location : t =
5050+ redirect ~permanent:false ~headers location
5151+5252+let see_other ?(headers = []) location : t =
5353+ {
5454+ status = `See_other;
5555+ headers = ("Location", location) :: headers;
5656+ response_body = H1_server.Body_string "";
5757+ }
5858+5959+let temporary_redirect ?(headers = []) location : t =
6060+ {
6161+ status = `Temporary_redirect;
6262+ headers = ("Location", location) :: headers;
6363+ response_body = H1_server.Body_string "";
6464+ }
6565+6666+let not_modified ?(headers = []) () : t =
6767+ { status = `Not_modified; headers; response_body = H1_server.Body_string "" }
6868+6969+(** {1 Status shortcuts - 4xx Client Errors} *)
7070+7171+let bad_request ?(headers = []) ?(body = "Bad Request") () : t =
7272+ { status = `Bad_request; headers; response_body = H1_server.Body_string body }
7373+7474+let unauthorized ?(headers = []) ?www_authenticate () : t =
7575+ let headers =
7676+ match www_authenticate with
7777+ | Some auth -> ("WWW-Authenticate", auth) :: headers
7878+ | None -> headers
7979+ in
8080+ {
8181+ status = `Unauthorized;
8282+ headers;
8383+ response_body = H1_server.Body_string "Unauthorized";
8484+ }
8585+8686+let forbidden ?(headers = []) ?(body = "Forbidden") () : t =
8787+ { status = `Forbidden; headers; response_body = H1_server.Body_string body }
8888+8989+let not_found ?(headers = []) ?(body = "Not Found") () : t =
9090+ { status = `Not_found; headers; response_body = H1_server.Body_string body }
9191+9292+let method_not_allowed ?(headers = []) ~allowed () : t =
9393+ let allow_header =
9494+ String.concat ", " (List.map H1.Method.to_string allowed)
9595+ in
9696+ {
9797+ status = `Method_not_allowed;
9898+ headers = ("Allow", allow_header) :: headers;
9999+ response_body = H1_server.Body_string "Method Not Allowed";
100100+ }
101101+102102+let conflict ?(headers = []) ?(body = "Conflict") () : t =
103103+ { status = `Conflict; headers; response_body = H1_server.Body_string body }
104104+105105+let gone ?(headers = []) ?(body = "Gone") () : t =
106106+ { status = `Gone; headers; response_body = H1_server.Body_string body }
107107+108108+let unprocessable_entity ?(headers = []) ?(body = "Unprocessable Entity") () : t
109109+ =
110110+ { status = `Code 422; headers; response_body = H1_server.Body_string body }
111111+112112+let too_many_requests ?(headers = []) ?retry_after () : t =
113113+ let headers =
114114+ match retry_after with
115115+ | Some secs -> ("Retry-After", string_of_int secs) :: headers
116116+ | None -> headers
117117+ in
118118+ {
119119+ status = `Code 429;
120120+ headers;
121121+ response_body = H1_server.Body_string "Too Many Requests";
122122+ }
123123+124124+(** {1 Status shortcuts - 5xx Server Errors} *)
125125+126126+let internal_error ?(headers = []) ?(body = "Internal Server Error") () : t =
127127+ {
128128+ status = `Internal_server_error;
129129+ headers;
130130+ response_body = H1_server.Body_string body;
131131+ }
132132+133133+let not_implemented ?(headers = []) ?(body = "Not Implemented") () : t =
134134+ {
135135+ status = `Not_implemented;
136136+ headers;
137137+ response_body = H1_server.Body_string body;
138138+ }
139139+140140+let bad_gateway ?(headers = []) ?(body = "Bad Gateway") () : t =
141141+ { status = `Bad_gateway; headers; response_body = H1_server.Body_string body }
142142+143143+let service_unavailable ?(headers = []) ?retry_after () : t =
144144+ let headers =
145145+ match retry_after with
146146+ | Some secs -> ("Retry-After", string_of_int secs) :: headers
147147+ | None -> headers
148148+ in
149149+ {
150150+ status = `Service_unavailable;
151151+ headers;
152152+ response_body = H1_server.Body_string "Service Unavailable";
153153+ }
154154+155155+let gateway_timeout ?(headers = []) ?(body = "Gateway Timeout") () : t =
156156+ {
157157+ status = `Gateway_timeout;
158158+ headers;
159159+ response_body = H1_server.Body_string body;
160160+ }
161161+162162+(** {1 Content-Type helpers} *)
163163+164164+let text ?(status = `OK) body : t =
165165+ {
166166+ status;
167167+ headers = [ ("Content-Type", "text/plain; charset=utf-8") ];
168168+ response_body = H1_server.Body_string body;
169169+ }
170170+171171+let html ?(status = `OK) body : t =
172172+ {
173173+ status;
174174+ headers = [ ("Content-Type", "text/html; charset=utf-8") ];
175175+ response_body = H1_server.Body_string body;
176176+ }
177177+178178+let json ?(status = `OK) body : t =
179179+ {
180180+ status;
181181+ headers = [ ("Content-Type", "application/json; charset=utf-8") ];
182182+ response_body = H1_server.Body_string body;
183183+ }
184184+185185+let xml ?(status = `OK) body : t =
186186+ {
187187+ status;
188188+ headers = [ ("Content-Type", "application/xml; charset=utf-8") ];
189189+ response_body = H1_server.Body_string body;
190190+ }
191191+192192+(** {1 Response modifiers} *)
193193+194194+(** Add a header to the response *)
195195+let with_header name value (resp : t) : t =
196196+ { resp with headers = (name, value) :: resp.headers }
197197+198198+(** Add multiple headers to the response *)
199199+let with_headers headers (resp : t) : t =
200200+ { resp with headers = headers @ resp.headers }
201201+202202+(** Replace the response body *)
203203+let with_body body (resp : t) : t =
204204+ { resp with response_body = H1_server.Body_string body }
205205+206206+(** Replace the response status *)
207207+let with_status status (resp : t) : t = { resp with status }
208208+209209+(** Set Content-Type header *)
210210+let with_content_type content_type resp =
211211+ with_header "Content-Type" content_type resp
212212+213213+(** Set Cache-Control header *)
214214+let with_cache_control directive resp =
215215+ with_header "Cache-Control" directive resp
216216+217217+(** Set no-cache headers *)
218218+let with_no_cache resp =
219219+ resp
220220+ |> with_header "Cache-Control" "no-store, no-cache, must-revalidate"
221221+ |> with_header "Pragma" "no-cache"
222222+223223+(** Set CORS headers for all origins *)
224224+let with_cors ?(origin = "*") ?(methods = "GET, POST, PUT, DELETE, OPTIONS")
225225+ ?(headers = "Content-Type, Authorization") resp =
226226+ resp
227227+ |> with_header "Access-Control-Allow-Origin" origin
228228+ |> with_header "Access-Control-Allow-Methods" methods
229229+ |> with_header "Access-Control-Allow-Headers" headers
230230+231231+(** {1 Cookie helpers} *)
232232+233233+(** Set a cookie *)
234234+let with_cookie ?(path = "/") ?(http_only = true) ?(secure = false)
235235+ ?(same_site = "Lax") ?max_age name value resp =
236236+ let parts = [ Printf.sprintf "%s=%s" name value; "Path=" ^ path ] in
237237+ let parts = if http_only then parts @ [ "HttpOnly" ] else parts in
238238+ let parts = if secure then parts @ [ "Secure" ] else parts in
239239+ let parts = parts @ [ "SameSite=" ^ same_site ] in
240240+ let parts =
241241+ match max_age with
242242+ | Some age -> parts @ [ Printf.sprintf "Max-Age=%d" age ]
243243+ | None -> parts
244244+ in
245245+ with_header "Set-Cookie" (String.concat "; " parts) resp
246246+247247+(** Clear a cookie *)
248248+let clear_cookie ?(path = "/") name resp =
249249+ with_cookie ~path ~max_age:0 name "" resp
+176
lib/router.ml
···11+(** Type-safe router with radix trie for efficient path matching.
22+33+ This module provides:
44+ - Type-safe path patterns with parameter extraction
55+ - Radix trie for O(path_length) route lookup
66+ - Hashtbl for O(1) literal segment matching
77+ - Middleware support
88+ - Route scoping/grouping *)
99+1010+(** Path segment types *)
1111+type segment =
1212+ | Literal of string (** Exact match *)
1313+ | Param of string (** Named parameter capture *)
1414+ | Wildcard (** Match rest of path *)
1515+1616+type params = (string * string) list
1717+(** Parsed path parameters *)
1818+1919+type 'a route = {
2020+ method_ : H1.Method.t option; (** None = match any method *)
2121+ segments : segment list;
2222+ handler : 'a;
2323+}
2424+(** A route definition *)
2525+2626+type 'a trie_node = {
2727+ mutable handlers : (H1.Method.t option * 'a) list;
2828+ (** Handlers at this node *)
2929+ literal_children : (string, 'a trie_node) Hashtbl.t;
3030+ (** O(1) lookup for literal segments *)
3131+ mutable param_child : (string * 'a trie_node) option;
3232+ (** Single param child (with param name) *)
3333+ mutable wildcard_child : 'a trie_node option; (** Single wildcard child *)
3434+}
3535+(** Radix trie node with optimized child storage *)
3636+3737+type 'a t = { root : 'a trie_node }
3838+(** Compiled router *)
3939+4040+(** Create empty trie node *)
4141+let empty_node () =
4242+ {
4343+ handlers = [];
4444+ literal_children = Hashtbl.create 8;
4545+ param_child = None;
4646+ wildcard_child = None;
4747+ }
4848+4949+(** Create empty router *)
5050+let empty () = { root = empty_node () }
5151+5252+(** Parse path string into segments *)
5353+let parse_path path =
5454+ let path =
5555+ if String.length path > 0 && path.[0] = '/' then
5656+ String.sub path 1 (String.length path - 1)
5757+ else path
5858+ in
5959+ if path = "" then []
6060+ else
6161+ String.split_on_char '/' path
6262+ |> List.filter (fun s -> s <> "")
6363+ |> List.map (fun s ->
6464+ if String.length s > 0 && s.[0] = ':' then
6565+ Param (String.sub s 1 (String.length s - 1))
6666+ else if s = "*" then Wildcard
6767+ else Literal s)
6868+6969+(** Find or create child node for segment *)
7070+let find_or_create_child node seg =
7171+ match seg with
7272+ | Literal s -> (
7373+ match Hashtbl.find_opt node.literal_children s with
7474+ | Some child -> child
7575+ | None ->
7676+ let child = empty_node () in
7777+ Hashtbl.add node.literal_children s child;
7878+ child)
7979+ | Param name -> (
8080+ match node.param_child with
8181+ | Some (_, child) -> child
8282+ | None ->
8383+ let child = empty_node () in
8484+ node.param_child <- Some (name, child);
8585+ child)
8686+ | Wildcard -> (
8787+ match node.wildcard_child with
8888+ | Some child -> child
8989+ | None ->
9090+ let child = empty_node () in
9191+ node.wildcard_child <- Some child;
9292+ child)
9393+9494+(** Add a route to the trie *)
9595+let add_route router ~method_ ~path ~handler =
9696+ let segments = parse_path path in
9797+ let rec insert node = function
9898+ | [] -> node.handlers <- (method_, handler) :: node.handlers
9999+ | seg :: rest ->
100100+ let child = find_or_create_child node seg in
101101+ insert child rest
102102+ in
103103+ insert router.root segments
104104+105105+(** Lookup a path in the trie - optimized with index-based parsing *)
106106+let lookup router ~method_ ~path =
107107+ let len = String.length path in
108108+ let start = if len > 0 && path.[0] = '/' then 1 else 0 in
109109+110110+ let rec search node pos params =
111111+ if pos >= len then
112112+ List.find_opt
113113+ (fun (m, _) -> match m with None -> true | Some m' -> m' = method_)
114114+ node.handlers
115115+ |> Option.map (fun (_, handler) -> (handler, params))
116116+ else
117117+ let seg_end =
118118+ try String.index_from path pos '/' with Not_found -> len
119119+ in
120120+ if seg_end = pos then search node (pos + 1) params
121121+ else
122122+ let seg = String.sub path pos (seg_end - pos) in
123123+ let next_pos = if seg_end < len then seg_end + 1 else len in
124124+ match Hashtbl.find_opt node.literal_children seg with
125125+ | Some child -> search child next_pos params
126126+ | None -> (
127127+ match node.param_child with
128128+ | Some (name, child) -> search child next_pos ((name, seg) :: params)
129129+ | None -> (
130130+ match node.wildcard_child with
131131+ | Some child ->
132132+ let rest_path = String.sub path pos (len - pos) in
133133+ search child len (("*", rest_path) :: params)
134134+ | None -> None))
135135+ in
136136+ search router.root start []
137137+138138+(** Route builder DSL *)
139139+module Route = struct
140140+ type 'a t = { method_ : H1.Method.t option; path : string; handler : 'a }
141141+142142+ let get path handler = { method_ = Some `GET; path; handler }
143143+ let post path handler = { method_ = Some `POST; path; handler }
144144+ let put path handler = { method_ = Some `PUT; path; handler }
145145+ let delete path handler = { method_ = Some `DELETE; path; handler }
146146+ let patch path handler = { method_ = Some (`Other "PATCH"); path; handler }
147147+ let head path handler = { method_ = Some `HEAD; path; handler }
148148+ let options path handler = { method_ = Some `OPTIONS; path; handler }
149149+ let any path handler = { method_ = None; path; handler }
150150+end
151151+152152+(** Compile routes into a router *)
153153+let compile routes =
154154+ let router = empty () in
155155+ List.iter
156156+ (fun r ->
157157+ add_route router ~method_:r.Route.method_ ~path:r.path ~handler:r.handler)
158158+ routes;
159159+ router
160160+161161+(** Get parameter from params list *)
162162+let param name params = List.assoc_opt name params
163163+164164+(** Get parameter with default *)
165165+let param_or name ~default params =
166166+ match List.assoc_opt name params with Some v -> v | None -> default
167167+168168+(** Get parameter as int *)
169169+let param_int name params =
170170+ match List.assoc_opt name params with
171171+ | Some v -> int_of_string_opt v
172172+ | None -> None
173173+174174+(** Get parameter as int with default *)
175175+let param_int_or name ~default params =
176176+ match param_int name params with Some v -> v | None -> default
+1195
lib/server.ml
···11+(** Unified HTTP Server - High-performance server supporting HTTP/1.1, HTTP/2,
22+ and WebSocket.
33+44+ This module consolidates all server functionality into a single,
55+ high-performance implementation. Protocol detection overhead is optional and
66+ controlled via the [protocol] configuration.
77+88+ {2 Protocol Modes}
99+1010+ - [Http1_only]: Fastest path. No protocol detection, direct HTTP/1.1
1111+ handling.
1212+ - [Http2_only]: HTTP/2 only. h2c (cleartext) or h2 (over TLS).
1313+ - [Auto]: Auto-detect protocol via connection preface peek (h2c) or ALPN
1414+ (TLS).
1515+ - [Auto_websocket]: Auto-detect with WebSocket upgrade support.
1616+1717+ {2 Performance Features}
1818+1919+ - GC tuning for high-throughput scenarios
2020+ - Cached Date headers (1-second resolution)
2121+ - Pre-built responses for zero-allocation hot paths
2222+ - Zero-copy bigstring responses
2323+ - Streaming response support
2424+ - Multi-domain parallelism via Eio
2525+2626+ {2 Example}
2727+2828+ {[
2929+ (* Fastest HTTP/1.1 server *)
3030+ let handler req = Server.respond "Hello, World!" in
3131+ Server.run ~sw ~net handler
3232+3333+ (* Multi-protocol with auto-detection *)
3434+ let config = Server.{ default_config with protocol = Auto } in
3535+ Server.run ~sw ~net ~config handler
3636+ ]} *)
3737+3838+open Eio.Std
3939+4040+(** {1 Protocol Configuration} *)
4141+4242+(** Protocol mode controls detection overhead and supported protocols. *)
4343+type protocol =
4444+ | Http1_only
4545+ (** Fastest: No protocol detection, direct HTTP/1.1 handling. With TLS:
4646+ HTTP/1.1 over TLS (no ALPN negotiation). *)
4747+ | Http2_only
4848+ (** HTTP/2 only. Without TLS: h2c (HTTP/2 cleartext). With TLS: h2 with
4949+ ALPN advertising only "h2". *)
5050+ | Auto
5151+ (** Auto-detect protocol. Without TLS: Peek for "PRI " preface (h2c),
5252+ fallback to HTTP/1.1. With TLS: ALPN negotiation. *)
5353+ | Auto_websocket
5454+ (** Auto-detect with WebSocket support. Same as [Auto] but also handles
5555+ WebSocket upgrade requests in HTTP/1.1 mode. *)
5656+5757+(** {1 GC Tuning} *)
5858+5959+module Gc_tune = struct
6060+ type config = {
6161+ minor_heap_size : int; (** Minor heap size in bytes. Default: 64MB *)
6262+ major_heap_increment : int;
6363+ (** Major heap increment in bytes. Default: 32MB *)
6464+ space_overhead : int; (** Space overhead percentage. Default: 200 *)
6565+ max_overhead : int; (** Max overhead percentage. Default: 500 *)
6666+ }
6767+6868+ let default =
6969+ {
7070+ minor_heap_size = 64 * 1024 * 1024;
7171+ major_heap_increment = 32 * 1024 * 1024;
7272+ space_overhead = 200;
7373+ max_overhead = 500;
7474+ }
7575+7676+ let aggressive =
7777+ {
7878+ minor_heap_size = 128 * 1024 * 1024;
7979+ major_heap_increment = 64 * 1024 * 1024;
8080+ space_overhead = 400;
8181+ max_overhead = 1000;
8282+ }
8383+8484+ let tuned = ref false
8585+8686+ let apply ?(config = default) () =
8787+ if not !tuned then begin
8888+ let ctrl = Gc.get () in
8989+ Gc.set
9090+ {
9191+ ctrl with
9292+ minor_heap_size = config.minor_heap_size / (Sys.word_size / 8);
9393+ major_heap_increment =
9494+ config.major_heap_increment / (Sys.word_size / 8);
9595+ space_overhead = config.space_overhead;
9696+ max_overhead = config.max_overhead;
9797+ };
9898+ tuned := true
9999+ end
100100+end
101101+102102+(** {1 Date Header Cache} *)
103103+104104+module Date_cache = struct
105105+ let day_names = [| "Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat" |]
106106+107107+ let month_names =
108108+ [|
109109+ "Jan";
110110+ "Feb";
111111+ "Mar";
112112+ "Apr";
113113+ "May";
114114+ "Jun";
115115+ "Jul";
116116+ "Aug";
117117+ "Sep";
118118+ "Oct";
119119+ "Nov";
120120+ "Dec";
121121+ |]
122122+123123+ let cached_date = Atomic.make ""
124124+ let cached_time = Atomic.make 0.
125125+126126+ let format_date () =
127127+ let t = Unix.gettimeofday () in
128128+ let tm = Unix.gmtime t in
129129+ Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT"
130130+ day_names.(tm.Unix.tm_wday)
131131+ tm.Unix.tm_mday
132132+ month_names.(tm.Unix.tm_mon)
133133+ (1900 + tm.Unix.tm_year) tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
134134+135135+ let[@inline] get () =
136136+ let now = Unix.gettimeofday () in
137137+ let last = Atomic.get cached_time in
138138+ if now -. last >= 1.0 then begin
139139+ let date = format_date () in
140140+ Atomic.set cached_date date;
141141+ Atomic.set cached_time now;
142142+ date
143143+ end
144144+ else Atomic.get cached_date
145145+end
146146+147147+(** {1 Pre-built Responses} *)
148148+149149+(** Pre-built response for zero-allocation hot paths. Create once at startup,
150150+ respond many times without allocation. *)
151151+module Prebuilt = struct
152152+ type t = {
153153+ status : H1.Status.t;
154154+ headers : H1.Headers.t;
155155+ body : Bigstringaf.t;
156156+ (* Cached H1 response with date header - regenerated per-second (thread-safe) *)
157157+ cached_h1_response : H1.Response.t Atomic.t;
158158+ cached_second : int Atomic.t;
159159+ }
160160+161161+ (** Create a pre-built response from a string body. *)
162162+ let create ~status ?(headers = []) body =
163163+ let body_bstr =
164164+ Bigstringaf.of_string ~off:0 ~len:(String.length body) body
165165+ in
166166+ let body_len = Bigstringaf.length body_bstr in
167167+ let all_headers = ("content-length", string_of_int body_len) :: headers in
168168+ let h1_headers = H1.Headers.of_list all_headers in
169169+ let now = int_of_float (Unix.gettimeofday ()) in
170170+ let headers_with_date =
171171+ H1.Headers.add h1_headers "date" (Date_cache.get ())
172172+ in
173173+ let cached_resp = H1.Response.create ~headers:headers_with_date status in
174174+ {
175175+ status;
176176+ headers = h1_headers;
177177+ body = body_bstr;
178178+ cached_h1_response = Atomic.make cached_resp;
179179+ cached_second = Atomic.make now;
180180+ }
181181+182182+ (** Create a pre-built response from a bigstring body. *)
183183+ let create_bigstring ~status ?(headers = []) body =
184184+ let body_len = Bigstringaf.length body in
185185+ let all_headers = ("content-length", string_of_int body_len) :: headers in
186186+ let h1_headers = H1.Headers.of_list all_headers in
187187+ let now = int_of_float (Unix.gettimeofday ()) in
188188+ let headers_with_date =
189189+ H1.Headers.add h1_headers "date" (Date_cache.get ())
190190+ in
191191+ let cached_resp = H1.Response.create ~headers:headers_with_date status in
192192+ {
193193+ status;
194194+ headers = h1_headers;
195195+ body;
196196+ cached_h1_response = Atomic.make cached_resp;
197197+ cached_second = Atomic.make now;
198198+ }
199199+200200+ (** Get cached H1 response, regenerating if second has changed. *)
201201+ let[@inline] get_cached_h1_response t =
202202+ let now = int_of_float (Unix.gettimeofday ()) in
203203+ let last = Atomic.get t.cached_second in
204204+ if now <> last then begin
205205+ let headers = H1.Headers.add t.headers "date" (Date_cache.get ()) in
206206+ let resp = H1.Response.create ~headers t.status in
207207+ Atomic.set t.cached_h1_response resp;
208208+ Atomic.set t.cached_second now
209209+ end;
210210+ Atomic.get t.cached_h1_response
211211+212212+ (** Respond to an H1 request descriptor with a pre-built response. Uses cached
213213+ response that only regenerates when the second changes. *)
214214+ let[@inline] respond_h1 reqd t =
215215+ let response = get_cached_h1_response t in
216216+ H1.Reqd.respond_with_bigstring reqd response t.body
217217+218218+ (** Respond to an H2 request descriptor with a pre-built response. *)
219219+ let[@inline] respond_h2 reqd t =
220220+ let result = ref [] in
221221+ H1.Headers.iter
222222+ ~f:(fun name value -> result := (name, value) :: !result)
223223+ t.headers;
224224+ let h2_headers = H2.Headers.of_list (List.rev !result) in
225225+ let h2_status = (t.status :> H2.Status.t) in
226226+ let response = H2.Response.create ~headers:h2_headers h2_status in
227227+ H2.Reqd.respond_with_bigstring reqd response t.body
228228+end
229229+230230+(** {1 Types} *)
231231+232232+type config = {
233233+ (* Network *)
234234+ host : string; (** Bind address. Default: "0.0.0.0" *)
235235+ port : int; (** Listen port. Default: 8080 *)
236236+ backlog : int; (** Listen backlog. Default: 4096 *)
237237+ max_connections : int; (** Max concurrent connections. Default: 100000 *)
238238+ (* Parallelism *)
239239+ domain_count : int; (** Number of domains (CPUs) to use. Default: 1 *)
240240+ (* Protocol *)
241241+ protocol : protocol; (** Protocol mode. Default: Http1_only *)
242242+ (* Timeouts *)
243243+ read_timeout : float; (** Read timeout in seconds. Default: 60.0 *)
244244+ write_timeout : float; (** Write timeout in seconds. Default: 60.0 *)
245245+ idle_timeout : float; (** Idle connection timeout. Default: 120.0 *)
246246+ request_timeout : float; (** Request processing timeout. Default: 30.0 *)
247247+ (* Limits *)
248248+ max_header_size : int; (** Max header size in bytes. Default: 8192 *)
249249+ max_body_size : int64 option;
250250+ (** Max body size. None = unlimited. Default: None *)
251251+ (* Buffers *)
252252+ buffer_size : int; (** Read buffer size. Default: 16384 *)
253253+ (* Socket options *)
254254+ tcp_nodelay : bool; (** Set TCP_NODELAY on connections. Default: true *)
255255+ reuse_addr : bool; (** Set SO_REUSEADDR on listener. Default: true *)
256256+ reuse_port : bool; (** Set SO_REUSEPORT on listener. Default: true *)
257257+ (* TLS *)
258258+ tls : Tls_config.Server.t option; (** TLS config. None = plain HTTP *)
259259+ (* Performance *)
260260+ gc_tuning : Gc_tune.config option;
261261+ (** GC tuning config. Some = apply tuning. Default: Some Gc_tune.default
262262+ *)
263263+}
264264+(** Server configuration. *)
265265+266266+(** Default configuration optimized for HTTP/1.1 performance. *)
267267+let default_config =
268268+ {
269269+ host = "0.0.0.0";
270270+ port = 8080;
271271+ backlog = 4096;
272272+ max_connections = 100000;
273273+ domain_count = 1;
274274+ protocol = Http1_only;
275275+ read_timeout = 60.0;
276276+ write_timeout = 60.0;
277277+ idle_timeout = 120.0;
278278+ request_timeout = 30.0;
279279+ max_header_size = 8192;
280280+ max_body_size = None;
281281+ buffer_size = 16384;
282282+ tcp_nodelay = true;
283283+ reuse_addr = true;
284284+ reuse_port = true;
285285+ tls = None;
286286+ gc_tuning = Some Gc_tune.default;
287287+ }
288288+289289+(** Configuration for auto-detection mode. *)
290290+let auto_config = { default_config with protocol = Auto }
291291+292292+(** Configuration for WebSocket support. *)
293293+let websocket_config = { default_config with protocol = Auto_websocket }
294294+295295+(** {2 Config Builders} *)
296296+297297+let with_port port config = { config with port }
298298+let with_host host config = { config with host }
299299+let with_backlog backlog config = { config with backlog }
300300+let with_max_connections max config = { config with max_connections = max }
301301+let with_domain_count count config = { config with domain_count = count }
302302+let with_protocol protocol config = { config with protocol }
303303+let with_read_timeout timeout config = { config with read_timeout = timeout }
304304+let with_write_timeout timeout config = { config with write_timeout = timeout }
305305+let with_idle_timeout timeout config = { config with idle_timeout = timeout }
306306+307307+let with_request_timeout timeout config =
308308+ { config with request_timeout = timeout }
309309+310310+let with_max_header_size size config = { config with max_header_size = size }
311311+let with_max_body_size size config = { config with max_body_size = Some size }
312312+let with_buffer_size size config = { config with buffer_size = size }
313313+let with_tcp_nodelay enabled config = { config with tcp_nodelay = enabled }
314314+let with_tls tls config = { config with tls = Some tls }
315315+let with_gc_tuning gc config = { config with gc_tuning = Some gc }
316316+let without_gc_tuning config = { config with gc_tuning = None }
317317+318318+(** {1 Request/Response Types} *)
319319+320320+(** Protocol version indicator. *)
321321+type protocol_version = HTTP_1_1 | HTTP_2
322322+323323+type request = {
324324+ meth : H1.Method.t; (** HTTP method *)
325325+ target : string; (** Request target (path + query) *)
326326+ headers : (string * string) list; (** Headers as association list *)
327327+ body : string; (** Request body (empty for GET/HEAD) *)
328328+ version : protocol_version; (** Protocol version *)
329329+}
330330+(** Request type exposed to handlers. *)
331331+332332+(** Response body variants. *)
333333+type response_body =
334334+ | Body_empty (** Empty body (for 204 No Content, etc.) *)
335335+ | Body_string of string (** String body - will be copied *)
336336+ | Body_bigstring of Bigstringaf.t (** Bigstring body - zero-copy *)
337337+ | Body_prebuilt of Prebuilt.t (** Pre-built response - zero allocation *)
338338+ | Body_stream of {
339339+ content_length : int64 option;
340340+ next : unit -> Cstruct.t option;
341341+ } (** Streaming body *)
342342+343343+type response = {
344344+ status : H1.Status.t;
345345+ headers : (string * string) list;
346346+ body : response_body;
347347+}
348348+(** Response type. *)
349349+350350+type handler = request -> response
351351+(** Handler type. *)
352352+353353+type ws_handler = Websocket.t -> unit
354354+(** WebSocket handler type. *)
355355+356356+(** {1 Response Helpers} *)
357357+358358+(** Create a response with a string body. *)
359359+let respond ?(status = `OK) ?(headers = []) body =
360360+ { status; headers; body = Body_string body }
361361+362362+(** Create a response with a bigstring body (zero-copy). *)
363363+let respond_bigstring ?(status = `OK) ?(headers = []) body =
364364+ { status; headers; body = Body_bigstring body }
365365+366366+(** Create a response with a pre-built body (zero-allocation). *)
367367+let respond_prebuilt prebuilt =
368368+ {
369369+ status = prebuilt.Prebuilt.status;
370370+ headers = [];
371371+ body = Body_prebuilt prebuilt;
372372+ }
373373+374374+(** Create an empty response. *)
375375+let respond_empty ?(status = `No_content) ?(headers = []) () =
376376+ { status; headers; body = Body_empty }
377377+378378+(** Create a streaming response. *)
379379+let respond_stream ?(status = `OK) ?(headers = []) ?content_length next =
380380+ { status; headers; body = Body_stream { content_length; next } }
381381+382382+(** Create a plain text response. *)
383383+let respond_text ?(status = `OK) body =
384384+ {
385385+ status;
386386+ headers = [ ("content-type", "text/plain; charset=utf-8") ];
387387+ body = Body_string body;
388388+ }
389389+390390+(** Create an HTML response. *)
391391+let respond_html ?(status = `OK) body =
392392+ {
393393+ status;
394394+ headers = [ ("content-type", "text/html; charset=utf-8") ];
395395+ body = Body_string body;
396396+ }
397397+398398+(** Create a JSON response. *)
399399+let respond_json ?(status = `OK) body =
400400+ {
401401+ status;
402402+ headers = [ ("content-type", "application/json") ];
403403+ body = Body_string body;
404404+ }
405405+406406+(** {1 Internal: Socket Helpers} *)
407407+408408+let set_tcp_nodelay flow =
409409+ match Eio_unix.Resource.fd_opt flow with
410410+ | None -> ()
411411+ | Some fd ->
412412+ Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd ->
413413+ Unix.setsockopt unix_fd Unix.TCP_NODELAY true)
414414+415415+let shutdown_flow flow cmd =
416416+ try Eio.Flow.shutdown flow cmd with
417417+ | Unix.Unix_error (Unix.ENOTCONN, _, _) -> ()
418418+ | Eio.Io (Eio.Exn.X (Eio_unix.Unix_error (Unix.ENOTCONN, _, _)), _) -> ()
419419+420420+let[@inline] writev flow iovecs =
421421+ match iovecs with
422422+ | [] -> `Ok 0
423423+ | [ { Httpun_types.IOVec.buffer; off; len } ] -> (
424424+ let cs = Cstruct.of_bigarray buffer ~off ~len in
425425+ match Eio.Flow.write flow [ cs ] with
426426+ | () -> `Ok len
427427+ | exception End_of_file -> `Closed)
428428+ | _ -> (
429429+ let lenv, cstructs =
430430+ List.fold_left_map
431431+ (fun acc { Httpun_types.IOVec.buffer; off; len } ->
432432+ (acc + len, Cstruct.of_bigarray buffer ~off ~len))
433433+ 0 iovecs
434434+ in
435435+ match Eio.Flow.write flow cstructs with
436436+ | () -> `Ok lenv
437437+ | exception End_of_file -> `Closed)
438438+439439+(** {1 Internal: Header Conversion} *)
440440+441441+let h1_headers_to_list headers =
442442+ let result = ref [] in
443443+ H1.Headers.iter
444444+ ~f:(fun name value -> result := (name, value) :: !result)
445445+ headers;
446446+ List.rev !result
447447+448448+let h2_headers_to_list headers =
449449+ let result = ref [] in
450450+ H2.Headers.iter
451451+ ~f:(fun name value -> result := (name, value) :: !result)
452452+ headers;
453453+ List.rev !result
454454+455455+(** {1 Internal: Protocol Detection} *)
456456+457457+(** HTTP/2 connection preface starts with "PRI " *)
458458+let h2_preface_prefix = "PRI "
459459+460460+let h2_preface_prefix_len = 4
461461+462462+let peek_bytes flow n =
463463+ let buf = Cstruct.create n in
464464+ try
465465+ let read = Eio.Flow.single_read flow buf in
466466+ Ok (Cstruct.to_string (Cstruct.sub buf 0 read))
467467+ with
468468+ | End_of_file -> Error `Eof
469469+ | exn -> Error (`Exn exn)
470470+471471+let is_h2_preface data =
472472+ String.length data >= h2_preface_prefix_len
473473+ && String.sub data 0 h2_preface_prefix_len = h2_preface_prefix
474474+475475+(** {1 Internal: HTTP/1.1 Connection Handler} *)
476476+477477+module H1_handler = struct
478478+ let handle ~handler ~ws_handler ~initial_data flow =
479479+ let buffer_size = 16384 in
480480+ let read_buffer = Bigstringaf.create buffer_size in
481481+ let read_cstruct =
482482+ Cstruct.of_bigarray read_buffer ~off:0 ~len:buffer_size
483483+ in
484484+485485+ let pending_data = ref initial_data in
486486+ let ws_upgrade = ref None in
487487+488488+ let request_handler reqd =
489489+ let req = H1.Reqd.request reqd in
490490+ let h1_body = H1.Reqd.request_body reqd in
491491+492492+ (* Check for WebSocket upgrade *)
493493+ if Option.is_some ws_handler && Websocket.is_upgrade_request req.headers
494494+ then begin
495495+ match Websocket.get_websocket_key req.headers with
496496+ | Some key ->
497497+ ws_upgrade := Some key;
498498+ H1.Body.Reader.close h1_body;
499499+ let accept = Websocket.compute_accept_key key in
500500+ let headers =
501501+ H1.Headers.of_list
502502+ [
503503+ ("upgrade", "websocket");
504504+ ("connection", "Upgrade");
505505+ ("sec-websocket-accept", accept);
506506+ ]
507507+ in
508508+ H1.Reqd.respond_with_upgrade reqd headers
509509+ | None ->
510510+ H1.Body.Reader.close h1_body;
511511+ let headers =
512512+ H1.Headers.of_list
513513+ [ ("date", Date_cache.get ()); ("content-length", "11") ]
514514+ in
515515+ let resp = H1.Response.create ~headers `Bad_request in
516516+ H1.Reqd.respond_with_string reqd resp "Bad Request"
517517+ end
518518+ else begin
519519+ (* Regular HTTP/1.1 request *)
520520+ (* Read body for POST/PUT, skip for GET/HEAD *)
521521+ let body =
522522+ match req.meth with
523523+ | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE ->
524524+ H1.Body.Reader.close h1_body;
525525+ ""
526526+ | `POST | `PUT | `Other _ ->
527527+ let body_buffer = Buffer.create 4096 in
528528+ let body_done, resolver = Eio.Promise.create () in
529529+ let rec read_body () =
530530+ H1.Body.Reader.schedule_read h1_body
531531+ ~on_eof:(fun () -> Eio.Promise.resolve resolver ())
532532+ ~on_read:(fun buf ~off ~len ->
533533+ Buffer.add_string body_buffer
534534+ (Bigstringaf.substring buf ~off ~len);
535535+ read_body ())
536536+ in
537537+ read_body ();
538538+ Eio.Promise.await body_done;
539539+ Buffer.contents body_buffer
540540+ in
541541+542542+ let request =
543543+ {
544544+ meth = req.meth;
545545+ target = req.target;
546546+ headers = h1_headers_to_list req.headers;
547547+ body;
548548+ version = HTTP_1_1;
549549+ }
550550+ in
551551+ let response = handler request in
552552+553553+ (* Send response *)
554554+ let date_header = ("date", Date_cache.get ()) in
555555+ match response.body with
556556+ | Body_prebuilt prebuilt -> Prebuilt.respond_h1 reqd prebuilt
557557+ | Body_empty ->
558558+ let headers =
559559+ H1.Headers.of_list
560560+ (date_header :: ("content-length", "0") :: response.headers)
561561+ in
562562+ let resp = H1.Response.create ~headers response.status in
563563+ H1.Reqd.respond_with_string reqd resp ""
564564+ | Body_string body ->
565565+ let headers =
566566+ H1.Headers.of_list
567567+ (date_header
568568+ :: ("content-length", string_of_int (String.length body))
569569+ :: response.headers)
570570+ in
571571+ let resp = H1.Response.create ~headers response.status in
572572+ H1.Reqd.respond_with_string reqd resp body
573573+ | Body_bigstring body ->
574574+ let headers =
575575+ H1.Headers.of_list
576576+ (date_header
577577+ :: ("content-length", string_of_int (Bigstringaf.length body))
578578+ :: response.headers)
579579+ in
580580+ let resp = H1.Response.create ~headers response.status in
581581+ H1.Reqd.respond_with_bigstring reqd resp body
582582+ | Body_stream { content_length; next } ->
583583+ let headers =
584584+ match content_length with
585585+ | Some len ->
586586+ H1.Headers.of_list
587587+ (date_header
588588+ :: ("content-length", Int64.to_string len)
589589+ :: response.headers)
590590+ | None ->
591591+ H1.Headers.of_list
592592+ (date_header
593593+ :: ("transfer-encoding", "chunked")
594594+ :: response.headers)
595595+ in
596596+ let resp = H1.Response.create ~headers response.status in
597597+ let body_writer = H1.Reqd.respond_with_streaming reqd resp in
598598+ let rec write_chunks () =
599599+ match next () with
600600+ | None -> H1.Body.Writer.close body_writer
601601+ | Some cs ->
602602+ H1.Body.Writer.write_bigstring body_writer ~off:0
603603+ ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs);
604604+ write_chunks ()
605605+ in
606606+ write_chunks ()
607607+ end
608608+ in
609609+610610+ let error_handler ?request:_ _error start_response =
611611+ let resp_body = start_response H1.Headers.empty in
612612+ H1.Body.Writer.write_string resp_body "Internal Server Error";
613613+ H1.Body.Writer.close resp_body
614614+ in
615615+616616+ let conn = H1.Server_connection.create ~error_handler request_handler in
617617+ let shutdown = ref false in
618618+619619+ let rec read_loop () =
620620+ if not !shutdown then
621621+ match H1.Server_connection.next_read_operation conn with
622622+ | `Read ->
623623+ let socket_data =
624624+ match Eio.Flow.single_read flow read_cstruct with
625625+ | n -> Cstruct.to_string (Cstruct.sub read_cstruct 0 n)
626626+ | exception End_of_file -> ""
627627+ in
628628+ let data =
629629+ if String.length !pending_data > 0 then begin
630630+ let combined = !pending_data ^ socket_data in
631631+ pending_data := "";
632632+ combined
633633+ end
634634+ else socket_data
635635+ in
636636+ let len = String.length data in
637637+ if len = 0 then begin
638638+ let (_ : int) =
639639+ H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0
640640+ in
641641+ shutdown := true
642642+ end
643643+ else begin
644644+ Bigstringaf.blit_from_string data ~src_off:0 read_buffer
645645+ ~dst_off:0 ~len;
646646+ let (_ : int) =
647647+ H1.Server_connection.read conn read_buffer ~off:0 ~len
648648+ in
649649+ read_loop ()
650650+ end
651651+ | `Yield -> H1.Server_connection.yield_reader conn read_loop
652652+ | `Close -> shutdown := true
653653+ | `Upgrade -> shutdown := true
654654+ in
655655+656656+ let rec write_loop () =
657657+ if not !shutdown then
658658+ match H1.Server_connection.next_write_operation conn with
659659+ | `Write iovecs ->
660660+ let write_result = writev flow iovecs in
661661+ H1.Server_connection.report_write_result conn write_result;
662662+ write_loop ()
663663+ | `Yield -> H1.Server_connection.yield_writer conn write_loop
664664+ | `Close _ ->
665665+ shutdown := true;
666666+ shutdown_flow flow `Send
667667+ | `Upgrade -> shutdown := true
668668+ in
669669+670670+ Fiber.both read_loop write_loop;
671671+672672+ (* Handle WebSocket upgrade if requested *)
673673+ match !ws_upgrade with
674674+ | Some _key -> (
675675+ match ws_handler with
676676+ | Some ws_h ->
677677+ let ws =
678678+ {
679679+ Websocket.flow :> Eio.Flow.two_way_ty Eio.Std.r;
680680+ closed = false;
681681+ is_client = false;
682682+ read_buf = Buffer.create 4096;
683683+ }
684684+ in
685685+ (try ws_h ws with _ -> ());
686686+ if Websocket.is_open ws then Websocket.close ws
687687+ | None -> ())
688688+ | None -> ()
689689+690690+ (** Direct H1 handler - no protocol detection, no initial data buffering *)
691691+ let handle_direct ~handler flow =
692692+ let buffer_size = 16384 in
693693+ let read_buffer = Bigstringaf.create buffer_size in
694694+ let read_cstruct =
695695+ Cstruct.of_bigarray read_buffer ~off:0 ~len:buffer_size
696696+ in
697697+698698+ let request_handler reqd =
699699+ let req = H1.Reqd.request reqd in
700700+ let h1_body = H1.Reqd.request_body reqd in
701701+702702+ (* Read body for POST/PUT, skip for GET/HEAD *)
703703+ let body =
704704+ match req.meth with
705705+ | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE ->
706706+ H1.Body.Reader.close h1_body;
707707+ ""
708708+ | `POST | `PUT | `Other _ ->
709709+ let body_buffer = Buffer.create 4096 in
710710+ let body_done, resolver = Eio.Promise.create () in
711711+ let rec read_body () =
712712+ H1.Body.Reader.schedule_read h1_body
713713+ ~on_eof:(fun () -> Eio.Promise.resolve resolver ())
714714+ ~on_read:(fun buf ~off ~len ->
715715+ Buffer.add_string body_buffer
716716+ (Bigstringaf.substring buf ~off ~len);
717717+ read_body ())
718718+ in
719719+ read_body ();
720720+ Eio.Promise.await body_done;
721721+ Buffer.contents body_buffer
722722+ in
723723+724724+ let request =
725725+ {
726726+ meth = req.meth;
727727+ target = req.target;
728728+ headers = h1_headers_to_list req.headers;
729729+ body;
730730+ version = HTTP_1_1;
731731+ }
732732+ in
733733+ let response = handler request in
734734+735735+ (* Send response *)
736736+ let date_header = ("date", Date_cache.get ()) in
737737+ match response.body with
738738+ | Body_prebuilt prebuilt -> Prebuilt.respond_h1 reqd prebuilt
739739+ | Body_empty ->
740740+ let headers =
741741+ H1.Headers.of_list
742742+ (date_header :: ("content-length", "0") :: response.headers)
743743+ in
744744+ let resp = H1.Response.create ~headers response.status in
745745+ H1.Reqd.respond_with_string reqd resp ""
746746+ | Body_string body ->
747747+ let headers =
748748+ H1.Headers.of_list
749749+ (date_header
750750+ :: ("content-length", string_of_int (String.length body))
751751+ :: response.headers)
752752+ in
753753+ let resp = H1.Response.create ~headers response.status in
754754+ H1.Reqd.respond_with_string reqd resp body
755755+ | Body_bigstring body ->
756756+ let headers =
757757+ H1.Headers.of_list
758758+ (date_header
759759+ :: ("content-length", string_of_int (Bigstringaf.length body))
760760+ :: response.headers)
761761+ in
762762+ let resp = H1.Response.create ~headers response.status in
763763+ H1.Reqd.respond_with_bigstring reqd resp body
764764+ | Body_stream { content_length; next } ->
765765+ let headers =
766766+ match content_length with
767767+ | Some len ->
768768+ H1.Headers.of_list
769769+ (date_header
770770+ :: ("content-length", Int64.to_string len)
771771+ :: response.headers)
772772+ | None ->
773773+ H1.Headers.of_list
774774+ (date_header
775775+ :: ("transfer-encoding", "chunked")
776776+ :: response.headers)
777777+ in
778778+ let resp = H1.Response.create ~headers response.status in
779779+ let body_writer = H1.Reqd.respond_with_streaming reqd resp in
780780+ let rec write_chunks () =
781781+ match next () with
782782+ | None -> H1.Body.Writer.close body_writer
783783+ | Some cs ->
784784+ H1.Body.Writer.write_bigstring body_writer ~off:0
785785+ ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs);
786786+ write_chunks ()
787787+ in
788788+ write_chunks ()
789789+ in
790790+791791+ let error_handler ?request:_ _error start_response =
792792+ let resp_body = start_response H1.Headers.empty in
793793+ H1.Body.Writer.write_string resp_body "Internal Server Error";
794794+ H1.Body.Writer.close resp_body
795795+ in
796796+797797+ let conn = H1.Server_connection.create ~error_handler request_handler in
798798+ let shutdown = ref false in
799799+800800+ let rec read_loop () =
801801+ if not !shutdown then
802802+ match H1.Server_connection.next_read_operation conn with
803803+ | `Read -> (
804804+ match Eio.Flow.single_read flow read_cstruct with
805805+ | n ->
806806+ let (_ : int) =
807807+ H1.Server_connection.read conn read_buffer ~off:0 ~len:n
808808+ in
809809+ read_loop ()
810810+ | exception End_of_file ->
811811+ let (_ : int) =
812812+ H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0
813813+ in
814814+ shutdown := true)
815815+ | `Yield -> H1.Server_connection.yield_reader conn read_loop
816816+ | `Close | `Upgrade -> shutdown := true
817817+ in
818818+819819+ let rec write_loop () =
820820+ if not !shutdown then
821821+ match H1.Server_connection.next_write_operation conn with
822822+ | `Write iovecs ->
823823+ let write_result = writev flow iovecs in
824824+ H1.Server_connection.report_write_result conn write_result;
825825+ write_loop ()
826826+ | `Yield -> H1.Server_connection.yield_writer conn write_loop
827827+ | `Close _ ->
828828+ shutdown := true;
829829+ shutdown_flow flow `Send
830830+ | `Upgrade -> shutdown := true
831831+ in
832832+833833+ Fiber.both read_loop write_loop
834834+end
835835+836836+(** {1 Internal: HTTP/2 Connection Handler} *)
837837+838838+module H2_handler = struct
839839+ let handle ~handler ~initial_data flow =
840840+ let read_buffer_size = 0x4000 in
841841+ let read_buffer = Bigstringaf.create read_buffer_size in
842842+ let pending_data = ref initial_data in
843843+844844+ let request_handler reqd =
845845+ let req = H2.Reqd.request reqd in
846846+ let body_reader = H2.Reqd.request_body reqd in
847847+848848+ let body =
849849+ match req.meth with
850850+ | `GET | `HEAD ->
851851+ H2.Body.Reader.close body_reader;
852852+ ""
853853+ | _ ->
854854+ let body_buffer = Buffer.create 4096 in
855855+ let body_done, resolver = Eio.Promise.create () in
856856+ let rec read_body () =
857857+ H2.Body.Reader.schedule_read body_reader
858858+ ~on_eof:(fun () -> Eio.Promise.resolve resolver ())
859859+ ~on_read:(fun buf ~off ~len ->
860860+ Buffer.add_string body_buffer
861861+ (Bigstringaf.substring buf ~off ~len);
862862+ read_body ())
863863+ in
864864+ read_body ();
865865+ Eio.Promise.await body_done;
866866+ Buffer.contents body_buffer
867867+ in
868868+869869+ let target =
870870+ match H2.Headers.get req.headers ":path" with
871871+ | Some p -> p
872872+ | None -> "/"
873873+ in
874874+875875+ let request =
876876+ {
877877+ meth = req.meth;
878878+ target;
879879+ headers = h2_headers_to_list req.headers;
880880+ body;
881881+ version = HTTP_2;
882882+ }
883883+ in
884884+ let response = handler request in
885885+886886+ (* Convert H1.Status to H2.Status - they're compatible *)
887887+ let h2_status = (response.status :> H2.Status.t) in
888888+889889+ match response.body with
890890+ | Body_prebuilt prebuilt -> Prebuilt.respond_h2 reqd prebuilt
891891+ | Body_empty ->
892892+ let headers =
893893+ H2.Headers.of_list (("content-length", "0") :: response.headers)
894894+ in
895895+ let resp = H2.Response.create ~headers h2_status in
896896+ H2.Reqd.respond_with_string reqd resp ""
897897+ | Body_string body ->
898898+ let headers =
899899+ H2.Headers.of_list
900900+ (("content-length", string_of_int (String.length body))
901901+ :: response.headers)
902902+ in
903903+ let resp = H2.Response.create ~headers h2_status in
904904+ H2.Reqd.respond_with_string reqd resp body
905905+ | Body_bigstring body ->
906906+ let headers =
907907+ H2.Headers.of_list
908908+ (("content-length", string_of_int (Bigstringaf.length body))
909909+ :: response.headers)
910910+ in
911911+ let resp = H2.Response.create ~headers h2_status in
912912+ H2.Reqd.respond_with_bigstring reqd resp body
913913+ | Body_stream { content_length; next } ->
914914+ let headers =
915915+ match content_length with
916916+ | Some len ->
917917+ H2.Headers.of_list
918918+ (("content-length", Int64.to_string len) :: response.headers)
919919+ | None -> H2.Headers.of_list response.headers
920920+ in
921921+ let resp = H2.Response.create ~headers h2_status in
922922+ let body_writer = H2.Reqd.respond_with_streaming reqd resp in
923923+ let rec write_chunks () =
924924+ match next () with
925925+ | None -> H2.Body.Writer.close body_writer
926926+ | Some cs ->
927927+ H2.Body.Writer.write_bigstring body_writer ~off:0
928928+ ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs);
929929+ write_chunks ()
930930+ in
931931+ write_chunks ()
932932+ in
933933+934934+ let error_handler ?request:_ _error start_response =
935935+ let resp_body = start_response H2.Headers.empty in
936936+ H2.Body.Writer.write_string resp_body "Internal Server Error";
937937+ H2.Body.Writer.close resp_body
938938+ in
939939+940940+ let conn = H2.Server_connection.create ~error_handler request_handler in
941941+ let shutdown = ref false in
942942+943943+ let read_loop () =
944944+ let rec loop () =
945945+ if not !shutdown then
946946+ match H2.Server_connection.next_read_operation conn with
947947+ | `Read ->
948948+ let cs =
949949+ Cstruct.of_bigarray read_buffer ~off:0 ~len:read_buffer_size
950950+ in
951951+ let socket_data =
952952+ try
953953+ let n = Eio.Flow.single_read flow cs in
954954+ Cstruct.to_string (Cstruct.sub cs 0 n)
955955+ with End_of_file -> ""
956956+ in
957957+ let data =
958958+ if String.length !pending_data > 0 then begin
959959+ let combined = !pending_data ^ socket_data in
960960+ pending_data := "";
961961+ combined
962962+ end
963963+ else socket_data
964964+ in
965965+ let len = String.length data in
966966+ if len = 0 then begin
967967+ let _ =
968968+ H2.Server_connection.read_eof conn read_buffer ~off:0 ~len:0
969969+ in
970970+ shutdown := true
971971+ end
972972+ else begin
973973+ Bigstringaf.blit_from_string data ~src_off:0 read_buffer
974974+ ~dst_off:0 ~len;
975975+ let _ =
976976+ H2.Server_connection.read conn read_buffer ~off:0 ~len
977977+ in
978978+ loop ()
979979+ end
980980+ | `Close -> shutdown := true
981981+ in
982982+ loop ()
983983+ in
984984+985985+ let write_loop () =
986986+ let rec loop () =
987987+ if not !shutdown then
988988+ match H2.Server_connection.next_write_operation conn with
989989+ | `Write iovecs ->
990990+ let cstructs =
991991+ List.map
992992+ (fun iov ->
993993+ Cstruct.of_bigarray ~off:iov.H2.IOVec.off
994994+ ~len:iov.H2.IOVec.len iov.H2.IOVec.buffer)
995995+ iovecs
996996+ in
997997+ Eio.Flow.write flow cstructs;
998998+ let len =
999999+ List.fold_left (fun acc iov -> acc + iov.H2.IOVec.len) 0 iovecs
10001000+ in
10011001+ H2.Server_connection.report_write_result conn (`Ok len);
10021002+ loop ()
10031003+ | `Yield ->
10041004+ let continue = Eio.Promise.create () in
10051005+ H2.Server_connection.yield_writer conn (fun () ->
10061006+ Eio.Promise.resolve (snd continue) ());
10071007+ Eio.Promise.await (fst continue);
10081008+ loop ()
10091009+ | `Close _ -> shutdown := true
10101010+ in
10111011+ loop ()
10121012+ in
10131013+10141014+ Fiber.both read_loop write_loop
10151015+10161016+ (** Direct H2 handler - no protocol detection *)
10171017+ let handle_direct ~handler flow = handle ~handler ~initial_data:"" flow
10181018+end
10191019+10201020+(** {1 Internal: TLS Connection Handler} *)
10211021+10221022+module Tls_handler = struct
10231023+ let handle ~config ~handler ~ws_handler tls_cfg flow =
10241024+ try
10251025+ let tls_flow = Tls_eio.server_of_flow tls_cfg flow in
10261026+ match config.protocol with
10271027+ | Http1_only ->
10281028+ (* No ALPN check, direct H1 *)
10291029+ H1_handler.handle_direct ~handler tls_flow
10301030+ | Http2_only ->
10311031+ (* No ALPN check, direct H2 *)
10321032+ H2_handler.handle_direct ~handler tls_flow
10331033+ | Auto | Auto_websocket -> (
10341034+ (* Check ALPN negotiated protocol *)
10351035+ match Tls_config.negotiated_protocol tls_flow with
10361036+ | Some Tls_config.HTTP_2 -> H2_handler.handle_direct ~handler tls_flow
10371037+ | Some Tls_config.HTTP_1_1 | None ->
10381038+ if config.protocol = Auto_websocket then
10391039+ H1_handler.handle ~handler ~ws_handler:(Some ws_handler)
10401040+ ~initial_data:"" tls_flow
10411041+ else H1_handler.handle_direct ~handler tls_flow)
10421042+ with
10431043+ | Tls_eio.Tls_failure failure ->
10441044+ traceln "TLS error: %s" (Tls_config.failure_to_string failure)
10451045+ | exn -> traceln "Connection error: %s" (Printexc.to_string exn)
10461046+end
10471047+10481048+(** {1 Internal: Connection Handler} *)
10491049+10501050+let handle_connection ~config ~handler ~ws_handler flow =
10511051+ match config.protocol with
10521052+ | Http1_only ->
10531053+ (* Fastest path: direct H1, no detection *)
10541054+ H1_handler.handle_direct ~handler flow
10551055+ | Http2_only ->
10561056+ (* Direct H2 (h2c) *)
10571057+ H2_handler.handle_direct ~handler flow
10581058+ | Auto | Auto_websocket -> (
10591059+ (* Peek to detect protocol *)
10601060+ match peek_bytes flow h2_preface_prefix_len with
10611061+ | Error `Eof -> () (* Client disconnected immediately *)
10621062+ | Error (`Exn exn) ->
10631063+ traceln "Connection error: %s" (Printexc.to_string exn)
10641064+ | Ok initial_data ->
10651065+ if is_h2_preface initial_data then
10661066+ H2_handler.handle ~handler ~initial_data flow
10671067+ else if config.protocol = Auto_websocket then
10681068+ H1_handler.handle ~handler ~ws_handler:(Some ws_handler)
10691069+ ~initial_data flow
10701070+ else H1_handler.handle ~handler ~ws_handler:None ~initial_data flow)
10711071+10721072+(** {1 Public API} *)
10731073+10741074+(** Run an HTTP server.
10751075+10761076+ @param sw Switch for resource management
10771077+ @param net Eio network capability
10781078+ @param config Server configuration (default: [default_config])
10791079+ @param ws_handler WebSocket handler (required for [Auto_websocket] mode)
10801080+ @param handler Request handler *)
10811081+let run ~sw ~net ?(config = default_config) ?ws_handler handler =
10821082+ (* Apply GC tuning if configured *)
10831083+ (match config.gc_tuning with
10841084+ | Some gc_config -> Gc_tune.apply ~config:gc_config ()
10851085+ | None -> ());
10861086+10871087+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
10881088+ let socket =
10891089+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
10901090+ ~reuse_port:config.reuse_port net addr
10911091+ in
10921092+10931093+ let protocol_str =
10941094+ match (config.protocol, config.tls) with
10951095+ | Http1_only, None -> "HTTP/1.1"
10961096+ | Http1_only, Some _ -> "HTTP/1.1 (TLS)"
10971097+ | Http2_only, None -> "HTTP/2 h2c"
10981098+ | Http2_only, Some _ -> "HTTP/2 (TLS)"
10991099+ | Auto, None -> "HTTP/1.1 + HTTP/2 h2c"
11001100+ | Auto, Some _ -> "HTTP/1.1 + HTTP/2 (TLS, ALPN)"
11011101+ | Auto_websocket, None -> "HTTP/1.1 + HTTP/2 h2c + WebSocket"
11021102+ | Auto_websocket, Some _ -> "HTTP/1.1 + HTTP/2 + WebSocket (TLS, ALPN)"
11031103+ in
11041104+ traceln "Server listening on port %d (%s)" config.port protocol_str;
11051105+11061106+ (* Validate ws_handler for Auto_websocket mode *)
11071107+ let ws_handler =
11081108+ match (config.protocol, ws_handler) with
11091109+ | Auto_websocket, None ->
11101110+ failwith "WebSocket handler required for Auto_websocket mode"
11111111+ | Auto_websocket, Some h -> h
11121112+ | _, _ -> fun _ -> () (* Dummy handler for non-WS modes *)
11131113+ in
11141114+11151115+ let connection_handler flow _addr =
11161116+ if config.tcp_nodelay then set_tcp_nodelay flow;
11171117+ match config.tls with
11181118+ | None -> handle_connection ~config ~handler ~ws_handler flow
11191119+ | Some tls_config -> (
11201120+ match Tls_config.Server.to_tls_config tls_config with
11211121+ | Error (`Msg msg) -> traceln "TLS config error: %s" msg
11221122+ | Ok tls_cfg ->
11231123+ Tls_handler.handle ~config ~handler ~ws_handler tls_cfg flow)
11241124+ in
11251125+11261126+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
11271127+11281128+ Eio.Net.run_server socket connection_handler
11291129+ ~max_connections:config.max_connections ~on_error
11301130+11311131+(** Run an HTTP server with multi-domain parallelism.
11321132+11331133+ @param sw Switch for resource management
11341134+ @param net Eio network capability
11351135+ @param domain_mgr Eio domain manager
11361136+ @param config Server configuration (default: [default_config])
11371137+ @param ws_handler WebSocket handler (required for [Auto_websocket] mode)
11381138+ @param handler Request handler *)
11391139+let run_parallel ~sw ~net ~domain_mgr ?(config = default_config) ?ws_handler
11401140+ handler =
11411141+ (* Apply GC tuning if configured *)
11421142+ (match config.gc_tuning with
11431143+ | Some gc_config -> Gc_tune.apply ~config:gc_config ()
11441144+ | None -> ());
11451145+11461146+ let domain_count = max 1 config.domain_count in
11471147+ let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in
11481148+ let socket =
11491149+ Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr
11501150+ ~reuse_port:config.reuse_port net addr
11511151+ in
11521152+11531153+ let protocol_str =
11541154+ match (config.protocol, config.tls) with
11551155+ | Http1_only, None -> "HTTP/1.1"
11561156+ | Http1_only, Some _ -> "HTTP/1.1 (TLS)"
11571157+ | Http2_only, None -> "HTTP/2 h2c"
11581158+ | Http2_only, Some _ -> "HTTP/2 (TLS)"
11591159+ | Auto, None -> "HTTP/1.1 + HTTP/2 h2c"
11601160+ | Auto, Some _ -> "HTTP/1.1 + HTTP/2 (TLS, ALPN)"
11611161+ | Auto_websocket, None -> "HTTP/1.1 + HTTP/2 h2c + WebSocket"
11621162+ | Auto_websocket, Some _ -> "HTTP/1.1 + HTTP/2 + WebSocket (TLS, ALPN)"
11631163+ in
11641164+ traceln "Server listening on port %d (%s, %d domains)" config.port
11651165+ protocol_str domain_count;
11661166+11671167+ (* Validate ws_handler for Auto_websocket mode *)
11681168+ let ws_handler =
11691169+ match (config.protocol, ws_handler) with
11701170+ | Auto_websocket, None ->
11711171+ failwith "WebSocket handler required for Auto_websocket mode"
11721172+ | Auto_websocket, Some h -> h
11731173+ | _, _ -> fun _ -> ()
11741174+ in
11751175+11761176+ let connection_handler flow _addr =
11771177+ if config.tcp_nodelay then set_tcp_nodelay flow;
11781178+ match config.tls with
11791179+ | None -> handle_connection ~config ~handler ~ws_handler flow
11801180+ | Some tls_config -> (
11811181+ match Tls_config.Server.to_tls_config tls_config with
11821182+ | Error (`Msg msg) -> traceln "TLS config error: %s" msg
11831183+ | Ok tls_cfg ->
11841184+ Tls_handler.handle ~config ~handler ~ws_handler tls_cfg flow)
11851185+ in
11861186+11871187+ let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in
11881188+11891189+ if domain_count <= 1 then
11901190+ Eio.Net.run_server socket connection_handler
11911191+ ~max_connections:config.max_connections ~on_error
11921192+ else
11931193+ Eio.Net.run_server socket connection_handler
11941194+ ~max_connections:config.max_connections ~on_error
11951195+ ~additional_domains:(domain_mgr, domain_count - 1)
+295
lib/stream.ml
···11+(** Streaming abstractions for HCS HTTP library.
22+33+ This module provides both synchronous and asynchronous stream types for
44+ handling large payloads efficiently with backpressure support.
55+66+ {1 Synchronous Streams}
77+88+ For simple, pull-based iteration without runtime dependencies.
99+1010+ {1 Asynchronous Streams (Eio)}
1111+1212+ For streaming with Eio's structured concurrency, supporting flows, files,
1313+ and chunked transfer encoding. *)
1414+1515+(** {1 Synchronous Stream}
1616+1717+ A simple pull-based stream using OCaml's Seq. *)
1818+module Sync = struct
1919+ type 'a t = 'a Seq.t
2020+ (** A synchronous stream of values *)
2121+2222+ (** {2 Producers} *)
2323+2424+ let empty : 'a t = Seq.empty
2525+ let singleton x : 'a t = Seq.return x
2626+ let of_list l : 'a t = List.to_seq l
2727+ let of_array a : 'a t = Array.to_seq a
2828+2929+ (** Create a stream from an unfolding function *)
3030+ let unfold (f : 's -> ('a * 's) option) (init : 's) : 'a t = Seq.unfold f init
3131+3232+ (** Create a stream that repeats a value n times *)
3333+ let repeat n x : 'a t =
3434+ unfold (fun i -> if i > 0 then Some (x, i - 1) else None) n
3535+3636+ (** Create a stream from a generator function *)
3737+ let generate (f : unit -> 'a option) : 'a t =
3838+ let rec next () =
3939+ match f () with Some x -> Seq.Cons (x, next) | None -> Seq.Nil
4040+ in
4141+ next
4242+4343+ (** {2 Transformers} *)
4444+4545+ let map f s : 'a t = Seq.map f s
4646+ let filter p s : 'a t = Seq.filter p s
4747+ let filter_map f s : 'a t = Seq.filter_map f s
4848+4949+ (** Take the first n elements *)
5050+ let take n s : 'a t = Seq.take n s
5151+5252+ (** Drop the first n elements *)
5353+ let drop n s : 'a t = Seq.drop n s
5454+5555+ (** Split stream into chunks of size n *)
5656+ let chunks n s : 'a list t =
5757+ let rec next acc count seq () =
5858+ if count >= n then Seq.Cons (List.rev acc, next [] 0 seq)
5959+ else
6060+ match seq () with
6161+ | Seq.Nil ->
6262+ if acc = [] then Seq.Nil
6363+ else Seq.Cons (List.rev acc, fun () -> Seq.Nil)
6464+ | Seq.Cons (x, rest) -> next (x :: acc) (count + 1) rest ()
6565+ in
6666+ next [] 0 s
6767+6868+ (** Flatten a stream of streams *)
6969+ let flatten s : 'a t = Seq.flat_map Fun.id s
7070+7171+ (** Append two streams *)
7272+ let append s1 s2 : 'a t = Seq.append s1 s2
7373+7474+ (** {2 Consumers} *)
7575+7676+ (** Fold over the stream *)
7777+ let fold f init s = Seq.fold_left f init s
7878+7979+ (** Iterate over the stream for side effects *)
8080+ let iter f s = Seq.iter f s
8181+8282+ (** Drain the stream, discarding all values *)
8383+ let drain s = iter (fun _ -> ()) s
8484+8585+ (** Collect stream into a list *)
8686+ let to_list s = List.of_seq s
8787+8888+ (** Collect stream into an array *)
8989+ let to_array s = Array.of_seq s
9090+9191+ (** {2 Cstruct-specific operations} *)
9292+9393+ (** Concatenate a stream of Cstructs into a single string *)
9494+ let cstructs_to_string (s : Cstruct.t t) : string =
9595+ let bufs = to_list s in
9696+ let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in
9797+ let result = Bytes.create total in
9898+ let _ =
9999+ List.fold_left
100100+ (fun off cs ->
101101+ let len = Cstruct.length cs in
102102+ Cstruct.blit_to_bytes cs 0 result off len;
103103+ off + len)
104104+ 0 bufs
105105+ in
106106+ Bytes.to_string result
107107+108108+ (** Split a string into chunks of Cstructs *)
109109+ let string_to_cstructs ?(chunk_size = 4096) (s : string) : Cstruct.t t =
110110+ let len = String.length s in
111111+ unfold
112112+ (fun off ->
113113+ if off >= len then None
114114+ else
115115+ let chunk_len = min chunk_size (len - off) in
116116+ Some (Cstruct.of_string ~off ~len:chunk_len s, off + chunk_len))
117117+ 0
118118+end
119119+120120+(** {1 Asynchronous Stream (Eio)}
121121+122122+ Streams that integrate with Eio's structured concurrency. *)
123123+module Async = struct
124124+ type 'a t = unit -> 'a option
125125+ (** An asynchronous stream that can be pulled from *)
126126+127127+ (** {2 Producers} *)
128128+129129+ let empty : 'a t = fun () -> None
130130+131131+ let singleton x : 'a t =
132132+ let taken = ref false in
133133+ fun () ->
134134+ if !taken then None
135135+ else begin
136136+ taken := true;
137137+ Some x
138138+ end
139139+140140+ let of_list l : 'a t =
141141+ let r = ref l in
142142+ fun () ->
143143+ match !r with
144144+ | [] -> None
145145+ | x :: rest ->
146146+ r := rest;
147147+ Some x
148148+149149+ let of_seq s : 'a t =
150150+ let r = ref s in
151151+ fun () ->
152152+ match !r () with
153153+ | Seq.Nil -> None
154154+ | Seq.Cons (x, rest) ->
155155+ r := rest;
156156+ Some x
157157+158158+ (** Create a stream from an Eio flow (reads until EOF) *)
159159+ let of_flow ?(buf_size = 4096) (flow : _ Eio.Flow.source) : Cstruct.t t =
160160+ let buf = Cstruct.create buf_size in
161161+ let finished = ref false in
162162+ fun () ->
163163+ if !finished then None
164164+ else
165165+ try
166166+ let n = Eio.Flow.single_read flow buf in
167167+ Some (Cstruct.sub buf 0 n)
168168+ with End_of_file ->
169169+ finished := true;
170170+ None
171171+172172+ (** Create a stream that reads a file in chunks *)
173173+ let of_file ?(buf_size = 4096) ~fs path : Cstruct.t t =
174174+ let file = Eio.Path.open_in ~sw:(Eio.Switch.run Fun.id) (fs, path) in
175175+ of_flow ~buf_size file
176176+177177+ (** {2 Transformers} *)
178178+179179+ let map f (s : 'a t) : 'b t = fun () -> Option.map f (s ())
180180+181181+ let filter p (s : 'a t) : 'a t =
182182+ let rec next () =
183183+ match s () with None -> None | Some x -> if p x then Some x else next ()
184184+ in
185185+ next
186186+187187+ let filter_map f (s : 'a t) : 'b t =
188188+ let rec next () =
189189+ match s () with
190190+ | None -> None
191191+ | Some x -> ( match f x with Some y -> Some y | None -> next ())
192192+ in
193193+ next
194194+195195+ let take n (s : 'a t) : 'a t =
196196+ let count = ref 0 in
197197+ fun () ->
198198+ if !count >= n then None
199199+ else begin
200200+ incr count;
201201+ s ()
202202+ end
203203+204204+ (** {2 Consumers} *)
205205+206206+ (** Fold over the stream *)
207207+ let fold f init (s : 'a t) =
208208+ let rec loop acc =
209209+ match s () with None -> acc | Some x -> loop (f acc x)
210210+ in
211211+ loop init
212212+213213+ (** Iterate over the stream *)
214214+ let iter f (s : 'a t) =
215215+ let rec loop () =
216216+ match s () with
217217+ | None -> ()
218218+ | Some x ->
219219+ f x;
220220+ loop ()
221221+ in
222222+ loop ()
223223+224224+ (** Drain the stream *)
225225+ let drain s = iter (fun _ -> ()) s
226226+227227+ (** Collect into a list *)
228228+ let to_list (s : 'a t) = List.rev (fold (fun acc x -> x :: acc) [] s)
229229+230230+ (** Write stream to an Eio flow *)
231231+ let to_flow (flow : _ Eio.Flow.sink) (s : Cstruct.t t) =
232232+ iter (fun cs -> Eio.Flow.write flow [ cs ]) s
233233+234234+ (** {2 Cstruct-specific operations} *)
235235+236236+ (** Concatenate a stream of Cstructs into a single string *)
237237+ let cstructs_to_string (s : Cstruct.t t) : string =
238238+ let bufs = to_list s in
239239+ let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in
240240+ let result = Bytes.create total in
241241+ let _ =
242242+ List.fold_left
243243+ (fun off cs ->
244244+ let len = Cstruct.length cs in
245245+ Cstruct.blit_to_bytes cs 0 result off len;
246246+ off + len)
247247+ 0 bufs
248248+ in
249249+ Bytes.to_string result
250250+251251+ (** Read entire stream into a Cstruct *)
252252+ let cstructs_to_cstruct (s : Cstruct.t t) : Cstruct.t =
253253+ let bufs = to_list s in
254254+ let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in
255255+ let result = Cstruct.create total in
256256+ let _ =
257257+ List.fold_left
258258+ (fun off cs ->
259259+ let len = Cstruct.length cs in
260260+ Cstruct.blit cs 0 result off len;
261261+ off + len)
262262+ 0 bufs
263263+ in
264264+ result
265265+end
266266+267267+(** {1 Chunked Transfer Encoding} *)
268268+269269+(** Helpers for HTTP chunked transfer encoding *)
270270+module Chunked = struct
271271+ (** Encode chunks for chunked transfer encoding *)
272272+ let encode (chunks : Cstruct.t Sync.t) : Cstruct.t Sync.t =
273273+ let encode_chunk cs =
274274+ let len = Cstruct.length cs in
275275+ if len = 0 then Cstruct.of_string "0\r\n\r\n"
276276+ else
277277+ let header = Printf.sprintf "%x\r\n" len in
278278+ let trailer = "\r\n" in
279279+ let total = String.length header + len + String.length trailer in
280280+ let buf = Cstruct.create total in
281281+ Cstruct.blit_from_string header 0 buf 0 (String.length header);
282282+ Cstruct.blit cs 0 buf (String.length header) len;
283283+ Cstruct.blit_from_string trailer 0 buf
284284+ (String.length header + len)
285285+ (String.length trailer);
286286+ buf
287287+ in
288288+ Seq.append
289289+ (Seq.map encode_chunk chunks)
290290+ (Seq.return (Cstruct.of_string "0\r\n\r\n"))
291291+292292+ (** Calculate content length from chunks (consumes the stream) *)
293293+ let content_length (chunks : Cstruct.t Sync.t) : int =
294294+ Sync.fold (fun acc cs -> acc + Cstruct.length cs) 0 chunks
295295+end
+150
lib/tls_config.ml
···11+(** TLS configuration and helpers for HCS.
22+33+ This module provides TLS configuration that works with tls-eio and ca-certs
44+ for system certificate loading. *)
55+66+(** {1 ALPN Protocol Identifiers} *)
77+88+(** HTTP/2 over TLS ALPN identifier *)
99+let alpn_h2 = "h2"
1010+1111+(** HTTP/1.1 ALPN identifier *)
1212+let alpn_http11 = "http/1.1"
1313+1414+(** HTTP/2 cleartext (h2c) identifier - used in Upgrade header, not ALPN *)
1515+let alpn_h2c = "h2c"
1616+1717+(** Protocol type for negotiation results *)
1818+type protocol = HTTP_1_1 | HTTP_2
1919+2020+(** Convert ALPN string to protocol type *)
2121+let protocol_of_alpn = function
2222+ | s when s = alpn_h2 -> Some HTTP_2
2323+ | s when s = alpn_http11 -> Some HTTP_1_1
2424+ | _ -> None
2525+2626+(** Convert protocol to ALPN string *)
2727+let alpn_of_protocol = function HTTP_2 -> alpn_h2 | HTTP_1_1 -> alpn_http11
2828+2929+(** Client TLS configuration *)
3030+module Client = struct
3131+ (** Certificate verification mode *)
3232+ type verification =
3333+ | System_certs (** Use system CA certificates *)
3434+ | No_verify (** Disable verification (INSECURE!) *)
3535+3636+ type t = {
3737+ verification : verification;
3838+ alpn_protocols : string list option; (** ALPN: ["h2"; "http/1.1"] *)
3939+ }
4040+4141+ let default =
4242+ { verification = System_certs; alpn_protocols = Some [ "http/1.1" ] }
4343+4444+ (** TLS config for HTTP/2 - advertises h2 protocol *)
4545+ let h2 = { verification = System_certs; alpn_protocols = Some [ "h2" ] }
4646+4747+ (** TLS config that prefers HTTP/2 but falls back to HTTP/1.1 *)
4848+ let h2_or_http11 =
4949+ { verification = System_certs; alpn_protocols = Some [ "h2"; "http/1.1" ] }
5050+5151+ let with_alpn protocols config =
5252+ { config with alpn_protocols = Some protocols }
5353+5454+ let insecure =
5555+ { verification = No_verify; alpn_protocols = Some [ "http/1.1" ] }
5656+5757+ let insecure_h2 = { verification = No_verify; alpn_protocols = Some [ "h2" ] }
5858+5959+ (** Create tls-eio authenticator from config *)
6060+ let make_authenticator config =
6161+ match config.verification with
6262+ | System_certs -> (
6363+ match Ca_certs.authenticator () with
6464+ | Ok auth -> Ok auth
6565+ | Error (`Msg msg) -> Error msg)
6666+ | No_verify -> Ok (fun ?ip:_ ~host:_ _ -> Ok None)
6767+6868+ (** Create Tls.Config.client from our config *)
6969+ let to_tls_config config ~host:_ =
7070+ match make_authenticator config with
7171+ | Error msg -> Error msg
7272+ | Ok authenticator -> (
7373+ match
7474+ Tls.Config.client ~authenticator ?alpn_protocols:config.alpn_protocols
7575+ ()
7676+ with
7777+ | Ok tls_config -> Ok tls_config
7878+ | Error (`Msg msg) -> Error msg)
7979+end
8080+8181+module Server = struct
8282+ type t = {
8383+ certificate : Tls.Config.own_cert;
8484+ alpn_protocols : string list option;
8585+ }
8686+8787+ let with_alpn protocols config =
8888+ { config with alpn_protocols = Some protocols }
8989+9090+ let h1_only config = with_alpn [ alpn_http11 ] config
9191+ let h2_only config = with_alpn [ alpn_h2 ] config
9292+ let h2_or_http11 config = with_alpn [ alpn_h2; alpn_http11 ] config
9393+9494+ let of_pem ~cert_file ~key_file =
9595+ try
9696+ let cert_pem = In_channel.with_open_bin cert_file In_channel.input_all in
9797+ let key_pem = In_channel.with_open_bin key_file In_channel.input_all in
9898+ let certs = X509.Certificate.decode_pem_multiple cert_pem in
9999+ let key = X509.Private_key.decode_pem key_pem in
100100+ match (certs, key) with
101101+ | Ok certs, Ok key ->
102102+ Ok
103103+ {
104104+ certificate = `Single (certs, key);
105105+ alpn_protocols = Some [ alpn_h2; alpn_http11 ];
106106+ }
107107+ | Error (`Msg msg), _ -> Error ("Certificate error: " ^ msg)
108108+ | _, Error (`Msg msg) -> Error ("Key error: " ^ msg)
109109+ with Sys_error msg -> Error ("File error: " ^ msg)
110110+111111+ (** Create Tls.Config.server from our config *)
112112+ let to_tls_config config =
113113+ Tls.Config.server ~certificates:config.certificate
114114+ ?alpn_protocols:config.alpn_protocols ()
115115+end
116116+117117+(** Convert TLS failure to string *)
118118+let failure_to_string failure = Tls.Engine.string_of_failure failure
119119+120120+(** Wrap an Eio flow with TLS (client side) *)
121121+let client_wrap ~config flow =
122122+ match Client.to_tls_config config ~host:"" with
123123+ | Error msg -> Error msg
124124+ | Ok tls_config -> (
125125+ try
126126+ let tls_flow = Tls_eio.client_of_flow tls_config flow in
127127+ Ok tls_flow
128128+ with
129129+ | Tls_eio.Tls_failure failure -> Error (failure_to_string failure)
130130+ | exn -> Error (Printexc.to_string exn))
131131+132132+(** Wrap an Eio flow with TLS (server side) *)
133133+let server_wrap config flow =
134134+ match Server.to_tls_config config with
135135+ | Error (`Msg msg) -> Error msg
136136+ | Ok tls_config -> (
137137+ try
138138+ let tls_flow = Tls_eio.server_of_flow tls_config flow in
139139+ Ok tls_flow
140140+ with
141141+ | Tls_eio.Tls_failure failure -> Error (failure_to_string failure)
142142+ | exn -> Error (Printexc.to_string exn))
143143+144144+let negotiated_protocol (tls_flow : Tls_eio.t) : protocol option =
145145+ match Tls_eio.epoch tls_flow with
146146+ | Error () -> None
147147+ | Ok epoch_data -> (
148148+ match epoch_data.Tls.Core.alpn_protocol with
149149+ | None -> None
150150+ | Some alpn -> protocol_of_alpn alpn)
+548
lib/websocket.ml
···11+(** WebSocket implementation for HCS (RFC 6455).
22+33+ This module provides WebSocket client and server functionality with Eio for
44+ structured concurrency. *)
55+66+open Eio.Std
77+88+(** {1 Constants} *)
99+1010+(** UUID used in WebSocket handshake per RFC 6455 *)
1111+let websocket_uuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
1212+1313+(** {1 Types} *)
1414+1515+(** WebSocket frame opcode *)
1616+module Opcode = struct
1717+ type t =
1818+ | Continuation (** 0x0 *)
1919+ | Text (** 0x1 *)
2020+ | Binary (** 0x2 *)
2121+ | Close (** 0x8 *)
2222+ | Ping (** 0x9 *)
2323+ | Pong (** 0xA *)
2424+ | Ctrl of int (** Other control opcodes *)
2525+ | Nonctrl of int (** Other non-control opcodes *)
2626+2727+ let to_int = function
2828+ | Continuation -> 0
2929+ | Text -> 1
3030+ | Binary -> 2
3131+ | Close -> 8
3232+ | Ping -> 9
3333+ | Pong -> 10
3434+ | Ctrl n -> n
3535+ | Nonctrl n -> n
3636+3737+ let of_int = function
3838+ | 0 -> Continuation
3939+ | 1 -> Text
4040+ | 2 -> Binary
4141+ | 8 -> Close
4242+ | 9 -> Ping
4343+ | 10 -> Pong
4444+ | n when n > 7 -> Ctrl n
4545+ | n -> Nonctrl n
4646+4747+ let to_string = function
4848+ | Continuation -> "continuation"
4949+ | Text -> "text"
5050+ | Binary -> "binary"
5151+ | Close -> "close"
5252+ | Ping -> "ping"
5353+ | Pong -> "pong"
5454+ | Ctrl n -> Printf.sprintf "ctrl(%d)" n
5555+ | Nonctrl n -> Printf.sprintf "nonctrl(%d)" n
5656+5757+ let is_control = function Close | Ping | Pong | Ctrl _ -> true | _ -> false
5858+end
5959+6060+type frame = {
6161+ opcode : Opcode.t;
6262+ extension : int;
6363+ final : bool;
6464+ content : string;
6565+}
6666+(** WebSocket frame *)
6767+6868+let pp_frame fmt frame =
6969+ Format.fprintf fmt "{opcode=%s; final=%b; len=%d}"
7070+ (Opcode.to_string frame.opcode)
7171+ frame.final
7272+ (String.length frame.content)
7373+7474+let make_frame ?(opcode = Opcode.Text) ?(extension = 0) ?(final = true)
7575+ ?(content = "") () =
7676+ { opcode; extension; final; content }
7777+7878+let close_frame code =
7979+ let content =
8080+ if code < 0 then ""
8181+ else
8282+ let buf = Bytes.create 2 in
8383+ Bytes.set buf 0 (Char.chr ((code lsr 8) land 0xff));
8484+ Bytes.set buf 1 (Char.chr (code land 0xff));
8585+ Bytes.to_string buf
8686+ in
8787+ { opcode = Close; extension = 0; final = true; content }
8888+8989+type t = {
9090+ flow : Eio.Flow.two_way_ty r;
9191+ mutable closed : bool;
9292+ is_client : bool; (** Client must mask frames *)
9393+ read_buf : Buffer.t;
9494+}
9595+(** WebSocket connection *)
9696+9797+(** Error type *)
9898+type error = Connection_closed | Protocol_error of string | Io_error of string
9999+100100+(** {1 Cryptographic helpers} *)
101101+102102+(** Compute SHA-1 hash and base64 encode *)
103103+let b64_encoded_sha1sum s =
104104+ let hash = Digestif.SHA1.digest_string s in
105105+ Base64.encode_exn (Digestif.SHA1.to_raw_string hash)
106106+107107+(** Compute the Sec-WebSocket-Accept value *)
108108+let compute_accept_key key = b64_encoded_sha1sum (key ^ websocket_uuid)
109109+110110+(** {1 Random number generation for masking} *)
111111+112112+module Rng = struct
113113+ let initialized = ref false
114114+115115+ let init () =
116116+ if not !initialized then begin
117117+ Random.self_init ();
118118+ initialized := true
119119+ end
120120+121121+ (** Generate n random bytes *)
122122+ let generate n =
123123+ init ();
124124+ let buf = Bytes.create n in
125125+ for i = 0 to n - 1 do
126126+ Bytes.set buf i (Char.chr (Random.int 256))
127127+ done;
128128+ Bytes.to_string buf
129129+end
130130+131131+(** {1 Frame parsing/serialization} *)
132132+133133+(** Apply XOR mask to data *)
134134+let xor_mask mask data =
135135+ let len = String.length data in
136136+ let result = Bytes.create len in
137137+ for i = 0 to len - 1 do
138138+ let mask_byte = Char.code mask.[i mod 4] in
139139+ let data_byte = Char.code data.[i] in
140140+ Bytes.set result i (Char.chr (data_byte lxor mask_byte))
141141+ done;
142142+ Bytes.to_string result
143143+144144+(** Serialize a frame to bytes. Client frames must be masked, server frames must
145145+ not be masked. *)
146146+let write_frame_to_buf ~is_client buf frame =
147147+ let mask = is_client in
148148+ let opcode = Opcode.to_int frame.opcode in
149149+ let fin = if frame.final then 0x80 else 0 in
150150+ let rsv = (frame.extension land 0x7) lsl 4 in
151151+152152+ Buffer.add_char buf (Char.chr (fin lor rsv lor opcode));
153153+154154+ let len = String.length frame.content in
155155+ let mask_bit = if mask then 0x80 else 0 in
156156+157157+ (* Encode payload length *)
158158+ if len < 126 then Buffer.add_char buf (Char.chr (mask_bit lor len))
159159+ else if len < 65536 then begin
160160+ Buffer.add_char buf (Char.chr (mask_bit lor 126));
161161+ Buffer.add_char buf (Char.chr ((len lsr 8) land 0xff));
162162+ Buffer.add_char buf (Char.chr (len land 0xff))
163163+ end
164164+ else begin
165165+ Buffer.add_char buf (Char.chr (mask_bit lor 127));
166166+ (* 64-bit length, big-endian *)
167167+ for i = 7 downto 0 do
168168+ Buffer.add_char buf (Char.chr ((len lsr (i * 8)) land 0xff))
169169+ done
170170+ end;
171171+172172+ (* Add mask and payload *)
173173+ if mask then begin
174174+ let mask_key = Rng.generate 4 in
175175+ Buffer.add_string buf mask_key;
176176+ Buffer.add_string buf (xor_mask mask_key frame.content)
177177+ end
178178+ else Buffer.add_string buf frame.content
179179+180180+(** Read exactly n bytes from flow *)
181181+let read_exactly flow n =
182182+ let buf = Cstruct.create n in
183183+ let rec loop off =
184184+ if off < n then begin
185185+ let cs = Cstruct.sub buf off (n - off) in
186186+ let read = Eio.Flow.single_read flow cs in
187187+ loop (off + read)
188188+ end
189189+ in
190190+ loop 0;
191191+ Cstruct.to_string buf
192192+193193+(** Parse a frame from flow *)
194194+let read_frame ~is_client flow =
195195+ try
196196+ (* Read first 2 bytes *)
197197+ let header = read_exactly flow 2 in
198198+ let b0 = Char.code header.[0] in
199199+ let b1 = Char.code header.[1] in
200200+201201+ let final = b0 land 0x80 <> 0 in
202202+ let extension = (b0 land 0x70) lsr 4 in
203203+ let opcode = Opcode.of_int (b0 land 0x0f) in
204204+ let masked = b1 land 0x80 <> 0 in
205205+ let len0 = b1 land 0x7f in
206206+207207+ (* Server receiving from client: frames must be masked
208208+ Client receiving from server: frames must not be masked *)
209209+ if (not is_client) && not masked then
210210+ Error (Protocol_error "Client frames must be masked")
211211+ else if is_client && masked then
212212+ Error (Protocol_error "Server frames must not be masked")
213213+ else begin
214214+ (* Read extended length if needed *)
215215+ let len =
216216+ if len0 < 126 then len0
217217+ else if len0 = 126 then begin
218218+ let ext = read_exactly flow 2 in
219219+ (Char.code ext.[0] lsl 8) lor Char.code ext.[1]
220220+ end
221221+ else begin
222222+ (* 64-bit length *)
223223+ let ext = read_exactly flow 8 in
224224+ let len = ref 0 in
225225+ for i = 0 to 7 do
226226+ len := (!len lsl 8) lor Char.code ext.[i]
227227+ done;
228228+ !len
229229+ end
230230+ in
231231+232232+ (* Control frames cannot be fragmented and max 125 bytes *)
233233+ if Opcode.is_control opcode && ((not final) || len > 125) then
234234+ Error (Protocol_error "Invalid control frame")
235235+ else begin
236236+ (* Read mask key if present *)
237237+ let mask_key = if masked then Some (read_exactly flow 4) else None in
238238+239239+ (* Read payload *)
240240+ let content = if len > 0 then read_exactly flow len else "" in
241241+ let content =
242242+ match mask_key with
243243+ | Some key -> xor_mask key content
244244+ | None -> content
245245+ in
246246+247247+ Ok { opcode; extension; final; content }
248248+ end
249249+ end
250250+ with
251251+ | End_of_file -> Error Connection_closed
252252+ | exn -> Error (Io_error (Printexc.to_string exn))
253253+254254+(** {1 Connection API} *)
255255+256256+(** Check if connection is open *)
257257+let is_open t = not t.closed
258258+259259+(** Send a frame *)
260260+let send t frame =
261261+ if t.closed then Error Connection_closed
262262+ else
263263+ try
264264+ let buf = Buffer.create 128 in
265265+ write_frame_to_buf ~is_client:t.is_client buf frame;
266266+ Eio.Flow.write t.flow [ Cstruct.of_string (Buffer.contents buf) ];
267267+ Ok ()
268268+ with exn -> Error (Io_error (Printexc.to_string exn))
269269+270270+(** Send a text message *)
271271+let send_text t content = send t (make_frame ~opcode:Text ~content ())
272272+273273+(** Send a binary message *)
274274+let send_binary t content = send t (make_frame ~opcode:Binary ~content ())
275275+276276+(** Send a ping *)
277277+let send_ping t ?(content = "") () =
278278+ send t (make_frame ~opcode:Ping ~content ())
279279+280280+(** Send a pong *)
281281+let send_pong t ?(content = "") () =
282282+ send t (make_frame ~opcode:Pong ~content ())
283283+284284+(** Receive a frame *)
285285+let recv t =
286286+ if t.closed then Error Connection_closed
287287+ else
288288+ match read_frame ~is_client:t.is_client t.flow with
289289+ | Ok frame ->
290290+ (* Handle control frames *)
291291+ (match frame.opcode with
292292+ | Close ->
293293+ t.closed <- true;
294294+ (* Echo close frame back *)
295295+ ignore (send t (close_frame (-1)))
296296+ | Ping ->
297297+ (* Auto-respond to pings with pong *)
298298+ ignore (send_pong t ~content:frame.content ())
299299+ | _ -> ());
300300+ Ok frame
301301+ | Error e ->
302302+ t.closed <- true;
303303+ Error e
304304+305305+(** Receive a complete message (handles fragmentation) *)
306306+let recv_message t =
307307+ let rec collect_fragments first_opcode buf =
308308+ match recv t with
309309+ | Error e -> Error e
310310+ | Ok frame -> (
311311+ Buffer.add_string buf frame.content;
312312+ if frame.final then Ok (first_opcode, Buffer.contents buf)
313313+ else
314314+ match frame.opcode with
315315+ | Continuation -> collect_fragments first_opcode buf
316316+ | _ -> Error (Protocol_error "Expected continuation frame"))
317317+ in
318318+ let rec loop () =
319319+ match recv t with
320320+ | Error e -> Error e
321321+ | Ok frame -> (
322322+ match frame.opcode with
323323+ | Text | Binary ->
324324+ if frame.final then Ok (frame.opcode, frame.content)
325325+ else begin
326326+ let buf = Buffer.create 256 in
327327+ Buffer.add_string buf frame.content;
328328+ collect_fragments frame.opcode buf
329329+ end
330330+ | Close -> Error Connection_closed
331331+ | Ping | Pong ->
332332+ (* Control frames handled in recv, try again *)
333333+ loop ()
334334+ | Continuation -> Error (Protocol_error "Unexpected continuation")
335335+ | _ -> Error (Protocol_error "Unexpected opcode"))
336336+ in
337337+ loop ()
338338+339339+(** Close the connection *)
340340+let close ?(code = 1000) t =
341341+ if not t.closed then begin
342342+ t.closed <- true;
343343+ ignore (send t (close_frame code))
344344+ end
345345+346346+(** {1 Handshake helpers} *)
347347+348348+(** Check if request headers indicate a WebSocket upgrade *)
349349+let is_upgrade_request headers =
350350+ let upgrade = H1.Headers.get headers "upgrade" in
351351+ let connection = H1.Headers.get headers "connection" in
352352+ let key = H1.Headers.get headers "sec-websocket-key" in
353353+ match (upgrade, connection, key) with
354354+ | Some u, Some c, Some _ ->
355355+ let u = String.lowercase_ascii u in
356356+ let c = String.lowercase_ascii c in
357357+ u = "websocket" && (c = "upgrade" || String.sub c 0 7 = "upgrade")
358358+ | _ -> false
359359+360360+(** Get the Sec-WebSocket-Key from request headers *)
361361+let get_websocket_key headers = H1.Headers.get headers "sec-websocket-key"
362362+363363+(** Generate random base64-encoded key for client handshake *)
364364+let generate_key () = Base64.encode_exn (Rng.generate 16)
365365+366366+(** {1 Client API} *)
367367+368368+(** Connect to a WebSocket server *)
369369+let connect ~sw ~net ?(tls_config = Tls_config.Client.default) ?protocols url =
370370+ let uri = Uri.of_string url in
371371+ let scheme = Uri.scheme uri |> Option.value ~default:"ws" in
372372+ let is_secure = scheme = "wss" in
373373+ let host = Uri.host uri |> Option.value ~default:"localhost" in
374374+ let default_port = if is_secure then 443 else 80 in
375375+ let port = Uri.port uri |> Option.value ~default:default_port in
376376+ let path =
377377+ let p = Uri.path_and_query uri in
378378+ if p = "" then "/" else p
379379+ in
380380+381381+ (* Resolve and connect *)
382382+ let addrs = Eio.Net.getaddrinfo_stream net host in
383383+ match addrs with
384384+ | [] -> Error (Io_error ("Cannot resolve host: " ^ host))
385385+ | addr_info :: _ -> (
386386+ let addr =
387387+ match addr_info with
388388+ | `Tcp (ip, _) -> `Tcp (ip, port)
389389+ | `Unix _ -> failwith "Unix sockets not supported"
390390+ in
391391+ let tcp_flow = Eio.Net.connect ~sw net addr in
392392+393393+ (* Wrap with TLS if secure *)
394394+ let flow_result =
395395+ if is_secure then
396396+ match Tls_config.Client.to_tls_config tls_config ~host with
397397+ | Error msg -> Error (Io_error ("TLS error: " ^ msg))
398398+ | Ok tls_cfg -> (
399399+ try
400400+ let host_domain =
401401+ match Domain_name.of_string host with
402402+ | Ok dn -> (
403403+ match Domain_name.host dn with
404404+ | Ok h -> Some h
405405+ | Error _ -> None)
406406+ | Error _ -> None
407407+ in
408408+ let tls_flow =
409409+ Tls_eio.client_of_flow tls_cfg ?host:host_domain tcp_flow
410410+ in
411411+ Ok (tls_flow :> Eio.Flow.two_way_ty r)
412412+ with exn -> Error (Io_error (Printexc.to_string exn)))
413413+ else Ok (tcp_flow :> Eio.Flow.two_way_ty r)
414414+ in
415415+416416+ match flow_result with
417417+ | Error e -> Error e
418418+ | Ok flow -> (
419419+ (* Generate key and build upgrade request *)
420420+ let key = generate_key () in
421421+ let expected_accept = compute_accept_key key in
422422+423423+ let headers =
424424+ [
425425+ ("Host", host);
426426+ ("Upgrade", "websocket");
427427+ ("Connection", "Upgrade");
428428+ ("Sec-WebSocket-Key", key);
429429+ ("Sec-WebSocket-Version", "13");
430430+ ]
431431+ in
432432+ let headers =
433433+ match protocols with
434434+ | Some ps ->
435435+ ("Sec-WebSocket-Protocol", String.concat ", " ps) :: headers
436436+ | None -> headers
437437+ in
438438+439439+ (* Send HTTP upgrade request *)
440440+ let buf = Buffer.create 256 in
441441+ Buffer.add_string buf (Printf.sprintf "GET %s HTTP/1.1\r\n" path);
442442+ List.iter
443443+ (fun (k, v) ->
444444+ Buffer.add_string buf (Printf.sprintf "%s: %s\r\n" k v))
445445+ headers;
446446+ Buffer.add_string buf "\r\n";
447447+448448+ try
449449+ Eio.Flow.write flow [ Cstruct.of_string (Buffer.contents buf) ];
450450+451451+ (* Read response headers *)
452452+ let response_buf = Buffer.create 1024 in
453453+ let rec read_until_crlf_crlf () =
454454+ let byte = read_exactly flow 1 in
455455+ Buffer.add_string response_buf byte;
456456+ let len = Buffer.length response_buf in
457457+ if
458458+ len >= 4
459459+ && Buffer.nth response_buf (len - 4) = '\r'
460460+ && Buffer.nth response_buf (len - 3) = '\n'
461461+ && Buffer.nth response_buf (len - 2) = '\r'
462462+ && Buffer.nth response_buf (len - 1) = '\n'
463463+ then ()
464464+ else read_until_crlf_crlf ()
465465+ in
466466+ read_until_crlf_crlf ();
467467+468468+ (* Parse status line and headers *)
469469+ let response_str = Buffer.contents response_buf in
470470+ let lines = String.split_on_char '\n' response_str in
471471+472472+ (* Check status line *)
473473+ match lines with
474474+ | status_line :: header_lines -> (
475475+ let status_line = String.trim status_line in
476476+ if
477477+ not
478478+ (String.length status_line >= 12
479479+ && String.sub status_line 9 3 = "101")
480480+ then Error (Protocol_error ("Bad status: " ^ status_line))
481481+ else
482482+ (* Parse headers *)
483483+ let headers =
484484+ List.filter_map
485485+ (fun line ->
486486+ let line = String.trim line in
487487+ if line = "" then None
488488+ else
489489+ match String.index_opt line ':' with
490490+ | Some i ->
491491+ let key =
492492+ String.lowercase_ascii (String.sub line 0 i)
493493+ in
494494+ let value =
495495+ String.trim
496496+ (String.sub line (i + 1)
497497+ (String.length line - i - 1))
498498+ in
499499+ Some (key, value)
500500+ | None -> None)
501501+ header_lines
502502+ in
503503+504504+ (* Validate accept key *)
505505+ let accept = List.assoc_opt "sec-websocket-accept" headers in
506506+ match accept with
507507+ | Some a when a = expected_accept ->
508508+ Ok
509509+ {
510510+ flow;
511511+ closed = false;
512512+ is_client = true;
513513+ read_buf = Buffer.create 4096;
514514+ }
515515+ | Some a ->
516516+ Error
517517+ (Protocol_error
518518+ (Printf.sprintf "Bad accept key: %s (expected %s)" a
519519+ expected_accept))
520520+ | None -> Error (Protocol_error "Missing accept key"))
521521+ | [] -> Error (Protocol_error "Empty response")
522522+ with exn -> Error (Io_error (Printexc.to_string exn))))
523523+524524+(** {1 Server API} *)
525525+526526+(** Accept a WebSocket upgrade from an HTTP connection. Returns a WebSocket
527527+ connection after sending the upgrade response. *)
528528+let accept ~flow ~key =
529529+ let accept = compute_accept_key key in
530530+ let response =
531531+ Printf.sprintf
532532+ "HTTP/1.1 101 Switching Protocols\r\n\
533533+ Upgrade: websocket\r\n\
534534+ Connection: Upgrade\r\n\
535535+ Sec-WebSocket-Accept: %s\r\n\
536536+ \r\n"
537537+ accept
538538+ in
539539+ try
540540+ Eio.Flow.write flow [ Cstruct.of_string response ];
541541+ Ok
542542+ {
543543+ flow :> Eio.Flow.two_way_ty r;
544544+ closed = false;
545545+ is_client = false;
546546+ read_buf = Buffer.create 4096;
547547+ }
548548+ with exn -> Error (Io_error (Printexc.to_string exn))