ocaml http/1, http/2 and websocket client and server library

initial commit

+29
.beads/.gitignore
··· 1 + # SQLite databases 2 + *.db 3 + *.db?* 4 + *.db-journal 5 + *.db-wal 6 + *.db-shm 7 + 8 + # Daemon runtime files 9 + daemon.lock 10 + daemon.log 11 + daemon.pid 12 + bd.sock 13 + 14 + # Legacy database files 15 + db.sqlite 16 + bd.db 17 + 18 + # Merge artifacts (temporary files from 3-way merge) 19 + beads.base.jsonl 20 + beads.base.meta.json 21 + beads.left.jsonl 22 + beads.left.meta.json 23 + beads.right.jsonl 24 + beads.right.meta.json 25 + 26 + # Keep JSONL exports and config (source of truth for git) 27 + !issues.jsonl 28 + !metadata.json 29 + !config.json
+1
.beads/.local_version
··· 1 + 0.29.0
+81
.beads/README.md
··· 1 + # Beads - AI-Native Issue Tracking 2 + 3 + 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. 4 + 5 + ## What is Beads? 6 + 7 + 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. 8 + 9 + **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 + 11 + ## Quick Start 12 + 13 + ### Essential Commands 14 + 15 + ```bash 16 + # Create new issues 17 + bd create "Add user authentication" 18 + 19 + # View all issues 20 + bd list 21 + 22 + # View issue details 23 + bd show <issue-id> 24 + 25 + # Update issue status 26 + bd update <issue-id> --status in_progress 27 + bd update <issue-id> --status done 28 + 29 + # Sync with git remote 30 + bd sync 31 + ``` 32 + 33 + ### Working with Issues 34 + 35 + Issues in Beads are: 36 + - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 + - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 + - **Branch-aware**: Issues can follow your branch workflow 39 + - **Always in sync**: Auto-syncs with your commits 40 + 41 + ## Why Beads? 42 + 43 + ✨ **AI-Native Design** 44 + - Built specifically for AI-assisted development workflows 45 + - CLI-first interface works seamlessly with AI coding agents 46 + - No context switching to web UIs 47 + 48 + 🚀 **Developer Focused** 49 + - Issues live in your repo, right next to your code 50 + - Works offline, syncs when you push 51 + - Fast, lightweight, and stays out of your way 52 + 53 + 🔧 **Git Integration** 54 + - Automatic sync with git commits 55 + - Branch-aware issue tracking 56 + - Intelligent JSONL merge resolution 57 + 58 + ## Get Started with Beads 59 + 60 + Try Beads in your own projects: 61 + 62 + ```bash 63 + # Install Beads 64 + curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 + 66 + # Initialize in your repo 67 + bd init 68 + 69 + # Create your first issue 70 + bd create "Try out Beads" 71 + ``` 72 + 73 + ## Learn More 74 + 75 + - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 + - **Quick Start Guide**: Run `bd quickstart` 77 + - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 + 79 + --- 80 + 81 + *Beads: Issue tracking that moves at the speed of thought* ⚡
+62
.beads/config.yaml
··· 1 + # Beads Configuration File 2 + # This file configures default behavior for all bd commands in this repository 3 + # All settings can also be set via environment variables (BD_* prefix) 4 + # or overridden with command-line flags 5 + 6 + # Issue prefix for this repository (used by bd init) 7 + # If not set, bd init will auto-detect from directory name 8 + # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 + # issue-prefix: "" 10 + 11 + # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 + # When true, bd will use .beads/issues.jsonl as the source of truth 13 + # instead of SQLite database 14 + # no-db: false 15 + 16 + # Disable daemon for RPC communication (forces direct database access) 17 + # no-daemon: false 18 + 19 + # Disable auto-flush of database to JSONL after mutations 20 + # no-auto-flush: false 21 + 22 + # Disable auto-import from JSONL when it's newer than database 23 + # no-auto-import: false 24 + 25 + # Enable JSON output by default 26 + # json: false 27 + 28 + # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 + # actor: "" 30 + 31 + # Path to database (overridden by BEADS_DB or --db) 32 + # db: "" 33 + 34 + # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 + # auto-start-daemon: true 36 + 37 + # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 + # flush-debounce: "5s" 39 + 40 + # Git branch for beads commits (bd sync will commit to this branch) 41 + # IMPORTANT: Set this for team projects so all clones use the same sync branch. 42 + # This setting persists across clones (unlike database config which is gitignored). 43 + # Can also use BEADS_SYNC_BRANCH env var for local override. 44 + # If not set, bd sync will require you to run 'bd config set sync.branch <branch>'. 45 + # sync-branch: "beads-sync" 46 + 47 + # Multi-repo configuration (experimental - bd-307) 48 + # Allows hydrating from multiple repositories and routing writes to the correct JSONL 49 + # repos: 50 + # primary: "." # Primary repo (where this database lives) 51 + # additional: # Additional repos to hydrate from (read-only) 52 + # - ~/beads-planning # Personal planning repo 53 + # - ~/work-planning # Work planning repo 54 + 55 + # Integration settings (access with 'bd config get/set') 56 + # These are stored in the database, not in this file: 57 + # - jira.url 58 + # - jira.project 59 + # - linear.url 60 + # - linear.api-key 61 + # - github.org 62 + # - github.repo
+108
.beads/issues.jsonl
··· 1 + {"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"}]} 2 + {"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"}]} 3 + {"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"}]} 4 + {"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"}]} 5 + {"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"}]} 6 + {"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"}]} 7 + {"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"}]} 8 + {"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"}]} 9 + {"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"}]} 10 + {"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"} 11 + {"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"}]} 12 + {"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"}]} 13 + {"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"}]} 14 + {"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"}]} 15 + {"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"} 16 + {"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"}]} 17 + {"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"}]} 18 + {"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"} 19 + {"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"}]} 20 + {"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"}]} 21 + {"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"}]} 22 + {"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"}]} 23 + {"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"}]} 24 + {"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"}]} 25 + {"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"} 26 + {"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"}]} 27 + {"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"} 28 + {"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"}]} 29 + {"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"}]} 30 + {"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"} 31 + {"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"}]} 32 + {"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"}]} 33 + {"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"}]} 34 + {"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"}]} 35 + {"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"}]} 36 + {"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"}]} 37 + {"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"}]} 38 + {"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"}]} 39 + {"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"}]} 40 + {"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"} 41 + {"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"}]} 42 + {"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"}]} 43 + {"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"}]} 44 + {"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"}]} 45 + {"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"}]} 46 + {"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"}]} 47 + {"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"}]} 48 + {"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"}]} 49 + {"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"}]} 50 + {"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"}]} 51 + {"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"}]} 52 + {"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"} 53 + {"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"}]} 54 + {"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"}]} 55 + {"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"}]} 56 + {"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"}]} 57 + {"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"}]} 58 + {"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"}]} 59 + {"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"}]} 60 + {"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"} 61 + {"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"} 62 + {"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"}]} 63 + {"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"} 64 + {"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"} 65 + {"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"} 66 + {"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"}]} 67 + {"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"}]} 68 + {"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"} 69 + {"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"}]} 70 + {"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"}]} 71 + {"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"}]} 72 + {"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"}]} 73 + {"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"}]} 74 + {"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"}]} 75 + {"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"} 76 + {"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"}]} 77 + {"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"}]} 78 + {"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"}]} 79 + {"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"}]} 80 + {"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"}]} 81 + {"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"}]} 82 + {"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"}]} 83 + {"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"} 84 + {"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"}]} 85 + {"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"}]} 86 + {"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"}]} 87 + {"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"}]} 88 + {"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"}]} 89 + {"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"}]} 90 + {"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"}]} 91 + {"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"}]} 92 + {"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"}]} 93 + {"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"}]} 94 + {"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"}]} 95 + {"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"}]} 96 + {"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"}]} 97 + {"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"}]} 98 + {"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"}]} 99 + {"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"}]} 100 + {"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"}]} 101 + {"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"} 102 + {"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"}]} 103 + {"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"} 104 + {"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"}]} 105 + {"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"}]} 106 + {"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"} 107 + {"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"} 108 + {"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"}]}
+4
.beads/metadata.json
··· 1 + { 2 + "database": "beads.db", 3 + "jsonl_export": "issues.jsonl" 4 + }
+49
.gitignore
··· 1 + # OCaml compiled artifacts 2 + *.annot 3 + *.cmo 4 + *.cma 5 + *.cmi 6 + *.a 7 + *.o 8 + *.cmx 9 + *.cmxs 10 + *.cmxa 11 + *.cmt 12 + *.cmti 13 + *.byte 14 + *.native 15 + *.install 16 + 17 + # Build directories 18 + _build/ 19 + _opam/ 20 + 21 + # Editor/IDE 22 + .vscode/ 23 + .idea/ 24 + .merlin 25 + 26 + # Node.js 27 + node_modules/ 28 + 29 + # OpenCode 30 + .opencode/ 31 + 32 + # Beads 33 + .beads/*.log 34 + .beads/*.lock 35 + 36 + # Benchmark results and artifacts 37 + results/ 38 + **/results/*.txt 39 + bench/certs/ 40 + bench/fasthttp/ 41 + bench/hcs/alloc_*.ml 42 + bench/nethttp/bench-nethttp 43 + perf.data 44 + perf*.data 45 + 46 + # Misc 47 + setup.data 48 + setup.log 49 + *.sh
+2
.ocamlformat
··· 1 + version = 0.28.1 2 + profile = default
+15
LICENSE
··· 1 + ISC License 2 + 3 + Copyright (c) 2026 Gabriel Díaz López de la Llave 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 + PERFORMANCE OF THIS SOFTWARE.
+133
README.md
··· 1 + # hcs 2 + 3 + HTTP library for OCaml 5+ built on [Eio](https://github.com/ocaml-multicore/eio). Supports HTTP/1.1, HTTP/2, and WebSocket. 4 + 5 + ## Modules 6 + 7 + | Module | Description | 8 + |--------|-------------| 9 + | [Client](lib/client.ml) | HTTP client with auto-protocol selection | 10 + | [Server](lib/server.ml) | HTTP server with multi-domain parallelism | 11 + | [Router](lib/router.ml) | Radix trie router with path parameters | 12 + | [Middleware](lib/middleware.ml) | Composable middleware | 13 + | [Middleware_eio](lib/middleware_eio.ml) | Eio-specific middleware (logging, timeout, rate limiting, static files) | 14 + | [Websocket](lib/websocket.ml) | WebSocket client and server (RFC 6455) | 15 + | [Pool](lib/pool.ml) | Connection pooling | 16 + | [Pooled_client](lib/pooled_client.ml) | Client with connection pooling | 17 + | [Control](lib/control.ml) | Retry, circuit breaker, backoff strategies | 18 + | [Stream](lib/stream.ml) | Sync and async streaming | 19 + | [Codec](lib/codec.ml) | Codec signature for serialization | 20 + | [Tls_config](lib/tls_config.ml) | TLS configuration | 21 + | [Log](lib/log.ml) | Structured logging | 22 + | [Request](lib/request.ml) | Request helpers | 23 + | [Response](lib/response.ml) | Response helpers | 24 + | [Http](lib/http.ml) | Request builder DSL | 25 + | [H1_client](lib/h1_client.ml) | HTTP/1.1 client | 26 + | [H2_client](lib/h2_client.ml) | HTTP/2 client | 27 + | [H1_server](lib/h1_server.ml) | HTTP/1.1 server | 28 + | [H2_server](lib/h2_server.ml) | HTTP/2 server | 29 + 30 + ## CLI Tools 31 + 32 + ### hc - HTTP Client 33 + 34 + ``` 35 + hc [OPTIONS] <URL> 36 + ``` 37 + 38 + **Options:** 39 + 40 + | Flag | Description | 41 + |------|-------------| 42 + | `-X, --request METHOD` | HTTP method (GET, POST, PUT, DELETE, etc.) | 43 + | `-H, --header "Name: Value"` | Add header (can be repeated) | 44 + | `-d, --data DATA` | Request body | 45 + | `-2, --http2` | Force HTTP/2 | 46 + | `-1, --http1` | Force HTTP/1.1 | 47 + | `-k, --insecure` | Skip TLS verification | 48 + | `-v, --verbose` | Show headers | 49 + | `-I, --head` | HEAD request only | 50 + | `-L, --location` | Follow redirects (default) | 51 + | `--no-location` | Don't follow redirects | 52 + | `-o, --output FILE` | Write to file | 53 + | `-w, --websocket` | WebSocket mode | 54 + | `--ws-message MSG` | Message to send in WebSocket mode | 55 + 56 + **Examples:** 57 + 58 + ```bash 59 + # GET request 60 + hc https://httpbin.org/get 61 + 62 + # POST with data 63 + hc -d '{"key":"value"}' -H "Content-Type: application/json" https://httpbin.org/post 64 + 65 + # Force HTTP/2 66 + hc -2 https://nghttp2.org/httpbin/get 67 + 68 + # WebSocket 69 + hc -w wss://echo.websocket.org --ws-message "hello" 70 + 71 + # Download file 72 + hc -o image.png https://example.com/image.png 73 + ``` 74 + 75 + ### hs - HTTP File Server 76 + 77 + ``` 78 + hs [OPTIONS] [DIRECTORY] 79 + ``` 80 + 81 + **Options:** 82 + 83 + | Flag | Description | 84 + |------|-------------| 85 + | `-p, --port PORT` | Port (default: 8080) | 86 + | `-b, --bind ADDRESS` | Bind address (default: 0.0.0.0) | 87 + | `-d, --domains N` | Worker domains (default: CPU count) | 88 + | `-1, --http1` | HTTP/1.1 only | 89 + | `-2, --http2` | HTTP/2 only | 90 + | `--index FILE` | Index file (default: index.html) | 91 + | `--no-index` | Disable index file | 92 + | `--list` | Enable directory listing | 93 + | `--cors` | Enable CORS headers | 94 + | `-v, --verbose` | Log requests | 95 + 96 + **Examples:** 97 + 98 + ```bash 99 + # Serve current directory 100 + hs 101 + 102 + # Serve specific directory on port 3000 103 + hs -p 3000 ./public 104 + 105 + # With directory listing and CORS 106 + hs --list --cors ./dist 107 + 108 + # Verbose logging 109 + hs -v -p 8000 . 110 + ``` 111 + 112 + ## Building 113 + 114 + ```bash 115 + opam install . --deps-only 116 + dune build 117 + ``` 118 + 119 + ## Running Tests 120 + 121 + ```bash 122 + dune test 123 + ``` 124 + 125 + ## Dependencies 126 + 127 + - [eio](https://github.com/ocaml-multicore/eio) - Structured concurrency 128 + - [h1](https://github.com/anmonteiro/http-protocols) - HTTP/1.1 parsing 129 + - [h2](https://github.com/anmonteiro/http-protocols) - HTTP/2 parsing 130 + - [tls-eio](https://github.com/mirleft/ocaml-tls) - TLS with Eio 131 + - [ca-certs](https://github.com/mirage/ca-certs) - System CA certificates 132 + - [uri](https://github.com/mirage/ocaml-uri) - URI parsing 133 + - [climate](https://github.com/gridbugs/climate) - CLI parsing
+20
bench/.gitignore
··· 1 + # Rust build artifacts 2 + hyper/target/ 3 + 4 + # Go build artifacts 5 + fasthttp/bench-server 6 + 7 + # Results and logs 8 + results/ 9 + *.log 10 + 11 + # Editor/IDE 12 + .idea/ 13 + .vscode/ 14 + *.swp 15 + *.swo 16 + *~ 17 + 18 + # OS files 19 + .DS_Store 20 + Thumbs.db
+232
bench/client/bench_client.ml
··· 1 + module Stats = struct 2 + type t = { 3 + mutable total_requests : int; 4 + mutable successful : int; 5 + mutable failed : int; 6 + latencies : float Queue.t; 7 + start_time : float; 8 + mutable end_time : float; 9 + } 10 + 11 + let create () = 12 + { 13 + total_requests = 0; 14 + successful = 0; 15 + failed = 0; 16 + latencies = Queue.create (); 17 + start_time = Unix.gettimeofday (); 18 + end_time = 0.0; 19 + } 20 + 21 + let record_success stats latency_ms = 22 + stats.total_requests <- stats.total_requests + 1; 23 + stats.successful <- stats.successful + 1; 24 + Queue.push latency_ms stats.latencies 25 + 26 + let record_failure stats = 27 + stats.total_requests <- stats.total_requests + 1; 28 + stats.failed <- stats.failed + 1 29 + 30 + let finish stats = stats.end_time <- Unix.gettimeofday () 31 + 32 + let percentile arr p = 33 + let n = Array.length arr in 34 + if n = 0 then 0.0 35 + else 36 + let idx = int_of_float (float_of_int (n - 1) *. p) in 37 + arr.(idx) 38 + 39 + let report stats ~json ~protocol = 40 + finish stats; 41 + let duration = stats.end_time -. stats.start_time in 42 + let rps = float_of_int stats.successful /. duration in 43 + 44 + let latencies = Queue.to_seq stats.latencies |> Array.of_seq in 45 + Array.sort compare latencies; 46 + 47 + let p50 = percentile latencies 0.50 in 48 + let p90 = percentile latencies 0.90 in 49 + let p99 = percentile latencies 0.99 in 50 + let p999 = percentile latencies 0.999 in 51 + let avg = 52 + if Array.length latencies > 0 then 53 + Array.fold_left ( +. ) 0.0 latencies 54 + /. float_of_int (Array.length latencies) 55 + else 0.0 56 + in 57 + 58 + if json then 59 + Printf.printf 60 + {|{"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} 61 + |} 62 + protocol duration stats.total_requests stats.successful stats.failed rps 63 + avg p50 p90 p99 p999 64 + else begin 65 + Printf.printf "\n=== Benchmark Results (%s) ===\n" protocol; 66 + Printf.printf "Duration: %.2f seconds\n" duration; 67 + Printf.printf "Requests: %d total, %d successful, %d failed\n" 68 + stats.total_requests stats.successful stats.failed; 69 + Printf.printf "Throughput: %.2f req/s\n" rps; 70 + Printf.printf "\nLatency (ms):\n"; 71 + Printf.printf " avg: %.3f\n" avg; 72 + Printf.printf " p50: %.3f\n" p50; 73 + Printf.printf " p90: %.3f\n" p90; 74 + Printf.printf " p99: %.3f\n" p99; 75 + Printf.printf " p99.9: %.3f\n" p999 76 + end 77 + end 78 + 79 + module H1_benchmark = struct 80 + let worker ~sw ~net ~url ~stats ~stop_flag = 81 + let uri = Uri.of_string url in 82 + let host = Uri.host uri |> Option.value ~default:"localhost" in 83 + let port = Uri.port uri |> Option.value ~default:8080 in 84 + let path = Uri.path uri in 85 + let path = if path = "" then "/" else path in 86 + 87 + let request_line = 88 + Printf.sprintf 89 + "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n" path 90 + host 91 + in 92 + let request_bytes = Bytes.of_string request_line in 93 + let buf = Cstruct.create 4096 in 94 + let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in 95 + 96 + try 97 + let flow = Eio.Net.connect ~sw net addr in 98 + 99 + while not !stop_flag do 100 + let start = Unix.gettimeofday () in 101 + try 102 + Eio.Flow.write flow [ Cstruct.of_bytes request_bytes ]; 103 + let _ = Eio.Flow.single_read flow buf in 104 + let elapsed = (Unix.gettimeofday () -. start) *. 1000.0 in 105 + Stats.record_success stats elapsed 106 + with _ -> Stats.record_failure stats 107 + done; 108 + 109 + Eio.Flow.close flow 110 + with _ -> () 111 + 112 + let run ~net ~clock ~sw ~url ~concurrency ~duration ~json = 113 + if not json then begin 114 + Printf.printf "Benchmarking %s (HTTP/1.1)\n" url; 115 + Printf.printf "Concurrency: %d, Duration: %d seconds\n%!" concurrency 116 + duration 117 + end; 118 + 119 + let stats = Stats.create () in 120 + let stop_flag = ref false in 121 + 122 + for _ = 1 to concurrency do 123 + Eio.Fiber.fork ~sw (fun () -> worker ~sw ~net ~url ~stats ~stop_flag) 124 + done; 125 + 126 + Eio.Time.sleep clock (float_of_int duration); 127 + stop_flag := true; 128 + Eio.Time.sleep clock 0.1; 129 + 130 + Stats.report stats ~json ~protocol:"HTTP/1.1" 131 + end 132 + 133 + module Ws_benchmark = struct 134 + let worker ~sw ~net ~url ~stats ~stop_flag ~msg_size = 135 + let message = String.make msg_size 'x' in 136 + 137 + match Hcs.Websocket.connect ~sw ~net url with 138 + | Error e -> 139 + let err_msg = 140 + match e with 141 + | Hcs.Websocket.Connection_closed -> "Connection closed" 142 + | Hcs.Websocket.Protocol_error s -> "Protocol error: " ^ s 143 + | Hcs.Websocket.Io_error s -> "IO error: " ^ s 144 + in 145 + Printf.eprintf "WebSocket connect error: %s\n%!" err_msg 146 + | Ok ws -> 147 + while not !stop_flag do 148 + let start = Unix.gettimeofday () in 149 + match Hcs.Websocket.send_text ws message with 150 + | Error _ -> Stats.record_failure stats 151 + | Ok () -> ( 152 + match Hcs.Websocket.recv_message ws with 153 + | Error _ -> Stats.record_failure stats 154 + | Ok (_, _response) -> 155 + let elapsed = (Unix.gettimeofday () -. start) *. 1000.0 in 156 + Stats.record_success stats elapsed) 157 + done; 158 + Hcs.Websocket.close ws 159 + 160 + let run ~net ~clock ~sw ~url ~concurrency ~duration ~json ~msg_size = 161 + if not json then begin 162 + Printf.printf "Benchmarking %s (WebSocket)\n" url; 163 + Printf.printf 164 + "Concurrency: %d, Duration: %d seconds, Message size: %d\n%!" 165 + concurrency duration msg_size 166 + end; 167 + 168 + let stats = Stats.create () in 169 + let stop_flag = ref false in 170 + 171 + for _ = 1 to concurrency do 172 + Eio.Fiber.fork ~sw (fun () -> 173 + worker ~sw ~net ~url ~stats ~stop_flag ~msg_size) 174 + done; 175 + 176 + Eio.Time.sleep clock (float_of_int duration); 177 + stop_flag := true; 178 + Eio.Time.sleep clock 0.1; 179 + 180 + Stats.report stats ~json ~protocol:"WebSocket" 181 + end 182 + 183 + let run_benchmark protocol url concurrency duration json msg_size = 184 + Eio_main.run @@ fun env -> 185 + Eio.Switch.run @@ fun sw -> 186 + let net = Eio.Stdenv.net env in 187 + let clock = Eio.Stdenv.clock env in 188 + 189 + match protocol with 190 + | "h1" | "http1" | "http/1.1" -> 191 + H1_benchmark.run ~net ~clock ~sw ~url ~concurrency ~duration ~json 192 + | "h2" | "http2" | "http/2" | "h2c" -> 193 + Printf.eprintf 194 + "HTTP/2 benchmarks: use h2load from nghttp2\n\ 195 + Example: h2load -n 100000 -c %d -t 1 %s\n" 196 + concurrency url; 197 + exit 0 198 + | "ws" | "websocket" -> 199 + Ws_benchmark.run ~net ~clock ~sw ~url ~concurrency ~duration ~json 200 + ~msg_size 201 + | _ -> 202 + Printf.eprintf "Unknown protocol: %s (use h1, h2, or ws)\n" protocol; 203 + exit 1 204 + 205 + let command = 206 + Climate.Command.singleton 207 + ~doc: 208 + "HCS multi-protocol benchmark client (HTTP/1.1 native, WebSocket native, \ 209 + HTTP/2 via h2load)" 210 + @@ 211 + let open Climate.Arg_parser in 212 + let+ protocol = 213 + named_with_default [ "p"; "protocol" ] string ~default:"h1" 214 + ~doc: 215 + "Protocol to use: h1 (HTTP/1.1), h2 (HTTP/2 - prints h2load command), \ 216 + ws (WebSocket)" 217 + and+ url = named_req [ "u"; "url" ] string ~doc:"Target URL to benchmark" 218 + and+ concurrency = 219 + named_with_default [ "c"; "concurrency" ] int ~default:10 220 + ~doc:"Number of concurrent connections" 221 + and+ duration = 222 + named_with_default [ "d"; "duration" ] int ~default:10 223 + ~doc:"Test duration in seconds" 224 + and+ json = flag [ "json" ] ~doc:"Output results as JSON" 225 + and+ msg_size = 226 + named_with_default [ "m"; "msg-size" ] int ~default:32 227 + ~doc:"WebSocket message size in bytes" 228 + in 229 + 230 + run_benchmark protocol url concurrency duration json msg_size 231 + 232 + let () = Climate.Command.run command
+4
bench/client/dune
··· 1 + (executable 2 + (name bench_client) 3 + (public_name bench-client) 4 + (libraries hcs eio_main uri climate unix cstruct tls-eio))
+38
bench/dream/bench_server_dream.ml
··· 1 + type json_message = { message : string } 2 + 3 + let serialize_json () = 4 + let msg = { message = "Hello, World!" } in 5 + let json = `Assoc [ ("message", `String msg.message) ] in 6 + Yojson.Basic.to_string json 7 + 8 + let plaintext_body = "Hello, World!" 9 + 10 + let plaintext_handler _req = 11 + Dream.respond 12 + ~headers:[ ("Content-Type", "text/plain"); ("Server", "dream") ] 13 + plaintext_body 14 + 15 + let json_handler _req = 16 + let body = serialize_json () in 17 + Dream.respond 18 + ~headers:[ ("Content-Type", "application/json"); ("Server", "dream") ] 19 + body 20 + 21 + let command = 22 + Climate.Command.singleton ~doc:"TechEmpower benchmark server using Dream" 23 + @@ 24 + let open Climate.Arg_parser in 25 + let+ port = 26 + named_with_default [ "p"; "port" ] int ~default:8080 27 + ~doc:"Port to listen on" 28 + in 29 + 30 + Printf.printf "Starting Dream server on port %d\n%!" port; 31 + Dream.run ~port ~interface:"0.0.0.0" 32 + @@ Dream.router 33 + [ 34 + Dream.get "/plaintext" plaintext_handler; 35 + Dream.get "/json" json_handler; 36 + ] 37 + 38 + let () = Climate.Command.run command
+4
bench/dream/dune
··· 1 + (executable 2 + (name bench_server_dream) 3 + (public_name bench-dream) 4 + (libraries dream yojson climate))
+13
bench/fasthttp/go.mod
··· 1 + module bench-fasthttp 2 + 3 + go 1.21 4 + 5 + require github.com/valyala/fasthttp v1.51.0 6 + 7 + require ( 8 + github.com/andybalholm/brotli v1.0.5 // indirect 9 + github.com/klauspost/compress v1.17.0 // indirect 10 + github.com/valyala/bytebufferpool v1.0.0 // indirect 11 + github.com/valyala/tcplisten v1.0.0 // indirect 12 + golang.org/x/sys v0.13.0 // indirect 13 + )
+12
bench/fasthttp/go.sum
··· 1 + github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= 2 + github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 3 + github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= 4 + github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 5 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 6 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 7 + github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= 8 + github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= 9 + github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 10 + github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 11 + golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 12 + golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+128
bench/fasthttp/main.go
··· 1 + // TechEmpower-style benchmark server using fasthttp 2 + // 3 + // Implements: 4 + // - /plaintext - Returns "Hello, World!" as text/plain 5 + // - /json - Returns {"message":"Hello, World!"} as application/json 6 + // 7 + // Supports HTTP/1.1 with prefork for CPU scaling. 8 + // Usage: ./bench-fasthttp -port 8080 -prefork -threads 4 9 + 10 + package main 11 + 12 + import ( 13 + "encoding/json" 14 + "flag" 15 + "fmt" 16 + "log" 17 + "runtime" 18 + "sync" 19 + "sync/atomic" 20 + "time" 21 + 22 + "github.com/valyala/fasthttp" 23 + "github.com/valyala/fasthttp/prefork" 24 + ) 25 + 26 + const ( 27 + helloWorldStr = "Hello, World!" 28 + contentTypeJSON = "application/json" 29 + contentTypeText = "text/plain" 30 + serverName = "fasthttp" 31 + ) 32 + 33 + // Message for JSON response 34 + type Message struct { 35 + Message string `json:"message"` 36 + } 37 + 38 + var ( 39 + port int 40 + threads int 41 + usePrefork bool 42 + ) 43 + 44 + var ( 45 + dateCacheMu sync.RWMutex 46 + dateCacheTime int64 47 + dateCacheVal []byte 48 + ) 49 + 50 + func getCachedDate() []byte { 51 + now := time.Now().Unix() 52 + cached := atomic.LoadInt64(&dateCacheTime) 53 + 54 + if now != cached { 55 + dateCacheMu.Lock() 56 + if atomic.LoadInt64(&dateCacheTime) != now { 57 + dateCacheVal = []byte(time.Now().UTC().Format(time.RFC1123)) 58 + atomic.StoreInt64(&dateCacheTime, now) 59 + } 60 + dateCacheMu.Unlock() 61 + } 62 + 63 + dateCacheMu.RLock() 64 + defer dateCacheMu.RUnlock() 65 + return dateCacheVal 66 + } 67 + 68 + func init() { 69 + flag.IntVar(&port, "port", 8080, "Port to listen on") 70 + flag.IntVar(&threads, "threads", 1, "Number of threads (GOMAXPROCS)") 71 + flag.BoolVar(&usePrefork, "prefork", false, "Use prefork mode for multi-process scaling") 72 + flag.Parse() 73 + } 74 + 75 + func main() { 76 + // Set GOMAXPROCS 77 + runtime.GOMAXPROCS(threads) 78 + 79 + addr := fmt.Sprintf(":%d", port) 80 + 81 + server := &fasthttp.Server{ 82 + Name: serverName, 83 + Handler: router, 84 + DisableHeaderNamesNormalizing: true, 85 + } 86 + 87 + fmt.Printf("Starting fasthttp server on port %d with %d threads (prefork=%v)\n", port, threads, usePrefork) 88 + 89 + var err error 90 + if usePrefork { 91 + err = prefork.New(server).ListenAndServe(addr) 92 + } else { 93 + err = server.ListenAndServe(addr) 94 + } 95 + 96 + if err != nil { 97 + log.Fatalf("Error starting server: %v", err) 98 + } 99 + } 100 + 101 + func router(ctx *fasthttp.RequestCtx) { 102 + path := string(ctx.Path()) 103 + switch path { 104 + case "/plaintext": 105 + plaintext(ctx) 106 + case "/json": 107 + jsonHandler(ctx) 108 + default: 109 + ctx.Error(fasthttp.StatusMessage(fasthttp.StatusNotFound), fasthttp.StatusNotFound) 110 + } 111 + } 112 + 113 + func plaintext(ctx *fasthttp.RequestCtx) { 114 + ctx.Response.Header.SetServer(serverName) 115 + ctx.Response.Header.SetBytesV("Date", getCachedDate()) 116 + ctx.Response.Header.SetContentType(contentTypeText) 117 + ctx.Response.SetBodyString(helloWorldStr) 118 + } 119 + 120 + func jsonHandler(ctx *fasthttp.RequestCtx) { 121 + msg := Message{Message: helloWorldStr} 122 + data, _ := json.Marshal(msg) 123 + 124 + ctx.Response.Header.SetServer(serverName) 125 + ctx.Response.Header.SetBytesV("Date", getCachedDate()) 126 + ctx.Response.Header.SetContentType(contentTypeJSON) 127 + ctx.Response.SetBody(data) 128 + }
+75
bench/hcs/bench_server.ml
··· 1 + type json_message = { message : string } 2 + 3 + let serialize_json () = 4 + let msg = { message = "Hello, World!" } in 5 + let json = `Assoc [ ("message", `String msg.message) ] in 6 + Yojson.Basic.to_string json 7 + 8 + let plaintext_body = "Hello, World!" 9 + 10 + let plaintext_body_bstr = 11 + Bigstringaf.of_string ~off:0 12 + ~len:(String.length plaintext_body) 13 + plaintext_body 14 + 15 + let plaintext_headers = 16 + Hcs.H1_server.make_h1_headers 17 + [ 18 + ("Content-Type", "text/plain"); ("Server", "hcs"); ("Content-Length", "13"); 19 + ] 20 + 21 + let plaintext_h1_response = 22 + Hcs.H1_server.make_h1_response ~status:`OK plaintext_headers 23 + 24 + let notfound_body_bstr = Bigstringaf.of_string ~off:0 ~len:9 "Not Found" 25 + 26 + let notfound_headers = 27 + Hcs.H1_server.make_h1_headers [ ("Server", "hcs"); ("Content-Length", "9") ] 28 + 29 + let notfound_h1_response = 30 + Hcs.H1_server.make_h1_response ~status:`Not_found notfound_headers 31 + 32 + let handler (req : Hcs.H1_server.request) : Hcs.H1_server.response = 33 + match req.target with 34 + | "/plaintext" -> 35 + Hcs.H1_server.respond_prebuilt plaintext_h1_response plaintext_body_bstr 36 + | "/json" -> 37 + let body = serialize_json () in 38 + Hcs.H1_server.respond ~status:`OK 39 + ~headers:[ ("Content-Type", "application/json"); ("Server", "hcs") ] 40 + body 41 + | _ -> Hcs.H1_server.respond_prebuilt notfound_h1_response notfound_body_bstr 42 + 43 + let command = 44 + Climate.Command.singleton ~doc:"TechEmpower benchmark server using HCS" 45 + @@ 46 + let open Climate.Arg_parser in 47 + let+ port = 48 + named_with_default [ "p"; "port" ] int ~default:8080 49 + ~doc:"Port to listen on" 50 + and+ domains = 51 + named_with_default [ "d"; "domains" ] int ~default:1 52 + ~doc:"Number of domains (CPUs) to use" 53 + in 54 + 55 + Printf.printf "Starting HCS server on port %d with %d domains\n%!" port 56 + domains; 57 + 58 + Eio_main.run @@ fun env -> 59 + Eio.Switch.run @@ fun sw -> 60 + let config = 61 + Hcs.H1_server. 62 + { 63 + default_config with 64 + port; 65 + domain_count = domains; 66 + backlog = 4096; 67 + max_connections = 100000; 68 + buffer_size = 16384; 69 + } 70 + in 71 + Hcs.H1_server.run_parallel ~sw ~net:(Eio.Stdenv.net env) 72 + ~domain_mgr:(Eio.Stdenv.domain_mgr env) 73 + ~config handler 74 + 75 + let () = Climate.Command.run command
+62
bench/hcs/bench_server_fast.ml
··· 1 + type json_message = { message : string } 2 + 3 + let serialize_json () = 4 + let msg = { message = "Hello, World!" } in 5 + let json = `Assoc [ ("message", `String msg.message) ] in 6 + Yojson.Basic.to_string json 7 + 8 + let plaintext_response = 9 + Hcs.Server.Prebuilt.create ~status:`OK 10 + ~headers:[ ("content-type", "text/plain"); ("server", "hcs") ] 11 + "Hello, World!" 12 + 13 + let notfound_response = 14 + Hcs.Server.Prebuilt.create ~status:`Not_found 15 + ~headers:[ ("server", "hcs") ] 16 + "Not Found" 17 + 18 + let handler (req : Hcs.Server.request) : Hcs.Server.response = 19 + match req.target with 20 + | "/plaintext" -> Hcs.Server.respond_prebuilt plaintext_response 21 + | "/json" -> 22 + let body = serialize_json () in 23 + Hcs.Server.respond ~status:`OK 24 + ~headers:[ ("content-type", "application/json"); ("server", "hcs") ] 25 + body 26 + | _ -> Hcs.Server.respond_prebuilt notfound_response 27 + 28 + let command = 29 + Climate.Command.singleton 30 + ~doc:"TechEmpower benchmark server using Server (Http1_only mode)" 31 + @@ 32 + let open Climate.Arg_parser in 33 + let+ port = 34 + named_with_default [ "p"; "port" ] int ~default:8080 35 + ~doc:"Port to listen on" 36 + and+ domains = 37 + named_with_default [ "d"; "domains" ] int ~default:1 38 + ~doc:"Number of domains (CPUs) to use" 39 + in 40 + 41 + Printf.printf 42 + "Starting HCS Server (Http1_only mode) on port %d with %d domains\n%!" port 43 + domains; 44 + 45 + Eio_main.run @@ fun env -> 46 + Eio.Switch.run @@ fun sw -> 47 + let config = 48 + Hcs.Server. 49 + { 50 + default_config with 51 + port; 52 + domain_count = domains; 53 + protocol = Http1_only; 54 + backlog = 4096; 55 + max_connections = 100000; 56 + } 57 + in 58 + Hcs.Server.run_parallel ~sw ~net:(Eio.Stdenv.net env) 59 + ~domain_mgr:(Eio.Stdenv.domain_mgr env) 60 + ~config handler 61 + 62 + let () = Climate.Command.run command
+87
bench/hcs/bench_server_h2.ml
··· 1 + (** HCS HTTP/2 Benchmark Server 2 + 3 + TechEmpower-style benchmark server using HCS H2_server for HTTP/2 h2c 4 + (cleartext). Supports multi-domain (multi-CPU) operation via SO_REUSEPORT. 5 + *) 6 + 7 + (* TechEmpower-compliant JSON message type *) 8 + type json_message = { message : string } 9 + 10 + (* Serialize JSON per-request as required by TechEmpower spec *) 11 + let serialize_json () = 12 + let msg = { message = "Hello, World!" } in 13 + let json = `Assoc [ ("message", `String msg.message) ] in 14 + Yojson.Basic.to_string json 15 + 16 + (* Pre-built plaintext response *) 17 + let plaintext_body = Bigstringaf.of_string ~off:0 ~len:13 "Hello, World!" 18 + 19 + let plaintext_headers = 20 + Hcs.H2_server.make_h2_headers 21 + [ 22 + ("content-type", "text/plain"); 23 + ("server", "hcs-h2"); 24 + ("content-length", "13"); 25 + ] 26 + 27 + let plaintext_response = 28 + Hcs.H2_server.make_h2_response ~status:`OK plaintext_headers 29 + 30 + (* Pre-built not found response *) 31 + let notfound_body = Bigstringaf.of_string ~off:0 ~len:9 "Not Found" 32 + 33 + let notfound_headers = 34 + Hcs.H2_server.make_h2_headers 35 + [ ("server", "hcs-h2"); ("content-length", "9") ] 36 + 37 + let notfound_response = 38 + Hcs.H2_server.make_h2_response ~status:`Not_found notfound_headers 39 + 40 + let handler (req : Hcs.H2_server.request) : Hcs.H2_server.response = 41 + match req.target with 42 + | "/plaintext" -> 43 + Hcs.H2_server.respond_prebuilt plaintext_response plaintext_body 44 + | "/json" -> 45 + (* TechEmpower requires JSON serialization per-request *) 46 + let body = serialize_json () in 47 + Hcs.H2_server.respond_opt ~status:`OK 48 + ~headers:[ ("content-type", "application/json"); ("server", "hcs-h2") ] 49 + body 50 + | _ -> Hcs.H2_server.respond_prebuilt notfound_response notfound_body 51 + 52 + let command = 53 + Climate.Command.singleton 54 + ~doc:"TechEmpower benchmark server using HCS HTTP/2 (h2c)" 55 + @@ 56 + let open Climate.Arg_parser in 57 + let+ port = 58 + named_with_default [ "p"; "port" ] int ~default:8080 59 + ~doc:"Port to listen on" 60 + and+ domains = 61 + named_with_default [ "d"; "domains" ] int ~default:1 62 + ~doc:"Number of domains (CPUs) to use" 63 + in 64 + 65 + Printf.printf 66 + "Starting HCS HTTP/2 (h2c) server on port %d with %d domains\n%!" port 67 + domains; 68 + 69 + Eio_main.run @@ fun env -> 70 + Eio.Switch.run @@ fun sw -> 71 + let config = 72 + Hcs.H1_server. 73 + { 74 + default_config with 75 + port; 76 + domain_count = domains; 77 + backlog = 4096; 78 + max_connections = 100000; 79 + reuse_port = true; 80 + tcp_nodelay = true; 81 + } 82 + in 83 + Hcs.H2_server.run_parallel ~sw ~net:(Eio.Stdenv.net env) 84 + ~domain_mgr:(Eio.Stdenv.domain_mgr env) 85 + ~config handler 86 + 87 + let () = Climate.Command.run command
+83
bench/hcs/bench_server_unified.ml
··· 1 + type json_message = { message : string } 2 + 3 + let serialize_json () = 4 + let msg = { message = "Hello, World!" } in 5 + let json = `Assoc [ ("message", `String msg.message) ] in 6 + Yojson.Basic.to_string json 7 + 8 + let plaintext_body = "Hello, World!" 9 + 10 + let handler (req : Hcs.Server.request) : Hcs.Server.response = 11 + match req.target with 12 + | "/plaintext" -> 13 + Hcs.Server.respond ~status:`OK 14 + ~headers:[ ("content-type", "text/plain"); ("server", "hcs-unified") ] 15 + plaintext_body 16 + | "/json" -> 17 + let body = serialize_json () in 18 + Hcs.Server.respond ~status:`OK 19 + ~headers: 20 + [ ("content-type", "application/json"); ("server", "hcs-unified") ] 21 + body 22 + | _ -> 23 + Hcs.Server.respond ~status:`Not_found 24 + ~headers:[ ("server", "hcs-unified") ] 25 + "Not Found" 26 + 27 + let ws_handler (ws : Hcs.Websocket.t) = 28 + let rec loop () = 29 + match Hcs.Websocket.recv_message ws with 30 + | Ok (Hcs.Websocket.Opcode.Text, msg) -> ( 31 + match Hcs.Websocket.send_text ws msg with 32 + | Ok () -> loop () 33 + | Error _ -> ()) 34 + | Ok (Hcs.Websocket.Opcode.Binary, msg) -> ( 35 + match Hcs.Websocket.send_binary ws msg with 36 + | Ok () -> loop () 37 + | Error _ -> ()) 38 + | Ok _ -> loop () 39 + | Error Hcs.Websocket.Connection_closed -> () 40 + | Error _ -> () 41 + in 42 + loop () 43 + 44 + let command = 45 + Climate.Command.singleton 46 + ~doc:"Unified benchmark server (HTTP/1.1 + HTTP/2 h2c + WebSocket)" 47 + @@ 48 + let open Climate.Arg_parser in 49 + let+ port = 50 + named_with_default [ "p"; "port" ] int ~default:8080 51 + ~doc:"Port to listen on" 52 + and+ domains = 53 + named_with_default [ "d"; "domains" ] int ~default:1 54 + ~doc:"Number of domains (CPUs) to use" 55 + in 56 + 57 + Printf.printf 58 + "Starting HCS Server (Auto_websocket mode) on port %d with %d domains\n%!" 59 + port domains; 60 + Printf.printf "Supports: HTTP/1.1, HTTP/2 h2c, WebSocket\n%!"; 61 + Printf.printf "Endpoints:\n%!"; 62 + Printf.printf " GET /plaintext - Plain text response\n%!"; 63 + Printf.printf " GET /json - JSON response\n%!"; 64 + Printf.printf " WS / - WebSocket echo\n%!"; 65 + 66 + Eio_main.run @@ fun env -> 67 + Eio.Switch.run @@ fun sw -> 68 + let config = 69 + Hcs.Server. 70 + { 71 + default_config with 72 + port; 73 + domain_count = domains; 74 + protocol = Auto_websocket; 75 + backlog = 4096; 76 + max_connections = 100000; 77 + } 78 + in 79 + Hcs.Server.run_parallel ~sw ~net:(Eio.Stdenv.net env) 80 + ~domain_mgr:(Eio.Stdenv.domain_mgr env) 81 + ~config ~ws_handler handler 82 + 83 + let () = Climate.Command.run command
+19
bench/hcs/dune
··· 1 + (executable 2 + (name bench_server) 3 + (public_name bench-hcs) 4 + (libraries hcs eio_main yojson climate)) 5 + 6 + (executable 7 + (name bench_server_fast) 8 + (public_name bench-hcs-fast) 9 + (libraries hcs eio_main yojson climate)) 10 + 11 + (executable 12 + (name bench_server_h2) 13 + (public_name bench-hcs-h2) 14 + (libraries hcs eio_main yojson climate bigstringaf)) 15 + 16 + (executable 17 + (name bench_server_unified) 18 + (public_name bench-hcs-unified) 19 + (libraries hcs eio_main yojson climate bigstringaf))
+1012
bench/hyper/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "anstream" 7 + version = "0.6.21" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 10 + dependencies = [ 11 + "anstyle", 12 + "anstyle-parse", 13 + "anstyle-query", 14 + "anstyle-wincon", 15 + "colorchoice", 16 + "is_terminal_polyfill", 17 + "utf8parse", 18 + ] 19 + 20 + [[package]] 21 + name = "anstyle" 22 + version = "1.0.13" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 25 + 26 + [[package]] 27 + name = "anstyle-parse" 28 + version = "0.2.7" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 31 + dependencies = [ 32 + "utf8parse", 33 + ] 34 + 35 + [[package]] 36 + name = "anstyle-query" 37 + version = "1.1.5" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 40 + dependencies = [ 41 + "windows-sys 0.61.2", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle-wincon" 46 + version = "3.0.11" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 49 + dependencies = [ 50 + "anstyle", 51 + "once_cell_polyfill", 52 + "windows-sys 0.61.2", 53 + ] 54 + 55 + [[package]] 56 + name = "atomic-waker" 57 + version = "1.1.2" 58 + source = "registry+https://github.com/rust-lang/crates.io-index" 59 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 60 + 61 + [[package]] 62 + name = "base64" 63 + version = "0.21.7" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 66 + 67 + [[package]] 68 + name = "bench-hyper" 69 + version = "0.1.0" 70 + dependencies = [ 71 + "clap", 72 + "fastwebsockets", 73 + "http-body-util", 74 + "httpdate", 75 + "hyper", 76 + "hyper-util", 77 + "serde", 78 + "serde_json", 79 + "socket2 0.5.10", 80 + "tokio", 81 + ] 82 + 83 + [[package]] 84 + name = "bitflags" 85 + version = "2.10.0" 86 + source = "registry+https://github.com/rust-lang/crates.io-index" 87 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 88 + 89 + [[package]] 90 + name = "block-buffer" 91 + version = "0.10.4" 92 + source = "registry+https://github.com/rust-lang/crates.io-index" 93 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 94 + dependencies = [ 95 + "generic-array", 96 + ] 97 + 98 + [[package]] 99 + name = "bytes" 100 + version = "1.11.0" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 103 + 104 + [[package]] 105 + name = "cfg-if" 106 + version = "1.0.4" 107 + source = "registry+https://github.com/rust-lang/crates.io-index" 108 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 109 + 110 + [[package]] 111 + name = "clap" 112 + version = "4.5.53" 113 + source = "registry+https://github.com/rust-lang/crates.io-index" 114 + checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" 115 + dependencies = [ 116 + "clap_builder", 117 + "clap_derive", 118 + ] 119 + 120 + [[package]] 121 + name = "clap_builder" 122 + version = "4.5.53" 123 + source = "registry+https://github.com/rust-lang/crates.io-index" 124 + checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" 125 + dependencies = [ 126 + "anstream", 127 + "anstyle", 128 + "clap_lex", 129 + "strsim", 130 + ] 131 + 132 + [[package]] 133 + name = "clap_derive" 134 + version = "4.5.49" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 137 + dependencies = [ 138 + "heck", 139 + "proc-macro2", 140 + "quote", 141 + "syn", 142 + ] 143 + 144 + [[package]] 145 + name = "clap_lex" 146 + version = "0.7.6" 147 + source = "registry+https://github.com/rust-lang/crates.io-index" 148 + checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 149 + 150 + [[package]] 151 + name = "colorchoice" 152 + version = "1.0.4" 153 + source = "registry+https://github.com/rust-lang/crates.io-index" 154 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 155 + 156 + [[package]] 157 + name = "cpufeatures" 158 + version = "0.2.17" 159 + source = "registry+https://github.com/rust-lang/crates.io-index" 160 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 161 + dependencies = [ 162 + "libc", 163 + ] 164 + 165 + [[package]] 166 + name = "crypto-common" 167 + version = "0.1.7" 168 + source = "registry+https://github.com/rust-lang/crates.io-index" 169 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 170 + dependencies = [ 171 + "generic-array", 172 + "typenum", 173 + ] 174 + 175 + [[package]] 176 + name = "digest" 177 + version = "0.10.7" 178 + source = "registry+https://github.com/rust-lang/crates.io-index" 179 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 180 + dependencies = [ 181 + "block-buffer", 182 + "crypto-common", 183 + ] 184 + 185 + [[package]] 186 + name = "equivalent" 187 + version = "1.0.2" 188 + source = "registry+https://github.com/rust-lang/crates.io-index" 189 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 190 + 191 + [[package]] 192 + name = "errno" 193 + version = "0.3.14" 194 + source = "registry+https://github.com/rust-lang/crates.io-index" 195 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 196 + dependencies = [ 197 + "libc", 198 + "windows-sys 0.61.2", 199 + ] 200 + 201 + [[package]] 202 + name = "fastwebsockets" 203 + version = "0.8.0" 204 + source = "registry+https://github.com/rust-lang/crates.io-index" 205 + checksum = "26da0c7b5cef45c521a6f9cdfffdfeb6c9f5804fbac332deb5ae254634c7a6be" 206 + dependencies = [ 207 + "base64", 208 + "bytes", 209 + "http-body-util", 210 + "hyper", 211 + "hyper-util", 212 + "pin-project", 213 + "rand", 214 + "sha1", 215 + "simdutf8", 216 + "thiserror", 217 + "tokio", 218 + "utf-8", 219 + ] 220 + 221 + [[package]] 222 + name = "fnv" 223 + version = "1.0.7" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 226 + 227 + [[package]] 228 + name = "futures-channel" 229 + version = "0.3.31" 230 + source = "registry+https://github.com/rust-lang/crates.io-index" 231 + checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 232 + dependencies = [ 233 + "futures-core", 234 + ] 235 + 236 + [[package]] 237 + name = "futures-core" 238 + version = "0.3.31" 239 + source = "registry+https://github.com/rust-lang/crates.io-index" 240 + checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 241 + 242 + [[package]] 243 + name = "futures-sink" 244 + version = "0.3.31" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 247 + 248 + [[package]] 249 + name = "generic-array" 250 + version = "0.14.7" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 253 + dependencies = [ 254 + "typenum", 255 + "version_check", 256 + ] 257 + 258 + [[package]] 259 + name = "getrandom" 260 + version = "0.2.16" 261 + source = "registry+https://github.com/rust-lang/crates.io-index" 262 + checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 263 + dependencies = [ 264 + "cfg-if", 265 + "libc", 266 + "wasi", 267 + ] 268 + 269 + [[package]] 270 + name = "h2" 271 + version = "0.4.12" 272 + source = "registry+https://github.com/rust-lang/crates.io-index" 273 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 274 + dependencies = [ 275 + "atomic-waker", 276 + "bytes", 277 + "fnv", 278 + "futures-core", 279 + "futures-sink", 280 + "http", 281 + "indexmap", 282 + "slab", 283 + "tokio", 284 + "tokio-util", 285 + "tracing", 286 + ] 287 + 288 + [[package]] 289 + name = "hashbrown" 290 + version = "0.16.1" 291 + source = "registry+https://github.com/rust-lang/crates.io-index" 292 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 293 + 294 + [[package]] 295 + name = "heck" 296 + version = "0.5.0" 297 + source = "registry+https://github.com/rust-lang/crates.io-index" 298 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 299 + 300 + [[package]] 301 + name = "http" 302 + version = "1.4.0" 303 + source = "registry+https://github.com/rust-lang/crates.io-index" 304 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 305 + dependencies = [ 306 + "bytes", 307 + "itoa", 308 + ] 309 + 310 + [[package]] 311 + name = "http-body" 312 + version = "1.0.1" 313 + source = "registry+https://github.com/rust-lang/crates.io-index" 314 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 315 + dependencies = [ 316 + "bytes", 317 + "http", 318 + ] 319 + 320 + [[package]] 321 + name = "http-body-util" 322 + version = "0.1.3" 323 + source = "registry+https://github.com/rust-lang/crates.io-index" 324 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 325 + dependencies = [ 326 + "bytes", 327 + "futures-core", 328 + "http", 329 + "http-body", 330 + "pin-project-lite", 331 + ] 332 + 333 + [[package]] 334 + name = "httparse" 335 + version = "1.10.1" 336 + source = "registry+https://github.com/rust-lang/crates.io-index" 337 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 338 + 339 + [[package]] 340 + name = "httpdate" 341 + version = "1.0.3" 342 + source = "registry+https://github.com/rust-lang/crates.io-index" 343 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 344 + 345 + [[package]] 346 + name = "hyper" 347 + version = "1.8.1" 348 + source = "registry+https://github.com/rust-lang/crates.io-index" 349 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 350 + dependencies = [ 351 + "atomic-waker", 352 + "bytes", 353 + "futures-channel", 354 + "futures-core", 355 + "h2", 356 + "http", 357 + "http-body", 358 + "httparse", 359 + "httpdate", 360 + "itoa", 361 + "pin-project-lite", 362 + "pin-utils", 363 + "smallvec", 364 + "tokio", 365 + "want", 366 + ] 367 + 368 + [[package]] 369 + name = "hyper-util" 370 + version = "0.1.19" 371 + source = "registry+https://github.com/rust-lang/crates.io-index" 372 + checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" 373 + dependencies = [ 374 + "bytes", 375 + "futures-core", 376 + "http", 377 + "http-body", 378 + "hyper", 379 + "pin-project-lite", 380 + "tokio", 381 + ] 382 + 383 + [[package]] 384 + name = "indexmap" 385 + version = "2.12.1" 386 + source = "registry+https://github.com/rust-lang/crates.io-index" 387 + checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" 388 + dependencies = [ 389 + "equivalent", 390 + "hashbrown", 391 + ] 392 + 393 + [[package]] 394 + name = "is_terminal_polyfill" 395 + version = "1.70.2" 396 + source = "registry+https://github.com/rust-lang/crates.io-index" 397 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 398 + 399 + [[package]] 400 + name = "itoa" 401 + version = "1.0.17" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 404 + 405 + [[package]] 406 + name = "libc" 407 + version = "0.2.178" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" 410 + 411 + [[package]] 412 + name = "lock_api" 413 + version = "0.4.14" 414 + source = "registry+https://github.com/rust-lang/crates.io-index" 415 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 416 + dependencies = [ 417 + "scopeguard", 418 + ] 419 + 420 + [[package]] 421 + name = "memchr" 422 + version = "2.7.6" 423 + source = "registry+https://github.com/rust-lang/crates.io-index" 424 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 425 + 426 + [[package]] 427 + name = "mio" 428 + version = "1.1.1" 429 + source = "registry+https://github.com/rust-lang/crates.io-index" 430 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 431 + dependencies = [ 432 + "libc", 433 + "wasi", 434 + "windows-sys 0.61.2", 435 + ] 436 + 437 + [[package]] 438 + name = "once_cell" 439 + version = "1.21.3" 440 + source = "registry+https://github.com/rust-lang/crates.io-index" 441 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 442 + 443 + [[package]] 444 + name = "once_cell_polyfill" 445 + version = "1.70.2" 446 + source = "registry+https://github.com/rust-lang/crates.io-index" 447 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 448 + 449 + [[package]] 450 + name = "parking_lot" 451 + version = "0.12.5" 452 + source = "registry+https://github.com/rust-lang/crates.io-index" 453 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 454 + dependencies = [ 455 + "lock_api", 456 + "parking_lot_core", 457 + ] 458 + 459 + [[package]] 460 + name = "parking_lot_core" 461 + version = "0.9.12" 462 + source = "registry+https://github.com/rust-lang/crates.io-index" 463 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 464 + dependencies = [ 465 + "cfg-if", 466 + "libc", 467 + "redox_syscall", 468 + "smallvec", 469 + "windows-link", 470 + ] 471 + 472 + [[package]] 473 + name = "pin-project" 474 + version = "1.1.10" 475 + source = "registry+https://github.com/rust-lang/crates.io-index" 476 + checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 477 + dependencies = [ 478 + "pin-project-internal", 479 + ] 480 + 481 + [[package]] 482 + name = "pin-project-internal" 483 + version = "1.1.10" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 486 + dependencies = [ 487 + "proc-macro2", 488 + "quote", 489 + "syn", 490 + ] 491 + 492 + [[package]] 493 + name = "pin-project-lite" 494 + version = "0.2.16" 495 + source = "registry+https://github.com/rust-lang/crates.io-index" 496 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 497 + 498 + [[package]] 499 + name = "pin-utils" 500 + version = "0.1.0" 501 + source = "registry+https://github.com/rust-lang/crates.io-index" 502 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 503 + 504 + [[package]] 505 + name = "ppv-lite86" 506 + version = "0.2.21" 507 + source = "registry+https://github.com/rust-lang/crates.io-index" 508 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 509 + dependencies = [ 510 + "zerocopy", 511 + ] 512 + 513 + [[package]] 514 + name = "proc-macro2" 515 + version = "1.0.104" 516 + source = "registry+https://github.com/rust-lang/crates.io-index" 517 + checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" 518 + dependencies = [ 519 + "unicode-ident", 520 + ] 521 + 522 + [[package]] 523 + name = "quote" 524 + version = "1.0.42" 525 + source = "registry+https://github.com/rust-lang/crates.io-index" 526 + checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 527 + dependencies = [ 528 + "proc-macro2", 529 + ] 530 + 531 + [[package]] 532 + name = "rand" 533 + version = "0.8.5" 534 + source = "registry+https://github.com/rust-lang/crates.io-index" 535 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 536 + dependencies = [ 537 + "libc", 538 + "rand_chacha", 539 + "rand_core", 540 + ] 541 + 542 + [[package]] 543 + name = "rand_chacha" 544 + version = "0.3.1" 545 + source = "registry+https://github.com/rust-lang/crates.io-index" 546 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 547 + dependencies = [ 548 + "ppv-lite86", 549 + "rand_core", 550 + ] 551 + 552 + [[package]] 553 + name = "rand_core" 554 + version = "0.6.4" 555 + source = "registry+https://github.com/rust-lang/crates.io-index" 556 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 557 + dependencies = [ 558 + "getrandom", 559 + ] 560 + 561 + [[package]] 562 + name = "redox_syscall" 563 + version = "0.5.18" 564 + source = "registry+https://github.com/rust-lang/crates.io-index" 565 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 566 + dependencies = [ 567 + "bitflags", 568 + ] 569 + 570 + [[package]] 571 + name = "scopeguard" 572 + version = "1.2.0" 573 + source = "registry+https://github.com/rust-lang/crates.io-index" 574 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 575 + 576 + [[package]] 577 + name = "serde" 578 + version = "1.0.228" 579 + source = "registry+https://github.com/rust-lang/crates.io-index" 580 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 581 + dependencies = [ 582 + "serde_core", 583 + "serde_derive", 584 + ] 585 + 586 + [[package]] 587 + name = "serde_core" 588 + version = "1.0.228" 589 + source = "registry+https://github.com/rust-lang/crates.io-index" 590 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 591 + dependencies = [ 592 + "serde_derive", 593 + ] 594 + 595 + [[package]] 596 + name = "serde_derive" 597 + version = "1.0.228" 598 + source = "registry+https://github.com/rust-lang/crates.io-index" 599 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 600 + dependencies = [ 601 + "proc-macro2", 602 + "quote", 603 + "syn", 604 + ] 605 + 606 + [[package]] 607 + name = "serde_json" 608 + version = "1.0.148" 609 + source = "registry+https://github.com/rust-lang/crates.io-index" 610 + checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" 611 + dependencies = [ 612 + "itoa", 613 + "memchr", 614 + "serde", 615 + "serde_core", 616 + "zmij", 617 + ] 618 + 619 + [[package]] 620 + name = "sha1" 621 + version = "0.10.6" 622 + source = "registry+https://github.com/rust-lang/crates.io-index" 623 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 624 + dependencies = [ 625 + "cfg-if", 626 + "cpufeatures", 627 + "digest", 628 + ] 629 + 630 + [[package]] 631 + name = "signal-hook-registry" 632 + version = "1.4.8" 633 + source = "registry+https://github.com/rust-lang/crates.io-index" 634 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 635 + dependencies = [ 636 + "errno", 637 + "libc", 638 + ] 639 + 640 + [[package]] 641 + name = "simdutf8" 642 + version = "0.1.5" 643 + source = "registry+https://github.com/rust-lang/crates.io-index" 644 + checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 645 + 646 + [[package]] 647 + name = "slab" 648 + version = "0.4.11" 649 + source = "registry+https://github.com/rust-lang/crates.io-index" 650 + checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 651 + 652 + [[package]] 653 + name = "smallvec" 654 + version = "1.15.1" 655 + source = "registry+https://github.com/rust-lang/crates.io-index" 656 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 657 + 658 + [[package]] 659 + name = "socket2" 660 + version = "0.5.10" 661 + source = "registry+https://github.com/rust-lang/crates.io-index" 662 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 663 + dependencies = [ 664 + "libc", 665 + "windows-sys 0.52.0", 666 + ] 667 + 668 + [[package]] 669 + name = "socket2" 670 + version = "0.6.1" 671 + source = "registry+https://github.com/rust-lang/crates.io-index" 672 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 673 + dependencies = [ 674 + "libc", 675 + "windows-sys 0.60.2", 676 + ] 677 + 678 + [[package]] 679 + name = "strsim" 680 + version = "0.11.1" 681 + source = "registry+https://github.com/rust-lang/crates.io-index" 682 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 683 + 684 + [[package]] 685 + name = "syn" 686 + version = "2.0.112" 687 + source = "registry+https://github.com/rust-lang/crates.io-index" 688 + checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" 689 + dependencies = [ 690 + "proc-macro2", 691 + "quote", 692 + "unicode-ident", 693 + ] 694 + 695 + [[package]] 696 + name = "thiserror" 697 + version = "1.0.69" 698 + source = "registry+https://github.com/rust-lang/crates.io-index" 699 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 700 + dependencies = [ 701 + "thiserror-impl", 702 + ] 703 + 704 + [[package]] 705 + name = "thiserror-impl" 706 + version = "1.0.69" 707 + source = "registry+https://github.com/rust-lang/crates.io-index" 708 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 709 + dependencies = [ 710 + "proc-macro2", 711 + "quote", 712 + "syn", 713 + ] 714 + 715 + [[package]] 716 + name = "tokio" 717 + version = "1.48.0" 718 + source = "registry+https://github.com/rust-lang/crates.io-index" 719 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 720 + dependencies = [ 721 + "bytes", 722 + "libc", 723 + "mio", 724 + "parking_lot", 725 + "pin-project-lite", 726 + "signal-hook-registry", 727 + "socket2 0.6.1", 728 + "tokio-macros", 729 + "windows-sys 0.61.2", 730 + ] 731 + 732 + [[package]] 733 + name = "tokio-macros" 734 + version = "2.6.0" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 737 + dependencies = [ 738 + "proc-macro2", 739 + "quote", 740 + "syn", 741 + ] 742 + 743 + [[package]] 744 + name = "tokio-util" 745 + version = "0.7.17" 746 + source = "registry+https://github.com/rust-lang/crates.io-index" 747 + checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" 748 + dependencies = [ 749 + "bytes", 750 + "futures-core", 751 + "futures-sink", 752 + "pin-project-lite", 753 + "tokio", 754 + ] 755 + 756 + [[package]] 757 + name = "tracing" 758 + version = "0.1.44" 759 + source = "registry+https://github.com/rust-lang/crates.io-index" 760 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 761 + dependencies = [ 762 + "pin-project-lite", 763 + "tracing-core", 764 + ] 765 + 766 + [[package]] 767 + name = "tracing-core" 768 + version = "0.1.36" 769 + source = "registry+https://github.com/rust-lang/crates.io-index" 770 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 771 + dependencies = [ 772 + "once_cell", 773 + ] 774 + 775 + [[package]] 776 + name = "try-lock" 777 + version = "0.2.5" 778 + source = "registry+https://github.com/rust-lang/crates.io-index" 779 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 780 + 781 + [[package]] 782 + name = "typenum" 783 + version = "1.19.0" 784 + source = "registry+https://github.com/rust-lang/crates.io-index" 785 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 786 + 787 + [[package]] 788 + name = "unicode-ident" 789 + version = "1.0.22" 790 + source = "registry+https://github.com/rust-lang/crates.io-index" 791 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 792 + 793 + [[package]] 794 + name = "utf-8" 795 + version = "0.7.6" 796 + source = "registry+https://github.com/rust-lang/crates.io-index" 797 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 798 + 799 + [[package]] 800 + name = "utf8parse" 801 + version = "0.2.2" 802 + source = "registry+https://github.com/rust-lang/crates.io-index" 803 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 804 + 805 + [[package]] 806 + name = "version_check" 807 + version = "0.9.5" 808 + source = "registry+https://github.com/rust-lang/crates.io-index" 809 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 810 + 811 + [[package]] 812 + name = "want" 813 + version = "0.3.1" 814 + source = "registry+https://github.com/rust-lang/crates.io-index" 815 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 816 + dependencies = [ 817 + "try-lock", 818 + ] 819 + 820 + [[package]] 821 + name = "wasi" 822 + version = "0.11.1+wasi-snapshot-preview1" 823 + source = "registry+https://github.com/rust-lang/crates.io-index" 824 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 825 + 826 + [[package]] 827 + name = "windows-link" 828 + version = "0.2.1" 829 + source = "registry+https://github.com/rust-lang/crates.io-index" 830 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 831 + 832 + [[package]] 833 + name = "windows-sys" 834 + version = "0.52.0" 835 + source = "registry+https://github.com/rust-lang/crates.io-index" 836 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 837 + dependencies = [ 838 + "windows-targets 0.52.6", 839 + ] 840 + 841 + [[package]] 842 + name = "windows-sys" 843 + version = "0.60.2" 844 + source = "registry+https://github.com/rust-lang/crates.io-index" 845 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 846 + dependencies = [ 847 + "windows-targets 0.53.5", 848 + ] 849 + 850 + [[package]] 851 + name = "windows-sys" 852 + version = "0.61.2" 853 + source = "registry+https://github.com/rust-lang/crates.io-index" 854 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 855 + dependencies = [ 856 + "windows-link", 857 + ] 858 + 859 + [[package]] 860 + name = "windows-targets" 861 + version = "0.52.6" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 864 + dependencies = [ 865 + "windows_aarch64_gnullvm 0.52.6", 866 + "windows_aarch64_msvc 0.52.6", 867 + "windows_i686_gnu 0.52.6", 868 + "windows_i686_gnullvm 0.52.6", 869 + "windows_i686_msvc 0.52.6", 870 + "windows_x86_64_gnu 0.52.6", 871 + "windows_x86_64_gnullvm 0.52.6", 872 + "windows_x86_64_msvc 0.52.6", 873 + ] 874 + 875 + [[package]] 876 + name = "windows-targets" 877 + version = "0.53.5" 878 + source = "registry+https://github.com/rust-lang/crates.io-index" 879 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 880 + dependencies = [ 881 + "windows-link", 882 + "windows_aarch64_gnullvm 0.53.1", 883 + "windows_aarch64_msvc 0.53.1", 884 + "windows_i686_gnu 0.53.1", 885 + "windows_i686_gnullvm 0.53.1", 886 + "windows_i686_msvc 0.53.1", 887 + "windows_x86_64_gnu 0.53.1", 888 + "windows_x86_64_gnullvm 0.53.1", 889 + "windows_x86_64_msvc 0.53.1", 890 + ] 891 + 892 + [[package]] 893 + name = "windows_aarch64_gnullvm" 894 + version = "0.52.6" 895 + source = "registry+https://github.com/rust-lang/crates.io-index" 896 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 897 + 898 + [[package]] 899 + name = "windows_aarch64_gnullvm" 900 + version = "0.53.1" 901 + source = "registry+https://github.com/rust-lang/crates.io-index" 902 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 903 + 904 + [[package]] 905 + name = "windows_aarch64_msvc" 906 + version = "0.52.6" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 909 + 910 + [[package]] 911 + name = "windows_aarch64_msvc" 912 + version = "0.53.1" 913 + source = "registry+https://github.com/rust-lang/crates.io-index" 914 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 915 + 916 + [[package]] 917 + name = "windows_i686_gnu" 918 + version = "0.52.6" 919 + source = "registry+https://github.com/rust-lang/crates.io-index" 920 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 921 + 922 + [[package]] 923 + name = "windows_i686_gnu" 924 + version = "0.53.1" 925 + source = "registry+https://github.com/rust-lang/crates.io-index" 926 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 927 + 928 + [[package]] 929 + name = "windows_i686_gnullvm" 930 + version = "0.52.6" 931 + source = "registry+https://github.com/rust-lang/crates.io-index" 932 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 933 + 934 + [[package]] 935 + name = "windows_i686_gnullvm" 936 + version = "0.53.1" 937 + source = "registry+https://github.com/rust-lang/crates.io-index" 938 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 939 + 940 + [[package]] 941 + name = "windows_i686_msvc" 942 + version = "0.52.6" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 945 + 946 + [[package]] 947 + name = "windows_i686_msvc" 948 + version = "0.53.1" 949 + source = "registry+https://github.com/rust-lang/crates.io-index" 950 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 951 + 952 + [[package]] 953 + name = "windows_x86_64_gnu" 954 + version = "0.52.6" 955 + source = "registry+https://github.com/rust-lang/crates.io-index" 956 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 957 + 958 + [[package]] 959 + name = "windows_x86_64_gnu" 960 + version = "0.53.1" 961 + source = "registry+https://github.com/rust-lang/crates.io-index" 962 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 963 + 964 + [[package]] 965 + name = "windows_x86_64_gnullvm" 966 + version = "0.52.6" 967 + source = "registry+https://github.com/rust-lang/crates.io-index" 968 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 969 + 970 + [[package]] 971 + name = "windows_x86_64_gnullvm" 972 + version = "0.53.1" 973 + source = "registry+https://github.com/rust-lang/crates.io-index" 974 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 975 + 976 + [[package]] 977 + name = "windows_x86_64_msvc" 978 + version = "0.52.6" 979 + source = "registry+https://github.com/rust-lang/crates.io-index" 980 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 981 + 982 + [[package]] 983 + name = "windows_x86_64_msvc" 984 + version = "0.53.1" 985 + source = "registry+https://github.com/rust-lang/crates.io-index" 986 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 987 + 988 + [[package]] 989 + name = "zerocopy" 990 + version = "0.8.31" 991 + source = "registry+https://github.com/rust-lang/crates.io-index" 992 + checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" 993 + dependencies = [ 994 + "zerocopy-derive", 995 + ] 996 + 997 + [[package]] 998 + name = "zerocopy-derive" 999 + version = "0.8.31" 1000 + source = "registry+https://github.com/rust-lang/crates.io-index" 1001 + checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" 1002 + dependencies = [ 1003 + "proc-macro2", 1004 + "quote", 1005 + "syn", 1006 + ] 1007 + 1008 + [[package]] 1009 + name = "zmij" 1010 + version = "1.0.4" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "77cc0158b0d3103d58e9e82bdbe9cf9289d80dbcf4e686ff16730eb9e5814d1a"
+21
bench/hyper/Cargo.toml
··· 1 + [package] 2 + name = "bench-hyper" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [dependencies] 7 + hyper = { version = "1.0", features = ["server", "http1", "http2"] } 8 + hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } 9 + http-body-util = "0.1" 10 + httpdate = "1" 11 + tokio = { version = "1", features = ["full"] } 12 + serde = { version = "1", features = ["derive"] } 13 + serde_json = "1" 14 + clap = { version = "4", features = ["derive"] } 15 + socket2 = { version = "0.5", features = ["all"] } 16 + fastwebsockets = { version = "0.8", features = ["upgrade"] } 17 + 18 + [profile.release] 19 + opt-level = 3 20 + lto = true 21 + codegen-units = 1
+223
bench/hyper/src/main.rs
··· 1 + use std::convert::Infallible; 2 + use std::net::{Ipv4Addr, SocketAddr}; 3 + use std::sync::atomic::{AtomicU64, Ordering}; 4 + use std::sync::RwLock; 5 + use std::{io, thread}; 6 + use std::time::{SystemTime, UNIX_EPOCH}; 7 + 8 + use clap::Parser; 9 + use fastwebsockets::{upgrade, FragmentCollector, Frame, OpCode}; 10 + use httpdate::HttpDate; 11 + use http_body_util::combinators::BoxBody; 12 + use http_body_util::{BodyExt, Empty, Full}; 13 + use hyper::body::Bytes; 14 + use hyper::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE, DATE, SERVER}; 15 + use hyper::service::service_fn; 16 + use hyper::{Request, Response, StatusCode}; 17 + use hyper_util::rt::{TokioExecutor, TokioIo}; 18 + use hyper_util::server::conn::auto::Builder; 19 + use serde::Serialize; 20 + use socket2::{Domain, SockAddr, Socket, Type}; 21 + use tokio::net::TcpListener; 22 + use tokio::runtime; 23 + 24 + static SERVER_HEADER: HeaderValue = HeaderValue::from_static("hyper"); 25 + static TEXT_PLAIN: HeaderValue = HeaderValue::from_static("text/plain"); 26 + static APPLICATION_JSON: HeaderValue = HeaderValue::from_static("application/json"); 27 + static PLAINTEXT_BODY: &[u8] = b"Hello, World!"; 28 + 29 + static DATE_CACHE_SECS: AtomicU64 = AtomicU64::new(0); 30 + static DATE_CACHE: RwLock<String> = RwLock::new(String::new()); 31 + 32 + fn get_cached_date() -> HeaderValue { 33 + let now = SystemTime::now() 34 + .duration_since(UNIX_EPOCH) 35 + .unwrap() 36 + .as_secs(); 37 + let cached_secs = DATE_CACHE_SECS.load(Ordering::Relaxed); 38 + 39 + if now != cached_secs { 40 + let date_str = HttpDate::from(SystemTime::now()).to_string(); 41 + if let Ok(mut cache) = DATE_CACHE.write() { 42 + *cache = date_str; 43 + DATE_CACHE_SECS.store(now, Ordering::Relaxed); 44 + } 45 + } 46 + 47 + let cache = DATE_CACHE.read().unwrap(); 48 + HeaderValue::from_str(&cache).unwrap_or_else(|_| HeaderValue::from_static("")) 49 + } 50 + 51 + #[derive(Serialize)] 52 + struct JsonMessage { 53 + message: &'static str, 54 + } 55 + 56 + #[derive(Debug, Parser)] 57 + #[command(name = "bench-hyper")] 58 + #[command(about = "TechEmpower benchmark server using Hyper (HTTP/1.1 + HTTP/2 h2c + WebSocket)")] 59 + struct Args { 60 + #[arg(short, long, default_value_t = 8080)] 61 + port: u16, 62 + 63 + #[arg(short, long, default_value_t = 1)] 64 + threads: usize, 65 + } 66 + 67 + fn main() -> io::Result<()> { 68 + let args = Args::parse(); 69 + 70 + println!("Starting Hyper server on port {} with {} threads (HTTP/1.1 + h2c + WebSocket)", args.port, args.threads); 71 + 72 + let handles: Vec<_> = (0..args.threads) 73 + .map(|_| { 74 + let port = args.port; 75 + thread::spawn(move || { 76 + let rt = runtime::Builder::new_current_thread() 77 + .enable_all() 78 + .build() 79 + .expect("Failed to create runtime"); 80 + rt.block_on(run_server(port)).expect("Server error"); 81 + }) 82 + }) 83 + .collect(); 84 + 85 + for handle in handles { 86 + handle.join().expect("Worker thread panicked"); 87 + } 88 + 89 + Ok(()) 90 + } 91 + 92 + async fn run_server(port: u16) -> io::Result<()> { 93 + let addr = SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)); 94 + let socket = create_reusable_socket(addr)?; 95 + let listener = TcpListener::from_std(socket.into())?; 96 + 97 + loop { 98 + let (stream, _) = listener.accept().await?; 99 + let io = TokioIo::new(stream); 100 + 101 + tokio::spawn(async move { 102 + let builder = Builder::new(TokioExecutor::new()); 103 + let service = service_fn(router); 104 + let _ = builder.serve_connection_with_upgrades(io, service).await; 105 + }); 106 + } 107 + } 108 + 109 + fn create_reusable_socket(addr: SocketAddr) -> io::Result<Socket> { 110 + let domain = match addr { 111 + SocketAddr::V4(_) => Domain::IPV4, 112 + SocketAddr::V6(_) => Domain::IPV6, 113 + }; 114 + 115 + let socket = Socket::new(domain, Type::STREAM, None)?; 116 + 117 + #[cfg(unix)] 118 + socket.set_reuse_port(true)?; 119 + socket.set_reuse_address(true)?; 120 + socket.set_nodelay(true)?; 121 + socket.set_nonblocking(true)?; 122 + 123 + socket.bind(&SockAddr::from(addr))?; 124 + socket.listen(4096)?; 125 + 126 + Ok(socket) 127 + } 128 + 129 + async fn router(req: Request<hyper::body::Incoming>) -> Result<Response<BoxBody<Bytes, Infallible>>, Infallible> { 130 + match req.uri().path() { 131 + "/plaintext" => Ok(plaintext()), 132 + "/json" => Ok(json()), 133 + "/ws" => Ok(websocket_upgrade(req).await), 134 + _ => Ok(not_found()), 135 + } 136 + } 137 + 138 + fn plaintext() -> Response<BoxBody<Bytes, Infallible>> { 139 + Response::builder() 140 + .header(SERVER, SERVER_HEADER.clone()) 141 + .header(DATE, get_cached_date()) 142 + .header(CONTENT_TYPE, TEXT_PLAIN.clone()) 143 + .header(CONTENT_LENGTH, PLAINTEXT_BODY.len()) 144 + .body(Full::from(PLAINTEXT_BODY).boxed()) 145 + .unwrap() 146 + } 147 + 148 + fn json() -> Response<BoxBody<Bytes, Infallible>> { 149 + let msg = JsonMessage { message: "Hello, World!" }; 150 + let body = serde_json::to_vec(&msg).unwrap(); 151 + let len = body.len(); 152 + 153 + Response::builder() 154 + .header(SERVER, SERVER_HEADER.clone()) 155 + .header(DATE, get_cached_date()) 156 + .header(CONTENT_TYPE, APPLICATION_JSON.clone()) 157 + .header(CONTENT_LENGTH, len) 158 + .body(Full::from(body).boxed()) 159 + .unwrap() 160 + } 161 + 162 + fn not_found() -> Response<BoxBody<Bytes, Infallible>> { 163 + Response::builder() 164 + .status(StatusCode::NOT_FOUND) 165 + .header(SERVER, SERVER_HEADER.clone()) 166 + .header(DATE, get_cached_date()) 167 + .body(Empty::new().boxed()) 168 + .unwrap() 169 + } 170 + 171 + async fn websocket_upgrade(mut req: Request<hyper::body::Incoming>) -> Response<BoxBody<Bytes, Infallible>> { 172 + if !upgrade::is_upgrade_request(&req) { 173 + return Response::builder() 174 + .status(StatusCode::BAD_REQUEST) 175 + .header(SERVER, SERVER_HEADER.clone()) 176 + .header(DATE, get_cached_date()) 177 + .body(Full::from("Not a WebSocket upgrade request").boxed()) 178 + .unwrap(); 179 + } 180 + 181 + let (response, fut) = match upgrade::upgrade(&mut req) { 182 + Ok(r) => r, 183 + Err(_) => { 184 + return Response::builder() 185 + .status(StatusCode::BAD_REQUEST) 186 + .header(SERVER, SERVER_HEADER.clone()) 187 + .header(DATE, get_cached_date()) 188 + .body(Full::from("WebSocket upgrade failed").boxed()) 189 + .unwrap(); 190 + } 191 + }; 192 + 193 + tokio::spawn(async move { 194 + if let Err(e) = handle_websocket(fut).await { 195 + eprintln!("WebSocket error: {}", e); 196 + } 197 + }); 198 + 199 + let (parts, _) = response.into_parts(); 200 + Response::from_parts(parts, Empty::new().boxed()) 201 + } 202 + 203 + async fn handle_websocket(fut: upgrade::UpgradeFut) -> Result<(), fastwebsockets::WebSocketError> { 204 + let mut ws = fut.await?; 205 + ws.set_auto_pong(true); 206 + ws.set_auto_close(true); 207 + let mut ws = FragmentCollector::new(ws); 208 + 209 + loop { 210 + let frame = ws.read_frame().await?; 211 + 212 + match frame.opcode { 213 + OpCode::Close => break, 214 + OpCode::Text | OpCode::Binary => { 215 + let echo_frame = Frame::new(true, frame.opcode, None, frame.payload); 216 + ws.write_frame(echo_frame).await?; 217 + } 218 + _ => {} 219 + } 220 + } 221 + 222 + Ok(()) 223 + }
+10
bench/nethttp/go.mod
··· 1 + module github.com/hcs/bench-nethttp 2 + 3 + go 1.21 4 + 5 + require golang.org/x/net v0.34.0 6 + 7 + require ( 8 + github.com/gorilla/websocket v1.5.3 // indirect 9 + golang.org/x/text v0.21.0 // indirect 10 + )
+6
bench/nethttp/go.sum
··· 1 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 + golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 4 + golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 5 + golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 6 + golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+138
bench/nethttp/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "flag" 6 + "fmt" 7 + "log" 8 + "net" 9 + "net/http" 10 + "runtime" 11 + "sync" 12 + "sync/atomic" 13 + "time" 14 + 15 + "github.com/gorilla/websocket" 16 + "golang.org/x/net/http2" 17 + "golang.org/x/net/http2/h2c" 18 + ) 19 + 20 + const ( 21 + helloWorldStr = "Hello, World!" 22 + contentTypeJSON = "application/json" 23 + contentTypeText = "text/plain" 24 + serverName = "go-nethttp" 25 + ) 26 + 27 + type Message struct { 28 + Message string `json:"message"` 29 + } 30 + 31 + var ( 32 + port int 33 + threads int 34 + ) 35 + 36 + var ( 37 + dateCacheMu sync.RWMutex 38 + dateCacheTime int64 39 + dateCacheVal string 40 + ) 41 + 42 + func getCachedDate() string { 43 + now := time.Now().Unix() 44 + cached := atomic.LoadInt64(&dateCacheTime) 45 + 46 + if now != cached { 47 + dateCacheMu.Lock() 48 + if atomic.LoadInt64(&dateCacheTime) != now { 49 + dateCacheVal = time.Now().UTC().Format(http.TimeFormat) 50 + atomic.StoreInt64(&dateCacheTime, now) 51 + } 52 + dateCacheMu.Unlock() 53 + } 54 + 55 + dateCacheMu.RLock() 56 + defer dateCacheMu.RUnlock() 57 + return dateCacheVal 58 + } 59 + 60 + var jsonBody []byte 61 + 62 + func init() { 63 + flag.IntVar(&port, "port", 8080, "Port to listen on") 64 + flag.IntVar(&threads, "threads", 1, "Number of threads (GOMAXPROCS)") 65 + 66 + msg := Message{Message: helloWorldStr} 67 + jsonBody, _ = json.Marshal(msg) 68 + } 69 + 70 + func main() { 71 + flag.Parse() 72 + runtime.GOMAXPROCS(threads) 73 + 74 + mux := http.NewServeMux() 75 + mux.HandleFunc("/plaintext", plaintextHandler) 76 + mux.HandleFunc("/json", jsonHandler) 77 + mux.HandleFunc("/ws", wsHandler) 78 + 79 + h2s := &http2.Server{} 80 + handler := h2c.NewHandler(mux, h2s) 81 + 82 + server := &http.Server{ 83 + Addr: fmt.Sprintf(":%d", port), 84 + Handler: handler, 85 + } 86 + 87 + ln, err := net.Listen("tcp", server.Addr) 88 + if err != nil { 89 + log.Fatalf("Failed to listen: %v", err) 90 + } 91 + 92 + fmt.Printf("Starting Go net/http server on port %d with %d threads (HTTP/1.1 + h2c + WebSocket)\n", port, threads) 93 + 94 + if err := server.Serve(ln); err != nil { 95 + log.Fatalf("Server error: %v", err) 96 + } 97 + } 98 + 99 + func plaintextHandler(w http.ResponseWriter, r *http.Request) { 100 + h := w.Header() 101 + h.Set("Server", serverName) 102 + h.Set("Date", getCachedDate()) 103 + h.Set("Content-Type", contentTypeText) 104 + w.Write([]byte(helloWorldStr)) 105 + } 106 + 107 + func jsonHandler(w http.ResponseWriter, r *http.Request) { 108 + h := w.Header() 109 + h.Set("Server", serverName) 110 + h.Set("Date", getCachedDate()) 111 + h.Set("Content-Type", contentTypeJSON) 112 + w.Write(jsonBody) 113 + } 114 + 115 + var upgrader = websocket.Upgrader{ 116 + ReadBufferSize: 1024, 117 + WriteBufferSize: 1024, 118 + CheckOrigin: func(r *http.Request) bool { return true }, 119 + } 120 + 121 + func wsHandler(w http.ResponseWriter, r *http.Request) { 122 + conn, err := upgrader.Upgrade(w, r, nil) 123 + if err != nil { 124 + log.Printf("WebSocket upgrade error: %v", err) 125 + return 126 + } 127 + defer conn.Close() 128 + 129 + for { 130 + messageType, message, err := conn.ReadMessage() 131 + if err != nil { 132 + break 133 + } 134 + if err := conn.WriteMessage(messageType, message); err != nil { 135 + break 136 + } 137 + } 138 + }
+118
bench/piaf/bench_server_piaf.ml
··· 1 + module Date_cache = struct 2 + let day_names = [| "Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat" |] 3 + 4 + let month_names = 5 + [| 6 + "Jan"; 7 + "Feb"; 8 + "Mar"; 9 + "Apr"; 10 + "May"; 11 + "Jun"; 12 + "Jul"; 13 + "Aug"; 14 + "Sep"; 15 + "Oct"; 16 + "Nov"; 17 + "Dec"; 18 + |] 19 + 20 + let cached_date = Atomic.make "" 21 + let cached_time = Atomic.make 0. 22 + 23 + let format_date () = 24 + let t = Unix.gettimeofday () in 25 + let tm = Unix.gmtime t in 26 + Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" 27 + day_names.(tm.Unix.tm_wday) 28 + tm.Unix.tm_mday 29 + month_names.(tm.Unix.tm_mon) 30 + (1900 + tm.Unix.tm_year) tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 31 + 32 + let[@inline] get () = 33 + let now = Unix.gettimeofday () in 34 + let last = Atomic.get cached_time in 35 + if now -. last >= 1.0 then begin 36 + let date = format_date () in 37 + Atomic.set cached_date date; 38 + Atomic.set cached_time now; 39 + date 40 + end 41 + else Atomic.get cached_date 42 + end 43 + 44 + type json_message = { message : string } 45 + 46 + let serialize_json () = 47 + let msg = { message = "Hello, World!" } in 48 + let json = `Assoc [ ("message", `String msg.message) ] in 49 + Yojson.Basic.to_string json 50 + 51 + let plaintext_body = "Hello, World!" 52 + 53 + let plaintext_headers () = 54 + Piaf.Headers.of_list 55 + [ 56 + ("content-type", "text/plain"); 57 + ("server", "piaf"); 58 + ("date", Date_cache.get ()); 59 + ("content-length", string_of_int (String.length plaintext_body)); 60 + ] 61 + 62 + let notfound_headers () = 63 + Piaf.Headers.of_list 64 + [ ("server", "piaf"); ("date", Date_cache.get ()); ("content-length", "9") ] 65 + 66 + let request_handler (ctx : Piaf.Request_info.t Piaf.Server.ctx) = 67 + let target = ctx.request.target in 68 + match target with 69 + | "/plaintext" -> 70 + Piaf.Response.create ~headers:(plaintext_headers ()) 71 + ~body:(Piaf.Body.of_string plaintext_body) 72 + `OK 73 + | "/json" -> 74 + let body = serialize_json () in 75 + let json_headers = 76 + Piaf.Headers.of_list 77 + [ 78 + ("content-type", "application/json"); 79 + ("server", "piaf"); 80 + ("date", Date_cache.get ()); 81 + ("content-length", string_of_int (String.length body)); 82 + ] 83 + in 84 + Piaf.Response.create ~headers:json_headers 85 + ~body:(Piaf.Body.of_string body) `OK 86 + | _ -> 87 + Piaf.Response.create ~headers:(notfound_headers ()) 88 + ~body:(Piaf.Body.of_string "Not Found") 89 + `Not_found 90 + 91 + let command = 92 + Climate.Command.singleton ~doc:"TechEmpower benchmark server using Piaf" 93 + @@ 94 + let open Climate.Arg_parser in 95 + let+ port = 96 + named_with_default [ "p"; "port" ] int ~default:8080 97 + ~doc:"Port to listen on" 98 + and+ domains = 99 + named_with_default [ "d"; "domains" ] int ~default:1 100 + ~doc:"Number of domains (CPUs) to use" 101 + in 102 + 103 + Printf.printf "Starting Piaf server on port %d with %d domains\n%!" port 104 + domains; 105 + 106 + Eio_main.run @@ fun env -> 107 + Eio.Switch.run @@ fun sw -> 108 + let address = `Tcp (Eio.Net.Ipaddr.V4.any, port) in 109 + (* Optimize: higher backlog (default is 128), larger buffers *) 110 + let config = 111 + Piaf.Server.Config.create ~domains ~backlog:4096 ~buffer_size:16384 112 + ~body_buffer_size:16384 address 113 + in 114 + let server = Piaf.Server.create ~config request_handler in 115 + let _command = Piaf.Server.Command.start ~sw env server in 116 + () 117 + 118 + let () = Climate.Command.run command
+4
bench/piaf/dune
··· 1 + (executable 2 + (name bench_server_piaf) 3 + (public_name bench-piaf) 4 + (libraries piaf eio_main yojson climate))
+531
bench/scripts/run_benchmark.sh
··· 1 + #!/usr/bin/env bash 2 + # Multi-Protocol Benchmark Runner 3 + # 4 + # Benchmarks all servers: HCS (OCaml), Hyper (Rust), Go net/http 5 + # Tests HTTP/1.1, HTTP/2 (h2c), and WebSocket protocols 6 + # Includes memory profiling (RSS per server) 7 + # 8 + # Usage: ./run_benchmark.sh [options] 9 + # -d, --duration Test duration in seconds (default: 10) 10 + # -c, --concurrency Number of concurrent connections (default: 50) 11 + # -w, --warmup Warmup duration in seconds (default: 2) 12 + # -o, --output Output directory for results (default: ./results) 13 + # -s, --server Run only specific server: hcs|hyper|go (default: all) 14 + # -p, --protocol Run only specific protocol: h1|h2|ws (default: all) 15 + # -t, --threads Number of server threads/domains (default: 4) 16 + # -h, --help Show this help message 17 + # 18 + # Requirements: 19 + # - h2load (from nghttp2) for HTTP/2 benchmarks 20 + # - curl for health checks 21 + # - Built servers in expected locations 22 + 23 + set -euo pipefail 24 + 25 + # Default configuration 26 + DURATION=10 27 + CONCURRENCY=50 28 + WARMUP=2 29 + OUTPUT_DIR="./results" 30 + SERVER_FILTER="all" 31 + PROTOCOL_FILTER="all" 32 + THREADS=4 33 + 34 + # Colors for output 35 + RED='\033[0;31m' 36 + GREEN='\033[0;32m' 37 + YELLOW='\033[1;33m' 38 + BLUE='\033[0;34m' 39 + CYAN='\033[0;36m' 40 + BOLD='\033[1m' 41 + NC='\033[0m' # No Color 42 + 43 + # Parse arguments 44 + while [[ $# -gt 0 ]]; do 45 + case $1 in 46 + -d|--duration) DURATION="$2"; shift 2 ;; 47 + -c|--concurrency) CONCURRENCY="$2"; shift 2 ;; 48 + -w|--warmup) WARMUP="$2"; shift 2 ;; 49 + -o|--output) OUTPUT_DIR="$2"; shift 2 ;; 50 + -s|--server) SERVER_FILTER="$2"; shift 2 ;; 51 + -p|--protocol) PROTOCOL_FILTER="$2"; shift 2 ;; 52 + -t|--threads) THREADS="$2"; shift 2 ;; 53 + -h|--help) 54 + head -20 "$0" | tail -16 55 + exit 0 56 + ;; 57 + *) echo "Unknown option: $1"; exit 1 ;; 58 + esac 59 + done 60 + 61 + # Directory setup 62 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 63 + BENCH_DIR="$(dirname "$SCRIPT_DIR")" 64 + PROJECT_DIR="$(dirname "$BENCH_DIR")" 65 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) 66 + RESULT_FILE="$OUTPUT_DIR/benchmark_$TIMESTAMP.txt" 67 + JSON_FILE="$OUTPUT_DIR/benchmark_$TIMESTAMP.json" 68 + 69 + mkdir -p "$OUTPUT_DIR" 70 + 71 + # Server configurations 72 + declare -A SERVER_CMDS 73 + declare -A SERVER_PORTS 74 + declare -A SERVER_WS_PATHS 75 + 76 + SERVER_CMDS["hcs"]="$PROJECT_DIR/_build/default/bench/hcs/bench_server_unified.exe -p PORT -d $THREADS" 77 + SERVER_PORTS["hcs"]=8080 78 + SERVER_WS_PATHS["hcs"]="/" 79 + 80 + SERVER_CMDS["hyper"]="$BENCH_DIR/hyper/target/release/bench-hyper -p PORT -t $THREADS" 81 + SERVER_PORTS["hyper"]=8081 82 + SERVER_WS_PATHS["hyper"]="/ws" 83 + 84 + SERVER_CMDS["go"]="$BENCH_DIR/nethttp/bench-nethttp -port PORT -threads $THREADS" 85 + SERVER_PORTS["go"]=8082 86 + SERVER_WS_PATHS["go"]="/ws" 87 + 88 + SERVER_CMDS["piaf"]="$PROJECT_DIR/_build/default/bench/piaf/bench_server_piaf.exe -p PORT -d $THREADS" 89 + SERVER_PORTS["piaf"]=8083 90 + SERVER_WS_PATHS["piaf"]="" 91 + 92 + SERVER_CMDS["fasthttp"]="$BENCH_DIR/fasthttp/bench-fasthttp -port PORT" 93 + SERVER_PORTS["fasthttp"]=8084 94 + SERVER_WS_PATHS["fasthttp"]="" 95 + 96 + # Logging functions 97 + log() { echo -e "${BLUE}[INFO]${NC} $1"; } 98 + success() { echo -e "${GREEN}[OK]${NC} $1"; } 99 + warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 100 + error() { echo -e "${RED}[ERROR]${NC} $1"; } 101 + header() { echo -e "\n${BOLD}${CYAN}=== $1 ===${NC}"; } 102 + 103 + # Check dependencies 104 + check_deps() { 105 + local missing=() 106 + 107 + if ! command -v h2load &> /dev/null; then 108 + missing+=("h2load (install nghttp2)") 109 + fi 110 + 111 + if ! command -v curl &> /dev/null; then 112 + missing+=("curl") 113 + fi 114 + 115 + if [[ ${#missing[@]} -gt 0 ]]; then 116 + error "Missing dependencies: ${missing[*]}" 117 + exit 1 118 + fi 119 + } 120 + 121 + # Build all servers 122 + build_servers() { 123 + header "Building servers" 124 + 125 + if [[ "$SERVER_FILTER" == "all" || "$SERVER_FILTER" == "hcs" ]]; then 126 + log "Building HCS server..." 127 + if (cd "$PROJECT_DIR" && dune build bench/hcs/bench_server_unified.exe bench/client/bench_client.exe 2>&1); then 128 + success "HCS built" 129 + else 130 + error "HCS build failed" 131 + return 1 132 + fi 133 + fi 134 + 135 + if [[ "$SERVER_FILTER" == "all" || "$SERVER_FILTER" == "hyper" ]]; then 136 + log "Building Hyper server..." 137 + if (cd "$BENCH_DIR/hyper" && cargo build --release 2>&1 | tail -5); then 138 + success "Hyper built" 139 + else 140 + error "Hyper build failed" 141 + return 1 142 + fi 143 + fi 144 + 145 + if [[ "$SERVER_FILTER" == "all" || "$SERVER_FILTER" == "go" ]]; then 146 + log "Building Go net/http server..." 147 + if (cd "$BENCH_DIR/nethttp" && go build -o bench-nethttp . 2>&1); then 148 + success "Go server built" 149 + else 150 + error "Go build failed" 151 + return 1 152 + fi 153 + fi 154 + 155 + if [[ "$SERVER_FILTER" == "all" || "$SERVER_FILTER" == "piaf" ]]; then 156 + log "Building Piaf server..." 157 + if (cd "$PROJECT_DIR" && dune build bench/piaf/bench_server_piaf.exe 2>&1); then 158 + success "Piaf built" 159 + else 160 + error "Piaf build failed" 161 + return 1 162 + fi 163 + fi 164 + 165 + if [[ "$SERVER_FILTER" == "all" || "$SERVER_FILTER" == "fasthttp" ]]; then 166 + log "Building fasthttp server..." 167 + if (cd "$BENCH_DIR/fasthttp" && go build -o bench-fasthttp . 2>&1); then 168 + success "fasthttp built" 169 + else 170 + error "fasthttp build failed" 171 + return 1 172 + fi 173 + fi 174 + } 175 + 176 + # Get memory usage (RSS in KB) for a PID 177 + get_rss_kb() { 178 + local pid=$1 179 + if [[ -f "/proc/$pid/status" ]]; then 180 + grep VmRSS "/proc/$pid/status" 2>/dev/null | awk '{print $2}' || echo "0" 181 + else 182 + # macOS fallback 183 + ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ' || echo "0" 184 + fi 185 + } 186 + 187 + # Check if systemd-run with cgroup memory tracking is available 188 + CGROUP_AVAILABLE=false 189 + # Disabled: systemd-run --scope has issues in non-interactive shells 190 + # if command -v systemd-run &> /dev/null; then 191 + # if systemd-run --user --scope -p MemoryAccounting=yes true 2>/dev/null; then 192 + # CGROUP_AVAILABLE=true 193 + # fi 194 + # fi 195 + 196 + # Get cgroup path for a scope 197 + get_cgroup_path() { 198 + local scope_name=$1 199 + echo "/sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/app.slice/${scope_name}.scope" 200 + } 201 + 202 + # Get peak memory from cgroup (in KB) 203 + get_peak_memory_kb() { 204 + local cgroup_path=$1 205 + if [[ -f "$cgroup_path/memory.peak" ]]; then 206 + local bytes=$(cat "$cgroup_path/memory.peak" 2>/dev/null || echo "0") 207 + echo $((bytes / 1024)) 208 + else 209 + echo "0" 210 + fi 211 + } 212 + 213 + # Get current memory from cgroup (in KB) 214 + get_current_memory_kb() { 215 + local cgroup_path=$1 216 + if [[ -f "$cgroup_path/memory.current" ]]; then 217 + local bytes=$(cat "$cgroup_path/memory.current" 2>/dev/null || echo "0") 218 + echo $((bytes / 1024)) 219 + else 220 + echo "0" 221 + fi 222 + } 223 + 224 + start_memory_tracker() { 225 + local pid=$1 226 + local output_file=$2 227 + local pid_file=$3 228 + local interval=${4:-0.1} 229 + 230 + echo "0" > "$output_file" 231 + 232 + ( 233 + peak=0 234 + while kill -0 "$pid" 2>/dev/null; do 235 + if [[ -f "/proc/$pid/status" ]]; then 236 + current=$(grep VmRSS "/proc/$pid/status" 2>/dev/null | awk '{print $2}') 237 + if [[ -n "$current" && "$current" -gt "$peak" ]] 2>/dev/null; then 238 + peak=$current 239 + echo "$peak" > "$output_file" 240 + fi 241 + fi 242 + sleep "$interval" 243 + done 244 + echo "$peak" > "$output_file" 245 + ) & 246 + echo $! > "$pid_file" 247 + } 248 + 249 + stop_memory_tracker() { 250 + local pid_file=$1 251 + local output_file=$2 252 + 253 + if [[ -f "$pid_file" ]]; then 254 + local tracker_pid=$(cat "$pid_file") 255 + rm -f "$pid_file" 256 + sleep 0.2 257 + kill "$tracker_pid" 2>/dev/null || true 258 + wait "$tracker_pid" 2>/dev/null || true 259 + fi 260 + 261 + if [[ -f "$output_file" ]]; then 262 + cat "$output_file" 263 + rm -f "$output_file" 264 + else 265 + echo "0" 266 + fi 267 + } 268 + 269 + # Wait for server to be ready 270 + wait_for_server() { 271 + local port=$1 272 + local max_attempts=50 273 + local attempt=0 274 + 275 + while ! curl -s "http://127.0.0.1:$port/plaintext" > /dev/null 2>&1; do 276 + attempt=$((attempt + 1)) 277 + if [[ $attempt -ge $max_attempts ]]; then 278 + error "Server on port $port failed to start" 279 + return 1 280 + fi 281 + sleep 0.1 282 + done 283 + return 0 284 + } 285 + 286 + cleanup_ports() { 287 + for port in 8080 8081 8082 8083 8084; do 288 + pkill -9 -f "port.*$port" 2>/dev/null || true 289 + pkill -9 -f "\-p.*$port" 2>/dev/null || true 290 + done 291 + sleep 0.5 292 + } 293 + 294 + strip_ansi() { 295 + sed 's/\x1b\[[0-9;]*m//g' 296 + } 297 + 298 + benchmark_h1() { 299 + local name=$1 300 + local port=$2 301 + local endpoint=$3 302 + 303 + log "HTTP/1.1 $endpoint..." 304 + "$PROJECT_DIR/_build/default/bench/client/bench_client.exe" \ 305 + -p h1 \ 306 + -u "http://127.0.0.1:$port$endpoint" \ 307 + -c "$CONCURRENCY" \ 308 + -d "$DURATION" 2>&1 || true 309 + } 310 + 311 + # Run HTTP/2 benchmark using h2load 312 + benchmark_h2() { 313 + local name=$1 314 + local port=$2 315 + local endpoint=$3 316 + local requests=$((CONCURRENCY * DURATION * 1000)) # Estimate based on expected throughput 317 + 318 + log "HTTP/2 (h2c) $endpoint..." 319 + h2load -n "$requests" -c "$CONCURRENCY" -t 4 \ 320 + "http://127.0.0.1:$port$endpoint" 2>&1 || true 321 + } 322 + 323 + # Run WebSocket benchmark 324 + benchmark_ws() { 325 + local name=$1 326 + local port=$2 327 + local ws_path=$3 328 + local ws_timeout=$((DURATION + 5)) 329 + 330 + log "WebSocket $ws_path..." 331 + # Use timeout to prevent hanging (known issue with some WS servers) 332 + timeout "$ws_timeout" "$PROJECT_DIR/_build/default/bench/client/bench_client.exe" \ 333 + -p ws \ 334 + -u "ws://127.0.0.1:$port$ws_path" \ 335 + -c 10 \ 336 + -d "$DURATION" \ 337 + --msg-size 128 2>&1 || { 338 + local exit_code=$? 339 + if [[ $exit_code -eq 124 ]]; then 340 + warn "WebSocket benchmark timed out (known issue with HCS server)" 341 + fi 342 + true 343 + } 344 + } 345 + 346 + # Run all benchmarks for a server 347 + benchmark_server() { 348 + local name=$1 349 + local cmd_template=${SERVER_CMDS[$name]} 350 + local port=${SERVER_PORTS[$name]} 351 + local ws_path=${SERVER_WS_PATHS[$name]} 352 + 353 + local cmd="${cmd_template//PORT/$port}" 354 + 355 + header "$name (port $port)" 356 + 357 + log "Starting $name server..." 358 + eval "$cmd" & 359 + local server_pid=$! 360 + 361 + if ! wait_for_server "$port"; then 362 + kill "$server_pid" 2>/dev/null || true 363 + return 1 364 + fi 365 + 366 + success "$name server ready (PID: $server_pid)" 367 + 368 + local rss_start=$(get_rss_kb "$server_pid") 369 + log "Initial RSS: ${rss_start} KB" 370 + 371 + local peak_tracker_file="/tmp/bench_peak_${name}_$$.txt" 372 + local tracker_pid_file="/tmp/bench_tracker_pid_${name}_$$.txt" 373 + start_memory_tracker "$server_pid" "$peak_tracker_file" "$tracker_pid_file" 0.05 374 + 375 + log "Warming up for ${WARMUP}s..." 376 + "$PROJECT_DIR/_build/default/bench/client/bench_client.exe" \ 377 + -p h1 \ 378 + -u "http://127.0.0.1:$port/plaintext" \ 379 + -c "$CONCURRENCY" \ 380 + -d "$WARMUP" > /dev/null 2>&1 || true 381 + 382 + { 383 + echo "" 384 + echo "==========================================" 385 + echo " $name" 386 + echo "==========================================" 387 + echo "Port: $port, Threads: $THREADS" 388 + echo "" 389 + } >> "$RESULT_FILE" 390 + 391 + local tmp_out="/tmp/bench_out_$$.txt" 392 + 393 + if [[ "$PROTOCOL_FILTER" == "all" || "$PROTOCOL_FILTER" == "h1" ]]; then 394 + echo "--- HTTP/1.1 ---" >> "$RESULT_FILE" 395 + benchmark_h1 "$name" "$port" "/plaintext" > "$tmp_out" 2>&1 396 + cat "$tmp_out" 397 + strip_ansi < "$tmp_out" >> "$RESULT_FILE" 398 + echo "" >> "$RESULT_FILE" 399 + benchmark_h1 "$name" "$port" "/json" > "$tmp_out" 2>&1 400 + cat "$tmp_out" 401 + strip_ansi < "$tmp_out" >> "$RESULT_FILE" 402 + echo "" >> "$RESULT_FILE" 403 + fi 404 + 405 + if [[ "$PROTOCOL_FILTER" == "all" || "$PROTOCOL_FILTER" == "h2" ]]; then 406 + echo "--- HTTP/2 (h2c) ---" >> "$RESULT_FILE" 407 + benchmark_h2 "$name" "$port" "/plaintext" > "$tmp_out" 2>&1 408 + cat "$tmp_out" 409 + strip_ansi < "$tmp_out" >> "$RESULT_FILE" 410 + echo "" >> "$RESULT_FILE" 411 + benchmark_h2 "$name" "$port" "/json" > "$tmp_out" 2>&1 412 + cat "$tmp_out" 413 + strip_ansi < "$tmp_out" >> "$RESULT_FILE" 414 + echo "" >> "$RESULT_FILE" 415 + fi 416 + 417 + if [[ "$PROTOCOL_FILTER" == "all" || "$PROTOCOL_FILTER" == "ws" ]]; then 418 + echo "--- WebSocket ---" >> "$RESULT_FILE" 419 + benchmark_ws "$name" "$port" "$ws_path" > "$tmp_out" 2>&1 420 + cat "$tmp_out" 421 + strip_ansi < "$tmp_out" >> "$RESULT_FILE" 422 + echo "" >> "$RESULT_FILE" 423 + fi 424 + 425 + rm -f "$tmp_out" 426 + 427 + local rss_end=$(get_rss_kb "$server_pid") 428 + 429 + kill "$server_pid" 2>/dev/null || true 430 + wait "$server_pid" 2>/dev/null || true 431 + 432 + local peak_rss=$(stop_memory_tracker "$tracker_pid_file" "$peak_tracker_file") 433 + 434 + log "Memory - Initial: ${rss_start} KB, Final: ${rss_end} KB, Peak: ${peak_rss} KB" 435 + 436 + { 437 + echo "--- Memory ---" 438 + echo "Initial RSS: ${rss_start} KB" 439 + echo "Final RSS: ${rss_end} KB" 440 + echo "Peak RSS: ${peak_rss} KB" 441 + echo "Peak RSS (MB): $((peak_rss / 1024)) MB" 442 + echo "" 443 + } >> "$RESULT_FILE" 444 + 445 + success "$name benchmark complete" 446 + sleep 1 447 + } 448 + 449 + # Generate summary comparison 450 + generate_summary() { 451 + header "Summary" 452 + 453 + { 454 + echo "" 455 + echo "==========================================" 456 + echo " BENCHMARK SUMMARY" 457 + echo "==========================================" 458 + echo "Date: $(date)" 459 + echo "Duration: ${DURATION}s per test" 460 + echo "Concurrency: $CONCURRENCY connections" 461 + echo "Threads: $THREADS per server" 462 + echo "" 463 + echo "For detailed req/s and latency metrics," 464 + echo "see individual server sections above." 465 + echo "" 466 + echo "Note: HTTP/2 uses h2load, HTTP/1.1 and WS use bench-client" 467 + echo "" 468 + } >> "$RESULT_FILE" 469 + 470 + log "Results saved to: $RESULT_FILE" 471 + } 472 + 473 + # Main execution 474 + main() { 475 + echo "" 476 + echo -e "${BOLD}╔════════════════════════════════════════════╗${NC}" 477 + echo -e "${BOLD}║ Multi-Protocol Benchmark Suite ║${NC}" 478 + echo -e "${BOLD}╚════════════════════════════════════════════╝${NC}" 479 + echo "" 480 + echo " Duration: ${DURATION}s" 481 + echo " Concurrency: $CONCURRENCY" 482 + echo " Warmup: ${WARMUP}s" 483 + echo " Threads: $THREADS" 484 + echo " Servers: $SERVER_FILTER" 485 + echo " Protocols: $PROTOCOL_FILTER" 486 + echo " Output: $RESULT_FILE" 487 + echo -e " Memory: ${GREEN}peak RSS tracking enabled${NC}" 488 + echo "" 489 + 490 + check_deps 491 + 492 + # Write header to results file 493 + { 494 + echo "Multi-Protocol Benchmark Results" 495 + echo "================================" 496 + echo "Date: $(date)" 497 + echo "Duration: ${DURATION}s, Concurrency: $CONCURRENCY, Threads: $THREADS" 498 + echo "System: $(uname -a)" 499 + echo "CPU: $(grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs || sysctl -n machdep.cpu.brand_string 2>/dev/null || echo 'unknown')" 500 + echo "" 501 + } > "$RESULT_FILE" 502 + 503 + build_servers 504 + cleanup_ports 505 + 506 + # Run benchmarks for selected servers 507 + local servers=() 508 + if [[ "$SERVER_FILTER" == "all" ]]; then 509 + servers=("hcs" "piaf" "hyper" "go" "fasthttp") 510 + else 511 + servers=("$SERVER_FILTER") 512 + fi 513 + 514 + for server in "${servers[@]}"; do 515 + if [[ -n "${SERVER_CMDS[$server]:-}" ]]; then 516 + benchmark_server "$server" 517 + else 518 + warn "Unknown server: $server" 519 + fi 520 + done 521 + 522 + generate_summary 523 + cleanup_ports 524 + 525 + echo "" 526 + echo -e "${BOLD}${GREEN}✓ All benchmarks complete!${NC}" 527 + echo " Results: $RESULT_FILE" 528 + echo "" 529 + } 530 + 531 + main "$@"
+11
bin/dune
··· 1 + (executable 2 + (public_name hc) 3 + (package hcs) 4 + (name hc) 5 + (libraries hcs eio_main mirage-crypto-rng.unix climate)) 6 + 7 + (executable 8 + (public_name hs) 9 + (package hcs) 10 + (name hs) 11 + (libraries hcs eio_main climate))
+223
bin/hc.ml
··· 1 + (** hc - HTTP client using HCS. 2 + 3 + Supports HTTP/1.1, HTTP/2, WebSocket, TLS verification, and common HTTP 4 + methods. *) 5 + 6 + open Hcs 7 + 8 + (** Parse a header string "Name: Value" into a tuple *) 9 + let parse_header s = 10 + match String.index_opt s ':' with 11 + | Some i -> 12 + let name = String.trim (String.sub s 0 i) in 13 + let value = 14 + String.trim (String.sub s (i + 1) (String.length s - i - 1)) 15 + in 16 + (name, value) 17 + | None -> failwith (Printf.sprintf "Invalid header format: %s" s) 18 + 19 + (** Protocol to string *) 20 + let protocol_to_string = function 21 + | Client.HTTP_1_1 -> "HTTP/1.1" 22 + | Client.HTTP_2 -> "HTTP/2" 23 + 24 + (** Print response headers *) 25 + let print_headers status protocol headers = 26 + Printf.printf "< %s %d\n" (protocol_to_string protocol) status; 27 + List.iter (fun (name, value) -> Printf.printf "< %s: %s\n" name value) headers; 28 + print_newline () 29 + 30 + (** Handle WebSocket connection *) 31 + let handle_websocket ~sw ~net ~tls_config ~url ~ws_message ~verbose:_ = 32 + Printf.printf "> Connecting to WebSocket: %s\n%!" url; 33 + match Websocket.connect ~sw ~net ~tls_config url with 34 + | Error err -> 35 + let msg = 36 + match err with 37 + | Websocket.Connection_closed -> "Connection closed" 38 + | Websocket.Protocol_error s -> "Protocol error: " ^ s 39 + | Websocket.Io_error s -> "IO error: " ^ s 40 + in 41 + Printf.eprintf "WebSocket error: %s\n" msg; 42 + exit 1 43 + | Ok ws -> 44 + Printf.printf "< Connected!\n%!"; 45 + (* Send message if provided *) 46 + (match ws_message with 47 + | Some msg -> ( 48 + Printf.printf "> Sending: %s\n%!" msg; 49 + match Websocket.send_text ws msg with 50 + | Ok () -> () 51 + | Error _ -> 52 + Printf.eprintf "Failed to send message\n"; 53 + exit 1) 54 + | None -> ()); 55 + (* Receive messages *) 56 + let rec recv_loop () = 57 + match Websocket.recv_message ws with 58 + | Ok (Websocket.Opcode.Text, content) -> 59 + Printf.printf "< Text: %s\n%!" content; 60 + recv_loop () 61 + | Ok (Websocket.Opcode.Binary, content) -> 62 + Printf.printf "< Binary: %d bytes\n%!" (String.length content); 63 + recv_loop () 64 + | Ok _ -> recv_loop () 65 + | Error Websocket.Connection_closed -> 66 + Printf.printf "< Connection closed\n%!" 67 + | Error (Websocket.Protocol_error s) -> 68 + Printf.eprintf "Protocol error: %s\n" s 69 + | Error (Websocket.Io_error s) -> Printf.eprintf "IO error: %s\n" s 70 + in 71 + (* Only enter receive loop if we sent a message (otherwise just test connection) *) 72 + if Option.is_some ws_message then recv_loop (); 73 + Websocket.close ws 74 + 75 + (** Handle HTTP request *) 76 + let handle_http ~sw ~net ~clock ~url ~method_ ~headers ~data ~http2 ~http1 77 + ~insecure ~verbose ~head_only ~follow_redirects ~output_file = 78 + (* Build client config *) 79 + let config = 80 + let base = Client.default_config in 81 + let config = if insecure then Client.with_insecure_tls base else base in 82 + let config = 83 + if http2 then Client.with_http2 config 84 + else if http1 then Client.with_http11 config 85 + else config 86 + in 87 + if follow_redirects then config else Client.without_redirects config 88 + in 89 + 90 + (* Determine actual method *) 91 + let method_ = if head_only then "HEAD" else method_ in 92 + let method_ = 93 + if Option.is_some data && method_ = "GET" then "POST" else method_ 94 + in 95 + 96 + (* Print request info in verbose mode *) 97 + if verbose then begin 98 + Printf.printf "> %s %s\n" method_ url; 99 + List.iter 100 + (fun (name, value) -> Printf.printf "> %s: %s\n" name value) 101 + headers; 102 + (match data with Some d -> Printf.printf "> Body: %s\n" d | None -> ()); 103 + print_newline () 104 + end; 105 + 106 + (* Make request based on method *) 107 + let result = 108 + match (method_, data) with 109 + | "GET", None -> Client.get ~sw ~net ~clock ~config url 110 + | "HEAD", None -> Client.get ~sw ~net ~clock ~config url 111 + | "POST", Some body -> Client.post ~sw ~net ~clock ~config url ~body 112 + | "POST", None -> Client.post ~sw ~net ~clock ~config url ~body:"" 113 + | "PUT", Some body -> Client.post ~sw ~net ~clock ~config url ~body 114 + | "PUT", None -> Client.post ~sw ~net ~clock ~config url ~body:"" 115 + | "DELETE", _ -> Client.get ~sw ~net ~clock ~config url 116 + | "OPTIONS", _ -> Client.get ~sw ~net ~clock ~config url 117 + | meth, _ -> 118 + Printf.eprintf "Method %s not fully supported yet, using GET\n" meth; 119 + Client.get ~sw ~net ~clock ~config url 120 + in 121 + 122 + match result with 123 + | Error err -> 124 + let msg = 125 + match err with 126 + | Client.Connection_failed s -> "Connection failed: " ^ s 127 + | Client.Tls_error s -> "TLS error: " ^ s 128 + | Client.Protocol_error s -> "Protocol error: " ^ s 129 + | Client.Timeout -> "Request timed out" 130 + | Client.Invalid_response s -> "Invalid response: " ^ s 131 + | Client.Too_many_redirects -> "Too many redirects" 132 + in 133 + Printf.eprintf "Error: %s\n" msg; 134 + exit 1 135 + | Ok resp -> 136 + (* Print headers in verbose mode or for HEAD requests *) 137 + if verbose || head_only then 138 + print_headers resp.status resp.protocol resp.headers; 139 + 140 + (* Output body *) 141 + if not head_only then begin 142 + match output_file with 143 + | Some file -> 144 + let oc = open_out file in 145 + output_string oc resp.body; 146 + close_out oc; 147 + if verbose then 148 + Printf.printf "Wrote %d bytes to %s\n" (String.length resp.body) 149 + file 150 + | None -> print_string resp.body 151 + end 152 + 153 + let command = 154 + let open Climate in 155 + Command.singleton 156 + ~doc:"HTTP client supporting HTTP/1.1, HTTP/2, WebSocket, and TLS" 157 + @@ 158 + let open Arg_parser in 159 + let+ url = pos_req 0 string ~doc:"URL to request" 160 + and+ method_ = 161 + named_opt [ "X"; "request" ] string ~doc:"HTTP method" ~value_name:"METHOD" 162 + and+ header_strs = 163 + named_multi [ "H"; "header" ] string 164 + ~doc:"Add header (format: \"Name: Value\")" 165 + and+ data = 166 + named_opt [ "d"; "data" ] string ~doc:"Request body data" ~value_name:"DATA" 167 + and+ http2 = flag [ "2"; "http2" ] ~doc:"Force HTTP/2" 168 + and+ http1 = flag [ "1"; "http1" ] ~doc:"Force HTTP/1.1" 169 + and+ insecure = 170 + flag [ "k"; "insecure" ] ~doc:"Skip TLS certificate verification" 171 + and+ verbose = flag [ "v"; "verbose" ] ~doc:"Verbose output (show headers)" 172 + and+ head_only = flag [ "I"; "head" ] ~doc:"HEAD request (show headers only)" 173 + and+ follow_redirects_flag = 174 + flag [ "L"; "location" ] ~doc:"Follow redirects (default: enabled)" 175 + and+ no_follow_redirects = 176 + flag [ "no-location" ] ~doc:"Do not follow redirects" 177 + and+ output_file = 178 + named_opt [ "o"; "output" ] string ~doc:"Write output to file" 179 + ~value_name:"FILE" 180 + and+ websocket = flag [ "w"; "websocket" ] ~doc:"WebSocket mode" 181 + and+ ws_message = 182 + named_opt [ "ws-message" ] string ~doc:"Message to send in WebSocket mode" 183 + ~value_name:"MSG" 184 + in 185 + 186 + (* Parse headers from strings *) 187 + let headers = List.map parse_header header_strs in 188 + 189 + (* Compute actual values *) 190 + let method_ = Option.value method_ ~default:"GET" |> String.uppercase_ascii in 191 + let follow_redirects = 192 + if no_follow_redirects then false 193 + else if follow_redirects_flag then true 194 + else true (* default *) 195 + in 196 + 197 + (* Initialize RNG for TLS *) 198 + Mirage_crypto_rng_unix.use_default (); 199 + 200 + Eio_main.run @@ fun env -> 201 + Eio.Switch.run @@ fun sw -> 202 + let net = Eio.Stdenv.net env in 203 + let clock = Eio.Stdenv.clock env in 204 + 205 + (* Determine if this is a WebSocket URL *) 206 + let is_ws = 207 + websocket 208 + || String.length url >= 5 209 + && (String.sub url 0 5 = "ws://" 210 + || (String.length url >= 6 && String.sub url 0 6 = "wss://")) 211 + in 212 + 213 + if is_ws then begin 214 + let tls_config = 215 + if insecure then Tls_config.Client.insecure else Tls_config.Client.default 216 + in 217 + handle_websocket ~sw ~net ~tls_config ~url ~ws_message ~verbose 218 + end 219 + else 220 + handle_http ~sw ~net ~clock ~url ~method_ ~headers ~data ~http2 ~http1 221 + ~insecure ~verbose ~head_only ~follow_redirects ~output_file 222 + 223 + let () = Climate.Command.run command
+360
bin/hs.ml
··· 1 + (** hs - HTTP file server using HCS. 2 + 3 + Serves files from a directory over HTTP/1.1 and HTTP/2. *) 4 + 5 + open Hcs 6 + 7 + (** MIME type mapping *) 8 + let mime_type_of_extension ext = 9 + match String.lowercase_ascii ext with 10 + | ".html" | ".htm" -> "text/html; charset=utf-8" 11 + | ".css" -> "text/css; charset=utf-8" 12 + | ".js" | ".mjs" -> "application/javascript; charset=utf-8" 13 + | ".json" -> "application/json" 14 + | ".xml" -> "application/xml" 15 + | ".txt" -> "text/plain; charset=utf-8" 16 + | ".md" -> "text/markdown; charset=utf-8" 17 + | ".png" -> "image/png" 18 + | ".jpg" | ".jpeg" -> "image/jpeg" 19 + | ".gif" -> "image/gif" 20 + | ".svg" -> "image/svg+xml" 21 + | ".ico" -> "image/x-icon" 22 + | ".webp" -> "image/webp" 23 + | ".avif" -> "image/avif" 24 + | ".woff" -> "font/woff" 25 + | ".woff2" -> "font/woff2" 26 + | ".ttf" -> "font/ttf" 27 + | ".otf" -> "font/otf" 28 + | ".eot" -> "application/vnd.ms-fontobject" 29 + | ".pdf" -> "application/pdf" 30 + | ".zip" -> "application/zip" 31 + | ".gz" -> "application/gzip" 32 + | ".tar" -> "application/x-tar" 33 + | ".mp3" -> "audio/mpeg" 34 + | ".ogg" -> "audio/ogg" 35 + | ".wav" -> "audio/wav" 36 + | ".mp4" -> "video/mp4" 37 + | ".webm" -> "video/webm" 38 + | ".avi" -> "video/x-msvideo" 39 + | ".wasm" -> "application/wasm" 40 + | ".map" -> "application/json" 41 + | _ -> "application/octet-stream" 42 + 43 + (** Get file extension from path *) 44 + let extension path = 45 + match String.rindex_opt path '.' with 46 + | Some i -> String.sub path i (String.length path - i) 47 + | None -> "" 48 + 49 + (** Normalize and validate path to prevent directory traversal *) 50 + let normalize_path path = 51 + (* Remove leading slash *) 52 + let path = 53 + if String.length path > 0 && path.[0] = '/' then 54 + String.sub path 1 (String.length path - 1) 55 + else path 56 + in 57 + (* Remove query string *) 58 + let path = 59 + match String.index_opt path '?' with 60 + | Some i -> String.sub path 0 i 61 + | None -> path 62 + in 63 + (* Remove fragment *) 64 + let path = 65 + match String.index_opt path '#' with 66 + | Some i -> String.sub path 0 i 67 + | None -> path 68 + in 69 + (* Check for directory traversal attempts *) 70 + let segments = String.split_on_char '/' path in 71 + let rec check_segments acc = function 72 + | [] -> Some (String.concat "/" (List.rev acc)) 73 + | ".." :: _ -> None (* Reject any path with .. *) 74 + | "." :: rest -> check_segments acc rest 75 + | "" :: rest -> check_segments acc rest 76 + | seg :: rest -> 77 + if String.contains seg '\x00' then None 78 + else check_segments (seg :: acc) rest 79 + in 80 + check_segments [] segments 81 + 82 + (** HTML escape for directory listing *) 83 + let html_escape s = 84 + let buf = Buffer.create (String.length s) in 85 + String.iter 86 + (function 87 + | '<' -> Buffer.add_string buf "&lt;" 88 + | '>' -> Buffer.add_string buf "&gt;" 89 + | '&' -> Buffer.add_string buf "&amp;" 90 + | '"' -> Buffer.add_string buf "&quot;" 91 + | c -> Buffer.add_char buf c) 92 + s; 93 + Buffer.contents buf 94 + 95 + (** Generate directory listing HTML *) 96 + let directory_listing ~path entries = 97 + let buf = Buffer.create 4096 in 98 + Buffer.add_string buf "<!DOCTYPE html>\n<html>\n<head>\n"; 99 + Buffer.add_string buf "<meta charset=\"utf-8\">\n"; 100 + Buffer.add_string buf 101 + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"; 102 + Printf.bprintf buf "<title>Index of /%s</title>\n" (html_escape path); 103 + Buffer.add_string buf "<style>\n"; 104 + Buffer.add_string buf 105 + "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', \ 106 + Roboto, sans-serif; margin: 2em; }\n"; 107 + Buffer.add_string buf "h1 { font-weight: normal; }\n"; 108 + Buffer.add_string buf 109 + "table { border-collapse: collapse; width: 100%; max-width: 800px; }\n"; 110 + Buffer.add_string buf "th, td { text-align: left; padding: 0.5em 1em; }\n"; 111 + Buffer.add_string buf "th { border-bottom: 2px solid #333; }\n"; 112 + Buffer.add_string buf "tr:hover { background: #f5f5f5; }\n"; 113 + Buffer.add_string buf "a { color: #0066cc; text-decoration: none; }\n"; 114 + Buffer.add_string buf "a:hover { text-decoration: underline; }\n"; 115 + Buffer.add_string buf ".size { color: #666; }\n"; 116 + Buffer.add_string buf "</style>\n</head>\n<body>\n"; 117 + Printf.bprintf buf "<h1>Index of /%s</h1>\n" (html_escape path); 118 + Buffer.add_string buf "<table>\n<thead>\n<tr>"; 119 + Buffer.add_string buf "<th>Name</th><th>Size</th>"; 120 + Buffer.add_string buf "</tr>\n</thead>\n<tbody>\n"; 121 + 122 + (* Parent directory link *) 123 + if path <> "" then begin 124 + Buffer.add_string buf "<tr><td><a href=\"../\">..</a></td><td>-</td></tr>\n" 125 + end; 126 + 127 + (* Sort entries: directories first, then files *) 128 + let sorted = 129 + List.sort 130 + (fun (a, a_is_dir) (b, b_is_dir) -> 131 + match (a_is_dir, b_is_dir) with 132 + | true, false -> -1 133 + | false, true -> 1 134 + | _ -> String.compare a b) 135 + entries 136 + in 137 + 138 + List.iter 139 + (fun (name, is_dir) -> 140 + let display_name = if is_dir then name ^ "/" else name in 141 + let href = if is_dir then name ^ "/" else name in 142 + let size = if is_dir then "-" else "" in 143 + Printf.bprintf buf 144 + "<tr><td><a href=\"%s\">%s</a></td><td class=\"size\">%s</td></tr>\n" 145 + (html_escape href) (html_escape display_name) size) 146 + sorted; 147 + 148 + Buffer.add_string buf "</tbody>\n</table>\n"; 149 + Buffer.add_string buf "<hr>\n<p><em>Served by hs (HCS)</em></p>\n"; 150 + Buffer.add_string buf "</body>\n</html>\n"; 151 + Buffer.contents buf 152 + 153 + (** Create the file server handler *) 154 + let make_handler ~fs ~root ~index_file ~enable_listing ~enable_cors ~verbose = 155 + fun (req : Server.request) -> 156 + let start_time = Unix.gettimeofday () in 157 + let target = req.target in 158 + 159 + (* Normalize path *) 160 + let path = normalize_path target in 161 + 162 + let response = 163 + match path with 164 + | None -> 165 + (* Invalid path (directory traversal attempt) *) 166 + Server.respond ~status:`Forbidden "Forbidden" 167 + | Some path -> ( 168 + let full_path = Eio.Path.(fs / root / path) in 169 + try 170 + (* Try to get file info *) 171 + let stat = Eio.Path.stat ~follow:true full_path in 172 + match stat.kind with 173 + | `Directory -> ( 174 + (* Try index file first *) 175 + let index_result = 176 + match index_file with 177 + | Some idx -> ( 178 + try 179 + let idx_path = Eio.Path.(full_path / idx) in 180 + let content = Eio.Path.load idx_path in 181 + let content_type = 182 + mime_type_of_extension (extension idx) 183 + in 184 + Some 185 + { 186 + Server.status = `OK; 187 + headers = 188 + [ 189 + ("Content-Type", content_type); 190 + ( "Content-Length", 191 + string_of_int (String.length content) ); 192 + ]; 193 + body = Server.Body_string content; 194 + } 195 + with _ -> None) 196 + | None -> None 197 + in 198 + match index_result with 199 + | Some resp -> resp 200 + | None -> 201 + if enable_listing then begin 202 + (* Generate directory listing *) 203 + let entries = 204 + Eio.Path.read_dir full_path 205 + |> List.filter_map (fun name -> 206 + try 207 + let child_path = Eio.Path.(full_path / name) in 208 + let child_stat = 209 + Eio.Path.stat ~follow:true child_path 210 + in 211 + let is_dir = child_stat.kind = `Directory in 212 + Some (name, is_dir) 213 + with _ -> None) 214 + in 215 + let html = directory_listing ~path entries in 216 + { 217 + Server.status = `OK; 218 + headers = 219 + [ 220 + ("Content-Type", "text/html; charset=utf-8"); 221 + ("Content-Length", string_of_int (String.length html)); 222 + ]; 223 + body = Server.Body_string html; 224 + } 225 + end 226 + else 227 + Server.respond ~status:`Forbidden 228 + "Directory listing disabled") 229 + | `Regular_file -> 230 + (* Serve the file *) 231 + let content = Eio.Path.load full_path in 232 + let content_type = mime_type_of_extension (extension path) in 233 + { 234 + Server.status = `OK; 235 + headers = 236 + [ 237 + ("Content-Type", content_type); 238 + ("Content-Length", string_of_int (String.length content)); 239 + ]; 240 + body = Server.Body_string content; 241 + } 242 + | _ -> Server.respond ~status:`Forbidden "Not a file" 243 + with 244 + | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> 245 + Server.respond ~status:`Not_found "Not Found" 246 + | Eio.Io (Eio.Fs.E (Eio.Fs.Permission_denied _), _) -> 247 + Server.respond ~status:`Forbidden "Permission Denied" 248 + | exn -> 249 + if verbose then 250 + Printf.eprintf "Error serving %s: %s\n%!" target 251 + (Printexc.to_string exn); 252 + Server.respond ~status:`Internal_server_error 253 + "Internal Server Error") 254 + in 255 + 256 + (* Add CORS headers if enabled *) 257 + let response = 258 + if enable_cors then 259 + { 260 + response with 261 + headers = 262 + ("Access-Control-Allow-Origin", "*") 263 + :: ("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") 264 + :: ("Access-Control-Allow-Headers", "*") 265 + :: response.headers; 266 + } 267 + else response 268 + in 269 + 270 + (* Log request if verbose *) 271 + if verbose then begin 272 + let duration = (Unix.gettimeofday () -. start_time) *. 1000.0 in 273 + let status_code = H1.Status.to_code response.status in 274 + Printf.printf "%s %s -> %d (%.2fms)\n%!" 275 + (H1.Method.to_string req.meth) 276 + target status_code duration 277 + end; 278 + 279 + response 280 + 281 + let command = 282 + Climate.Command.singleton 283 + ~doc:"HTTP file server using HCS - serves files over HTTP/1.1 and HTTP/2" 284 + @@ 285 + let open Climate.Arg_parser in 286 + let+ directory = 287 + pos_with_default 0 string ~default:"." ~value_name:"DIRECTORY" 288 + ~doc:"Directory to serve" 289 + and+ port = 290 + named_opt [ "p"; "port" ] int ~value_name:"PORT" 291 + ~doc:"Port to listen on (default: 8080)" 292 + and+ bind_address = 293 + named_opt [ "b"; "bind" ] string ~value_name:"ADDRESS" 294 + ~doc:"Address to bind to (default: 0.0.0.0)" 295 + and+ domains = 296 + named_opt [ "d"; "domains" ] int ~value_name:"N" 297 + ~doc:"Number of domains/threads (default: CPU count)" 298 + and+ http1 = flag [ "1"; "http1" ] ~doc:"HTTP/1.1 only (fastest)" 299 + and+ http2 = flag [ "2"; "http2" ] ~doc:"HTTP/2 only" 300 + and+ index_opt = 301 + named_opt [ "index" ] string ~value_name:"FILE" 302 + ~doc:"Index file for directories (default: index.html)" 303 + and+ no_index = flag [ "no-index" ] ~doc:"Disable index file lookup" 304 + and+ enable_listing = flag [ "list" ] ~doc:"Enable directory listing" 305 + and+ enable_cors = flag [ "cors" ] ~doc:"Enable CORS headers" 306 + and+ verbose = flag [ "v"; "verbose" ] ~doc:"Verbose logging" in 307 + 308 + let port = Option.value port ~default:8080 in 309 + let bind_address = Option.value bind_address ~default:"0.0.0.0" in 310 + let domains = 311 + Option.value domains ~default:(Domain.recommended_domain_count ()) 312 + in 313 + let index_file = 314 + if no_index then None 315 + else Some (Option.value index_opt ~default:"index.html") 316 + in 317 + 318 + if not (Sys.file_exists directory && Sys.is_directory directory) then begin 319 + Printf.eprintf "Error: Directory '%s' does not exist\n" directory; 320 + exit 1 321 + end; 322 + 323 + let protocol = 324 + if http1 then Server.Http1_only 325 + else if http2 then Server.Http2_only 326 + else Server.Http1_only 327 + in 328 + 329 + Printf.printf "Serving %s on http://%s:%d\n%!" 330 + (if directory = "." then "current directory" else directory) 331 + bind_address port; 332 + Printf.printf "Protocol: %s, Domains: %d\n%!" 333 + (match protocol with 334 + | Server.Http1_only -> "HTTP/1.1" 335 + | Server.Http2_only -> "HTTP/2" 336 + | Server.Auto -> "Auto" 337 + | Server.Auto_websocket -> "Auto+WebSocket") 338 + domains; 339 + if enable_listing then Printf.printf "Directory listing: enabled\n%!"; 340 + if enable_cors then Printf.printf "CORS: enabled\n%!"; 341 + Printf.printf "Press Ctrl+C to stop\n%!\n"; 342 + 343 + Eio_main.run @@ fun env -> 344 + Eio.Switch.run @@ fun sw -> 345 + let net = Eio.Stdenv.net env in 346 + let fs = Eio.Stdenv.fs env in 347 + let domain_mgr = Eio.Stdenv.domain_mgr env in 348 + 349 + let handler = 350 + make_handler ~fs ~root:directory ~index_file ~enable_listing ~enable_cors 351 + ~verbose 352 + in 353 + 354 + let config = 355 + { Server.default_config with port; domain_count = domains; protocol } 356 + in 357 + 358 + Server.run_parallel ~sw ~net ~domain_mgr ~config handler 359 + 360 + let () = Climate.Command.run command
+48
dune-project
··· 1 + (lang dune 3.20) 2 + 3 + (name hcs) 4 + 5 + (generate_opam_files true) 6 + 7 + (source 8 + ; (tangled @gdiazlo.tngl.sh/hcs) 9 + (uri https://tangled.org/gdiazlo.tngl.sh/hcs)) 10 + 11 + (authors "Gabriel Díaz") 12 + 13 + (maintainers "Gabriel Díaz") 14 + 15 + (license ISC) 16 + (homepage https://tangled.org/gdiazlo.tngl.sh/hcs) 17 + (bug_reports https://tangled.org/gdiazlo.tngl.sh/hcs/issues) 18 + (documentation https://tangled.org/gdiazlo.tngl.sh/hcs) 19 + 20 + (package 21 + (name hcs) 22 + (synopsis "Eio based HTTP client/server library for OCaml 5+") 23 + (description 24 + "HCS is a HTTP client/server library for OCaml 5+ supporting HTTP/1.1, HTTP/2, and WebSocket. Built on Eio.") 25 + (depends 26 + (ocaml (>= 5.4)) 27 + (dune (>= 3.20)) 28 + (eio (>= 1.2)) 29 + (eio_main (>= 1.2)) 30 + (h1 (>= 1.0)) 31 + (h2 (>= 0.13)) 32 + (tls (>= 2.0)) 33 + (tls-eio (>= 2.0)) 34 + (ca-certs (>= 1.0)) 35 + (x509 (>= 1.0)) 36 + (mirage-crypto-rng (>= 1.0)) 37 + (uri (>= 4.4)) 38 + (ptime (>= 1.2)) 39 + (cstruct (>= 6.2)) 40 + (digestif (>= 1.2)) 41 + (base64 (>= 3.5)) 42 + (bigstringaf (>= 0.10)) 43 + (faraday (>= 0.8)) 44 + (climate (>= 0.9)) 45 + (alcotest (and (>= 1.9) :with-test)) 46 + (qcheck (and (>= 0.21) :with-test)) 47 + (qcheck-alcotest (and (>= 0.21) :with-test)) 48 + (yojson :with-test)))
+50
hcs.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Eio based HTTP client/server library for OCaml 5+" 4 + description: 5 + "HCS is a HTTP client/server library for OCaml 5+ supporting HTTP/1.1, HTTP/2, and WebSocket. Built on Eio." 6 + maintainer: ["Gabriel Díaz"] 7 + authors: ["Gabriel Díaz"] 8 + license: "ISC" 9 + depends: [ 10 + "ocaml" {>= "5.4"} 11 + "dune" {>= "3.20" & >= "3.20"} 12 + "eio" {>= "1.2"} 13 + "eio_main" {>= "1.2"} 14 + "h1" {>= "1.0"} 15 + "h2" {>= "0.13"} 16 + "tls" {>= "2.0"} 17 + "tls-eio" {>= "2.0"} 18 + "ca-certs" {>= "1.0"} 19 + "x509" {>= "1.0"} 20 + "mirage-crypto-rng" {>= "1.0"} 21 + "uri" {>= "4.4"} 22 + "ptime" {>= "1.2"} 23 + "cstruct" {>= "6.2"} 24 + "digestif" {>= "1.2"} 25 + "base64" {>= "3.5"} 26 + "bigstringaf" {>= "0.10"} 27 + "faraday" {>= "0.8"} 28 + "climate" {>= "0.9"} 29 + "alcotest" {>= "1.9" & with-test} 30 + "qcheck" {>= "0.21" & with-test} 31 + "qcheck-alcotest" {>= "0.21" & with-test} 32 + "yojson" {with-test} 33 + "odoc" {with-doc} 34 + ] 35 + build: [ 36 + ["dune" "subst"] {dev} 37 + [ 38 + "dune" 39 + "build" 40 + "-p" 41 + name 42 + "-j" 43 + jobs 44 + "@install" 45 + "@runtest" {with-test} 46 + "@doc" {with-doc} 47 + ] 48 + ] 49 + dev-repo: "https://tangled.org/gdiazlo.tngl.sh/hcs" 50 + x-maintenance-intent: ["(latest)"]
+220
lib/client.ml
··· 1 + (** Unified HTTP Client supporting HTTP/1.1 and HTTP/2. 2 + 3 + This module provides a high-level HTTP client that automatically selects the 4 + appropriate protocol based on ALPN negotiation or configuration. *) 5 + 6 + (** {1 Types} *) 7 + 8 + (** Protocol version *) 9 + type protocol = HTTP_1_1 | HTTP_2 10 + 11 + type config = { 12 + (* Timeouts *) 13 + connect_timeout : float; (** Connection timeout in seconds. Default: 30.0 *) 14 + read_timeout : float; (** Read timeout in seconds. Default: 30.0 *) 15 + write_timeout : float; (** Write timeout in seconds. Default: 30.0 *) 16 + (* Behavior *) 17 + follow_redirects : int option; 18 + (** Max redirects to follow. None = don't follow. Default: Some 10 *) 19 + preferred_protocol : protocol option; 20 + (** Preferred protocol. None = auto-detect via ALPN. Default: None *) 21 + (* Buffers *) 22 + buffer_size : int; (** Read buffer size. Default: 16384 *) 23 + max_response_body : int64 option; 24 + (** Max response body size. None = unlimited. Default: None *) 25 + (* TLS *) 26 + tls : Tls_config.Client.t; (** TLS configuration for HTTP/1.1 *) 27 + (* Headers *) 28 + default_headers : (string * string) list; 29 + (** Headers to add to every request *) 30 + } 31 + (** Client configuration *) 32 + 33 + let default_config = 34 + { 35 + connect_timeout = 30.0; 36 + read_timeout = 30.0; 37 + write_timeout = 30.0; 38 + follow_redirects = Some 10; 39 + preferred_protocol = Some HTTP_1_1; 40 + (* Default to HTTP/1.1 for broader compatibility *) 41 + buffer_size = 16384; 42 + max_response_body = None; 43 + tls = Tls_config.Client.default; 44 + (* HTTP/1.1 ALPN *) 45 + default_headers = [ ("User-Agent", "hcs/0.1.0") ]; 46 + } 47 + 48 + (** {2 Config builders} *) 49 + 50 + let with_timeout timeout config = 51 + { config with connect_timeout = timeout; read_timeout = timeout } 52 + 53 + let with_connect_timeout timeout config = 54 + { config with connect_timeout = timeout } 55 + 56 + let with_read_timeout timeout config = { config with read_timeout = timeout } 57 + let with_write_timeout timeout config = { config with write_timeout = timeout } 58 + 59 + let with_redirects max_redirects config = 60 + { config with follow_redirects = Some max_redirects } 61 + 62 + let without_redirects config = { config with follow_redirects = None } 63 + let with_buffer_size size config = { config with buffer_size = size } 64 + 65 + let with_max_response_body max_size config = 66 + { config with max_response_body = Some max_size } 67 + 68 + let with_tls tls config = { config with tls } 69 + let with_insecure_tls config = { config with tls = Tls_config.Client.insecure } 70 + 71 + let with_http2 config = 72 + { config with preferred_protocol = Some HTTP_2; tls = Tls_config.Client.h2 } 73 + 74 + let with_http11 config = 75 + { 76 + config with 77 + preferred_protocol = Some HTTP_1_1; 78 + tls = Tls_config.Client.default; 79 + } 80 + 81 + let with_default_header name value config = 82 + { config with default_headers = (name, value) :: config.default_headers } 83 + 84 + let with_default_headers headers config = 85 + { config with default_headers = headers @ config.default_headers } 86 + 87 + (** Error type for client operations *) 88 + type error = 89 + | Connection_failed of string 90 + | Tls_error of string 91 + | Protocol_error of string 92 + | Timeout 93 + | Invalid_response of string 94 + | Too_many_redirects 95 + 96 + type response = { 97 + status : int; 98 + headers : (string * string) list; 99 + body : string; 100 + protocol : protocol; 101 + } 102 + (** Response type - unified across protocols *) 103 + 104 + (** {1 Internal helpers} *) 105 + 106 + let h1_status_to_int status = H1.Status.to_code status 107 + let h2_status_to_int status = H2.Status.to_code status 108 + 109 + let h1_headers_to_list headers = 110 + let result = ref [] in 111 + H1.Headers.iter 112 + ~f:(fun name value -> result := (name, value) :: !result) 113 + headers; 114 + List.rev !result 115 + 116 + let h2_headers_to_list headers = 117 + let result = ref [] in 118 + H2.Headers.iter 119 + ~f:(fun name value -> result := (name, value) :: !result) 120 + headers; 121 + List.rev !result 122 + 123 + (** {1 Public API} *) 124 + 125 + (** Perform an HTTP GET request. The protocol is selected based on: 1. 126 + config.preferred_protocol if set 2. ALPN negotiation if using HTTPS 3. 127 + HTTP/1.1 for plain HTTP *) 128 + let get ~sw ~net ~clock ?(config = default_config) url = 129 + let uri = Uri.of_string url in 130 + let scheme = Uri.scheme uri |> Option.value ~default:"http" in 131 + let is_https = String.equal scheme "https" in 132 + 133 + let use_h2 = 134 + match config.preferred_protocol with 135 + | Some HTTP_2 -> true 136 + | Some HTTP_1_1 -> false 137 + | None -> ( 138 + is_https 139 + && 140 + match config.tls.alpn_protocols with 141 + | Some protos -> List.mem Tls_config.alpn_h2 protos 142 + | None -> false) 143 + in 144 + 145 + if use_h2 then 146 + (* Use HTTP/2 *) 147 + match H2_client.get ~sw ~net ~clock url with 148 + | Ok resp -> 149 + Ok 150 + { 151 + status = h2_status_to_int resp.H2_client.status; 152 + headers = h2_headers_to_list resp.headers; 153 + body = resp.body; 154 + protocol = HTTP_2; 155 + } 156 + | Error (H2_client.Connection_failed msg) -> Error (Connection_failed msg) 157 + | Error (H2_client.Tls_error msg) -> Error (Tls_error msg) 158 + | Error (H2_client.Protocol_error msg) -> Error (Protocol_error msg) 159 + | Error H2_client.Timeout -> Error Timeout 160 + | Error (H2_client.Invalid_response msg) -> Error (Invalid_response msg) 161 + else 162 + (* Use HTTP/1.1 *) 163 + let h1_config : H1_client.config = 164 + { 165 + connect_timeout = config.connect_timeout; 166 + read_timeout = config.read_timeout; 167 + write_timeout = config.write_timeout; 168 + follow_redirects = config.follow_redirects; 169 + buffer_size = config.buffer_size; 170 + max_response_body = config.max_response_body; 171 + tls = config.tls; 172 + default_headers = config.default_headers; 173 + } 174 + in 175 + match H1_client.get ~sw ~net ~clock ~config:h1_config url with 176 + | Ok resp -> 177 + Ok 178 + { 179 + status = h1_status_to_int resp.H1_client.status; 180 + headers = h1_headers_to_list resp.headers; 181 + body = resp.body; 182 + protocol = HTTP_1_1; 183 + } 184 + | Error (H1_client.Connection_failed msg) -> Error (Connection_failed msg) 185 + | Error (H1_client.Tls_error msg) -> Error (Tls_error msg) 186 + | Error H1_client.Timeout -> Error Timeout 187 + | Error (H1_client.Invalid_response msg) -> Error (Invalid_response msg) 188 + | Error H1_client.Too_many_redirects -> Error Too_many_redirects 189 + 190 + (** Perform an HTTP POST request *) 191 + let post ~sw ~net ~clock ?(config = default_config) url ~body:request_body = 192 + (* For now, POST only uses HTTP/1.1 - H2 POST can be added later *) 193 + let h1_config : H1_client.config = 194 + { 195 + connect_timeout = config.connect_timeout; 196 + read_timeout = config.read_timeout; 197 + write_timeout = config.write_timeout; 198 + follow_redirects = config.follow_redirects; 199 + buffer_size = config.buffer_size; 200 + max_response_body = config.max_response_body; 201 + tls = config.tls; 202 + default_headers = config.default_headers; 203 + } 204 + in 205 + match 206 + H1_client.post ~sw ~net ~clock ~config:h1_config url ~body:request_body 207 + with 208 + | Ok resp -> 209 + Ok 210 + { 211 + status = h1_status_to_int resp.H1_client.status; 212 + headers = h1_headers_to_list resp.headers; 213 + body = resp.body; 214 + protocol = HTTP_1_1; 215 + } 216 + | Error (H1_client.Connection_failed msg) -> Error (Connection_failed msg) 217 + | Error (H1_client.Tls_error msg) -> Error (Tls_error msg) 218 + | Error H1_client.Timeout -> Error Timeout 219 + | Error (H1_client.Invalid_response msg) -> Error (Invalid_response msg) 220 + | Error H1_client.Too_many_redirects -> Error Too_many_redirects
+206
lib/codec.ml
··· 1 + (** CODEC module for type-safe serialization/deserialization. 2 + 3 + This module provides a signature for codecs that can encode and decode 4 + values to/from binary buffers. Users implement this signature with their 5 + preferred serialization library (yojson, jsonm, msgpck, etc.). 6 + 7 + The library itself does not depend on any specific serialization library. *) 8 + 9 + (** {1 CODEC Signature} *) 10 + 11 + (** Module signature for codecs. 12 + 13 + Codecs provide encoding/decoding between OCaml values and binary buffers. 14 + The Cstruct.t type is used for efficient zero-copy binary handling. *) 15 + module type CODEC = sig 16 + type 'a encoder 17 + (** Encoder for type 'a - converts values to binary format *) 18 + 19 + type 'a decoder 20 + (** Decoder for type 'a - parses values from binary format *) 21 + 22 + val content_type : string 23 + (** Content-Type header value for this codec. Examples: "application/json", 24 + "application/msgpack", "application/cbor" *) 25 + 26 + val encode : 'a encoder -> 'a -> (Cstruct.t, string) result 27 + (** Encode a value to a buffer. Returns Error with message on encoding 28 + failure. *) 29 + 30 + val decode : 'a decoder -> Cstruct.t -> ('a, string) result 31 + (** Decode a value from a buffer. Returns Error with message on decoding 32 + failure. *) 33 + 34 + val encode_stream : 'a encoder -> 'a -> Cstruct.t Seq.t option 35 + (** Optional: Streaming encode for large payloads. Returns None if streaming 36 + is not supported. *) 37 + 38 + val decode_stream : 39 + 'a decoder -> Cstruct.t Seq.t -> ('a, string) result option 40 + (** Optional: Streaming decode for large payloads. Returns None if streaming 41 + is not supported. *) 42 + end 43 + 44 + (** {1 With_codec Functor} *) 45 + 46 + (** Error type for codec operations *) 47 + type codec_error = 48 + | Encode_error of string 49 + | Decode_error of string 50 + | Unsupported_body_type 51 + 52 + let codec_error_to_string = function 53 + | Encode_error msg -> "Encode error: " ^ msg 54 + | Decode_error msg -> "Decode error: " ^ msg 55 + | Unsupported_body_type -> "Unsupported body type for codec operation" 56 + 57 + (** Functor that provides helpers for working with a specific codec. 58 + 59 + This functor generates request/response helpers that automatically set 60 + Content-Type headers and handle encoding/decoding. *) 61 + module With_codec (C : CODEC) = struct 62 + (** Encode a value to a string body *) 63 + let encode_body encoder value = 64 + match C.encode encoder value with 65 + | Ok buf -> Ok (Cstruct.to_string buf) 66 + | Error msg -> Error (Encode_error msg) 67 + 68 + (** Decode a string body to a value *) 69 + let decode_body decoder body_str = 70 + let buf = Cstruct.of_string body_str in 71 + match C.decode decoder buf with 72 + | Ok value -> Ok value 73 + | Error msg -> Error (Decode_error msg) 74 + 75 + (** Encode a value directly to Cstruct *) 76 + let encode encoder value = 77 + match C.encode encoder value with 78 + | Ok buf -> Ok buf 79 + | Error msg -> Error (Encode_error msg) 80 + 81 + (** Decode a Cstruct to a value *) 82 + let decode decoder buf = 83 + match C.decode decoder buf with 84 + | Ok value -> Ok value 85 + | Error msg -> Error (Decode_error msg) 86 + 87 + (** Get the content type for this codec *) 88 + let content_type = C.content_type 89 + end 90 + 91 + (** {1 Example Implementations} 92 + 93 + These are examples showing how to implement codecs. Users should implement 94 + their own codecs with their preferred libraries. 95 + 96 + {2 JSON Example} 97 + 98 + {[ 99 + module Json_codec : Hcs.Codec.CODEC = struct 100 + (* Using yojson as an example *) 101 + type 'a encoder = 'a -> Yojson.Safe.t 102 + type 'a decoder = Yojson.Safe.t -> ('a, string) result 103 + 104 + let content_type = "application/json" 105 + 106 + let encode enc value = 107 + try Ok (Cstruct.of_string (Yojson.Safe.to_string (enc value))) 108 + with exn -> Error (Printexc.to_string exn) 109 + 110 + let decode dec buf = 111 + try 112 + let json = Yojson.Safe.from_string (Cstruct.to_string buf) in 113 + dec json 114 + with exn -> Error (Printexc.to_string exn) 115 + 116 + let encode_stream _ _ = None 117 + let decode_stream _ _ = None 118 + end 119 + ]} 120 + 121 + {2 MessagePack Example} 122 + 123 + {[ 124 + module Msgpack_codec : Hcs.Codec.CODEC = struct 125 + type 'a encoder = 'a -> Msgpck.t 126 + type 'a decoder = Msgpck.t -> ('a, string) result 127 + 128 + let content_type = "application/msgpack" 129 + 130 + let encode enc value = 131 + try 132 + let packed = 133 + Msgpck.Bytes.to_string (Msgpck.Bytes.of_msgpck (enc value)) 134 + in 135 + Ok (Cstruct.of_string packed) 136 + with exn -> Error (Printexc.to_string exn) 137 + 138 + let decode dec buf = 139 + try 140 + match 141 + Msgpck.Bytes.read (Bytes.of_string (Cstruct.to_string buf)) 142 + with 143 + | Some (msgpack, _) -> dec msgpack 144 + | None -> Error "Failed to parse msgpack" 145 + with exn -> Error (Printexc.to_string exn) 146 + 147 + let encode_stream _ _ = None 148 + let decode_stream _ _ = None 149 + end 150 + ]} 151 + 152 + {2 Plain Text Codec} 153 + 154 + {[ 155 + module Text_codec : Hcs.Codec.CODEC = struct 156 + type 'a encoder = 'a -> string 157 + type 'a decoder = string -> ('a, string) result 158 + 159 + let content_type = "text/plain; charset=utf-8" 160 + let encode enc value = Ok (Cstruct.of_string (enc value)) 161 + let decode dec buf = dec (Cstruct.to_string buf) 162 + let encode_stream _ _ = None 163 + let decode_stream _ _ = None 164 + end 165 + ]} *) 166 + 167 + (** {1 Built-in Identity Codec} 168 + 169 + A simple pass-through codec for raw binary data. *) 170 + module Identity_codec : 171 + CODEC 172 + with type 'a encoder = 'a -> Cstruct.t 173 + and type 'a decoder = Cstruct.t -> ('a, string) result = struct 174 + type 'a encoder = 'a -> Cstruct.t 175 + type 'a decoder = Cstruct.t -> ('a, string) result 176 + 177 + let content_type = "application/octet-stream" 178 + 179 + let encode enc value = 180 + try Ok (enc value) with exn -> Error (Printexc.to_string exn) 181 + 182 + let decode dec buf = dec buf 183 + let encode_stream _ _ = None 184 + let decode_stream _ _ = None 185 + end 186 + 187 + (** {1 Built-in String Codec} 188 + 189 + A simple codec for UTF-8 text. *) 190 + module String_codec : 191 + CODEC 192 + with type 'a encoder = 'a -> string 193 + and type 'a decoder = string -> ('a, string) result = struct 194 + type 'a encoder = 'a -> string 195 + type 'a decoder = string -> ('a, string) result 196 + 197 + let content_type = "text/plain; charset=utf-8" 198 + 199 + let encode enc value = 200 + try Ok (Cstruct.of_string (enc value)) 201 + with exn -> Error (Printexc.to_string exn) 202 + 203 + let decode dec buf = dec (Cstruct.to_string buf) 204 + let encode_stream _ _ = None 205 + let decode_stream _ _ = None 206 + end
+166
lib/control.ml
··· 1 + (** Control flow combinators for HCS. 2 + 3 + This module provides runtime-agnostic control flow patterns: 4 + - Retry with backoff 5 + - Circuit breaker 6 + - Rate limiting state 7 + 8 + Time and sleep operations are passed as parameters to keep this module pure 9 + and compatible with any runtime (Eio, Lwt). *) 10 + 11 + (** Backoff strategies *) 12 + module Backoff = struct 13 + (** Constant delay between retries *) 14 + let constant delay _attempt = delay 15 + 16 + (** Exponential backoff: base * 2^attempt *) 17 + let exponential ~base attempt = base *. Float.pow 2.0 (Float.of_int attempt) 18 + 19 + (** Exponential backoff with jitter *) 20 + let exponential_jitter ~base ~jitter attempt = 21 + let delay = exponential ~base attempt in 22 + let jitter_amount = delay *. jitter *. (Random.float 2.0 -. 1.0) in 23 + max 0.0 (delay +. jitter_amount) 24 + 25 + (** Linear backoff: base * attempt *) 26 + let linear ~base attempt = base *. Float.of_int (attempt + 1) 27 + 28 + (** Capped backoff: applies cap to any strategy *) 29 + let capped ~max_delay strategy attempt = min max_delay (strategy attempt) 30 + end 31 + 32 + type retry_config = { 33 + max_attempts : int; 34 + backoff : int -> float; (** attempt -> delay in seconds *) 35 + should_retry : exn -> bool; (** Which exceptions to retry *) 36 + } 37 + (** Retry configuration *) 38 + 39 + let default_retry_config = 40 + { 41 + max_attempts = 3; 42 + backoff = Backoff.exponential ~base:1.0; 43 + should_retry = (fun _ -> true); 44 + } 45 + 46 + (** Retry with backoff. [sleep] is provided by the runtime (e.g., 47 + Eio.Time.sleep) *) 48 + let with_retry ~sleep ~config f = 49 + let rec loop attempt = 50 + match f () with 51 + | result -> Ok result 52 + | exception exn 53 + when config.should_retry exn && attempt < config.max_attempts -> 54 + let delay = config.backoff attempt in 55 + sleep delay; 56 + loop (attempt + 1) 57 + | exception exn -> Error exn 58 + in 59 + loop 0 60 + 61 + (** Circuit breaker states *) 62 + type circuit_state = 63 + | Closed (** Normal operation *) 64 + | Open of float (** Failing, timestamp when opened *) 65 + | Half_open (** Testing if recovered *) 66 + 67 + type circuit_breaker = { 68 + mutable state : circuit_state; 69 + mutable failure_count : int; 70 + failure_threshold : int; 71 + reset_timeout : float; (** Seconds before trying again *) 72 + mutable success_count : int; (** Successes needed to close from half-open *) 73 + success_threshold : int; 74 + } 75 + (** Circuit breaker *) 76 + 77 + (** Create a circuit breaker *) 78 + let create_breaker ?(failure_threshold = 5) ?(reset_timeout = 30.0) 79 + ?(success_threshold = 2) () = 80 + { 81 + state = Closed; 82 + failure_count = 0; 83 + failure_threshold; 84 + reset_timeout; 85 + success_count = 0; 86 + success_threshold; 87 + } 88 + 89 + (** Check if circuit allows requests. [now] is current time in seconds (provided 90 + by runtime) *) 91 + let circuit_allow breaker ~now = 92 + match breaker.state with 93 + | Closed -> true 94 + | Open opened_at -> 95 + if now -. opened_at >= breaker.reset_timeout then begin 96 + breaker.state <- Half_open; 97 + breaker.success_count <- 0; 98 + true 99 + end 100 + else false 101 + | Half_open -> true 102 + 103 + (** Record a successful call *) 104 + let circuit_success breaker = 105 + match breaker.state with 106 + | Closed -> breaker.failure_count <- 0 107 + | Half_open -> 108 + breaker.success_count <- breaker.success_count + 1; 109 + if breaker.success_count >= breaker.success_threshold then begin 110 + breaker.state <- Closed; 111 + breaker.failure_count <- 0 112 + end 113 + | Open _ -> () 114 + 115 + (** Record a failed call. [now] is current time (provided by runtime) *) 116 + let circuit_failure breaker ~now = 117 + match breaker.state with 118 + | Closed -> 119 + breaker.failure_count <- breaker.failure_count + 1; 120 + if breaker.failure_count >= breaker.failure_threshold then 121 + breaker.state <- Open now 122 + | Half_open -> breaker.state <- Open now 123 + | Open _ -> () 124 + 125 + (** Execute with circuit breaker. [now] provides current time, [on_open] called 126 + when circuit is open *) 127 + let with_circuit_breaker ~now ~on_open breaker f = 128 + if not (circuit_allow breaker ~now) then on_open () 129 + else 130 + match f () with 131 + | result -> 132 + circuit_success breaker; 133 + Ok result 134 + | exception exn -> 135 + circuit_failure breaker ~now; 136 + Error exn 137 + 138 + type rate_limiter = { 139 + mutable tokens : float; 140 + mutable last_update : float; 141 + rate : float; (** Tokens per second *) 142 + capacity : float; (** Max tokens *) 143 + } 144 + (** Rate limiter state (token bucket) *) 145 + 146 + (** Create a rate limiter *) 147 + let create_rate_limiter ~rate ~capacity = 148 + { tokens = capacity; last_update = 0.0; rate; capacity } 149 + 150 + (** Try to acquire a token. [now] is current time in seconds *) 151 + let rate_limit_acquire limiter ~now = 152 + (* Refill tokens based on elapsed time *) 153 + let elapsed = now -. limiter.last_update in 154 + limiter.tokens <- 155 + min limiter.capacity (limiter.tokens +. (elapsed *. limiter.rate)); 156 + limiter.last_update <- now; 157 + 158 + if limiter.tokens >= 1.0 then begin 159 + limiter.tokens <- limiter.tokens -. 1.0; 160 + true 161 + end 162 + else false 163 + 164 + (** Calculate wait time until a token is available *) 165 + let rate_limit_wait_time limiter = 166 + if limiter.tokens >= 1.0 then 0.0 else (1.0 -. limiter.tokens) /. limiter.rate
+4
lib/dune
··· 1 + (library 2 + (name hcs) 3 + (public_name hcs) 4 + (libraries eio eio.unix h1 h2 tls-eio tls ca-certs x509 ptime ptime.clock.os cstruct uri digestif base64 bigstringaf faraday))
+324
lib/h1_client.ml
··· 1 + (** HTTP/1.1 Client implementation using h1. 2 + 3 + This module provides HTTP/1.1 client functionality built on Eio. *) 4 + 5 + (** {1 Configuration} *) 6 + 7 + type config = { 8 + (* Timeouts *) 9 + connect_timeout : float; (** Connection timeout in seconds. Default: 30.0 *) 10 + read_timeout : float; (** Read timeout in seconds. Default: 30.0 *) 11 + write_timeout : float; (** Write timeout in seconds. Default: 30.0 *) 12 + (* Behavior *) 13 + follow_redirects : int option; 14 + (** Max redirects to follow. None = don't follow. Default: Some 10 *) 15 + (* Buffers *) 16 + buffer_size : int; (** Read buffer size. Default: 16384 *) 17 + max_response_body : int64 option; 18 + (** Max response body size. None = unlimited. Default: None *) 19 + (* TLS *) 20 + tls : Tls_config.Client.t; (** TLS configuration *) 21 + (* Headers *) 22 + default_headers : (string * string) list; 23 + (** Headers to add to every request *) 24 + } 25 + (** Client configuration *) 26 + 27 + let default_config = 28 + { 29 + connect_timeout = 30.0; 30 + read_timeout = 30.0; 31 + write_timeout = 30.0; 32 + follow_redirects = Some 10; 33 + buffer_size = 16384; 34 + max_response_body = None; 35 + tls = Tls_config.Client.default; 36 + default_headers = [ ("User-Agent", "hcs/0.1.0") ]; 37 + } 38 + 39 + (** {2 Config builders} *) 40 + 41 + let with_timeout timeout config = 42 + { config with connect_timeout = timeout; read_timeout = timeout } 43 + 44 + let with_connect_timeout timeout config = 45 + { config with connect_timeout = timeout } 46 + 47 + let with_read_timeout timeout config = { config with read_timeout = timeout } 48 + let with_write_timeout timeout config = { config with write_timeout = timeout } 49 + 50 + let with_redirects max_redirects config = 51 + { config with follow_redirects = Some max_redirects } 52 + 53 + let without_redirects config = { config with follow_redirects = None } 54 + let with_buffer_size size config = { config with buffer_size = size } 55 + 56 + let with_max_response_body max_size config = 57 + { config with max_response_body = Some max_size } 58 + 59 + let with_tls tls config = { config with tls } 60 + let with_insecure_tls config = { config with tls = Tls_config.Client.insecure } 61 + 62 + let with_default_header name value config = 63 + { config with default_headers = (name, value) :: config.default_headers } 64 + 65 + let with_default_headers headers config = 66 + { config with default_headers = headers @ config.default_headers } 67 + 68 + (** Error type for client operations *) 69 + type error = 70 + | Connection_failed of string 71 + | Tls_error of string 72 + | Timeout 73 + | Invalid_response of string 74 + | Too_many_redirects 75 + 76 + type response = { status : H1.Status.t; headers : H1.Headers.t; body : string } 77 + (** Response type *) 78 + 79 + (** Write all IOVecs to the flow *) 80 + let write_iovecs flow iovecs = 81 + let cstructs = 82 + List.map 83 + (fun iov -> 84 + Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off 85 + ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer) 86 + iovecs 87 + in 88 + Eio.Flow.write flow cstructs 89 + 90 + (** Read from flow into bigstring buffer *) 91 + let read_into_bigstring flow buf ~off ~len = 92 + let cs = Cstruct.of_bigarray ~off ~len buf in 93 + try 94 + let n = Eio.Flow.single_read flow cs in 95 + `Ok n 96 + with End_of_file -> `Eof 97 + 98 + (** Resolve hostname to IP address *) 99 + let resolve_host net host = 100 + let addrs = Eio.Net.getaddrinfo_stream net host in 101 + match addrs with addr :: _ -> Some addr | [] -> None 102 + 103 + (** Perform an HTTP/1.1 request on a connected flow *) 104 + let do_request ?(request_body = "") flow req = 105 + (* Set up response handling *) 106 + let response_received = Eio.Promise.create () in 107 + let body_buffer = Buffer.create 4096 in 108 + let resolved = ref false in 109 + 110 + let resolve_once result = 111 + if not !resolved then begin 112 + resolved := true; 113 + Eio.Promise.resolve (snd response_received) result 114 + end 115 + in 116 + 117 + let response_handler resp body_reader = 118 + let rec read_body () = 119 + H1.Body.Reader.schedule_read body_reader 120 + ~on_eof:(fun () -> 121 + let body = Buffer.contents body_buffer in 122 + resolve_once 123 + (Ok 124 + { 125 + status = resp.H1.Response.status; 126 + headers = resp.headers; 127 + body; 128 + })) 129 + ~on_read:(fun buf ~off ~len -> 130 + Buffer.add_string body_buffer (Bigstringaf.substring buf ~off ~len); 131 + read_body ()) 132 + in 133 + read_body () 134 + in 135 + 136 + let error_handler err = 137 + let msg = 138 + match err with 139 + | `Malformed_response s -> s 140 + | `Invalid_response_body_length _ -> "Invalid response body length" 141 + | `Exn exn -> Printexc.to_string exn 142 + in 143 + resolve_once (Error (Invalid_response msg)) 144 + in 145 + 146 + (* Create the client connection *) 147 + let body_writer, conn = 148 + H1.Client_connection.request req ~error_handler ~response_handler 149 + in 150 + 151 + (* Write request body if provided, then close *) 152 + if String.length request_body > 0 then begin 153 + H1.Body.Writer.write_string body_writer request_body; 154 + H1.Body.Writer.flush body_writer (fun () -> ()) 155 + end; 156 + H1.Body.Writer.close body_writer; 157 + 158 + (* Buffer for reading - track unconsumed bytes between reads *) 159 + let read_buffer_size = 0x4000 in 160 + let read_buffer = Bigstringaf.create read_buffer_size in 161 + let unconsumed = ref 0 in 162 + 163 + (* Connection loop - handle both read and write operations *) 164 + let rec loop () = 165 + (* First, handle any pending writes *) 166 + let write_done = 167 + match H1.Client_connection.next_write_operation conn with 168 + | `Write iovecs -> 169 + write_iovecs flow iovecs; 170 + let len = 171 + List.fold_left 172 + (fun acc iov -> acc + iov.Httpun_types.IOVec.len) 173 + 0 iovecs 174 + in 175 + H1.Client_connection.report_write_result conn (`Ok len); 176 + false 177 + | `Yield -> true 178 + | `Close _ -> true 179 + in 180 + 181 + (* Then handle reads *) 182 + let read_done = 183 + match H1.Client_connection.next_read_operation conn with 184 + | `Read -> ( 185 + let available = read_buffer_size - !unconsumed in 186 + match 187 + read_into_bigstring flow read_buffer ~off:!unconsumed ~len:available 188 + with 189 + | `Ok n -> 190 + let total = !unconsumed + n in 191 + let consumed = 192 + H1.Client_connection.read conn read_buffer ~off:0 ~len:total 193 + in 194 + (* Shift unconsumed bytes to start of buffer *) 195 + let remaining = total - consumed in 196 + if remaining > 0 && consumed > 0 then 197 + Bigstringaf.blit read_buffer ~src_off:consumed read_buffer 198 + ~dst_off:0 ~len:remaining; 199 + unconsumed := remaining; 200 + false 201 + | `Eof -> 202 + let _ = 203 + H1.Client_connection.read_eof conn read_buffer ~off:0 204 + ~len:!unconsumed 205 + in 206 + true) 207 + | `Close -> true 208 + in 209 + 210 + (* Continue until both read and write are done *) 211 + if not (write_done && read_done) then loop () 212 + in 213 + 214 + loop (); 215 + Eio.Promise.await (fst response_received) 216 + 217 + (** Wrap flow with TLS if needed, returns the flow to use *) 218 + let maybe_wrap_tls ~config ~host ~is_https flow = 219 + if is_https then 220 + match Tls_config.Client.to_tls_config config.tls ~host with 221 + | Error msg -> Error (Tls_error msg) 222 + | Ok tls_config -> ( 223 + try 224 + (* Parse host as domain name for SNI *) 225 + let host_domain = 226 + match Domain_name.of_string host with 227 + | Ok dn -> ( 228 + match Domain_name.host dn with 229 + | Ok h -> Some h 230 + | Error _ -> None) 231 + | Error _ -> None 232 + in 233 + let tls_flow = 234 + Tls_eio.client_of_flow tls_config ?host:host_domain flow 235 + in 236 + Ok (tls_flow :> Eio.Flow.two_way_ty Eio.Std.r) 237 + with 238 + | Tls_eio.Tls_failure failure -> 239 + Error (Tls_error (Tls_config.failure_to_string failure)) 240 + | exn -> Error (Tls_error (Printexc.to_string exn))) 241 + else Ok (flow :> Eio.Flow.two_way_ty Eio.Std.r) 242 + 243 + (** Perform a GET request *) 244 + let get ~sw ~net ~clock ?(config = default_config) url = 245 + let uri = Uri.of_string url in 246 + let scheme = Uri.scheme uri |> Option.value ~default:"http" in 247 + let is_https = String.equal scheme "https" in 248 + let host = Uri.host uri |> Option.value ~default:"localhost" in 249 + let default_port = if is_https then 443 else 80 in 250 + let port = Uri.port uri |> Option.value ~default:default_port in 251 + let path = Uri.path_and_query uri in 252 + let path = if path = "" then "/" else path in 253 + 254 + (* Wrap in timeout - use with_timeout_exn and catch the exception *) 255 + let total_timeout = config.connect_timeout +. config.read_timeout in 256 + try 257 + Eio.Time.with_timeout_exn clock total_timeout @@ fun () -> 258 + match resolve_host net host with 259 + | None -> Error (Connection_failed ("Cannot resolve host: " ^ host)) 260 + | Some addr_info -> ( 261 + let addr = 262 + match addr_info with 263 + | `Tcp (ip, _) -> `Tcp (ip, port) 264 + | `Unix _ -> failwith "Unix sockets not supported" 265 + in 266 + let tcp_flow = Eio.Net.connect ~sw net addr in 267 + 268 + (* Wrap with TLS if HTTPS *) 269 + match maybe_wrap_tls ~config ~host ~is_https tcp_flow with 270 + | Error e -> Error e 271 + | Ok flow -> 272 + let req = 273 + H1.Request.create 274 + ~headers: 275 + (H1.Headers.of_list 276 + [ ("Host", host); ("Connection", "keep-alive") ]) 277 + `GET path 278 + in 279 + do_request flow req) 280 + with Eio.Time.Timeout -> Error Timeout 281 + 282 + (** Perform a POST request with body *) 283 + let post ~sw ~net ~clock ?(config = default_config) url ~body:request_body = 284 + let uri = Uri.of_string url in 285 + let scheme = Uri.scheme uri |> Option.value ~default:"http" in 286 + let is_https = String.equal scheme "https" in 287 + let host = Uri.host uri |> Option.value ~default:"localhost" in 288 + let default_port = if is_https then 443 else 80 in 289 + let port = Uri.port uri |> Option.value ~default:default_port in 290 + let path = Uri.path_and_query uri in 291 + let path = if path = "" then "/" else path in 292 + 293 + (* Wrap in timeout *) 294 + let total_timeout = config.connect_timeout +. config.read_timeout in 295 + try 296 + Eio.Time.with_timeout_exn clock total_timeout @@ fun () -> 297 + match resolve_host net host with 298 + | None -> Error (Connection_failed ("Cannot resolve host: " ^ host)) 299 + | Some addr_info -> ( 300 + let addr = 301 + match addr_info with 302 + | `Tcp (ip, _) -> `Tcp (ip, port) 303 + | `Unix _ -> failwith "Unix sockets not supported" 304 + in 305 + let tcp_flow = Eio.Net.connect ~sw net addr in 306 + 307 + (* Wrap with TLS if HTTPS *) 308 + match maybe_wrap_tls ~config ~host ~is_https tcp_flow with 309 + | Error e -> Error e 310 + | Ok flow -> 311 + let content_length = String.length request_body in 312 + let req = 313 + H1.Request.create 314 + ~headers: 315 + (H1.Headers.of_list 316 + [ 317 + ("Host", host); 318 + ("Connection", "keep-alive"); 319 + ("Content-Length", string_of_int content_length); 320 + ]) 321 + `POST path 322 + in 323 + do_request ~request_body flow req) 324 + with Eio.Time.Timeout -> Error Timeout
+588
lib/h1_server.ml
··· 1 + (** HTTP/1.1 Server implementation using h1. 2 + 3 + This module provides HTTP/1.1 server functionality built on Eio. 4 + 5 + Features: 6 + - Lazy body reading: request body is not read until accessed 7 + - Zero-copy responses: bigstring bodies avoid copying 8 + - Streaming responses: write large responses without buffering 9 + - Connection pooling via buffer reuse *) 10 + 11 + open Eio.Std 12 + 13 + (** {1 Read Buffer Pool} *) 14 + 15 + module Read_buffer_pool : sig 16 + val acquire : unit -> Bigstringaf.t * Cstruct.t 17 + val release : Bigstringaf.t -> unit 18 + end = struct 19 + let buffer_size = 0x4000 20 + 21 + let acquire () = 22 + let buf = Bigstringaf.create buffer_size in 23 + (buf, Cstruct.of_bigarray buf ~off:0 ~len:buffer_size) 24 + 25 + let release _ = () 26 + end 27 + 28 + (** {1 Configuration} *) 29 + 30 + type config = { 31 + (* Network *) 32 + host : string; (** Bind address. Default: "0.0.0.0" *) 33 + port : int; (** Listen port. Default: 8080 *) 34 + backlog : int; (** Listen backlog. Default: 2048 *) 35 + max_connections : int; (** Max concurrent connections. Default: 10000 *) 36 + (* Parallelism *) 37 + domain_count : int; (** Number of domains (CPUs) to use. Default: 1 *) 38 + (* Timeouts *) 39 + read_timeout : float; (** Read timeout in seconds. Default: 60.0 *) 40 + write_timeout : float; (** Write timeout in seconds. Default: 60.0 *) 41 + idle_timeout : float; (** Idle connection timeout. Default: 120.0 *) 42 + request_timeout : float; (** Request processing timeout. Default: 30.0 *) 43 + (* Limits *) 44 + max_header_size : int; (** Max header size in bytes. Default: 8192 *) 45 + max_body_size : int64 option; 46 + (** Max body size. None = unlimited. Default: None *) 47 + (* Buffers *) 48 + buffer_size : int; (** Read buffer size. Default: 16384 *) 49 + (* TLS *) 50 + tls : Tls_config.Server.t option; (** TLS config. None = plain HTTP *) 51 + (* Socket options *) 52 + tcp_nodelay : bool; (** Enable TCP_NODELAY (disable Nagle). Default: true *) 53 + reuse_addr : bool; (** Enable SO_REUSEADDR. Default: true *) 54 + reuse_port : bool; 55 + (** Enable SO_REUSEPORT for multi-process scaling. Default: true *) 56 + } 57 + (** Server configuration *) 58 + 59 + let default_config = 60 + { 61 + host = "0.0.0.0"; 62 + port = 8080; 63 + backlog = 2048; 64 + max_connections = 10000; 65 + domain_count = 1; 66 + read_timeout = 60.0; 67 + write_timeout = 60.0; 68 + idle_timeout = 120.0; 69 + request_timeout = 30.0; 70 + max_header_size = 8192; 71 + max_body_size = None; 72 + buffer_size = 16384; 73 + tls = None; 74 + tcp_nodelay = true; 75 + reuse_addr = true; 76 + reuse_port = true; 77 + } 78 + 79 + (** {2 Config builders} *) 80 + 81 + let with_port port config = { config with port } 82 + let with_host host config = { config with host } 83 + let with_backlog backlog config = { config with backlog } 84 + let with_max_connections max config = { config with max_connections = max } 85 + let with_read_timeout timeout config = { config with read_timeout = timeout } 86 + let with_write_timeout timeout config = { config with write_timeout = timeout } 87 + let with_idle_timeout timeout config = { config with idle_timeout = timeout } 88 + 89 + let with_request_timeout timeout config = 90 + { config with request_timeout = timeout } 91 + 92 + let with_domain_count count config = { config with domain_count = count } 93 + let with_max_header_size size config = { config with max_header_size = size } 94 + let with_max_body_size size config = { config with max_body_size = Some size } 95 + let with_buffer_size size config = { config with buffer_size = size } 96 + let with_tls tls config = { config with tls = Some tls } 97 + let with_tcp_nodelay enabled config = { config with tcp_nodelay = enabled } 98 + let with_reuse_addr enabled config = { config with reuse_addr = enabled } 99 + let with_reuse_port enabled config = { config with reuse_port = enabled } 100 + 101 + (** {1 GC Tuning} *) 102 + 103 + type gc_config = { 104 + minor_heap_size : int; 105 + major_heap_increment : int; 106 + space_overhead : int; 107 + max_overhead : int; 108 + } 109 + 110 + let default_gc_config = 111 + { 112 + minor_heap_size = 64 * 1024 * 1024; 113 + major_heap_increment = 16 * 1024 * 1024; 114 + space_overhead = 120; 115 + max_overhead = 500; 116 + } 117 + 118 + let tune_gc ?(config = default_gc_config) () = 119 + let ctrl = Gc.get () in 120 + Gc.set 121 + { 122 + ctrl with 123 + minor_heap_size = config.minor_heap_size / (Sys.word_size / 8); 124 + major_heap_increment = config.major_heap_increment / (Sys.word_size / 8); 125 + space_overhead = config.space_overhead; 126 + max_overhead = config.max_overhead; 127 + } 128 + 129 + let gc_tuned = ref false 130 + 131 + let ensure_gc_tuned () = 132 + if not !gc_tuned then begin 133 + tune_gc (); 134 + gc_tuned := true 135 + end 136 + 137 + (** {1 Cached Date Header} *) 138 + 139 + module Date_cache : sig 140 + val get : unit -> string 141 + end = struct 142 + let day_names = [| "Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat" |] 143 + 144 + let month_names = 145 + [| 146 + "Jan"; 147 + "Feb"; 148 + "Mar"; 149 + "Apr"; 150 + "May"; 151 + "Jun"; 152 + "Jul"; 153 + "Aug"; 154 + "Sep"; 155 + "Oct"; 156 + "Nov"; 157 + "Dec"; 158 + |] 159 + 160 + let cached_date = Atomic.make "" 161 + let cached_time = Atomic.make 0. 162 + 163 + let format_date () = 164 + let t = Unix.gettimeofday () in 165 + let tm = Unix.gmtime t in 166 + Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" 167 + day_names.(tm.Unix.tm_wday) 168 + tm.Unix.tm_mday 169 + month_names.(tm.Unix.tm_mon) 170 + (1900 + tm.Unix.tm_year) tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 171 + 172 + let get () = 173 + let now = Unix.gettimeofday () in 174 + let last = Atomic.get cached_time in 175 + if now -. last >= 1.0 then begin 176 + let date = format_date () in 177 + Atomic.set cached_date date; 178 + Atomic.set cached_time now; 179 + date 180 + end 181 + else Atomic.get cached_date 182 + end 183 + 184 + (** {1 Cached Prebuilt Response} *) 185 + 186 + type cached_prebuilt = { 187 + base_response : H1.Response.t; 188 + body : Bigstringaf.t; 189 + cached_response : H1.Response.t Atomic.t; 190 + cached_second : int Atomic.t; 191 + } 192 + 193 + let make_cached_prebuilt h1_response body = 194 + let now = int_of_float (Unix.gettimeofday ()) in 195 + let headers = 196 + H1.Headers.add h1_response.H1.Response.headers "Date" (Date_cache.get ()) 197 + in 198 + let resp = H1.Response.create ~headers h1_response.H1.Response.status in 199 + { 200 + base_response = h1_response; 201 + body; 202 + cached_response = Atomic.make resp; 203 + cached_second = Atomic.make now; 204 + } 205 + 206 + let[@inline] get_cached_response cached = 207 + let now = int_of_float (Unix.gettimeofday ()) in 208 + let last = Atomic.get cached.cached_second in 209 + if now <> last then begin 210 + let headers = 211 + H1.Headers.add cached.base_response.H1.Response.headers "Date" 212 + (Date_cache.get ()) 213 + in 214 + let resp = 215 + H1.Response.create ~headers cached.base_response.H1.Response.status 216 + in 217 + Atomic.set cached.cached_response resp; 218 + Atomic.set cached.cached_second now 219 + end; 220 + Atomic.get cached.cached_response 221 + 222 + (** {1 Socket Helpers} *) 223 + 224 + let set_tcp_nodelay flow = 225 + match Eio_unix.Resource.fd_opt flow with 226 + | None -> () 227 + | Some fd -> 228 + Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd -> 229 + Unix.setsockopt unix_fd Unix.TCP_NODELAY true) 230 + 231 + (** {1 Body Types} *) 232 + 233 + type body_reader = { 234 + read : unit -> string; 235 + (** Read the entire body as a string. Can only be called once. *) 236 + read_stream : unit -> Cstruct.t option; 237 + (** Read body in chunks. Returns None when done. *) 238 + close : unit -> unit; (** Close the body reader without reading. *) 239 + } 240 + (** Lazy body reader - body is only read when [read] is called *) 241 + 242 + (** Response body - supports string, bigstring, streaming, and pre-built *) 243 + type response_body = 244 + | Body_string of string 245 + | Body_bigstring of Bigstringaf.t 246 + | Body_prebuilt of { h1_response : H1.Response.t; body : Bigstringaf.t } 247 + | Body_cached_prebuilt of cached_prebuilt 248 + | Body_stream of { 249 + content_length : int64 option; 250 + next : unit -> Cstruct.t option; 251 + } 252 + 253 + (** {1 Request and Response Types} *) 254 + 255 + type request = { 256 + meth : H1.Method.t; 257 + target : string; 258 + headers : H1.Headers.t; 259 + body_reader : body_reader; 260 + (** Lazy body reader - call [body_reader.read ()] to get the body *) 261 + } 262 + (** Request type with lazy body reading *) 263 + 264 + (** Read the request body as a string (convenience function) *) 265 + let read_body req = req.body_reader.read () 266 + 267 + (** Read the request body as a stream (for large bodies) *) 268 + let read_body_stream req = req.body_reader.read_stream 269 + 270 + (** Close the request body without reading (for ignored bodies) *) 271 + let close_body req = req.body_reader.close () 272 + 273 + type response = { 274 + status : H1.Status.t; 275 + headers : (string * string) list; 276 + response_body : response_body; 277 + } 278 + (** Response type with optimized body variants *) 279 + 280 + type handler = request -> response 281 + (** Handler type *) 282 + 283 + let respond ?(status = `OK) ?(headers = []) body = 284 + { status; headers; response_body = Body_string body } 285 + 286 + let respond_bigstring ?(status = `OK) ?(headers = []) bstr = 287 + { status; headers; response_body = Body_bigstring bstr } 288 + 289 + let respond_stream ?(status = `OK) ?(headers = []) ?content_length next = 290 + { status; headers; response_body = Body_stream { content_length; next } } 291 + 292 + let respond_prebuilt h1_response body = 293 + { 294 + status = `OK; 295 + headers = []; 296 + response_body = Body_prebuilt { h1_response; body }; 297 + } 298 + 299 + let respond_cached_prebuilt cached = 300 + { status = `OK; headers = []; response_body = Body_cached_prebuilt cached } 301 + 302 + type static_response = response 303 + 304 + let make_static_response cached : static_response = 305 + { status = `OK; headers = []; response_body = Body_cached_prebuilt cached } 306 + 307 + let[@inline always] respond_static (r : static_response) : response = r 308 + let make_h1_headers headers_list = H1.Headers.of_list headers_list 309 + 310 + let make_h1_response ?(status = `OK) headers = 311 + H1.Response.create ~headers status 312 + 313 + (** {1 Internal Helpers} *) 314 + 315 + (** Write all IOVecs to the flow - optimized version *) 316 + let write_iovecs flow iovecs = 317 + match iovecs with 318 + | [] -> () 319 + | [ iov ] -> 320 + (* Fast path for single iovec - common case *) 321 + let cs = 322 + Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off 323 + ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer 324 + in 325 + Eio.Flow.write flow [ cs ] 326 + | _ -> 327 + (* Multiple iovecs - build list directly *) 328 + let cstructs = 329 + List.map 330 + (fun iov -> 331 + Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off 332 + ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer) 333 + iovecs 334 + in 335 + Eio.Flow.write flow cstructs 336 + 337 + (** Check if method typically has no body *) 338 + let[@inline] method_has_no_body = function 339 + | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE -> true 340 + | `POST | `PUT | `PATCH | `Other _ -> false 341 + 342 + (** Create a lazy body reader from H1's body reader *) 343 + let make_body_reader (h1_body : H1.Body.Reader.t) : body_reader = 344 + let read_called = ref false in 345 + let closed = ref false in 346 + let chunks = ref [] in 347 + let done_promise, done_resolver = Eio.Promise.create () in 348 + 349 + (* Start reading in background - will block until first chunk or EOF *) 350 + let rec schedule_read () = 351 + if not !closed then 352 + H1.Body.Reader.schedule_read h1_body 353 + ~on_eof:(fun () -> Eio.Promise.resolve done_resolver ()) 354 + ~on_read:(fun buf ~off ~len -> 355 + (* Store chunk as Cstruct to avoid copying bigstring *) 356 + let chunk = Cstruct.of_bigarray ~off ~len buf |> Cstruct.to_string in 357 + chunks := chunk :: !chunks; 358 + schedule_read ()) 359 + in 360 + 361 + { 362 + read = 363 + (fun () -> 364 + if !read_called then failwith "Body already read" 365 + else begin 366 + read_called := true; 367 + if !closed then "" 368 + else begin 369 + schedule_read (); 370 + Eio.Promise.await done_promise; 371 + String.concat "" (List.rev !chunks) 372 + end 373 + end); 374 + read_stream = 375 + (fun () -> 376 + (* For streaming, we read one chunk at a time *) 377 + if !closed then None 378 + else begin 379 + let chunk_promise, chunk_resolver = Eio.Promise.create () in 380 + let got_chunk = ref false in 381 + H1.Body.Reader.schedule_read h1_body 382 + ~on_eof:(fun () -> 383 + if not !got_chunk then Eio.Promise.resolve chunk_resolver None) 384 + ~on_read:(fun buf ~off ~len -> 385 + got_chunk := true; 386 + let cs = Cstruct.of_bigarray ~off ~len buf in 387 + Eio.Promise.resolve chunk_resolver (Some cs)); 388 + Eio.Promise.await chunk_promise 389 + end); 390 + close = 391 + (fun () -> 392 + if not !closed then begin 393 + closed := true; 394 + H1.Body.Reader.close h1_body 395 + end); 396 + } 397 + 398 + (** Create a no-op body reader for methods without bodies *) 399 + let empty_body_reader () : body_reader = 400 + { 401 + read = (fun () -> ""); 402 + read_stream = (fun () -> None); 403 + close = (fun () -> ()); 404 + } 405 + 406 + let handle_connection handler flow = 407 + let read_buffer, read_cstruct = Read_buffer_pool.acquire () in 408 + Fun.protect ~finally:(fun () -> Read_buffer_pool.release read_buffer) 409 + @@ fun () -> 410 + let request_handler reqd = 411 + let req = H1.Reqd.request reqd in 412 + let h1_body = H1.Reqd.request_body reqd in 413 + 414 + (* Create lazy body reader *) 415 + let body_reader = 416 + if method_has_no_body req.H1.Request.meth then begin 417 + H1.Body.Reader.close h1_body; 418 + empty_body_reader () 419 + end 420 + else make_body_reader h1_body 421 + in 422 + 423 + (* Build request with lazy body *) 424 + let request = 425 + { 426 + meth = req.H1.Request.meth; 427 + target = req.target; 428 + headers = req.headers; 429 + body_reader; 430 + } 431 + in 432 + 433 + (* Call user handler *) 434 + let response = handler request in 435 + 436 + (* Ensure body is closed if not read *) 437 + body_reader.close (); 438 + 439 + let date_header = ("Date", Date_cache.get ()) in 440 + let filter_reserved headers = 441 + List.filter 442 + (fun (k, _) -> 443 + let lk = String.lowercase_ascii k in 444 + lk <> "content-length" && lk <> "date") 445 + headers 446 + in 447 + 448 + match response.response_body with 449 + | Body_string body -> 450 + let content_length = String.length body in 451 + let headers = 452 + H1.Headers.of_list 453 + (date_header 454 + :: ("Content-Length", string_of_int content_length) 455 + :: filter_reserved response.headers) 456 + in 457 + let resp = H1.Response.create ~headers response.status in 458 + H1.Reqd.respond_with_string reqd resp body 459 + | Body_bigstring bstr -> 460 + let content_length = Bigstringaf.length bstr in 461 + let headers = 462 + H1.Headers.of_list 463 + (date_header 464 + :: ("Content-Length", string_of_int content_length) 465 + :: filter_reserved response.headers) 466 + in 467 + let resp = H1.Response.create ~headers response.status in 468 + H1.Reqd.respond_with_bigstring reqd resp bstr 469 + | Body_prebuilt { h1_response; body } -> 470 + let headers = 471 + H1.Headers.add h1_response.headers "Date" (Date_cache.get ()) 472 + in 473 + let resp = { h1_response with H1.Response.headers } in 474 + H1.Reqd.respond_with_bigstring reqd resp body 475 + | Body_cached_prebuilt cached -> 476 + let resp = get_cached_response cached in 477 + H1.Reqd.respond_with_bigstring reqd resp cached.body 478 + | Body_stream { content_length; next } -> 479 + let headers = 480 + match content_length with 481 + | Some len -> 482 + H1.Headers.of_list 483 + (date_header 484 + :: ("Content-Length", Int64.to_string len) 485 + :: filter_reserved response.headers) 486 + | None -> 487 + H1.Headers.of_list 488 + (date_header 489 + :: ("Transfer-Encoding", "chunked") 490 + :: filter_reserved response.headers) 491 + in 492 + let resp = H1.Response.create ~headers response.status in 493 + let body_writer = H1.Reqd.respond_with_streaming reqd resp in 494 + (* Write chunks *) 495 + let rec write_chunks () = 496 + match next () with 497 + | None -> H1.Body.Writer.close body_writer 498 + | Some cs -> 499 + H1.Body.Writer.write_bigstring body_writer ~off:0 500 + ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs); 501 + write_chunks () 502 + in 503 + write_chunks () 504 + in 505 + 506 + let error_handler ?request:_ _error start_response = 507 + let resp_body = start_response H1.Headers.empty in 508 + H1.Body.Writer.write_string resp_body "Internal Server Error"; 509 + H1.Body.Writer.close resp_body 510 + in 511 + 512 + let conn = H1.Server_connection.create ~error_handler request_handler in 513 + 514 + let shutdown = ref false in 515 + 516 + let rec read_loop () = 517 + if not !shutdown then 518 + match H1.Server_connection.next_read_operation conn with 519 + | `Read -> ( 520 + try 521 + let n = Eio.Flow.single_read flow read_cstruct in 522 + let _ = H1.Server_connection.read conn read_buffer ~off:0 ~len:n in 523 + read_loop () 524 + with End_of_file -> 525 + let _ = 526 + H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0 527 + in 528 + shutdown := true) 529 + | `Yield -> H1.Server_connection.yield_reader conn read_loop 530 + | `Close | `Upgrade -> shutdown := true 531 + in 532 + 533 + let rec write_loop () = 534 + if not !shutdown then 535 + match H1.Server_connection.next_write_operation conn with 536 + | `Write iovecs -> 537 + write_iovecs flow iovecs; 538 + let len = 539 + List.fold_left 540 + (fun acc iov -> acc + iov.Httpun_types.IOVec.len) 541 + 0 iovecs 542 + in 543 + H1.Server_connection.report_write_result conn (`Ok len); 544 + write_loop () 545 + | `Yield -> H1.Server_connection.yield_writer conn write_loop 546 + | `Upgrade -> shutdown := true 547 + | `Close _ -> shutdown := true 548 + in 549 + 550 + Fiber.both read_loop write_loop 551 + 552 + let run ~sw ~net ?(config = default_config) handler = 553 + ensure_gc_tuned (); 554 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 555 + let socket = 556 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 557 + ~reuse_port:config.reuse_port net addr 558 + in 559 + traceln "Server listening on port %d" config.port; 560 + let connection_handler flow _addr = 561 + if config.tcp_nodelay then set_tcp_nodelay flow; 562 + handle_connection handler flow 563 + in 564 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 565 + Eio.Net.run_server socket connection_handler 566 + ~max_connections:config.max_connections ~on_error 567 + 568 + let run_parallel ~sw ~net ~domain_mgr ?(config = default_config) handler = 569 + ensure_gc_tuned (); 570 + let domain_count = max 1 config.domain_count in 571 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 572 + let socket = 573 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 574 + ~reuse_port:config.reuse_port net addr 575 + in 576 + traceln "Server listening on port %d (%d domains)" config.port domain_count; 577 + let connection_handler flow _addr = 578 + if config.tcp_nodelay then set_tcp_nodelay flow; 579 + handle_connection handler flow 580 + in 581 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 582 + if domain_count <= 1 then 583 + Eio.Net.run_server socket connection_handler 584 + ~max_connections:config.max_connections ~on_error 585 + else 586 + Eio.Net.run_server socket connection_handler 587 + ~max_connections:config.max_connections ~on_error 588 + ~additional_domains:(domain_mgr, domain_count - 1)
+316
lib/h2_client.ml
··· 1 + (** HTTP/2 Client implementation using h2. 2 + 3 + This module provides HTTP/2 client functionality built on the h2 library 4 + with Eio for structured concurrency. *) 5 + 6 + open Eio.Std 7 + 8 + (** {1 Types} *) 9 + 10 + type error = 11 + | Connection_failed of string 12 + | Tls_error of string 13 + | Protocol_error of string 14 + | Timeout 15 + | Invalid_response of string 16 + 17 + type response = { status : H2.Status.t; headers : H2.Headers.t; body : string } 18 + 19 + (** {1 Internal helpers} *) 20 + 21 + (** Write IOVecs to the flow, returns bytes written or `Closed *) 22 + let writev flow iovecs = 23 + let lenv, cstructs = 24 + List.fold_left_map 25 + (fun acc iov -> 26 + let len = iov.H2.IOVec.len in 27 + let cs = 28 + Cstruct.of_bigarray ~off:iov.H2.IOVec.off ~len iov.H2.IOVec.buffer 29 + in 30 + (acc + len, cs)) 31 + 0 iovecs 32 + in 33 + match Eio.Flow.write flow cstructs with 34 + | () -> `Ok lenv 35 + | exception End_of_file -> `Closed 36 + 37 + (** Shutdown flow in specified direction *) 38 + let shutdown flow cmd = 39 + try Eio.Flow.shutdown flow cmd with 40 + | Unix.Unix_error (Unix.ENOTCONN, _, _) -> () 41 + | Eio.Io (Eio.Net.E (Eio.Net.Connection_reset _), _) -> () 42 + 43 + (** Simple read buffer that tracks unconsumed data. This is necessary because 44 + angstrom (used by h2) tracks uncommitted bytes and will fail if the buffer 45 + appears to shrink. Based on Gluten.Buffer pattern. *) 46 + module Read_buffer : sig 47 + type t 48 + 49 + val create : int -> t 50 + 51 + val read : t -> _ Eio.Flow.source -> int 52 + (** Read data from flow into the buffer. Returns bytes read. *) 53 + 54 + val get : t -> f:(Bigstringaf.t -> off:int -> len:int -> int) -> int 55 + (** Feed buffered data to consumer. Returns bytes consumed. *) 56 + end = struct 57 + type t = { 58 + buffer : Bigstringaf.t; 59 + mutable off : int; (* Start of unconsumed data *) 60 + mutable len : int; (* Length of unconsumed data *) 61 + cap : int; 62 + } 63 + 64 + let create size = 65 + { buffer = Bigstringaf.create size; off = 0; len = 0; cap = size } 66 + 67 + let compress t = 68 + if t.len = 0 then begin 69 + t.off <- 0; 70 + t.len <- 0 71 + end 72 + else if t.off > 0 then begin 73 + Bigstringaf.blit t.buffer ~src_off:t.off t.buffer ~dst_off:0 ~len:t.len; 74 + t.off <- 0 75 + end 76 + 77 + let read t flow = 78 + compress t; 79 + let off = t.off + t.len in 80 + let available = t.cap - t.len - t.off in 81 + if available > 0 then begin 82 + let cs = Cstruct.of_bigarray t.buffer ~off ~len:available in 83 + let n = Eio.Flow.single_read flow cs in 84 + t.len <- t.len + n; 85 + n 86 + end 87 + else 0 88 + 89 + let get t ~f = 90 + let n = f t.buffer ~off:t.off ~len:t.len in 91 + t.off <- t.off + n; 92 + t.len <- t.len - n; 93 + if t.len = 0 then t.off <- 0; 94 + n 95 + end 96 + 97 + (** {1 Connection handling} *) 98 + 99 + (** Perform an HTTP/2 request on a connected flow. 100 + 101 + HTTP/2 requires interleaved read/write handling because: 1. The server may 102 + send SETTINGS frames immediately after connection 2. We need to send the 103 + client preface and request while receiving 3. Flow control (WINDOW_UPDATE) 104 + must be processed promptly 105 + 106 + Note: We use a single-threaded interleaved approach rather than Fiber.both 107 + because TLS flows are not safe for concurrent read/write operations. *) 108 + let do_request flow req = 109 + let response_received = Eio.Promise.create () in 110 + let body_buffer = Buffer.create 4096 in 111 + let resolved = ref false in 112 + 113 + let resolve_once result = 114 + if not !resolved then begin 115 + resolved := true; 116 + Eio.Promise.resolve (snd response_received) result 117 + end 118 + in 119 + 120 + let response_handler resp body_reader = 121 + let rec read_body () = 122 + H2.Body.Reader.schedule_read body_reader 123 + ~on_eof:(fun () -> 124 + let body = Buffer.contents body_buffer in 125 + resolve_once 126 + (Ok 127 + { 128 + status = resp.H2.Response.status; 129 + headers = resp.headers; 130 + body; 131 + })) 132 + ~on_read:(fun buf ~off ~len -> 133 + Buffer.add_string body_buffer (Bigstringaf.substring buf ~off ~len); 134 + read_body ()) 135 + in 136 + read_body () 137 + in 138 + 139 + let error_handler err = 140 + let msg = 141 + match err with 142 + | `Malformed_response s -> "Malformed response: " ^ s 143 + | `Invalid_response_body_length _ -> "Invalid response body length" 144 + | `Protocol_error (code, msg) -> 145 + Printf.sprintf "Protocol error %s: %s" 146 + (H2.Error_code.to_string code) 147 + msg 148 + | `Exn exn -> Printexc.to_string exn 149 + in 150 + resolve_once (Error (Invalid_response msg)) 151 + in 152 + 153 + (* Create the HTTP/2 client connection *) 154 + let conn = H2.Client_connection.create ~error_handler () in 155 + 156 + (* We'll queue the request after writing the connection preface *) 157 + let request_sent = ref false in 158 + let send_request () = 159 + if not !request_sent then begin 160 + request_sent := true; 161 + (* Send the request and immediately close the body to signal END_STREAM. 162 + For GET requests, the body writer should be closed immediately which 163 + sets the END_STREAM flag on the HEADERS frame. *) 164 + let body_writer = 165 + H2.Client_connection.request conn req 166 + ~flush_headers_immediately:true (* Send HEADERS right away *) 167 + ~error_handler ~response_handler 168 + in 169 + (* Close immediately for requests with no body *) 170 + H2.Body.Writer.close body_writer 171 + end 172 + in 173 + 174 + (* Buffer for reading - tracks unconsumed data properly *) 175 + let read_buffer = Read_buffer.create 0x4000 in 176 + 177 + (* Flag to track if we've sent the request *) 178 + let request_queued = ref false in 179 + 180 + (* Read loop - runs concurrently with write loop. 181 + Uses the gluten pattern: read from network first, then feed to h2. *) 182 + let read_loop () = 183 + let rec read_loop_step () = 184 + match H2.Client_connection.next_read_operation conn with 185 + | `Read -> ( 186 + match Read_buffer.read read_buffer flow with 187 + | _n -> 188 + (* Feed buffered data to h2 - separate from network read *) 189 + let _consumed = 190 + Read_buffer.get read_buffer ~f:(fun buf ~off ~len -> 191 + H2.Client_connection.read conn buf ~off ~len) 192 + in 193 + read_loop_step () 194 + | exception End_of_file -> 195 + (* Feed any remaining buffered data as EOF *) 196 + let _ = 197 + Read_buffer.get read_buffer ~f:(fun buf ~off ~len -> 198 + H2.Client_connection.read_eof conn buf ~off ~len) 199 + in 200 + ()) 201 + | `Yield -> 202 + let p, u = Eio.Promise.create () in 203 + H2.Client_connection.yield_reader conn (fun () -> 204 + Eio.Promise.resolve u ()); 205 + Eio.Promise.await p; 206 + read_loop_step () 207 + | `Close -> shutdown flow `Receive 208 + in 209 + try read_loop_step () with exn -> H2.Client_connection.report_exn conn exn 210 + in 211 + 212 + (* Write loop - runs concurrently with read loop *) 213 + let write_loop () = 214 + let rec loop () = 215 + match H2.Client_connection.next_write_operation conn with 216 + | `Write iovecs -> ( 217 + match writev flow iovecs with 218 + | `Ok len -> 219 + H2.Client_connection.report_write_result conn (`Ok len); 220 + (* After first write (connection preface), queue the request *) 221 + if not !request_queued then begin 222 + request_queued := true; 223 + send_request () 224 + end; 225 + loop () 226 + | `Closed -> H2.Client_connection.report_write_result conn `Closed) 227 + | `Yield -> 228 + let p, u = Eio.Promise.create () in 229 + H2.Client_connection.yield_writer conn (fun () -> 230 + Eio.Promise.resolve u ()); 231 + Eio.Promise.await p; 232 + loop () 233 + | `Close _ -> shutdown flow `Send 234 + in 235 + try loop () with exn -> H2.Client_connection.report_exn conn exn 236 + in 237 + 238 + (* Run read and write loops concurrently until response is received. 239 + For a single request, we use Fiber.any to exit when response arrives. *) 240 + let io_loops () = 241 + Eio.Fiber.both read_loop write_loop; 242 + (* If loops exit naturally (connection closed), return error *) 243 + Error (Protocol_error "Connection closed before response") 244 + in 245 + 246 + let wait_for_response () = 247 + let result = Eio.Promise.await (fst response_received) in 248 + (* After getting response, shutdown the connection to exit loops *) 249 + H2.Client_connection.shutdown conn; 250 + result 251 + in 252 + 253 + Eio.Fiber.any [ io_loops; wait_for_response ] 254 + 255 + (** {1 Public API} *) 256 + 257 + (** Perform an HTTP/2 GET request. Note: HTTP/2 requires TLS with ALPN 258 + negotiation in most cases. *) 259 + let get ~sw ~net ~clock:_ ?config:_ url = 260 + let uri = Uri.of_string url in 261 + let scheme = Uri.scheme uri |> Option.value ~default:"https" in 262 + let is_https = String.equal scheme "https" in 263 + let host = Uri.host uri |> Option.value ~default:"localhost" in 264 + let default_port = if is_https then 443 else 80 in 265 + let port = Uri.port uri |> Option.value ~default:default_port in 266 + let path = Uri.path_and_query uri in 267 + let path = if path = "" then "/" else path in 268 + 269 + (* Resolve and connect *) 270 + let addrs = Eio.Net.getaddrinfo_stream net host in 271 + match addrs with 272 + | [] -> Error (Connection_failed ("Cannot resolve host: " ^ host)) 273 + | addr_info :: _ -> 274 + let addr = 275 + match addr_info with 276 + | `Tcp (ip, _) -> `Tcp (ip, port) 277 + | `Unix _ -> failwith "Unix sockets not supported" 278 + in 279 + let tcp_flow = Eio.Net.connect ~sw net addr in 280 + 281 + (* HTTP/2 requires TLS with ALPN advertising h2 *) 282 + if is_https then 283 + (* Force h2 ALPN for HTTP/2 *) 284 + let h2_tls = Tls_config.Client.h2 in 285 + match Tls_config.Client.to_tls_config h2_tls ~host with 286 + | Error msg -> Error (Tls_error msg) 287 + | Ok tls_config -> ( 288 + try 289 + let host_domain = 290 + match Domain_name.of_string host with 291 + | Ok dn -> ( 292 + match Domain_name.host dn with 293 + | Ok h -> Some h 294 + | Error _ -> None) 295 + | Error _ -> None 296 + in 297 + let tls_flow = 298 + Tls_eio.client_of_flow tls_config ?host:host_domain tcp_flow 299 + in 300 + let flow = (tls_flow :> Eio.Flow.two_way_ty r) in 301 + 302 + (* Build HTTP/2 request - h2 handles pseudo-headers automatically *) 303 + let headers = H2.Headers.of_list [ (":authority", host) ] in 304 + let req = H2.Request.create ~headers ~scheme `GET path in 305 + 306 + do_request flow req 307 + with 308 + | Tls_eio.Tls_failure failure -> 309 + Error (Tls_error (Tls_config.failure_to_string failure)) 310 + | exn -> Error (Tls_error (Printexc.to_string exn))) 311 + else 312 + (* HTTP/2 over cleartext (h2c) - less common *) 313 + let flow = (tcp_flow :> Eio.Flow.two_way_ty r) in 314 + let headers = H2.Headers.of_list [ (":authority", host) ] in 315 + let req = H2.Request.create ~headers ~scheme `GET path in 316 + do_request flow req
+280
lib/h2_server.ml
··· 1 + (** HTTP/2 Server implementation using h2. 2 + 3 + This module provides HTTP/2 server functionality built on the h2 library 4 + with Eio for structured concurrency. *) 5 + 6 + open Eio.Std 7 + 8 + (** {1 Types} *) 9 + 10 + type request = { 11 + meth : H2.Method.t; 12 + target : string; 13 + headers : H2.Headers.t; 14 + body : string; 15 + } 16 + (** Request type exposed to handlers - same as Server.request for compatibility 17 + *) 18 + 19 + (** {1 Response Types} *) 20 + 21 + (** Response body - supports string, bigstring, streaming, and pre-built *) 22 + type response_body = 23 + | Body_string of string 24 + | Body_bigstring of Bigstringaf.t 25 + | Body_prebuilt of { h2_response : H2.Response.t; body : Bigstringaf.t } 26 + | Body_stream of { 27 + content_length : int64 option; 28 + next : unit -> Cstruct.t option; 29 + } 30 + 31 + type response = { 32 + status : H2.Status.t; 33 + headers : (string * string) list; 34 + response_body : response_body; 35 + } 36 + (** Response type with body variants *) 37 + 38 + type handler = request -> response 39 + 40 + (** {2 Optimized Response Constructors} *) 41 + 42 + let respond_opt ?(status = `OK) ?(headers = []) body = 43 + { status; headers; response_body = Body_string body } 44 + 45 + let respond_bigstring ?(status = `OK) ?(headers = []) bstr = 46 + { status; headers; response_body = Body_bigstring bstr } 47 + 48 + let respond_stream ?(status = `OK) ?(headers = []) ?content_length next = 49 + { status; headers; response_body = Body_stream { content_length; next } } 50 + 51 + let respond_prebuilt h2_response body = 52 + { 53 + status = `OK; 54 + headers = []; 55 + response_body = Body_prebuilt { h2_response; body }; 56 + } 57 + 58 + let make_h2_headers headers_list = H2.Headers.of_list headers_list 59 + 60 + let make_h2_response ?(status = `OK) headers = 61 + H2.Response.create ~headers status 62 + 63 + (** {1 Internal helpers} *) 64 + 65 + let set_tcp_nodelay flow = 66 + match Eio_unix.Resource.fd_opt flow with 67 + | None -> () 68 + | Some fd -> 69 + Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd -> 70 + Unix.setsockopt unix_fd Unix.TCP_NODELAY true) 71 + 72 + let write_iovecs flow iovecs = 73 + let cstructs = 74 + List.map 75 + (fun iov -> 76 + Cstruct.of_bigarray ~off:iov.H2.IOVec.off ~len:iov.H2.IOVec.len 77 + iov.H2.IOVec.buffer) 78 + iovecs 79 + in 80 + Eio.Flow.write flow cstructs 81 + 82 + (** {1 Connection handling} *) 83 + 84 + let handle_connection handler flow = 85 + let read_buffer_size = 0x4000 in 86 + let read_buffer = Bigstringaf.create read_buffer_size in 87 + 88 + let request_handler reqd = 89 + let req = H2.Reqd.request reqd in 90 + let body_reader = H2.Reqd.request_body reqd in 91 + 92 + let body = 93 + match req.meth with 94 + | `GET | `HEAD -> 95 + H2.Body.Reader.close body_reader; 96 + "" 97 + | `POST | `PUT | `DELETE | `CONNECT | `OPTIONS | `TRACE | `Other _ -> 98 + let body_buffer = Buffer.create 4096 in 99 + let body_done_promise, body_done_resolver = Eio.Promise.create () in 100 + let rec read_body () = 101 + H2.Body.Reader.schedule_read body_reader 102 + ~on_eof:(fun () -> Eio.Promise.resolve body_done_resolver ()) 103 + ~on_read:(fun buf ~off ~len -> 104 + Buffer.add_string body_buffer 105 + (Bigstringaf.substring buf ~off ~len); 106 + read_body ()) 107 + in 108 + read_body (); 109 + Eio.Promise.await body_done_promise; 110 + Buffer.contents body_buffer 111 + in 112 + 113 + let target = 114 + match H2.Headers.get req.headers ":path" with Some p -> p | None -> "/" 115 + in 116 + 117 + let request = { meth = req.meth; target; headers = req.headers; body } in 118 + let response = handler request in 119 + 120 + match response.response_body with 121 + | Body_string body -> 122 + let headers = 123 + H2.Headers.of_list 124 + (("content-length", string_of_int (String.length body)) 125 + :: response.headers) 126 + in 127 + let resp = H2.Response.create ~headers response.status in 128 + H2.Reqd.respond_with_string reqd resp body 129 + | Body_bigstring bstr -> 130 + let headers = 131 + H2.Headers.of_list 132 + (("content-length", string_of_int (Bigstringaf.length bstr)) 133 + :: response.headers) 134 + in 135 + let resp = H2.Response.create ~headers response.status in 136 + H2.Reqd.respond_with_bigstring reqd resp bstr 137 + | Body_prebuilt { h2_response; body } -> 138 + H2.Reqd.respond_with_bigstring reqd h2_response body 139 + | Body_stream { content_length; next } -> 140 + let headers = 141 + match content_length with 142 + | Some len -> 143 + H2.Headers.of_list 144 + (("content-length", Int64.to_string len) :: response.headers) 145 + | None -> H2.Headers.of_list response.headers 146 + in 147 + let resp = H2.Response.create ~headers response.status in 148 + let body_writer = H2.Reqd.respond_with_streaming reqd resp in 149 + let rec write_chunks () = 150 + match next () with 151 + | None -> H2.Body.Writer.close body_writer 152 + | Some cs -> 153 + H2.Body.Writer.write_bigstring body_writer ~off:0 154 + ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs); 155 + write_chunks () 156 + in 157 + write_chunks () 158 + in 159 + 160 + let error_handler ?request:_ _error start_response = 161 + let resp_body = start_response H2.Headers.empty in 162 + H2.Body.Writer.write_string resp_body "Internal Server Error"; 163 + H2.Body.Writer.close resp_body 164 + in 165 + 166 + let conn = H2.Server_connection.create ~error_handler request_handler in 167 + 168 + let shutdown = ref false in 169 + 170 + let read_loop () = 171 + let rec loop () = 172 + if not !shutdown then 173 + match H2.Server_connection.next_read_operation conn with 174 + | `Read -> ( 175 + let cs = 176 + Cstruct.of_bigarray read_buffer ~off:0 ~len:read_buffer_size 177 + in 178 + try 179 + let n = Eio.Flow.single_read flow cs in 180 + let _ = 181 + H2.Server_connection.read conn read_buffer ~off:0 ~len:n 182 + in 183 + loop () 184 + with End_of_file -> 185 + let _ = 186 + H2.Server_connection.read_eof conn read_buffer ~off:0 ~len:0 187 + in 188 + shutdown := true) 189 + | `Close -> shutdown := true 190 + in 191 + loop () 192 + in 193 + 194 + let write_loop () = 195 + let rec loop () = 196 + if not !shutdown then 197 + match H2.Server_connection.next_write_operation conn with 198 + | `Write iovecs -> 199 + write_iovecs flow iovecs; 200 + let len = 201 + List.fold_left (fun acc iov -> acc + iov.H2.IOVec.len) 0 iovecs 202 + in 203 + H2.Server_connection.report_write_result conn (`Ok len); 204 + loop () 205 + | `Yield -> 206 + let continue = Eio.Promise.create () in 207 + H2.Server_connection.yield_writer conn (fun () -> 208 + Eio.Promise.resolve (snd continue) ()); 209 + Eio.Promise.await (fst continue); 210 + loop () 211 + | `Close _ -> shutdown := true 212 + in 213 + loop () 214 + in 215 + 216 + Fiber.both read_loop write_loop 217 + 218 + (** {1 Public API} *) 219 + 220 + let run ~sw ~net ?(config = H1_server.default_config) handler = 221 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 222 + let socket = 223 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 224 + ~reuse_port:config.reuse_port net addr 225 + in 226 + traceln "HTTP/2 Server listening on port %d" config.port; 227 + let connection_handler flow _addr = 228 + if config.tcp_nodelay then set_tcp_nodelay flow; 229 + handle_connection handler flow 230 + in 231 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 232 + Eio.Net.run_server socket connection_handler 233 + ~max_connections:config.max_connections ~on_error 234 + 235 + let run_tls ~sw ~net ?(config = H1_server.default_config) ~tls_config handler = 236 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 237 + let socket = 238 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 239 + ~reuse_port:config.reuse_port net addr 240 + in 241 + traceln "HTTP/2 Server (TLS) listening on port %d" config.port; 242 + let connection_handler flow _addr = 243 + if config.tcp_nodelay then set_tcp_nodelay flow; 244 + match Tls_config.Server.to_tls_config tls_config with 245 + | Error (`Msg msg) -> traceln "TLS config error: %s" msg 246 + | Ok tls_cfg -> ( 247 + try 248 + let tls_flow = Tls_eio.server_of_flow tls_cfg flow in 249 + handle_connection handler tls_flow 250 + with 251 + | Tls_eio.Tls_failure failure -> 252 + traceln "TLS error: %s" (Tls_config.failure_to_string failure) 253 + | exn -> traceln "TLS error: %s" (Printexc.to_string exn)) 254 + in 255 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 256 + Eio.Net.run_server socket connection_handler 257 + ~max_connections:config.max_connections ~on_error 258 + 259 + let run_parallel ~sw ~net ~domain_mgr ?(config = H1_server.default_config) 260 + handler = 261 + let domain_count = max 1 config.domain_count in 262 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 263 + let socket = 264 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 265 + ~reuse_port:config.reuse_port net addr 266 + in 267 + traceln "HTTP/2 Server listening on port %d (%d domains)" config.port 268 + domain_count; 269 + let connection_handler flow _addr = 270 + if config.tcp_nodelay then set_tcp_nodelay flow; 271 + handle_connection handler flow 272 + in 273 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 274 + if domain_count <= 1 then 275 + Eio.Net.run_server socket connection_handler 276 + ~max_connections:config.max_connections ~on_error 277 + else 278 + Eio.Net.run_server socket connection_handler 279 + ~max_connections:config.max_connections ~on_error 280 + ~additional_domains:(domain_mgr, domain_count - 1)
+73
lib/hcs.ml
··· 1 + (** HCS - High-performance HTTP library for OCaml 5+. 2 + 3 + Built on Eio for structured concurrency, using h1 for HTTP/1.1 and h2 for 4 + HTTP/2 protocol handling. 5 + 6 + The library is designed with runtime abstraction in mind, making future Lwt 7 + support possible without major rewrites. *) 8 + 9 + module Client = Client 10 + (** HTTP Client *) 11 + 12 + module Server = Server 13 + (** HTTP Server *) 14 + 15 + module Router = Router 16 + (** Router with radix trie *) 17 + 18 + module Middleware = Middleware 19 + (** Middleware combinators *) 20 + 21 + module Middleware_eio = Middleware_eio 22 + (** Eio-specific middleware (logging, timeout, rate limiting, static files) *) 23 + 24 + module Control = Control 25 + (** Control flow: retry, circuit breaker, rate limiting *) 26 + 27 + module Pool = Pool 28 + (** Connection pool *) 29 + 30 + module Pooled_client = Pooled_client 31 + (** HTTP Client with connection pooling *) 32 + 33 + module Tls_config = Tls_config 34 + (** TLS configuration *) 35 + 36 + module Request = Request 37 + (** Request helpers *) 38 + 39 + module Response = Response 40 + (** Response helpers *) 41 + 42 + module H1_client = H1_client 43 + (** HTTP/1.1 Client *) 44 + 45 + module H2_client = H2_client 46 + (** HTTP/2 Client *) 47 + 48 + module H1_server = H1_server 49 + (** HTTP/1.1 Server (low-level, optimized) *) 50 + 51 + module H2_server = H2_server 52 + (** HTTP/2 Server *) 53 + 54 + module Websocket = Websocket 55 + (** WebSocket support *) 56 + 57 + module Codec = Codec 58 + (** Codec system for serialization/deserialization *) 59 + 60 + module Log = Log 61 + (** Logging system *) 62 + 63 + module Stream = Stream 64 + (** Streaming abstractions *) 65 + 66 + module Http = Http 67 + (** HTTP Request builder DSL *) 68 + 69 + module Method = H1.Method 70 + (** Re-exports for convenience *) 71 + 72 + module Status = H1.Status 73 + module Headers = H1.Headers
+261
lib/http.ml
··· 1 + (** HTTP Request Builder DSL. 2 + 3 + This module provides a fluent API for building HTTP requests. It's designed 4 + to be used with the Client module for a high-level HTTP client experience. 5 + 6 + {1 Usage} 7 + 8 + {[ 9 + open Hcs.Http 10 + 11 + (* Simple GET request *) 12 + let req = get "https://api.example.com/users" |> build 13 + 14 + (* POST with JSON body *) 15 + let req = 16 + post "https://api.example.com/users" 17 + |> content_type "application/json" 18 + |> body_string {|{"name": "Alice"}|} 19 + |> build 20 + 21 + (* GET with query params and auth *) 22 + let req = 23 + get "https://api.example.com/search" 24 + |> query "q" "ocaml" |> query "limit" "10" |> bearer "my-token" |> build 25 + ]} *) 26 + 27 + (** {1 Types} *) 28 + 29 + (** HTTP method *) 30 + type meth = 31 + | GET 32 + | POST 33 + | PUT 34 + | DELETE 35 + | PATCH 36 + | HEAD 37 + | OPTIONS 38 + | CONNECT 39 + | TRACE 40 + | Other of string 41 + 42 + (** Body content *) 43 + type body = Empty | String of string | Form of (string * string) list 44 + 45 + type builder = { 46 + meth : meth; 47 + uri : Uri.t; 48 + headers : (string * string) list; 49 + body : body; 50 + } 51 + (** Request builder - accumulates request parameters *) 52 + 53 + type request = { 54 + req_meth : meth; 55 + req_uri : Uri.t; 56 + req_headers : (string * string) list; 57 + req_body : body; 58 + } 59 + (** Built request ready for execution *) 60 + 61 + (** {1 Method to H1.Method conversion} *) 62 + 63 + let meth_to_h1 = function 64 + | GET -> `GET 65 + | POST -> `POST 66 + | PUT -> `PUT 67 + | DELETE -> `DELETE 68 + | PATCH -> `Other "PATCH" 69 + | HEAD -> `HEAD 70 + | OPTIONS -> `OPTIONS 71 + | CONNECT -> `CONNECT 72 + | TRACE -> `TRACE 73 + | Other s -> `Other s 74 + 75 + let meth_of_string = function 76 + | "GET" -> GET 77 + | "POST" -> POST 78 + | "PUT" -> PUT 79 + | "DELETE" -> DELETE 80 + | "PATCH" -> PATCH 81 + | "HEAD" -> HEAD 82 + | "OPTIONS" -> OPTIONS 83 + | "CONNECT" -> CONNECT 84 + | "TRACE" -> TRACE 85 + | s -> Other s 86 + 87 + let meth_to_string = function 88 + | GET -> "GET" 89 + | POST -> "POST" 90 + | PUT -> "PUT" 91 + | DELETE -> "DELETE" 92 + | PATCH -> "PATCH" 93 + | HEAD -> "HEAD" 94 + | OPTIONS -> "OPTIONS" 95 + | CONNECT -> "CONNECT" 96 + | TRACE -> "TRACE" 97 + | Other s -> s 98 + 99 + (** {1 Request Builders} *) 100 + 101 + (** Create a builder with the given method and URL *) 102 + let create meth url = 103 + let uri = Uri.of_string url in 104 + { meth; uri; headers = []; body = Empty } 105 + 106 + (** Create a GET request builder *) 107 + let get url = create GET url 108 + 109 + (** Create a POST request builder *) 110 + let post url = create POST url 111 + 112 + (** Create a PUT request builder *) 113 + let put url = create PUT url 114 + 115 + (** Create a DELETE request builder *) 116 + let delete url = create DELETE url 117 + 118 + (** Create a PATCH request builder *) 119 + let patch url = create PATCH url 120 + 121 + (** Create a HEAD request builder *) 122 + let head url = create HEAD url 123 + 124 + (** Create an OPTIONS request builder *) 125 + let options url = create OPTIONS url 126 + 127 + (** Create a request builder from a Uri *) 128 + let of_uri meth uri = { meth; uri; headers = []; body = Empty } 129 + 130 + (** {2 Headers} *) 131 + 132 + (** Add a header to the request *) 133 + let header name value builder = 134 + { builder with headers = (name, value) :: builder.headers } 135 + 136 + (** Add multiple headers to the request *) 137 + let headers hdrs builder = 138 + { builder with headers = List.rev_append hdrs builder.headers } 139 + 140 + (** Set the Content-Type header *) 141 + let content_type ct builder = header "Content-Type" ct builder 142 + 143 + (** Set the Accept header *) 144 + let accept ct builder = header "Accept" ct builder 145 + 146 + (** Set the User-Agent header *) 147 + let user_agent ua builder = header "User-Agent" ua builder 148 + 149 + (** Set Bearer authentication *) 150 + let bearer token builder = header "Authorization" ("Bearer " ^ token) builder 151 + 152 + (** Set Basic authentication *) 153 + let basic_auth ~user ~pass builder = 154 + let credentials = Base64.encode_string (user ^ ":" ^ pass) in 155 + header "Authorization" ("Basic " ^ credentials) builder 156 + 157 + (** Set a cookie header *) 158 + let cookie name value builder = 159 + let existing = 160 + List.find_opt (fun (n, _) -> String.equal n "Cookie") builder.headers 161 + in 162 + let new_cookie = 163 + match existing with 164 + | Some (_, v) -> v ^ "; " ^ name ^ "=" ^ value 165 + | None -> name ^ "=" ^ value 166 + in 167 + let headers = 168 + List.filter (fun (n, _) -> not (String.equal n "Cookie")) builder.headers 169 + in 170 + { builder with headers = ("Cookie", new_cookie) :: headers } 171 + 172 + (** Set cookies from a list *) 173 + let cookies cs builder = 174 + List.fold_left (fun b (n, v) -> cookie n v b) builder cs 175 + 176 + (** {2 Query Parameters} *) 177 + 178 + (** Add a query parameter *) 179 + let query name value builder = 180 + let uri = Uri.add_query_param' builder.uri (name, value) in 181 + { builder with uri } 182 + 183 + (** Add multiple query parameters *) 184 + let queries qs builder = 185 + let uri = 186 + List.fold_left 187 + (fun u (n, v) -> Uri.add_query_param' u (n, v)) 188 + builder.uri qs 189 + in 190 + { builder with uri } 191 + 192 + (** {2 Body} *) 193 + 194 + (** Set the request body *) 195 + let body b builder = { builder with body = b } 196 + 197 + (** Set the body as a string with optional content type *) 198 + let body_string ?content_type:ct str builder = 199 + let builder = { builder with body = String str } in 200 + match ct with Some ct -> content_type ct builder | None -> builder 201 + 202 + (** Set the body as a JSON string *) 203 + let body_json json builder = 204 + builder |> body_string ~content_type:"application/json" json 205 + 206 + (** Set the body as form data *) 207 + let form fields builder = 208 + { builder with body = Form fields } 209 + |> content_type "application/x-www-form-urlencoded" 210 + 211 + (** {1 Building} *) 212 + 213 + (** Build the final request *) 214 + let build builder = 215 + { 216 + req_meth = builder.meth; 217 + req_uri = builder.uri; 218 + req_headers = List.rev builder.headers; 219 + req_body = builder.body; 220 + } 221 + 222 + (** Get the URL as a string *) 223 + let url request = Uri.to_string request.req_uri 224 + 225 + (** Get the host from the request *) 226 + let host request = Uri.host request.req_uri |> Option.value ~default:"localhost" 227 + 228 + (** Get the port from the request *) 229 + let port request = 230 + match Uri.port request.req_uri with 231 + | Some p -> p 232 + | None -> ( 233 + match Uri.scheme request.req_uri with Some "https" -> 443 | _ -> 80) 234 + 235 + (** Get the path from the request *) 236 + let path request = 237 + let p = Uri.path request.req_uri in 238 + if p = "" then "/" else p 239 + 240 + (** Get the path and query from the request *) 241 + let path_and_query request = Uri.path_and_query request.req_uri 242 + 243 + (** Check if the request is HTTPS *) 244 + let is_https request = 245 + match Uri.scheme request.req_uri with Some "https" -> true | _ -> false 246 + 247 + (** {1 Body Encoding} *) 248 + 249 + (** Encode form data as URL-encoded string *) 250 + let encode_form fields = 251 + String.concat "&" 252 + (List.map (fun (k, v) -> Uri.pct_encode k ^ "=" ^ Uri.pct_encode v) fields) 253 + 254 + (** Get the body as a string *) 255 + let body_to_string = function 256 + | Empty -> "" 257 + | String s -> s 258 + | Form fields -> encode_form fields 259 + 260 + (** Get the Content-Length for the body *) 261 + let body_length body = String.length (body_to_string body)
+311
lib/log.ml
··· 1 + (** Logging module for HCS HTTP library. 2 + 3 + Provides structured logging for HTTP events including requests, responses, 4 + connections, and errors. The module is runtime-agnostic and uses a 5 + callback-based approach for flexibility. 6 + 7 + {1 Usage} 8 + 9 + {[ 10 + (* Use built-in stderr logger *) 11 + let logger = Hcs.Log.stderr () 12 + 13 + (* Use null logger (no output) *) 14 + let logger = Hcs.Log.null 15 + 16 + (* Use custom logger *) 17 + let logger = 18 + Hcs.Log.custom (fun level msg -> 19 + match level with 20 + | Hcs.Log.Error -> Printf.eprintf "[ERROR] %s\n%!" msg 21 + | _ -> 22 + Printf.printf "[%s] %s\n%!" (Hcs.Log.level_to_string level) msg) 23 + ]} *) 24 + 25 + (** {1 Types} *) 26 + 27 + (** Log levels *) 28 + type level = 29 + | Debug (** Detailed debugging information *) 30 + | Info (** General information about operations *) 31 + | Warn (** Warning conditions *) 32 + | Error (** Error conditions *) 33 + 34 + (** HTTP method for logging (simplified) *) 35 + type http_method = 36 + | GET 37 + | POST 38 + | PUT 39 + | DELETE 40 + | PATCH 41 + | HEAD 42 + | OPTIONS 43 + | CONNECT 44 + | TRACE 45 + | Other of string 46 + 47 + (** Log events - structured events that can be logged *) 48 + type event = 49 + | Request_start of { 50 + id : string; 51 + meth : http_method; 52 + uri : string; 53 + headers : (string * string) list; 54 + } (** Request started *) 55 + | Request_end of { 56 + id : string; 57 + status : int; 58 + duration_ms : float; 59 + body_size : int option; 60 + } (** Request completed *) 61 + | Connection_open of { 62 + host : string; 63 + port : int; 64 + protocol : string; (** "http/1.1" or "h2" *) 65 + } (** Connection opened *) 66 + | Connection_close of { host : string; port : int; reason : string } 67 + (** Connection closed *) 68 + | Connection_reuse of { host : string; port : int } 69 + (** Connection reused from pool *) 70 + | Tls_handshake of { 71 + host : string; 72 + protocol : string; (** TLS version *) 73 + cipher : string option; 74 + } (** TLS handshake completed *) 75 + | Retry of { 76 + id : string; 77 + attempt : int; 78 + reason : string; 79 + delay_ms : float option; 80 + } (** Request retry *) 81 + | Redirect of { 82 + id : string; 83 + from_uri : string; 84 + to_uri : string; 85 + status : int; 86 + } (** Following redirect *) 87 + | Error of { id : string option; error : string; context : string option } 88 + (** Error occurred *) 89 + | Custom of { name : string; data : (string * string) list } 90 + (** Custom event *) 91 + 92 + type logger = level -> event -> unit 93 + (** Logger function type *) 94 + 95 + (** {1 Level operations} *) 96 + 97 + (** Convert level to string *) 98 + let level_to_string = function 99 + | Debug -> "DEBUG" 100 + | Info -> "INFO" 101 + | Warn -> "WARN" 102 + | Error -> "ERROR" 103 + 104 + (** Parse level from string *) 105 + let level_of_string = function 106 + | "DEBUG" | "debug" -> Some Debug 107 + | "INFO" | "info" -> Some Info 108 + | "WARN" | "warn" | "WARNING" | "warning" -> Some Warn 109 + | "ERROR" | "error" -> Some Error 110 + | _ -> None 111 + 112 + (** Compare log levels (for filtering) *) 113 + let level_to_int = function Debug -> 0 | Info -> 1 | Warn -> 2 | Error -> 3 114 + 115 + let level_gte l1 l2 = level_to_int l1 >= level_to_int l2 116 + 117 + (** {1 HTTP method operations} *) 118 + 119 + let method_to_string = function 120 + | GET -> "GET" 121 + | POST -> "POST" 122 + | PUT -> "PUT" 123 + | DELETE -> "DELETE" 124 + | PATCH -> "PATCH" 125 + | HEAD -> "HEAD" 126 + | OPTIONS -> "OPTIONS" 127 + | CONNECT -> "CONNECT" 128 + | TRACE -> "TRACE" 129 + | Other s -> s 130 + 131 + let method_of_h1 (m : Httpun_types.Method.t) : http_method = 132 + match m with 133 + | `GET -> GET 134 + | `POST -> POST 135 + | `PUT -> PUT 136 + | `DELETE -> DELETE 137 + | `HEAD -> HEAD 138 + | `OPTIONS -> OPTIONS 139 + | `CONNECT -> CONNECT 140 + | `TRACE -> TRACE 141 + | `Other s -> Other s 142 + 143 + (** {1 Event formatting} *) 144 + 145 + (** Format event as a human-readable string *) 146 + let event_to_string = function 147 + | Request_start { id; meth; uri; headers = _ } -> 148 + Printf.sprintf "Request[%s] %s %s" id (method_to_string meth) uri 149 + | Request_end { id; status; duration_ms; body_size } -> 150 + let size_str = 151 + match body_size with 152 + | Some s -> Printf.sprintf ", %d bytes" s 153 + | None -> "" 154 + in 155 + Printf.sprintf "Request[%s] completed: status=%d, duration=%.2fms%s" id 156 + status duration_ms size_str 157 + | Connection_open { host; port; protocol } -> 158 + Printf.sprintf "Connection opened: %s:%d (%s)" host port protocol 159 + | Connection_close { host; port; reason } -> 160 + Printf.sprintf "Connection closed: %s:%d (%s)" host port reason 161 + | Connection_reuse { host; port } -> 162 + Printf.sprintf "Connection reused: %s:%d" host port 163 + | Tls_handshake { host; protocol; cipher } -> 164 + let cipher_str = 165 + match cipher with Some c -> ", cipher=" ^ c | None -> "" 166 + in 167 + Printf.sprintf "TLS handshake: %s (%s%s)" host protocol cipher_str 168 + | Retry { id; attempt; reason; delay_ms } -> 169 + let delay_str = 170 + match delay_ms with 171 + | Some d -> Printf.sprintf ", delay=%.0fms" d 172 + | None -> "" 173 + in 174 + Printf.sprintf "Request[%s] retry #%d: %s%s" id attempt reason delay_str 175 + | Redirect { id; from_uri; to_uri; status } -> 176 + Printf.sprintf "Request[%s] redirect %d: %s -> %s" id status from_uri 177 + to_uri 178 + | Error { id; error; context } -> 179 + let id_str = 180 + match id with Some i -> Printf.sprintf "[%s] " i | None -> "" 181 + in 182 + let ctx_str = 183 + match context with Some c -> " (" ^ c ^ ")" | None -> "" 184 + in 185 + Printf.sprintf "Error%s: %s%s" id_str error ctx_str 186 + | Custom { name; data } -> 187 + let data_str = 188 + String.concat ", " 189 + (List.map (fun (k, v) -> Printf.sprintf "%s=%s" k v) data) 190 + in 191 + Printf.sprintf "Custom[%s]: %s" name data_str 192 + 193 + (** Format event as JSON string *) 194 + let event_to_json = function 195 + | Request_start { id; meth; uri; headers } -> 196 + let headers_json = 197 + String.concat "," 198 + (List.map (fun (k, v) -> Printf.sprintf {|"%s":"%s"|} k v) headers) 199 + in 200 + Printf.sprintf 201 + {|{"event":"request_start","id":"%s","method":"%s","uri":"%s","headers":{%s}}|} 202 + id (method_to_string meth) uri headers_json 203 + | Request_end { id; status; duration_ms; body_size } -> 204 + let size_json = 205 + match body_size with 206 + | Some s -> Printf.sprintf {|,"body_size":%d|} s 207 + | None -> "" 208 + in 209 + Printf.sprintf 210 + {|{"event":"request_end","id":"%s","status":%d,"duration_ms":%.2f%s}|} 211 + id status duration_ms size_json 212 + | Connection_open { host; port; protocol } -> 213 + Printf.sprintf 214 + {|{"event":"connection_open","host":"%s","port":%d,"protocol":"%s"}|} 215 + host port protocol 216 + | Connection_close { host; port; reason } -> 217 + Printf.sprintf 218 + {|{"event":"connection_close","host":"%s","port":%d,"reason":"%s"}|} 219 + host port reason 220 + | Connection_reuse { host; port } -> 221 + Printf.sprintf {|{"event":"connection_reuse","host":"%s","port":%d}|} host 222 + port 223 + | Tls_handshake { host; protocol; cipher } -> 224 + let cipher_json = 225 + match cipher with 226 + | Some c -> Printf.sprintf {|,"cipher":"%s"|} c 227 + | None -> "" 228 + in 229 + Printf.sprintf {|{"event":"tls_handshake","host":"%s","protocol":"%s"%s}|} 230 + host protocol cipher_json 231 + | Retry { id; attempt; reason; delay_ms } -> 232 + let delay_json = 233 + match delay_ms with 234 + | Some d -> Printf.sprintf {|,"delay_ms":%.0f|} d 235 + | None -> "" 236 + in 237 + Printf.sprintf 238 + {|{"event":"retry","id":"%s","attempt":%d,"reason":"%s"%s}|} id attempt 239 + reason delay_json 240 + | Redirect { id; from_uri; to_uri; status } -> 241 + Printf.sprintf 242 + {|{"event":"redirect","id":"%s","from":"%s","to":"%s","status":%d}|} id 243 + from_uri to_uri status 244 + | Error { id; error; context } -> 245 + let id_json = 246 + match id with Some i -> Printf.sprintf {|"id":"%s",|} i | None -> "" 247 + in 248 + let ctx_json = 249 + match context with 250 + | Some c -> Printf.sprintf {|,"context":"%s"|} c 251 + | None -> "" 252 + in 253 + Printf.sprintf {|{"event":"error",%s"error":"%s"%s}|} id_json error 254 + ctx_json 255 + | Custom { name; data } -> 256 + let data_json = 257 + String.concat "," 258 + (List.map (fun (k, v) -> Printf.sprintf {|"%s":"%s"|} k v) data) 259 + in 260 + Printf.sprintf {|{"event":"custom","name":"%s","data":{%s}}|} name 261 + data_json 262 + 263 + (** {1 Built-in Loggers} *) 264 + 265 + (** Null logger - discards all events *) 266 + let null : logger = fun _ _ -> () 267 + 268 + (** Stderr logger with optional minimum level filter *) 269 + let stderr ?(min_level = Debug) ?(json = false) () : logger = 270 + fun level event -> 271 + if level_gte level min_level then 272 + let formatted = 273 + if json then event_to_json event else event_to_string event 274 + in 275 + Printf.eprintf "[%s] %s\n%!" (level_to_string level) formatted 276 + 277 + (** Stdout logger with optional minimum level filter *) 278 + let stdout ?(min_level = Debug) ?(json = false) () : logger = 279 + fun level event -> 280 + if level_gte level min_level then 281 + let formatted = 282 + if json then event_to_json event else event_to_string event 283 + in 284 + Printf.printf "[%s] %s\n%!" (level_to_string level) formatted 285 + 286 + (** Custom logger from a simple callback *) 287 + let custom (f : level -> string -> unit) : logger = 288 + fun level event -> f level (event_to_string event) 289 + 290 + (** Custom logger with JSON output *) 291 + let custom_json (f : level -> string -> unit) : logger = 292 + fun level event -> f level (event_to_json event) 293 + 294 + (** Combine multiple loggers *) 295 + let combine (loggers : logger list) : logger = 296 + fun level event -> List.iter (fun logger -> logger level event) loggers 297 + 298 + (** Filter logger by minimum level *) 299 + let with_min_level (min_level : level) (logger : logger) : logger = 300 + fun level event -> if level_gte level min_level then logger level event 301 + 302 + (** {1 Request ID generation} *) 303 + 304 + (** Counter for unique request IDs *) 305 + let request_id_counter = ref 0 306 + 307 + (** Generate a unique request ID *) 308 + let generate_request_id () = 309 + incr request_id_counter; 310 + let random = Random.int 0xFFFF in 311 + Printf.sprintf "req-%06d-%04x" !request_id_counter random
+53
lib/middleware.ml
··· 1 + (** Middleware support for HCS HTTP server. 2 + 3 + Middleware wraps request handlers to add cross-cutting concerns like 4 + logging, authentication, CORS, etc. *) 5 + 6 + type ('req, 'resp) t = ('req -> 'resp) -> 'req -> 'resp 7 + (** Middleware type - wraps a handler *) 8 + 9 + (** Identity middleware - does nothing *) 10 + let identity : ('req, 'resp) t = fun handler req -> handler req 11 + 12 + (** Compose two middleware: m1 runs before m2 *) 13 + let compose (m1 : ('req, 'resp) t) (m2 : ('req, 'resp) t) : ('req, 'resp) t = 14 + fun handler -> m1 (m2 handler) 15 + 16 + (** Compose a list of middleware *) 17 + let compose_all middlewares = List.fold_right compose middlewares identity 18 + 19 + (** Infix operator for middleware composition *) 20 + let ( @> ) = compose 21 + 22 + (** Apply middleware to a handler *) 23 + let apply middleware handler = middleware handler 24 + 25 + (** Logging middleware - logs requests (generic version) *) 26 + let logging ~log : ('req, 'resp) t = 27 + fun handler req -> 28 + log "Request received"; 29 + let resp = handler req in 30 + log "Response sent"; 31 + resp 32 + 33 + (** Timing middleware - measures request duration *) 34 + let timing ~on_complete : ('req, 'resp) t = 35 + fun handler req -> 36 + let start = Unix.gettimeofday () in 37 + let resp = handler req in 38 + let duration = Unix.gettimeofday () -. start in 39 + on_complete duration; 40 + resp 41 + 42 + (** Exception recovery middleware *) 43 + let recover ~on_error : ('req, 'resp) t = 44 + fun handler req -> try handler req with exn -> on_error exn 45 + 46 + (** Conditional middleware - only applies if predicate is true *) 47 + let when_ predicate middleware : ('req, 'resp) t = 48 + fun handler req -> 49 + if predicate req then middleware handler req else handler req 50 + 51 + (** Skip middleware for certain requests *) 52 + let unless predicate middleware = 53 + when_ (fun req -> not (predicate req)) middleware
+349
lib/middleware_eio.ml
··· 1 + (** Eio-specific middleware implementations. 2 + 3 + These middleware require Eio runtime features like clocks, filesystem 4 + access, and structured concurrency. 5 + 6 + {1 Usage} 7 + 8 + {[ 9 + open Hcs.Middleware_eio 10 + 11 + (* Add logging with timing *) 12 + let handler = 13 + handler 14 + |> Middleware.apply (logging ~clock (Log.stderr ())) 15 + |> Middleware.apply (timeout ~clock 30.0) 16 + ]} *) 17 + 18 + (** {1 String Helpers} *) 19 + 20 + (** Check if string starts with prefix *) 21 + let string_starts_with ~prefix s = 22 + let plen = String.length prefix in 23 + let slen = String.length s in 24 + plen <= slen && String.sub s 0 plen = prefix 25 + 26 + (** Check if substring exists in string *) 27 + let string_contains_substring ~substring s = 28 + let rec check i = 29 + if i + String.length substring > String.length s then false 30 + else if String.sub s i (String.length substring) = substring then true 31 + else check (i + 1) 32 + in 33 + check 0 34 + 35 + (** {1 Types} *) 36 + 37 + type request = Server.request 38 + (** Server request type (simplified for middleware) *) 39 + 40 + type response = Server.response 41 + (** Server response type *) 42 + 43 + type middleware = (request -> response) -> request -> response 44 + (** Middleware type matching Server.handler *) 45 + 46 + (** {1 Response Helpers} *) 47 + 48 + (** Get the body size from a response, if known *) 49 + let response_body_size (resp : response) : int option = 50 + match resp.Server.body with 51 + | Server.Body_empty -> Some 0 52 + | Server.Body_string s -> Some (String.length s) 53 + | Server.Body_bigstring b -> Some (Bigstringaf.length b) 54 + | Server.Body_prebuilt p -> Some (Bigstringaf.length p.Server.Prebuilt.body) 55 + | Server.Body_stream _ -> None 56 + 57 + let response_body_string (resp : response) : string = 58 + match resp.Server.body with 59 + | Server.Body_empty -> "" 60 + | Server.Body_string s -> s 61 + | Server.Body_bigstring b -> Bigstringaf.to_string b 62 + | Server.Body_prebuilt p -> Bigstringaf.to_string p.Server.Prebuilt.body 63 + | Server.Body_stream _ -> "" 64 + 65 + (** {1 Logging Middleware} *) 66 + 67 + (** Logging middleware that records request timing and details. 68 + 69 + Uses the Log module for structured event logging. *) 70 + let logging ~(clock : _ Eio.Time.clock) (logger : Log.logger) : middleware = 71 + fun handler req -> 72 + let id = Log.generate_request_id () in 73 + let start = Eio.Time.now clock in 74 + let meth = Log.method_of_h1 req.Server.meth in 75 + logger Log.Info 76 + (Log.Request_start { id; meth; uri = req.target; headers = req.headers }); 77 + let resp = handler req in 78 + let duration_ms = (Eio.Time.now clock -. start) *. 1000.0 in 79 + let status = H1.Status.to_code resp.Server.status in 80 + let body_size = response_body_size resp in 81 + logger Log.Info (Log.Request_end { id; status; duration_ms; body_size }); 82 + resp 83 + 84 + (** {1 Timeout Middleware} *) 85 + 86 + (** Timeout middleware that cancels requests exceeding the time limit. 87 + 88 + Returns a 504 Gateway Timeout response if the handler takes too long. *) 89 + let timeout ~(clock : _ Eio.Time.clock) (seconds : float) : middleware = 90 + fun handler req -> 91 + let timeout_response () = 92 + Eio.Time.sleep clock seconds; 93 + Server.respond ~status:`Gateway_timeout "Request timed out" 94 + in 95 + Eio.Fiber.first (fun () -> handler req) timeout_response 96 + 97 + (** {1 Rate Limiting} *) 98 + 99 + (** Simple in-memory rate limiter state *) 100 + module Rate_limit_state = struct 101 + type t = { 102 + mutable requests : int; 103 + mutable window_start : float; 104 + window_seconds : float; 105 + max_requests : int; 106 + } 107 + 108 + let create ~max_requests ~window_seconds = 109 + { requests = 0; window_start = 0.0; window_seconds; max_requests } 110 + 111 + let check_and_increment t now = 112 + if now -. t.window_start >= t.window_seconds then begin 113 + t.window_start <- now; 114 + t.requests <- 1; 115 + true 116 + end 117 + else if t.requests < t.max_requests then begin 118 + t.requests <- t.requests + 1; 119 + true 120 + end 121 + else false 122 + 123 + let remaining t = max 0 (t.max_requests - t.requests) 124 + let reset_at t = t.window_start +. t.window_seconds 125 + end 126 + 127 + (** Rate limiting middleware. 128 + 129 + Limits requests per time window, keyed by a function (e.g., by IP, by user). 130 + Returns 429 Too Many Requests when the limit is exceeded. *) 131 + let rate_limit ~(clock : _ Eio.Time.clock) ~(key : request -> string) 132 + ~(requests : int) ~(per : float) : middleware = 133 + let states : (string, Rate_limit_state.t) Hashtbl.t = Hashtbl.create 256 in 134 + let mutex = Eio.Mutex.create () in 135 + fun handler req -> 136 + let k = key req in 137 + let now = Eio.Time.now clock in 138 + let allowed, remaining, reset_at = 139 + Eio.Mutex.use_rw ~protect:true mutex (fun () -> 140 + let state = 141 + match Hashtbl.find_opt states k with 142 + | Some s -> s 143 + | None -> 144 + let s = 145 + Rate_limit_state.create ~max_requests:requests 146 + ~window_seconds:per 147 + in 148 + Hashtbl.add states k s; 149 + s 150 + in 151 + let allowed = Rate_limit_state.check_and_increment state now in 152 + ( allowed, 153 + Rate_limit_state.remaining state, 154 + Rate_limit_state.reset_at state )) 155 + in 156 + if allowed then handler req 157 + else 158 + let headers = 159 + [ 160 + ("X-RateLimit-Limit", string_of_int requests); 161 + ("X-RateLimit-Remaining", string_of_int remaining); 162 + ("X-RateLimit-Reset", string_of_int (int_of_float reset_at)); 163 + ("Retry-After", string_of_int (int_of_float (reset_at -. now))); 164 + ] 165 + in 166 + { 167 + Server.status = `Code 429; 168 + headers; 169 + body = Server.Body_string "Too Many Requests"; 170 + } 171 + 172 + (** {1 ETag and Caching} *) 173 + 174 + (** Generate ETag from response body using MD5 hash *) 175 + let generate_etag body = 176 + let hash = Digestif.MD5.digest_string body in 177 + "\"" ^ Digestif.MD5.to_hex hash ^ "\"" 178 + 179 + let etag : middleware = 180 + fun handler req -> 181 + let resp = handler req in 182 + let body_str = response_body_string resp in 183 + let etag_value = generate_etag body_str in 184 + let if_none_match = 185 + List.find_opt 186 + (fun (n, _) -> String.lowercase_ascii n = "if-none-match") 187 + req.headers 188 + |> Option.map snd 189 + in 190 + match if_none_match with 191 + | Some client_etag when String.equal client_etag etag_value -> 192 + { 193 + status = `Code 304; 194 + headers = [ ("ETag", etag_value) ]; 195 + body = Server.Body_empty; 196 + } 197 + | _ -> { resp with headers = ("ETag", etag_value) :: resp.headers } 198 + 199 + (** Cache-Control middleware - adds Cache-Control header *) 200 + let cache_control (directive : string) : middleware = 201 + fun handler req -> 202 + let resp = handler req in 203 + { resp with headers = ("Cache-Control", directive) :: resp.headers } 204 + 205 + (** {1 Static Files} *) 206 + 207 + (** MIME type mapping for common file extensions *) 208 + let mime_type_of_extension ext = 209 + match String.lowercase_ascii ext with 210 + | ".html" | ".htm" -> "text/html; charset=utf-8" 211 + | ".css" -> "text/css; charset=utf-8" 212 + | ".js" -> "application/javascript; charset=utf-8" 213 + | ".json" -> "application/json" 214 + | ".xml" -> "application/xml" 215 + | ".txt" -> "text/plain; charset=utf-8" 216 + | ".png" -> "image/png" 217 + | ".jpg" | ".jpeg" -> "image/jpeg" 218 + | ".gif" -> "image/gif" 219 + | ".svg" -> "image/svg+xml" 220 + | ".ico" -> "image/x-icon" 221 + | ".webp" -> "image/webp" 222 + | ".woff" -> "font/woff" 223 + | ".woff2" -> "font/woff2" 224 + | ".ttf" -> "font/ttf" 225 + | ".otf" -> "font/otf" 226 + | ".pdf" -> "application/pdf" 227 + | ".zip" -> "application/zip" 228 + | ".gz" -> "application/gzip" 229 + | ".mp3" -> "audio/mpeg" 230 + | ".mp4" -> "video/mp4" 231 + | ".webm" -> "video/webm" 232 + | _ -> "application/octet-stream" 233 + 234 + (** Get file extension from path *) 235 + let extension path = 236 + match String.rindex_opt path '.' with 237 + | Some i -> String.sub path i (String.length path - i) 238 + | None -> "" 239 + 240 + (** Static file middleware - serves files from a directory. 241 + 242 + @param fs The Eio filesystem to use 243 + @param root The root directory path for static files 244 + @param index 245 + Index files to try for directory requests (default: ["index.html"]) 246 + @param with_etag Whether to add ETag headers (default: true) *) 247 + let static ~(fs : _ Eio.Path.t) ?(index = [ "index.html" ]) ?(with_etag = true) 248 + (root : string) : middleware = 249 + fun handler req -> 250 + (* Only handle GET and HEAD *) 251 + match req.meth with 252 + | `GET | `HEAD -> ( 253 + (* Normalize and validate path to prevent directory traversal *) 254 + let path = req.target in 255 + let path = 256 + if String.length path > 0 && path.[0] = '/' then 257 + String.sub path 1 (String.length path - 1) 258 + else path 259 + in 260 + (* Remove query string *) 261 + let path = 262 + match String.index_opt path '?' with 263 + | Some i -> String.sub path 0 i 264 + | None -> path 265 + in 266 + (* Check for directory traversal *) 267 + if String.contains path '\x00' || string_starts_with ~prefix:".." path 268 + then handler req (* Pass to next handler *) 269 + else 270 + let full_path = Eio.Path.(fs / root / path) in 271 + try 272 + (* Try to read the file *) 273 + let content = Eio.Path.load full_path in 274 + let content_type = mime_type_of_extension (extension path) in 275 + let headers = 276 + [ 277 + ("Content-Type", content_type); 278 + ("Content-Length", string_of_int (String.length content)); 279 + ] 280 + in 281 + let headers = 282 + if with_etag then ("ETag", generate_etag content) :: headers 283 + else headers 284 + in 285 + (* Handle If-None-Match *) 286 + let if_none_match = 287 + List.find_opt 288 + (fun (n, _) -> String.lowercase_ascii n = "if-none-match") 289 + req.headers 290 + |> Option.map snd 291 + in 292 + let etag_value = generate_etag content in 293 + match if_none_match with 294 + | Some client_etag when String.equal client_etag etag_value -> 295 + { 296 + status = `Code 304; 297 + headers = [ ("ETag", etag_value) ]; 298 + body = Server.Body_empty; 299 + } 300 + | _ -> 301 + let body = 302 + if req.meth = `HEAD then Server.Body_empty 303 + else Server.Body_string content 304 + in 305 + { status = `OK; headers; body } 306 + with 307 + | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> ( 308 + (* Try index files if path looks like a directory *) 309 + let try_index () = 310 + List.find_map 311 + (fun idx -> 312 + try 313 + let idx_path = Eio.Path.(fs / root / path / idx) in 314 + let content = Eio.Path.load idx_path in 315 + Some (idx, content) 316 + with _ -> None) 317 + index 318 + in 319 + match try_index () with 320 + | Some (idx, content) -> 321 + let content_type = mime_type_of_extension (extension idx) in 322 + let headers = 323 + [ 324 + ("Content-Type", content_type); 325 + ("Content-Length", string_of_int (String.length content)); 326 + ] 327 + in 328 + let body = 329 + if req.meth = `HEAD then Server.Body_empty 330 + else Server.Body_string content 331 + in 332 + { status = `OK; headers; body } 333 + | None -> handler req) 334 + | _ -> handler req) 335 + | _ -> handler req 336 + 337 + (** {1 Compression} *) 338 + 339 + (** Note: Compression middleware would require a compression library like 340 + camlzip or decompress. For now, we provide a placeholder that can be 341 + implemented when those dependencies are available. *) 342 + 343 + (** Check if client accepts gzip encoding *) 344 + let accepts_gzip (req : request) = 345 + List.exists 346 + (fun (n, v) -> 347 + String.lowercase_ascii n = "accept-encoding" 348 + && string_contains_substring ~substring:"gzip" v) 349 + req.headers
+159
lib/pool.ml
··· 1 + (** Connection pool for HTTP client. 2 + 3 + This module provides a runtime-agnostic connection pool structure. The 4 + actual connection management is done by the runtime-specific code. *) 5 + 6 + type config = { 7 + max_connections_per_host : int; (** Max connections per host:port *) 8 + max_total_connections : int; (** Max total connections *) 9 + idle_timeout : float; (** Seconds before closing idle connection *) 10 + connection_timeout : float; (** Seconds to wait for connection *) 11 + } 12 + (** Pool configuration *) 13 + 14 + let default_config = 15 + { 16 + max_connections_per_host = 10; 17 + max_total_connections = 100; 18 + idle_timeout = 60.0; 19 + connection_timeout = 30.0; 20 + } 21 + 22 + type key = { host : string; port : int; tls : bool } 23 + (** Connection key - identifies a connection target *) 24 + 25 + let make_key ~host ~port ~tls = { host; port; tls } 26 + 27 + type 'conn entry = { 28 + conn : 'conn; 29 + created_at : float; 30 + mutable last_used : float; 31 + mutable in_use : bool; 32 + } 33 + (** Connection state *) 34 + 35 + type 'conn t = { 36 + config : config; 37 + mutable connections : (key * 'conn entry list) list; 38 + mutable total_count : int; 39 + } 40 + (** Connection pool - parameterized by connection type *) 41 + 42 + (** Create a new pool *) 43 + let create ?(config = default_config) () = 44 + { config; connections = []; total_count = 0 } 45 + 46 + (** Get connections for a key *) 47 + let get_entries pool key = 48 + List.assoc_opt key pool.connections |> Option.value ~default:[] 49 + 50 + (** Update connections for a key *) 51 + let set_entries pool key entries = 52 + let others = List.remove_assoc key pool.connections in 53 + if entries = [] then pool.connections <- others 54 + else pool.connections <- (key, entries) :: others 55 + 56 + (** Count connections for a key *) 57 + let count_for_key pool key = get_entries pool key |> List.length 58 + 59 + (** Try to acquire an idle connection. [now] is current time in seconds *) 60 + let acquire pool key ~now = 61 + let entries = get_entries pool key in 62 + let rec find_idle = function 63 + | [] -> None 64 + | entry :: rest -> 65 + if 66 + (not entry.in_use) 67 + && now -. entry.last_used < pool.config.idle_timeout 68 + then begin 69 + entry.in_use <- true; 70 + entry.last_used <- now; 71 + Some entry.conn 72 + end 73 + else find_idle rest 74 + in 75 + find_idle entries 76 + 77 + (** Release a connection back to the pool. [now] is current time *) 78 + let release pool key conn ~now = 79 + let entries = get_entries pool key in 80 + let entries = 81 + List.map 82 + (fun entry -> 83 + if entry.conn == conn then 84 + { entry with in_use = false; last_used = now } 85 + else entry) 86 + entries 87 + in 88 + set_entries pool key entries 89 + 90 + (** Add a new connection to the pool. Returns false if pool is at capacity *) 91 + let add pool key conn ~now = 92 + if pool.total_count >= pool.config.max_total_connections then false 93 + else if count_for_key pool key >= pool.config.max_connections_per_host then 94 + false 95 + else begin 96 + let entry = { conn; created_at = now; last_used = now; in_use = true } in 97 + let entries = get_entries pool key in 98 + set_entries pool key (entry :: entries); 99 + pool.total_count <- pool.total_count + 1; 100 + true 101 + end 102 + 103 + (** Remove a connection from the pool *) 104 + let remove pool key conn = 105 + let entries = get_entries pool key in 106 + let entries = List.filter (fun entry -> entry.conn != conn) entries in 107 + let removed = List.length (get_entries pool key) - List.length entries in 108 + set_entries pool key entries; 109 + pool.total_count <- pool.total_count - removed 110 + 111 + (** Close idle connections older than idle_timeout. [now] is current time, 112 + [close] is the connection close function *) 113 + let evict_idle pool ~now ~close = 114 + pool.connections <- 115 + List.filter_map 116 + (fun (key, entries) -> 117 + let kept, evicted = 118 + List.partition 119 + (fun entry -> 120 + entry.in_use || now -. entry.last_used < pool.config.idle_timeout) 121 + entries 122 + in 123 + List.iter (fun entry -> close entry.conn) evicted; 124 + pool.total_count <- pool.total_count - List.length evicted; 125 + if kept = [] then None else Some (key, kept)) 126 + pool.connections 127 + 128 + (** Close all connections *) 129 + let close_all pool ~close = 130 + List.iter 131 + (fun (_, entries) -> List.iter (fun entry -> close entry.conn) entries) 132 + pool.connections; 133 + pool.connections <- []; 134 + pool.total_count <- 0 135 + 136 + type stats = { 137 + total_connections : int; 138 + idle_connections : int; 139 + in_use_connections : int; 140 + hosts : int; 141 + } 142 + (** Get pool statistics *) 143 + 144 + let stats pool = 145 + let idle, in_use = 146 + List.fold_left 147 + (fun (idle, in_use) (_, entries) -> 148 + List.fold_left 149 + (fun (idle, in_use) entry -> 150 + if entry.in_use then (idle, in_use + 1) else (idle + 1, in_use)) 151 + (idle, in_use) entries) 152 + (0, 0) pool.connections 153 + in 154 + { 155 + total_connections = pool.total_count; 156 + idle_connections = idle; 157 + in_use_connections = in_use; 158 + hosts = List.length pool.connections; 159 + }
+422
lib/pooled_client.ml
··· 1 + (** High-performance HTTP client with connection pooling. 2 + 3 + This module provides an HTTP client optimized for making many requests to 4 + the same host. For best performance: 5 + 6 + 1. Parse the URI once and reuse it 2. Create a pool for the target host and 7 + pass it to requests 8 + 9 + Example: 10 + {[ 11 + let uri = Uri.of_string "http://localhost:8080/api" in 12 + let pool = Pooled_client.create_pool ~sw ~net ~clock uri in 13 + (* All requests reuse the same connection *) 14 + for _ = 1 to 1000 do 15 + let _ = Pooled_client.get ~sw ~net ~clock ~pool uri in 16 + () 17 + done 18 + ]} 19 + 20 + For one-off requests, omit the pool: 21 + {[ 22 + let uri = Uri.of_string "http://example.com/api" in 23 + let _ = Pooled_client.get ~sw ~net ~clock uri in 24 + () 25 + ]} *) 26 + 27 + (** {1 Types} *) 28 + 29 + (** Read buffer size for HTTP responses *) 30 + let read_buffer_size = 0x1000 31 + 32 + type conn = { 33 + flow : Eio.Flow.two_way_ty Eio.Std.r; 34 + mutable alive : bool; 35 + read_buffer : Bigstringaf.t; 36 + } 37 + (** Internal connection state *) 38 + 39 + type pool = { 40 + host : string; 41 + port : int; 42 + path : string; (* Pre-extracted path for fast access *) 43 + is_https : bool; 44 + tls_config : Tls.Config.client option; 45 + addr : Eio.Net.Sockaddr.stream option; (* Cached resolved address *) 46 + mutable conn : conn option; 47 + mutex : Eio.Mutex.t; 48 + } 49 + (** Connection pool for a single host. Thread-safe via mutex. *) 50 + 51 + type error = H1_client.error 52 + (** Error type alias *) 53 + 54 + (** {1 Connection Management} *) 55 + 56 + (** Close a connection safely *) 57 + let close_conn c = 58 + if c.alive then begin 59 + c.alive <- false; 60 + try Eio.Resource.close (Obj.magic c.flow) with _ -> () 61 + end 62 + 63 + (** Resolve hostname to IP address *) 64 + let resolve_host ~net host = 65 + match Eio.Net.getaddrinfo_stream net host with 66 + | addr :: _ -> Some addr 67 + | [] -> None 68 + 69 + (** Create a new TCP connection using cached or resolved address *) 70 + let connect_tcp pool ~sw ~net = 71 + let addr = 72 + match pool.addr with 73 + | Some a -> Some a 74 + | None -> ( 75 + (* Resolve if not cached *) 76 + match resolve_host ~net pool.host with 77 + | Some (`Tcp (ip, _)) -> Some (`Tcp (ip, pool.port)) 78 + | _ -> None) 79 + in 80 + match addr with 81 + | None -> 82 + Error (H1_client.Connection_failed ("Cannot resolve host: " ^ pool.host)) 83 + | Some a -> Ok (Eio.Net.connect ~sw net a) 84 + 85 + (** Create a new connection to the pool's target *) 86 + let create_conn pool ~sw ~net = 87 + match connect_tcp pool ~sw ~net with 88 + | Error e -> Error e 89 + | Ok tcp_flow -> 90 + if pool.is_https then 91 + match pool.tls_config with 92 + | None -> Error (H1_client.Tls_error "TLS not configured") 93 + | Some tls_config -> ( 94 + try 95 + let host_domain = 96 + match Domain_name.of_string pool.host with 97 + | Ok dn -> ( 98 + match Domain_name.host dn with 99 + | Ok h -> Some h 100 + | Error _ -> None) 101 + | Error _ -> None 102 + in 103 + let tls_flow = 104 + Tls_eio.client_of_flow tls_config ?host:host_domain tcp_flow 105 + in 106 + Ok 107 + { 108 + flow = (tls_flow :> Eio.Flow.two_way_ty Eio.Std.r); 109 + alive = true; 110 + read_buffer = Bigstringaf.create read_buffer_size; 111 + } 112 + with 113 + | Tls_eio.Tls_failure failure -> 114 + Error 115 + (H1_client.Tls_error (Tls_config.failure_to_string failure)) 116 + | exn -> Error (H1_client.Tls_error (Printexc.to_string exn))) 117 + else 118 + Ok 119 + { 120 + flow = (tcp_flow :> Eio.Flow.two_way_ty Eio.Std.r); 121 + alive = true; 122 + read_buffer = Bigstringaf.create read_buffer_size; 123 + } 124 + 125 + (** {1 Pool Creation} *) 126 + 127 + (** Create a connection pool for a URI. 128 + 129 + The pool maintains a single persistent connection to the target host. Pass 130 + this pool to [get] and [post] to reuse the connection. 131 + 132 + @param net 133 + Network for DNS resolution (optional, resolves lazily if not provided) 134 + @param tls 135 + TLS configuration for HTTPS. Uses system defaults if not provided. *) 136 + let create_pool ?net ?(tls = Tls_config.Client.default) uri = 137 + let scheme = Uri.scheme uri |> Option.value ~default:"http" in 138 + let is_https = String.equal scheme "https" in 139 + let host = Uri.host uri |> Option.value ~default:"localhost" in 140 + let port = 141 + match Uri.port uri with Some p -> p | None -> if is_https then 443 else 80 142 + in 143 + let path = 144 + let p = Uri.path_and_query uri in 145 + if p = "" then "/" else p 146 + in 147 + let tls_config = 148 + if is_https then 149 + match Tls_config.Client.to_tls_config tls ~host with 150 + | Ok c -> Some c 151 + | Error _ -> None 152 + else None 153 + in 154 + (* Pre-resolve DNS if net is provided *) 155 + let addr = 156 + match net with 157 + | Some n -> ( 158 + match Eio.Net.getaddrinfo_stream n host with 159 + | addr_info :: _ -> ( 160 + match addr_info with 161 + | `Tcp (ip, _) -> Some (`Tcp (ip, port)) 162 + | `Unix _ -> None) 163 + | [] -> None) 164 + | None -> None 165 + in 166 + { 167 + host; 168 + port; 169 + path; 170 + is_https; 171 + tls_config; 172 + addr; 173 + conn = None; 174 + mutex = Eio.Mutex.create (); 175 + } 176 + 177 + (** Close all connections in the pool *) 178 + let close_pool pool = 179 + Eio.Mutex.use_rw ~protect:true pool.mutex (fun () -> 180 + Option.iter close_conn pool.conn; 181 + pool.conn <- None) 182 + 183 + (** {1 Internal Request Handling} *) 184 + 185 + (** Write IOVecs to flow *) 186 + let write_iovecs flow iovecs = 187 + let cstructs = 188 + List.map 189 + (fun iov -> 190 + Cstruct.of_bigarray ~off:iov.Httpun_types.IOVec.off 191 + ~len:iov.Httpun_types.IOVec.len iov.Httpun_types.IOVec.buffer) 192 + iovecs 193 + in 194 + Eio.Flow.write flow cstructs 195 + 196 + (** Read into bigstring *) 197 + let read_into flow buf ~off ~len = 198 + let cs = Cstruct.of_bigarray ~off ~len buf in 199 + try `Ok (Eio.Flow.single_read flow cs) with End_of_file -> `Eof 200 + 201 + (** Perform HTTP request on connection. Returns (response, keep_alive) *) 202 + let do_request ?(body = "") conn req = 203 + let flow = conn.flow in 204 + let read_buffer = conn.read_buffer in 205 + let response_received = Eio.Promise.create () in 206 + let body_chunks = ref [] in 207 + (* Collect chunks, concat at end - faster than Buffer *) 208 + let resolved = ref false in 209 + let keep_alive = ref true in 210 + 211 + let resolve_once result = 212 + if not !resolved then begin 213 + resolved := true; 214 + Eio.Promise.resolve (snd response_received) result 215 + end 216 + in 217 + 218 + let response_handler resp body_reader = 219 + (* Check Connection header *) 220 + (match H1.Headers.get resp.H1.Response.headers "connection" with 221 + | Some v when String.lowercase_ascii v = "close" -> keep_alive := false 222 + | _ -> ()); 223 + 224 + let rec read_body () = 225 + H1.Body.Reader.schedule_read body_reader 226 + ~on_eof:(fun () -> 227 + let response_body = String.concat "" (List.rev !body_chunks) in 228 + resolve_once 229 + (Ok 230 + ( { 231 + H1_client.status = resp.H1.Response.status; 232 + headers = resp.headers; 233 + body = response_body; 234 + }, 235 + !keep_alive ))) 236 + ~on_read:(fun buf ~off ~len -> 237 + body_chunks := Bigstringaf.substring buf ~off ~len :: !body_chunks; 238 + read_body ()) 239 + in 240 + read_body () 241 + in 242 + 243 + let error_handler err = 244 + keep_alive := false; 245 + conn.alive <- false; 246 + let msg = 247 + match err with 248 + | `Malformed_response s -> s 249 + | `Invalid_response_body_length _ -> "Invalid body length" 250 + | `Exn exn -> Printexc.to_string exn 251 + in 252 + resolve_once (Error (H1_client.Invalid_response msg)) 253 + in 254 + 255 + let body_writer, h1_conn = 256 + H1.Client_connection.request req ~error_handler ~response_handler 257 + in 258 + 259 + if String.length body > 0 then begin 260 + H1.Body.Writer.write_string body_writer body; 261 + H1.Body.Writer.flush body_writer (fun () -> ()) 262 + end; 263 + H1.Body.Writer.close body_writer; 264 + 265 + (* I/O loop *) 266 + let rec loop () = 267 + let write_done = 268 + match H1.Client_connection.next_write_operation h1_conn with 269 + | `Write iovecs -> 270 + write_iovecs flow iovecs; 271 + let len = 272 + List.fold_left 273 + (fun acc iov -> acc + iov.Httpun_types.IOVec.len) 274 + 0 iovecs 275 + in 276 + H1.Client_connection.report_write_result h1_conn (`Ok len); 277 + false 278 + | `Yield -> true 279 + | `Close _ -> true 280 + in 281 + let read_done = 282 + match H1.Client_connection.next_read_operation h1_conn with 283 + | `Read -> ( 284 + match read_into flow read_buffer ~off:0 ~len:read_buffer_size with 285 + | `Ok n -> 286 + let _ = 287 + H1.Client_connection.read h1_conn read_buffer ~off:0 ~len:n 288 + in 289 + false 290 + | `Eof -> 291 + conn.alive <- false; 292 + let _ = 293 + H1.Client_connection.read_eof h1_conn read_buffer ~off:0 ~len:0 294 + in 295 + true) 296 + | `Close -> true 297 + in 298 + if not (write_done && read_done) then loop () 299 + in 300 + 301 + (try loop () with _ -> conn.alive <- false); 302 + Eio.Promise.await (fst response_received) 303 + 304 + (** Acquire connection from pool or create new *) 305 + let acquire_conn pool ~sw ~net = 306 + Eio.Mutex.use_rw ~protect:true pool.mutex (fun () -> 307 + match pool.conn with 308 + | Some c when c.alive -> Ok c 309 + | _ -> ( 310 + (* Create new connection *) 311 + match create_conn pool ~sw ~net with 312 + | Ok c -> 313 + pool.conn <- Some c; 314 + Ok c 315 + | Error e -> Error e)) 316 + 317 + (** Release connection back to pool *) 318 + let release_conn pool conn ~keep_alive = 319 + if not keep_alive then 320 + Eio.Mutex.use_rw ~protect:true pool.mutex (fun () -> 321 + close_conn conn; 322 + pool.conn <- None) 323 + 324 + (** {1 HTTP Methods} *) 325 + 326 + (** Perform a GET request. 327 + 328 + @param pool 329 + Optional connection pool. If provided, reuses connections (fast path). If 330 + omitted, creates a one-shot connection. *) 331 + let get ?pool ~sw ~net ~clock:_ uri = 332 + match pool with 333 + | Some p -> ( 334 + (* Fast path: use cached host/path from pool *) 335 + let headers = 336 + H1.Headers.of_list [ ("Host", p.host); ("Connection", "keep-alive") ] 337 + in 338 + let req = H1.Request.create ~headers `GET p.path in 339 + match acquire_conn p ~sw ~net with 340 + | Error e -> Error e 341 + | Ok conn -> ( 342 + let result = do_request conn req in 343 + match result with 344 + | Ok (resp, keep_alive) -> 345 + release_conn p conn ~keep_alive; 346 + Ok resp 347 + | Error e -> 348 + release_conn p conn ~keep_alive:false; 349 + Error e)) 350 + | None -> ( 351 + (* Slow path: parse URI for one-shot connection *) 352 + let host = Uri.host uri |> Option.value ~default:"localhost" in 353 + let path = 354 + let p = Uri.path_and_query uri in 355 + if p = "" then "/" else p 356 + in 357 + let headers = 358 + H1.Headers.of_list [ ("Host", host); ("Connection", "close") ] 359 + in 360 + let req = H1.Request.create ~headers `GET path in 361 + let temp_pool = create_pool uri in 362 + match acquire_conn temp_pool ~sw ~net with 363 + | Error e -> Error e 364 + | Ok conn -> 365 + let result = do_request conn req in 366 + close_conn conn; 367 + Result.map fst result) 368 + 369 + (** Perform a POST request. 370 + 371 + @param pool 372 + Optional connection pool. If provided, reuses connections (fast path). If 373 + omitted, creates a one-shot connection. 374 + @param body Request body *) 375 + let post ?pool ~sw ~net ~clock:_ uri ~body = 376 + let content_length = String.length body in 377 + match pool with 378 + | Some p -> ( 379 + (* Fast path: use cached host/path from pool *) 380 + let headers = 381 + H1.Headers.of_list 382 + [ 383 + ("Host", p.host); 384 + ("Connection", "keep-alive"); 385 + ("Content-Length", string_of_int content_length); 386 + ] 387 + in 388 + let req = H1.Request.create ~headers `POST p.path in 389 + match acquire_conn p ~sw ~net with 390 + | Error e -> Error e 391 + | Ok conn -> ( 392 + let result = do_request ~body conn req in 393 + match result with 394 + | Ok (resp, keep_alive) -> 395 + release_conn p conn ~keep_alive; 396 + Ok resp 397 + | Error e -> 398 + release_conn p conn ~keep_alive:false; 399 + Error e)) 400 + | None -> ( 401 + (* Slow path: parse URI for one-shot connection *) 402 + let host = Uri.host uri |> Option.value ~default:"localhost" in 403 + let path = 404 + let p = Uri.path_and_query uri in 405 + if p = "" then "/" else p 406 + in 407 + let headers = 408 + H1.Headers.of_list 409 + [ 410 + ("Host", host); 411 + ("Connection", "close"); 412 + ("Content-Length", string_of_int content_length); 413 + ] 414 + in 415 + let req = H1.Request.create ~headers `POST path in 416 + let temp_pool = create_pool uri in 417 + match acquire_conn temp_pool ~sw ~net with 418 + | Error e -> Error e 419 + | Ok conn -> 420 + let result = do_request ~body conn req in 421 + close_conn conn; 422 + Result.map fst result)
+173
lib/request.ml
··· 1 + (** Request helper functions. 2 + 3 + This module provides utility functions for working with HTTP requests, 4 + including header access, query parameter parsing, and body handling. *) 5 + 6 + (** {1 Internal helpers} *) 7 + 8 + let string_contains haystack pattern = 9 + let plen = String.length pattern in 10 + let hlen = String.length haystack in 11 + if plen > hlen then false 12 + else 13 + let rec check i = 14 + if i > hlen - plen then false 15 + else if String.sub haystack i plen = pattern then true 16 + else check (i + 1) 17 + in 18 + check 0 19 + 20 + (** {1 Types} *) 21 + 22 + type t = H1_server.request 23 + (** Request type - re-exported from H1_server for convenience *) 24 + 25 + (** {1 Basic accessors} *) 26 + 27 + let meth (req : t) = req.meth 28 + let target (req : t) = req.target 29 + let headers (req : t) = req.headers 30 + 31 + (** Read the request body as a string (reads lazily on first call) *) 32 + let body (req : t) = H1_server.read_body req 33 + 34 + (** Get the body reader for streaming access *) 35 + let body_reader (req : t) = req.body_reader 36 + 37 + (** {1 Path and Query} *) 38 + 39 + (** Get the path component (without query string) *) 40 + let path (req : t) = 41 + match String.index_opt req.target '?' with 42 + | Some i -> String.sub req.target 0 i 43 + | None -> req.target 44 + 45 + (** Get the query string (without leading ?) *) 46 + let query_string (req : t) = 47 + match String.index_opt req.target '?' with 48 + | Some i -> 49 + Some (String.sub req.target (i + 1) (String.length req.target - i - 1)) 50 + | None -> None 51 + 52 + (** Parse query string into key-value pairs *) 53 + let query_params (req : t) = 54 + match query_string req with 55 + | None -> [] 56 + | Some qs -> 57 + let pairs = String.split_on_char '&' qs in 58 + List.filter_map 59 + (fun pair -> 60 + match String.index_opt pair '=' with 61 + | Some i -> 62 + let key = String.sub pair 0 i in 63 + let value = 64 + String.sub pair (i + 1) (String.length pair - i - 1) 65 + in 66 + (* URL decode *) 67 + Some (Uri.pct_decode key, Uri.pct_decode value) 68 + | None -> Some (Uri.pct_decode pair, "")) 69 + pairs 70 + 71 + (** Get a single query parameter value *) 72 + let query key req = List.assoc_opt key (query_params req) 73 + 74 + (** Get all values for a query parameter *) 75 + let query_all key req = 76 + List.filter_map 77 + (fun (k, v) -> if String.equal k key then Some v else None) 78 + (query_params req) 79 + 80 + (** {1 Header accessors} *) 81 + 82 + (** Get a header value (case-insensitive) *) 83 + let header name (req : t) = H1.Headers.get req.headers name 84 + 85 + (** Get all values for a header *) 86 + let header_all name (req : t) = H1.Headers.get_multi req.headers name 87 + 88 + (** Check if header exists *) 89 + let has_header name (req : t) = 90 + match H1.Headers.get req.headers name with Some _ -> true | None -> false 91 + 92 + (** {1 Common header helpers} *) 93 + 94 + (** Get Content-Type header *) 95 + let content_type req = header "content-type" req 96 + 97 + (** Get Content-Length header as int64 *) 98 + let content_length req = 99 + match header "content-length" req with 100 + | Some s -> Int64.of_string_opt s 101 + | None -> None 102 + 103 + (** Check if request is keep-alive *) 104 + let is_keep_alive (req : t) = 105 + match header "connection" req with 106 + | Some v -> String.lowercase_ascii v = "keep-alive" 107 + | None -> true (* HTTP/1.1 default is keep-alive *) 108 + 109 + (** Get Host header *) 110 + let host req = header "host" req 111 + 112 + (** Get Accept header *) 113 + let accept req = header "accept" req 114 + 115 + (** Get Authorization header *) 116 + let authorization req = header "authorization" req 117 + 118 + (** Check if request accepts JSON *) 119 + let accepts_json req = 120 + match accept req with 121 + | Some v -> string_contains v "application/json" || string_contains v "*/*" 122 + | None -> false 123 + 124 + (** Check if request accepts HTML *) 125 + let accepts_html req = 126 + match accept req with 127 + | Some v -> string_contains v "text/html" || string_contains v "*/*" 128 + | None -> false 129 + 130 + (** {1 Method helpers} *) 131 + 132 + let is_get (req : t) = req.meth = `GET 133 + let is_post (req : t) = req.meth = `POST 134 + let is_put (req : t) = req.meth = `PUT 135 + let is_delete (req : t) = req.meth = `DELETE 136 + let is_patch (req : t) = req.meth = `Other "PATCH" 137 + let is_head (req : t) = req.meth = `HEAD 138 + let is_options (req : t) = req.meth = `OPTIONS 139 + 140 + (** Check if method is safe (GET, HEAD, OPTIONS) *) 141 + let is_safe (req : t) = 142 + match req.meth with `GET | `HEAD | `OPTIONS -> true | _ -> false 143 + 144 + (** Check if method is idempotent (GET, HEAD, PUT, DELETE, OPTIONS) *) 145 + let is_idempotent (req : t) = 146 + match req.meth with 147 + | `GET | `HEAD | `PUT | `DELETE | `OPTIONS -> true 148 + | _ -> false 149 + 150 + (** {1 Body helpers} *) 151 + 152 + let body_string (req : t) = body req 153 + let body_length (req : t) = String.length (body req) 154 + let has_body (req : t) = String.length (body req) > 0 155 + 156 + (** {1 Form data parsing} *) 157 + 158 + let form_data (req : t) = 159 + let b = body req in 160 + if String.length b = 0 then [] 161 + else 162 + let pairs = String.split_on_char '&' b in 163 + List.filter_map 164 + (fun pair -> 165 + match String.index_opt pair '=' with 166 + | Some i -> 167 + let key = String.sub pair 0 i in 168 + let value = String.sub pair (i + 1) (String.length pair - i - 1) in 169 + Some (Uri.pct_decode key, Uri.pct_decode value) 170 + | None -> Some (Uri.pct_decode pair, "")) 171 + pairs 172 + 173 + let form_field key req = List.assoc_opt key (form_data req)
+249
lib/response.ml
··· 1 + (** Response helper functions. 2 + 3 + This module provides utility functions for creating and modifying HTTP 4 + responses, including status shortcuts, body helpers, and header 5 + manipulation. *) 6 + 7 + (** {1 Types} *) 8 + 9 + type t = H1_server.response 10 + 11 + let make ?(status = `OK) ?(headers = []) body : t = 12 + { status; headers; response_body = H1_server.Body_string body } 13 + 14 + let empty ?(status = `OK) ?(headers = []) () : t = 15 + { status; headers; response_body = H1_server.Body_string "" } 16 + 17 + (** {1 Status shortcuts - 2xx Success} *) 18 + 19 + let ok ?(headers = []) body : t = 20 + { status = `OK; headers; response_body = H1_server.Body_string body } 21 + 22 + let created ?(headers = []) ?location body : t = 23 + let headers = 24 + match location with 25 + | Some loc -> ("Location", loc) :: headers 26 + | None -> headers 27 + in 28 + { status = `Created; headers; response_body = H1_server.Body_string body } 29 + 30 + let accepted ?(headers = []) body : t = 31 + { status = `Accepted; headers; response_body = H1_server.Body_string body } 32 + 33 + let no_content ?(headers = []) () : t = 34 + { status = `No_content; headers; response_body = H1_server.Body_string "" } 35 + 36 + (** {1 Status shortcuts - 3xx Redirection} *) 37 + 38 + let redirect ?(permanent = false) ?(headers = []) location : t = 39 + let status = if permanent then `Moved_permanently else `Found in 40 + { 41 + status; 42 + headers = ("Location", location) :: headers; 43 + response_body = H1_server.Body_string ""; 44 + } 45 + 46 + let moved_permanently ?(headers = []) location : t = 47 + redirect ~permanent:true ~headers location 48 + 49 + let found ?(headers = []) location : t = 50 + redirect ~permanent:false ~headers location 51 + 52 + let see_other ?(headers = []) location : t = 53 + { 54 + status = `See_other; 55 + headers = ("Location", location) :: headers; 56 + response_body = H1_server.Body_string ""; 57 + } 58 + 59 + let temporary_redirect ?(headers = []) location : t = 60 + { 61 + status = `Temporary_redirect; 62 + headers = ("Location", location) :: headers; 63 + response_body = H1_server.Body_string ""; 64 + } 65 + 66 + let not_modified ?(headers = []) () : t = 67 + { status = `Not_modified; headers; response_body = H1_server.Body_string "" } 68 + 69 + (** {1 Status shortcuts - 4xx Client Errors} *) 70 + 71 + let bad_request ?(headers = []) ?(body = "Bad Request") () : t = 72 + { status = `Bad_request; headers; response_body = H1_server.Body_string body } 73 + 74 + let unauthorized ?(headers = []) ?www_authenticate () : t = 75 + let headers = 76 + match www_authenticate with 77 + | Some auth -> ("WWW-Authenticate", auth) :: headers 78 + | None -> headers 79 + in 80 + { 81 + status = `Unauthorized; 82 + headers; 83 + response_body = H1_server.Body_string "Unauthorized"; 84 + } 85 + 86 + let forbidden ?(headers = []) ?(body = "Forbidden") () : t = 87 + { status = `Forbidden; headers; response_body = H1_server.Body_string body } 88 + 89 + let not_found ?(headers = []) ?(body = "Not Found") () : t = 90 + { status = `Not_found; headers; response_body = H1_server.Body_string body } 91 + 92 + let method_not_allowed ?(headers = []) ~allowed () : t = 93 + let allow_header = 94 + String.concat ", " (List.map H1.Method.to_string allowed) 95 + in 96 + { 97 + status = `Method_not_allowed; 98 + headers = ("Allow", allow_header) :: headers; 99 + response_body = H1_server.Body_string "Method Not Allowed"; 100 + } 101 + 102 + let conflict ?(headers = []) ?(body = "Conflict") () : t = 103 + { status = `Conflict; headers; response_body = H1_server.Body_string body } 104 + 105 + let gone ?(headers = []) ?(body = "Gone") () : t = 106 + { status = `Gone; headers; response_body = H1_server.Body_string body } 107 + 108 + let unprocessable_entity ?(headers = []) ?(body = "Unprocessable Entity") () : t 109 + = 110 + { status = `Code 422; headers; response_body = H1_server.Body_string body } 111 + 112 + let too_many_requests ?(headers = []) ?retry_after () : t = 113 + let headers = 114 + match retry_after with 115 + | Some secs -> ("Retry-After", string_of_int secs) :: headers 116 + | None -> headers 117 + in 118 + { 119 + status = `Code 429; 120 + headers; 121 + response_body = H1_server.Body_string "Too Many Requests"; 122 + } 123 + 124 + (** {1 Status shortcuts - 5xx Server Errors} *) 125 + 126 + let internal_error ?(headers = []) ?(body = "Internal Server Error") () : t = 127 + { 128 + status = `Internal_server_error; 129 + headers; 130 + response_body = H1_server.Body_string body; 131 + } 132 + 133 + let not_implemented ?(headers = []) ?(body = "Not Implemented") () : t = 134 + { 135 + status = `Not_implemented; 136 + headers; 137 + response_body = H1_server.Body_string body; 138 + } 139 + 140 + let bad_gateway ?(headers = []) ?(body = "Bad Gateway") () : t = 141 + { status = `Bad_gateway; headers; response_body = H1_server.Body_string body } 142 + 143 + let service_unavailable ?(headers = []) ?retry_after () : t = 144 + let headers = 145 + match retry_after with 146 + | Some secs -> ("Retry-After", string_of_int secs) :: headers 147 + | None -> headers 148 + in 149 + { 150 + status = `Service_unavailable; 151 + headers; 152 + response_body = H1_server.Body_string "Service Unavailable"; 153 + } 154 + 155 + let gateway_timeout ?(headers = []) ?(body = "Gateway Timeout") () : t = 156 + { 157 + status = `Gateway_timeout; 158 + headers; 159 + response_body = H1_server.Body_string body; 160 + } 161 + 162 + (** {1 Content-Type helpers} *) 163 + 164 + let text ?(status = `OK) body : t = 165 + { 166 + status; 167 + headers = [ ("Content-Type", "text/plain; charset=utf-8") ]; 168 + response_body = H1_server.Body_string body; 169 + } 170 + 171 + let html ?(status = `OK) body : t = 172 + { 173 + status; 174 + headers = [ ("Content-Type", "text/html; charset=utf-8") ]; 175 + response_body = H1_server.Body_string body; 176 + } 177 + 178 + let json ?(status = `OK) body : t = 179 + { 180 + status; 181 + headers = [ ("Content-Type", "application/json; charset=utf-8") ]; 182 + response_body = H1_server.Body_string body; 183 + } 184 + 185 + let xml ?(status = `OK) body : t = 186 + { 187 + status; 188 + headers = [ ("Content-Type", "application/xml; charset=utf-8") ]; 189 + response_body = H1_server.Body_string body; 190 + } 191 + 192 + (** {1 Response modifiers} *) 193 + 194 + (** Add a header to the response *) 195 + let with_header name value (resp : t) : t = 196 + { resp with headers = (name, value) :: resp.headers } 197 + 198 + (** Add multiple headers to the response *) 199 + let with_headers headers (resp : t) : t = 200 + { resp with headers = headers @ resp.headers } 201 + 202 + (** Replace the response body *) 203 + let with_body body (resp : t) : t = 204 + { resp with response_body = H1_server.Body_string body } 205 + 206 + (** Replace the response status *) 207 + let with_status status (resp : t) : t = { resp with status } 208 + 209 + (** Set Content-Type header *) 210 + let with_content_type content_type resp = 211 + with_header "Content-Type" content_type resp 212 + 213 + (** Set Cache-Control header *) 214 + let with_cache_control directive resp = 215 + with_header "Cache-Control" directive resp 216 + 217 + (** Set no-cache headers *) 218 + let with_no_cache resp = 219 + resp 220 + |> with_header "Cache-Control" "no-store, no-cache, must-revalidate" 221 + |> with_header "Pragma" "no-cache" 222 + 223 + (** Set CORS headers for all origins *) 224 + let with_cors ?(origin = "*") ?(methods = "GET, POST, PUT, DELETE, OPTIONS") 225 + ?(headers = "Content-Type, Authorization") resp = 226 + resp 227 + |> with_header "Access-Control-Allow-Origin" origin 228 + |> with_header "Access-Control-Allow-Methods" methods 229 + |> with_header "Access-Control-Allow-Headers" headers 230 + 231 + (** {1 Cookie helpers} *) 232 + 233 + (** Set a cookie *) 234 + let with_cookie ?(path = "/") ?(http_only = true) ?(secure = false) 235 + ?(same_site = "Lax") ?max_age name value resp = 236 + let parts = [ Printf.sprintf "%s=%s" name value; "Path=" ^ path ] in 237 + let parts = if http_only then parts @ [ "HttpOnly" ] else parts in 238 + let parts = if secure then parts @ [ "Secure" ] else parts in 239 + let parts = parts @ [ "SameSite=" ^ same_site ] in 240 + let parts = 241 + match max_age with 242 + | Some age -> parts @ [ Printf.sprintf "Max-Age=%d" age ] 243 + | None -> parts 244 + in 245 + with_header "Set-Cookie" (String.concat "; " parts) resp 246 + 247 + (** Clear a cookie *) 248 + let clear_cookie ?(path = "/") name resp = 249 + with_cookie ~path ~max_age:0 name "" resp
+176
lib/router.ml
··· 1 + (** Type-safe router with radix trie for efficient path matching. 2 + 3 + This module provides: 4 + - Type-safe path patterns with parameter extraction 5 + - Radix trie for O(path_length) route lookup 6 + - Hashtbl for O(1) literal segment matching 7 + - Middleware support 8 + - Route scoping/grouping *) 9 + 10 + (** Path segment types *) 11 + type segment = 12 + | Literal of string (** Exact match *) 13 + | Param of string (** Named parameter capture *) 14 + | Wildcard (** Match rest of path *) 15 + 16 + type params = (string * string) list 17 + (** Parsed path parameters *) 18 + 19 + type 'a route = { 20 + method_ : H1.Method.t option; (** None = match any method *) 21 + segments : segment list; 22 + handler : 'a; 23 + } 24 + (** A route definition *) 25 + 26 + type 'a trie_node = { 27 + mutable handlers : (H1.Method.t option * 'a) list; 28 + (** Handlers at this node *) 29 + literal_children : (string, 'a trie_node) Hashtbl.t; 30 + (** O(1) lookup for literal segments *) 31 + mutable param_child : (string * 'a trie_node) option; 32 + (** Single param child (with param name) *) 33 + mutable wildcard_child : 'a trie_node option; (** Single wildcard child *) 34 + } 35 + (** Radix trie node with optimized child storage *) 36 + 37 + type 'a t = { root : 'a trie_node } 38 + (** Compiled router *) 39 + 40 + (** Create empty trie node *) 41 + let empty_node () = 42 + { 43 + handlers = []; 44 + literal_children = Hashtbl.create 8; 45 + param_child = None; 46 + wildcard_child = None; 47 + } 48 + 49 + (** Create empty router *) 50 + let empty () = { root = empty_node () } 51 + 52 + (** Parse path string into segments *) 53 + let parse_path path = 54 + let path = 55 + if String.length path > 0 && path.[0] = '/' then 56 + String.sub path 1 (String.length path - 1) 57 + else path 58 + in 59 + if path = "" then [] 60 + else 61 + String.split_on_char '/' path 62 + |> List.filter (fun s -> s <> "") 63 + |> List.map (fun s -> 64 + if String.length s > 0 && s.[0] = ':' then 65 + Param (String.sub s 1 (String.length s - 1)) 66 + else if s = "*" then Wildcard 67 + else Literal s) 68 + 69 + (** Find or create child node for segment *) 70 + let find_or_create_child node seg = 71 + match seg with 72 + | Literal s -> ( 73 + match Hashtbl.find_opt node.literal_children s with 74 + | Some child -> child 75 + | None -> 76 + let child = empty_node () in 77 + Hashtbl.add node.literal_children s child; 78 + child) 79 + | Param name -> ( 80 + match node.param_child with 81 + | Some (_, child) -> child 82 + | None -> 83 + let child = empty_node () in 84 + node.param_child <- Some (name, child); 85 + child) 86 + | Wildcard -> ( 87 + match node.wildcard_child with 88 + | Some child -> child 89 + | None -> 90 + let child = empty_node () in 91 + node.wildcard_child <- Some child; 92 + child) 93 + 94 + (** Add a route to the trie *) 95 + let add_route router ~method_ ~path ~handler = 96 + let segments = parse_path path in 97 + let rec insert node = function 98 + | [] -> node.handlers <- (method_, handler) :: node.handlers 99 + | seg :: rest -> 100 + let child = find_or_create_child node seg in 101 + insert child rest 102 + in 103 + insert router.root segments 104 + 105 + (** Lookup a path in the trie - optimized with index-based parsing *) 106 + let lookup router ~method_ ~path = 107 + let len = String.length path in 108 + let start = if len > 0 && path.[0] = '/' then 1 else 0 in 109 + 110 + let rec search node pos params = 111 + if pos >= len then 112 + List.find_opt 113 + (fun (m, _) -> match m with None -> true | Some m' -> m' = method_) 114 + node.handlers 115 + |> Option.map (fun (_, handler) -> (handler, params)) 116 + else 117 + let seg_end = 118 + try String.index_from path pos '/' with Not_found -> len 119 + in 120 + if seg_end = pos then search node (pos + 1) params 121 + else 122 + let seg = String.sub path pos (seg_end - pos) in 123 + let next_pos = if seg_end < len then seg_end + 1 else len in 124 + match Hashtbl.find_opt node.literal_children seg with 125 + | Some child -> search child next_pos params 126 + | None -> ( 127 + match node.param_child with 128 + | Some (name, child) -> search child next_pos ((name, seg) :: params) 129 + | None -> ( 130 + match node.wildcard_child with 131 + | Some child -> 132 + let rest_path = String.sub path pos (len - pos) in 133 + search child len (("*", rest_path) :: params) 134 + | None -> None)) 135 + in 136 + search router.root start [] 137 + 138 + (** Route builder DSL *) 139 + module Route = struct 140 + type 'a t = { method_ : H1.Method.t option; path : string; handler : 'a } 141 + 142 + let get path handler = { method_ = Some `GET; path; handler } 143 + let post path handler = { method_ = Some `POST; path; handler } 144 + let put path handler = { method_ = Some `PUT; path; handler } 145 + let delete path handler = { method_ = Some `DELETE; path; handler } 146 + let patch path handler = { method_ = Some (`Other "PATCH"); path; handler } 147 + let head path handler = { method_ = Some `HEAD; path; handler } 148 + let options path handler = { method_ = Some `OPTIONS; path; handler } 149 + let any path handler = { method_ = None; path; handler } 150 + end 151 + 152 + (** Compile routes into a router *) 153 + let compile routes = 154 + let router = empty () in 155 + List.iter 156 + (fun r -> 157 + add_route router ~method_:r.Route.method_ ~path:r.path ~handler:r.handler) 158 + routes; 159 + router 160 + 161 + (** Get parameter from params list *) 162 + let param name params = List.assoc_opt name params 163 + 164 + (** Get parameter with default *) 165 + let param_or name ~default params = 166 + match List.assoc_opt name params with Some v -> v | None -> default 167 + 168 + (** Get parameter as int *) 169 + let param_int name params = 170 + match List.assoc_opt name params with 171 + | Some v -> int_of_string_opt v 172 + | None -> None 173 + 174 + (** Get parameter as int with default *) 175 + let param_int_or name ~default params = 176 + match param_int name params with Some v -> v | None -> default
+1195
lib/server.ml
··· 1 + (** Unified HTTP Server - High-performance server supporting HTTP/1.1, HTTP/2, 2 + and WebSocket. 3 + 4 + This module consolidates all server functionality into a single, 5 + high-performance implementation. Protocol detection overhead is optional and 6 + controlled via the [protocol] configuration. 7 + 8 + {2 Protocol Modes} 9 + 10 + - [Http1_only]: Fastest path. No protocol detection, direct HTTP/1.1 11 + handling. 12 + - [Http2_only]: HTTP/2 only. h2c (cleartext) or h2 (over TLS). 13 + - [Auto]: Auto-detect protocol via connection preface peek (h2c) or ALPN 14 + (TLS). 15 + - [Auto_websocket]: Auto-detect with WebSocket upgrade support. 16 + 17 + {2 Performance Features} 18 + 19 + - GC tuning for high-throughput scenarios 20 + - Cached Date headers (1-second resolution) 21 + - Pre-built responses for zero-allocation hot paths 22 + - Zero-copy bigstring responses 23 + - Streaming response support 24 + - Multi-domain parallelism via Eio 25 + 26 + {2 Example} 27 + 28 + {[ 29 + (* Fastest HTTP/1.1 server *) 30 + let handler req = Server.respond "Hello, World!" in 31 + Server.run ~sw ~net handler 32 + 33 + (* Multi-protocol with auto-detection *) 34 + let config = Server.{ default_config with protocol = Auto } in 35 + Server.run ~sw ~net ~config handler 36 + ]} *) 37 + 38 + open Eio.Std 39 + 40 + (** {1 Protocol Configuration} *) 41 + 42 + (** Protocol mode controls detection overhead and supported protocols. *) 43 + type protocol = 44 + | Http1_only 45 + (** Fastest: No protocol detection, direct HTTP/1.1 handling. With TLS: 46 + HTTP/1.1 over TLS (no ALPN negotiation). *) 47 + | Http2_only 48 + (** HTTP/2 only. Without TLS: h2c (HTTP/2 cleartext). With TLS: h2 with 49 + ALPN advertising only "h2". *) 50 + | Auto 51 + (** Auto-detect protocol. Without TLS: Peek for "PRI " preface (h2c), 52 + fallback to HTTP/1.1. With TLS: ALPN negotiation. *) 53 + | Auto_websocket 54 + (** Auto-detect with WebSocket support. Same as [Auto] but also handles 55 + WebSocket upgrade requests in HTTP/1.1 mode. *) 56 + 57 + (** {1 GC Tuning} *) 58 + 59 + module Gc_tune = struct 60 + type config = { 61 + minor_heap_size : int; (** Minor heap size in bytes. Default: 64MB *) 62 + major_heap_increment : int; 63 + (** Major heap increment in bytes. Default: 32MB *) 64 + space_overhead : int; (** Space overhead percentage. Default: 200 *) 65 + max_overhead : int; (** Max overhead percentage. Default: 500 *) 66 + } 67 + 68 + let default = 69 + { 70 + minor_heap_size = 64 * 1024 * 1024; 71 + major_heap_increment = 32 * 1024 * 1024; 72 + space_overhead = 200; 73 + max_overhead = 500; 74 + } 75 + 76 + let aggressive = 77 + { 78 + minor_heap_size = 128 * 1024 * 1024; 79 + major_heap_increment = 64 * 1024 * 1024; 80 + space_overhead = 400; 81 + max_overhead = 1000; 82 + } 83 + 84 + let tuned = ref false 85 + 86 + let apply ?(config = default) () = 87 + if not !tuned then begin 88 + let ctrl = Gc.get () in 89 + Gc.set 90 + { 91 + ctrl with 92 + minor_heap_size = config.minor_heap_size / (Sys.word_size / 8); 93 + major_heap_increment = 94 + config.major_heap_increment / (Sys.word_size / 8); 95 + space_overhead = config.space_overhead; 96 + max_overhead = config.max_overhead; 97 + }; 98 + tuned := true 99 + end 100 + end 101 + 102 + (** {1 Date Header Cache} *) 103 + 104 + module Date_cache = struct 105 + let day_names = [| "Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat" |] 106 + 107 + let month_names = 108 + [| 109 + "Jan"; 110 + "Feb"; 111 + "Mar"; 112 + "Apr"; 113 + "May"; 114 + "Jun"; 115 + "Jul"; 116 + "Aug"; 117 + "Sep"; 118 + "Oct"; 119 + "Nov"; 120 + "Dec"; 121 + |] 122 + 123 + let cached_date = Atomic.make "" 124 + let cached_time = Atomic.make 0. 125 + 126 + let format_date () = 127 + let t = Unix.gettimeofday () in 128 + let tm = Unix.gmtime t in 129 + Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" 130 + day_names.(tm.Unix.tm_wday) 131 + tm.Unix.tm_mday 132 + month_names.(tm.Unix.tm_mon) 133 + (1900 + tm.Unix.tm_year) tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 134 + 135 + let[@inline] get () = 136 + let now = Unix.gettimeofday () in 137 + let last = Atomic.get cached_time in 138 + if now -. last >= 1.0 then begin 139 + let date = format_date () in 140 + Atomic.set cached_date date; 141 + Atomic.set cached_time now; 142 + date 143 + end 144 + else Atomic.get cached_date 145 + end 146 + 147 + (** {1 Pre-built Responses} *) 148 + 149 + (** Pre-built response for zero-allocation hot paths. Create once at startup, 150 + respond many times without allocation. *) 151 + module Prebuilt = struct 152 + type t = { 153 + status : H1.Status.t; 154 + headers : H1.Headers.t; 155 + body : Bigstringaf.t; 156 + (* Cached H1 response with date header - regenerated per-second (thread-safe) *) 157 + cached_h1_response : H1.Response.t Atomic.t; 158 + cached_second : int Atomic.t; 159 + } 160 + 161 + (** Create a pre-built response from a string body. *) 162 + let create ~status ?(headers = []) body = 163 + let body_bstr = 164 + Bigstringaf.of_string ~off:0 ~len:(String.length body) body 165 + in 166 + let body_len = Bigstringaf.length body_bstr in 167 + let all_headers = ("content-length", string_of_int body_len) :: headers in 168 + let h1_headers = H1.Headers.of_list all_headers in 169 + let now = int_of_float (Unix.gettimeofday ()) in 170 + let headers_with_date = 171 + H1.Headers.add h1_headers "date" (Date_cache.get ()) 172 + in 173 + let cached_resp = H1.Response.create ~headers:headers_with_date status in 174 + { 175 + status; 176 + headers = h1_headers; 177 + body = body_bstr; 178 + cached_h1_response = Atomic.make cached_resp; 179 + cached_second = Atomic.make now; 180 + } 181 + 182 + (** Create a pre-built response from a bigstring body. *) 183 + let create_bigstring ~status ?(headers = []) body = 184 + let body_len = Bigstringaf.length body in 185 + let all_headers = ("content-length", string_of_int body_len) :: headers in 186 + let h1_headers = H1.Headers.of_list all_headers in 187 + let now = int_of_float (Unix.gettimeofday ()) in 188 + let headers_with_date = 189 + H1.Headers.add h1_headers "date" (Date_cache.get ()) 190 + in 191 + let cached_resp = H1.Response.create ~headers:headers_with_date status in 192 + { 193 + status; 194 + headers = h1_headers; 195 + body; 196 + cached_h1_response = Atomic.make cached_resp; 197 + cached_second = Atomic.make now; 198 + } 199 + 200 + (** Get cached H1 response, regenerating if second has changed. *) 201 + let[@inline] get_cached_h1_response t = 202 + let now = int_of_float (Unix.gettimeofday ()) in 203 + let last = Atomic.get t.cached_second in 204 + if now <> last then begin 205 + let headers = H1.Headers.add t.headers "date" (Date_cache.get ()) in 206 + let resp = H1.Response.create ~headers t.status in 207 + Atomic.set t.cached_h1_response resp; 208 + Atomic.set t.cached_second now 209 + end; 210 + Atomic.get t.cached_h1_response 211 + 212 + (** Respond to an H1 request descriptor with a pre-built response. Uses cached 213 + response that only regenerates when the second changes. *) 214 + let[@inline] respond_h1 reqd t = 215 + let response = get_cached_h1_response t in 216 + H1.Reqd.respond_with_bigstring reqd response t.body 217 + 218 + (** Respond to an H2 request descriptor with a pre-built response. *) 219 + let[@inline] respond_h2 reqd t = 220 + let result = ref [] in 221 + H1.Headers.iter 222 + ~f:(fun name value -> result := (name, value) :: !result) 223 + t.headers; 224 + let h2_headers = H2.Headers.of_list (List.rev !result) in 225 + let h2_status = (t.status :> H2.Status.t) in 226 + let response = H2.Response.create ~headers:h2_headers h2_status in 227 + H2.Reqd.respond_with_bigstring reqd response t.body 228 + end 229 + 230 + (** {1 Types} *) 231 + 232 + type config = { 233 + (* Network *) 234 + host : string; (** Bind address. Default: "0.0.0.0" *) 235 + port : int; (** Listen port. Default: 8080 *) 236 + backlog : int; (** Listen backlog. Default: 4096 *) 237 + max_connections : int; (** Max concurrent connections. Default: 100000 *) 238 + (* Parallelism *) 239 + domain_count : int; (** Number of domains (CPUs) to use. Default: 1 *) 240 + (* Protocol *) 241 + protocol : protocol; (** Protocol mode. Default: Http1_only *) 242 + (* Timeouts *) 243 + read_timeout : float; (** Read timeout in seconds. Default: 60.0 *) 244 + write_timeout : float; (** Write timeout in seconds. Default: 60.0 *) 245 + idle_timeout : float; (** Idle connection timeout. Default: 120.0 *) 246 + request_timeout : float; (** Request processing timeout. Default: 30.0 *) 247 + (* Limits *) 248 + max_header_size : int; (** Max header size in bytes. Default: 8192 *) 249 + max_body_size : int64 option; 250 + (** Max body size. None = unlimited. Default: None *) 251 + (* Buffers *) 252 + buffer_size : int; (** Read buffer size. Default: 16384 *) 253 + (* Socket options *) 254 + tcp_nodelay : bool; (** Set TCP_NODELAY on connections. Default: true *) 255 + reuse_addr : bool; (** Set SO_REUSEADDR on listener. Default: true *) 256 + reuse_port : bool; (** Set SO_REUSEPORT on listener. Default: true *) 257 + (* TLS *) 258 + tls : Tls_config.Server.t option; (** TLS config. None = plain HTTP *) 259 + (* Performance *) 260 + gc_tuning : Gc_tune.config option; 261 + (** GC tuning config. Some = apply tuning. Default: Some Gc_tune.default 262 + *) 263 + } 264 + (** Server configuration. *) 265 + 266 + (** Default configuration optimized for HTTP/1.1 performance. *) 267 + let default_config = 268 + { 269 + host = "0.0.0.0"; 270 + port = 8080; 271 + backlog = 4096; 272 + max_connections = 100000; 273 + domain_count = 1; 274 + protocol = Http1_only; 275 + read_timeout = 60.0; 276 + write_timeout = 60.0; 277 + idle_timeout = 120.0; 278 + request_timeout = 30.0; 279 + max_header_size = 8192; 280 + max_body_size = None; 281 + buffer_size = 16384; 282 + tcp_nodelay = true; 283 + reuse_addr = true; 284 + reuse_port = true; 285 + tls = None; 286 + gc_tuning = Some Gc_tune.default; 287 + } 288 + 289 + (** Configuration for auto-detection mode. *) 290 + let auto_config = { default_config with protocol = Auto } 291 + 292 + (** Configuration for WebSocket support. *) 293 + let websocket_config = { default_config with protocol = Auto_websocket } 294 + 295 + (** {2 Config Builders} *) 296 + 297 + let with_port port config = { config with port } 298 + let with_host host config = { config with host } 299 + let with_backlog backlog config = { config with backlog } 300 + let with_max_connections max config = { config with max_connections = max } 301 + let with_domain_count count config = { config with domain_count = count } 302 + let with_protocol protocol config = { config with protocol } 303 + let with_read_timeout timeout config = { config with read_timeout = timeout } 304 + let with_write_timeout timeout config = { config with write_timeout = timeout } 305 + let with_idle_timeout timeout config = { config with idle_timeout = timeout } 306 + 307 + let with_request_timeout timeout config = 308 + { config with request_timeout = timeout } 309 + 310 + let with_max_header_size size config = { config with max_header_size = size } 311 + let with_max_body_size size config = { config with max_body_size = Some size } 312 + let with_buffer_size size config = { config with buffer_size = size } 313 + let with_tcp_nodelay enabled config = { config with tcp_nodelay = enabled } 314 + let with_tls tls config = { config with tls = Some tls } 315 + let with_gc_tuning gc config = { config with gc_tuning = Some gc } 316 + let without_gc_tuning config = { config with gc_tuning = None } 317 + 318 + (** {1 Request/Response Types} *) 319 + 320 + (** Protocol version indicator. *) 321 + type protocol_version = HTTP_1_1 | HTTP_2 322 + 323 + type request = { 324 + meth : H1.Method.t; (** HTTP method *) 325 + target : string; (** Request target (path + query) *) 326 + headers : (string * string) list; (** Headers as association list *) 327 + body : string; (** Request body (empty for GET/HEAD) *) 328 + version : protocol_version; (** Protocol version *) 329 + } 330 + (** Request type exposed to handlers. *) 331 + 332 + (** Response body variants. *) 333 + type response_body = 334 + | Body_empty (** Empty body (for 204 No Content, etc.) *) 335 + | Body_string of string (** String body - will be copied *) 336 + | Body_bigstring of Bigstringaf.t (** Bigstring body - zero-copy *) 337 + | Body_prebuilt of Prebuilt.t (** Pre-built response - zero allocation *) 338 + | Body_stream of { 339 + content_length : int64 option; 340 + next : unit -> Cstruct.t option; 341 + } (** Streaming body *) 342 + 343 + type response = { 344 + status : H1.Status.t; 345 + headers : (string * string) list; 346 + body : response_body; 347 + } 348 + (** Response type. *) 349 + 350 + type handler = request -> response 351 + (** Handler type. *) 352 + 353 + type ws_handler = Websocket.t -> unit 354 + (** WebSocket handler type. *) 355 + 356 + (** {1 Response Helpers} *) 357 + 358 + (** Create a response with a string body. *) 359 + let respond ?(status = `OK) ?(headers = []) body = 360 + { status; headers; body = Body_string body } 361 + 362 + (** Create a response with a bigstring body (zero-copy). *) 363 + let respond_bigstring ?(status = `OK) ?(headers = []) body = 364 + { status; headers; body = Body_bigstring body } 365 + 366 + (** Create a response with a pre-built body (zero-allocation). *) 367 + let respond_prebuilt prebuilt = 368 + { 369 + status = prebuilt.Prebuilt.status; 370 + headers = []; 371 + body = Body_prebuilt prebuilt; 372 + } 373 + 374 + (** Create an empty response. *) 375 + let respond_empty ?(status = `No_content) ?(headers = []) () = 376 + { status; headers; body = Body_empty } 377 + 378 + (** Create a streaming response. *) 379 + let respond_stream ?(status = `OK) ?(headers = []) ?content_length next = 380 + { status; headers; body = Body_stream { content_length; next } } 381 + 382 + (** Create a plain text response. *) 383 + let respond_text ?(status = `OK) body = 384 + { 385 + status; 386 + headers = [ ("content-type", "text/plain; charset=utf-8") ]; 387 + body = Body_string body; 388 + } 389 + 390 + (** Create an HTML response. *) 391 + let respond_html ?(status = `OK) body = 392 + { 393 + status; 394 + headers = [ ("content-type", "text/html; charset=utf-8") ]; 395 + body = Body_string body; 396 + } 397 + 398 + (** Create a JSON response. *) 399 + let respond_json ?(status = `OK) body = 400 + { 401 + status; 402 + headers = [ ("content-type", "application/json") ]; 403 + body = Body_string body; 404 + } 405 + 406 + (** {1 Internal: Socket Helpers} *) 407 + 408 + let set_tcp_nodelay flow = 409 + match Eio_unix.Resource.fd_opt flow with 410 + | None -> () 411 + | Some fd -> 412 + Eio_unix.Fd.use_exn "set_tcp_nodelay" fd (fun unix_fd -> 413 + Unix.setsockopt unix_fd Unix.TCP_NODELAY true) 414 + 415 + let shutdown_flow flow cmd = 416 + try Eio.Flow.shutdown flow cmd with 417 + | Unix.Unix_error (Unix.ENOTCONN, _, _) -> () 418 + | Eio.Io (Eio.Exn.X (Eio_unix.Unix_error (Unix.ENOTCONN, _, _)), _) -> () 419 + 420 + let[@inline] writev flow iovecs = 421 + match iovecs with 422 + | [] -> `Ok 0 423 + | [ { Httpun_types.IOVec.buffer; off; len } ] -> ( 424 + let cs = Cstruct.of_bigarray buffer ~off ~len in 425 + match Eio.Flow.write flow [ cs ] with 426 + | () -> `Ok len 427 + | exception End_of_file -> `Closed) 428 + | _ -> ( 429 + let lenv, cstructs = 430 + List.fold_left_map 431 + (fun acc { Httpun_types.IOVec.buffer; off; len } -> 432 + (acc + len, Cstruct.of_bigarray buffer ~off ~len)) 433 + 0 iovecs 434 + in 435 + match Eio.Flow.write flow cstructs with 436 + | () -> `Ok lenv 437 + | exception End_of_file -> `Closed) 438 + 439 + (** {1 Internal: Header Conversion} *) 440 + 441 + let h1_headers_to_list headers = 442 + let result = ref [] in 443 + H1.Headers.iter 444 + ~f:(fun name value -> result := (name, value) :: !result) 445 + headers; 446 + List.rev !result 447 + 448 + let h2_headers_to_list headers = 449 + let result = ref [] in 450 + H2.Headers.iter 451 + ~f:(fun name value -> result := (name, value) :: !result) 452 + headers; 453 + List.rev !result 454 + 455 + (** {1 Internal: Protocol Detection} *) 456 + 457 + (** HTTP/2 connection preface starts with "PRI " *) 458 + let h2_preface_prefix = "PRI " 459 + 460 + let h2_preface_prefix_len = 4 461 + 462 + let peek_bytes flow n = 463 + let buf = Cstruct.create n in 464 + try 465 + let read = Eio.Flow.single_read flow buf in 466 + Ok (Cstruct.to_string (Cstruct.sub buf 0 read)) 467 + with 468 + | End_of_file -> Error `Eof 469 + | exn -> Error (`Exn exn) 470 + 471 + let is_h2_preface data = 472 + String.length data >= h2_preface_prefix_len 473 + && String.sub data 0 h2_preface_prefix_len = h2_preface_prefix 474 + 475 + (** {1 Internal: HTTP/1.1 Connection Handler} *) 476 + 477 + module H1_handler = struct 478 + let handle ~handler ~ws_handler ~initial_data flow = 479 + let buffer_size = 16384 in 480 + let read_buffer = Bigstringaf.create buffer_size in 481 + let read_cstruct = 482 + Cstruct.of_bigarray read_buffer ~off:0 ~len:buffer_size 483 + in 484 + 485 + let pending_data = ref initial_data in 486 + let ws_upgrade = ref None in 487 + 488 + let request_handler reqd = 489 + let req = H1.Reqd.request reqd in 490 + let h1_body = H1.Reqd.request_body reqd in 491 + 492 + (* Check for WebSocket upgrade *) 493 + if Option.is_some ws_handler && Websocket.is_upgrade_request req.headers 494 + then begin 495 + match Websocket.get_websocket_key req.headers with 496 + | Some key -> 497 + ws_upgrade := Some key; 498 + H1.Body.Reader.close h1_body; 499 + let accept = Websocket.compute_accept_key key in 500 + let headers = 501 + H1.Headers.of_list 502 + [ 503 + ("upgrade", "websocket"); 504 + ("connection", "Upgrade"); 505 + ("sec-websocket-accept", accept); 506 + ] 507 + in 508 + H1.Reqd.respond_with_upgrade reqd headers 509 + | None -> 510 + H1.Body.Reader.close h1_body; 511 + let headers = 512 + H1.Headers.of_list 513 + [ ("date", Date_cache.get ()); ("content-length", "11") ] 514 + in 515 + let resp = H1.Response.create ~headers `Bad_request in 516 + H1.Reqd.respond_with_string reqd resp "Bad Request" 517 + end 518 + else begin 519 + (* Regular HTTP/1.1 request *) 520 + (* Read body for POST/PUT, skip for GET/HEAD *) 521 + let body = 522 + match req.meth with 523 + | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE -> 524 + H1.Body.Reader.close h1_body; 525 + "" 526 + | `POST | `PUT | `Other _ -> 527 + let body_buffer = Buffer.create 4096 in 528 + let body_done, resolver = Eio.Promise.create () in 529 + let rec read_body () = 530 + H1.Body.Reader.schedule_read h1_body 531 + ~on_eof:(fun () -> Eio.Promise.resolve resolver ()) 532 + ~on_read:(fun buf ~off ~len -> 533 + Buffer.add_string body_buffer 534 + (Bigstringaf.substring buf ~off ~len); 535 + read_body ()) 536 + in 537 + read_body (); 538 + Eio.Promise.await body_done; 539 + Buffer.contents body_buffer 540 + in 541 + 542 + let request = 543 + { 544 + meth = req.meth; 545 + target = req.target; 546 + headers = h1_headers_to_list req.headers; 547 + body; 548 + version = HTTP_1_1; 549 + } 550 + in 551 + let response = handler request in 552 + 553 + (* Send response *) 554 + let date_header = ("date", Date_cache.get ()) in 555 + match response.body with 556 + | Body_prebuilt prebuilt -> Prebuilt.respond_h1 reqd prebuilt 557 + | Body_empty -> 558 + let headers = 559 + H1.Headers.of_list 560 + (date_header :: ("content-length", "0") :: response.headers) 561 + in 562 + let resp = H1.Response.create ~headers response.status in 563 + H1.Reqd.respond_with_string reqd resp "" 564 + | Body_string body -> 565 + let headers = 566 + H1.Headers.of_list 567 + (date_header 568 + :: ("content-length", string_of_int (String.length body)) 569 + :: response.headers) 570 + in 571 + let resp = H1.Response.create ~headers response.status in 572 + H1.Reqd.respond_with_string reqd resp body 573 + | Body_bigstring body -> 574 + let headers = 575 + H1.Headers.of_list 576 + (date_header 577 + :: ("content-length", string_of_int (Bigstringaf.length body)) 578 + :: response.headers) 579 + in 580 + let resp = H1.Response.create ~headers response.status in 581 + H1.Reqd.respond_with_bigstring reqd resp body 582 + | Body_stream { content_length; next } -> 583 + let headers = 584 + match content_length with 585 + | Some len -> 586 + H1.Headers.of_list 587 + (date_header 588 + :: ("content-length", Int64.to_string len) 589 + :: response.headers) 590 + | None -> 591 + H1.Headers.of_list 592 + (date_header 593 + :: ("transfer-encoding", "chunked") 594 + :: response.headers) 595 + in 596 + let resp = H1.Response.create ~headers response.status in 597 + let body_writer = H1.Reqd.respond_with_streaming reqd resp in 598 + let rec write_chunks () = 599 + match next () with 600 + | None -> H1.Body.Writer.close body_writer 601 + | Some cs -> 602 + H1.Body.Writer.write_bigstring body_writer ~off:0 603 + ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs); 604 + write_chunks () 605 + in 606 + write_chunks () 607 + end 608 + in 609 + 610 + let error_handler ?request:_ _error start_response = 611 + let resp_body = start_response H1.Headers.empty in 612 + H1.Body.Writer.write_string resp_body "Internal Server Error"; 613 + H1.Body.Writer.close resp_body 614 + in 615 + 616 + let conn = H1.Server_connection.create ~error_handler request_handler in 617 + let shutdown = ref false in 618 + 619 + let rec read_loop () = 620 + if not !shutdown then 621 + match H1.Server_connection.next_read_operation conn with 622 + | `Read -> 623 + let socket_data = 624 + match Eio.Flow.single_read flow read_cstruct with 625 + | n -> Cstruct.to_string (Cstruct.sub read_cstruct 0 n) 626 + | exception End_of_file -> "" 627 + in 628 + let data = 629 + if String.length !pending_data > 0 then begin 630 + let combined = !pending_data ^ socket_data in 631 + pending_data := ""; 632 + combined 633 + end 634 + else socket_data 635 + in 636 + let len = String.length data in 637 + if len = 0 then begin 638 + let (_ : int) = 639 + H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0 640 + in 641 + shutdown := true 642 + end 643 + else begin 644 + Bigstringaf.blit_from_string data ~src_off:0 read_buffer 645 + ~dst_off:0 ~len; 646 + let (_ : int) = 647 + H1.Server_connection.read conn read_buffer ~off:0 ~len 648 + in 649 + read_loop () 650 + end 651 + | `Yield -> H1.Server_connection.yield_reader conn read_loop 652 + | `Close -> shutdown := true 653 + | `Upgrade -> shutdown := true 654 + in 655 + 656 + let rec write_loop () = 657 + if not !shutdown then 658 + match H1.Server_connection.next_write_operation conn with 659 + | `Write iovecs -> 660 + let write_result = writev flow iovecs in 661 + H1.Server_connection.report_write_result conn write_result; 662 + write_loop () 663 + | `Yield -> H1.Server_connection.yield_writer conn write_loop 664 + | `Close _ -> 665 + shutdown := true; 666 + shutdown_flow flow `Send 667 + | `Upgrade -> shutdown := true 668 + in 669 + 670 + Fiber.both read_loop write_loop; 671 + 672 + (* Handle WebSocket upgrade if requested *) 673 + match !ws_upgrade with 674 + | Some _key -> ( 675 + match ws_handler with 676 + | Some ws_h -> 677 + let ws = 678 + { 679 + Websocket.flow :> Eio.Flow.two_way_ty Eio.Std.r; 680 + closed = false; 681 + is_client = false; 682 + read_buf = Buffer.create 4096; 683 + } 684 + in 685 + (try ws_h ws with _ -> ()); 686 + if Websocket.is_open ws then Websocket.close ws 687 + | None -> ()) 688 + | None -> () 689 + 690 + (** Direct H1 handler - no protocol detection, no initial data buffering *) 691 + let handle_direct ~handler flow = 692 + let buffer_size = 16384 in 693 + let read_buffer = Bigstringaf.create buffer_size in 694 + let read_cstruct = 695 + Cstruct.of_bigarray read_buffer ~off:0 ~len:buffer_size 696 + in 697 + 698 + let request_handler reqd = 699 + let req = H1.Reqd.request reqd in 700 + let h1_body = H1.Reqd.request_body reqd in 701 + 702 + (* Read body for POST/PUT, skip for GET/HEAD *) 703 + let body = 704 + match req.meth with 705 + | `GET | `HEAD | `DELETE | `OPTIONS | `CONNECT | `TRACE -> 706 + H1.Body.Reader.close h1_body; 707 + "" 708 + | `POST | `PUT | `Other _ -> 709 + let body_buffer = Buffer.create 4096 in 710 + let body_done, resolver = Eio.Promise.create () in 711 + let rec read_body () = 712 + H1.Body.Reader.schedule_read h1_body 713 + ~on_eof:(fun () -> Eio.Promise.resolve resolver ()) 714 + ~on_read:(fun buf ~off ~len -> 715 + Buffer.add_string body_buffer 716 + (Bigstringaf.substring buf ~off ~len); 717 + read_body ()) 718 + in 719 + read_body (); 720 + Eio.Promise.await body_done; 721 + Buffer.contents body_buffer 722 + in 723 + 724 + let request = 725 + { 726 + meth = req.meth; 727 + target = req.target; 728 + headers = h1_headers_to_list req.headers; 729 + body; 730 + version = HTTP_1_1; 731 + } 732 + in 733 + let response = handler request in 734 + 735 + (* Send response *) 736 + let date_header = ("date", Date_cache.get ()) in 737 + match response.body with 738 + | Body_prebuilt prebuilt -> Prebuilt.respond_h1 reqd prebuilt 739 + | Body_empty -> 740 + let headers = 741 + H1.Headers.of_list 742 + (date_header :: ("content-length", "0") :: response.headers) 743 + in 744 + let resp = H1.Response.create ~headers response.status in 745 + H1.Reqd.respond_with_string reqd resp "" 746 + | Body_string body -> 747 + let headers = 748 + H1.Headers.of_list 749 + (date_header 750 + :: ("content-length", string_of_int (String.length body)) 751 + :: response.headers) 752 + in 753 + let resp = H1.Response.create ~headers response.status in 754 + H1.Reqd.respond_with_string reqd resp body 755 + | Body_bigstring body -> 756 + let headers = 757 + H1.Headers.of_list 758 + (date_header 759 + :: ("content-length", string_of_int (Bigstringaf.length body)) 760 + :: response.headers) 761 + in 762 + let resp = H1.Response.create ~headers response.status in 763 + H1.Reqd.respond_with_bigstring reqd resp body 764 + | Body_stream { content_length; next } -> 765 + let headers = 766 + match content_length with 767 + | Some len -> 768 + H1.Headers.of_list 769 + (date_header 770 + :: ("content-length", Int64.to_string len) 771 + :: response.headers) 772 + | None -> 773 + H1.Headers.of_list 774 + (date_header 775 + :: ("transfer-encoding", "chunked") 776 + :: response.headers) 777 + in 778 + let resp = H1.Response.create ~headers response.status in 779 + let body_writer = H1.Reqd.respond_with_streaming reqd resp in 780 + let rec write_chunks () = 781 + match next () with 782 + | None -> H1.Body.Writer.close body_writer 783 + | Some cs -> 784 + H1.Body.Writer.write_bigstring body_writer ~off:0 785 + ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs); 786 + write_chunks () 787 + in 788 + write_chunks () 789 + in 790 + 791 + let error_handler ?request:_ _error start_response = 792 + let resp_body = start_response H1.Headers.empty in 793 + H1.Body.Writer.write_string resp_body "Internal Server Error"; 794 + H1.Body.Writer.close resp_body 795 + in 796 + 797 + let conn = H1.Server_connection.create ~error_handler request_handler in 798 + let shutdown = ref false in 799 + 800 + let rec read_loop () = 801 + if not !shutdown then 802 + match H1.Server_connection.next_read_operation conn with 803 + | `Read -> ( 804 + match Eio.Flow.single_read flow read_cstruct with 805 + | n -> 806 + let (_ : int) = 807 + H1.Server_connection.read conn read_buffer ~off:0 ~len:n 808 + in 809 + read_loop () 810 + | exception End_of_file -> 811 + let (_ : int) = 812 + H1.Server_connection.read_eof conn read_buffer ~off:0 ~len:0 813 + in 814 + shutdown := true) 815 + | `Yield -> H1.Server_connection.yield_reader conn read_loop 816 + | `Close | `Upgrade -> shutdown := true 817 + in 818 + 819 + let rec write_loop () = 820 + if not !shutdown then 821 + match H1.Server_connection.next_write_operation conn with 822 + | `Write iovecs -> 823 + let write_result = writev flow iovecs in 824 + H1.Server_connection.report_write_result conn write_result; 825 + write_loop () 826 + | `Yield -> H1.Server_connection.yield_writer conn write_loop 827 + | `Close _ -> 828 + shutdown := true; 829 + shutdown_flow flow `Send 830 + | `Upgrade -> shutdown := true 831 + in 832 + 833 + Fiber.both read_loop write_loop 834 + end 835 + 836 + (** {1 Internal: HTTP/2 Connection Handler} *) 837 + 838 + module H2_handler = struct 839 + let handle ~handler ~initial_data flow = 840 + let read_buffer_size = 0x4000 in 841 + let read_buffer = Bigstringaf.create read_buffer_size in 842 + let pending_data = ref initial_data in 843 + 844 + let request_handler reqd = 845 + let req = H2.Reqd.request reqd in 846 + let body_reader = H2.Reqd.request_body reqd in 847 + 848 + let body = 849 + match req.meth with 850 + | `GET | `HEAD -> 851 + H2.Body.Reader.close body_reader; 852 + "" 853 + | _ -> 854 + let body_buffer = Buffer.create 4096 in 855 + let body_done, resolver = Eio.Promise.create () in 856 + let rec read_body () = 857 + H2.Body.Reader.schedule_read body_reader 858 + ~on_eof:(fun () -> Eio.Promise.resolve resolver ()) 859 + ~on_read:(fun buf ~off ~len -> 860 + Buffer.add_string body_buffer 861 + (Bigstringaf.substring buf ~off ~len); 862 + read_body ()) 863 + in 864 + read_body (); 865 + Eio.Promise.await body_done; 866 + Buffer.contents body_buffer 867 + in 868 + 869 + let target = 870 + match H2.Headers.get req.headers ":path" with 871 + | Some p -> p 872 + | None -> "/" 873 + in 874 + 875 + let request = 876 + { 877 + meth = req.meth; 878 + target; 879 + headers = h2_headers_to_list req.headers; 880 + body; 881 + version = HTTP_2; 882 + } 883 + in 884 + let response = handler request in 885 + 886 + (* Convert H1.Status to H2.Status - they're compatible *) 887 + let h2_status = (response.status :> H2.Status.t) in 888 + 889 + match response.body with 890 + | Body_prebuilt prebuilt -> Prebuilt.respond_h2 reqd prebuilt 891 + | Body_empty -> 892 + let headers = 893 + H2.Headers.of_list (("content-length", "0") :: response.headers) 894 + in 895 + let resp = H2.Response.create ~headers h2_status in 896 + H2.Reqd.respond_with_string reqd resp "" 897 + | Body_string body -> 898 + let headers = 899 + H2.Headers.of_list 900 + (("content-length", string_of_int (String.length body)) 901 + :: response.headers) 902 + in 903 + let resp = H2.Response.create ~headers h2_status in 904 + H2.Reqd.respond_with_string reqd resp body 905 + | Body_bigstring body -> 906 + let headers = 907 + H2.Headers.of_list 908 + (("content-length", string_of_int (Bigstringaf.length body)) 909 + :: response.headers) 910 + in 911 + let resp = H2.Response.create ~headers h2_status in 912 + H2.Reqd.respond_with_bigstring reqd resp body 913 + | Body_stream { content_length; next } -> 914 + let headers = 915 + match content_length with 916 + | Some len -> 917 + H2.Headers.of_list 918 + (("content-length", Int64.to_string len) :: response.headers) 919 + | None -> H2.Headers.of_list response.headers 920 + in 921 + let resp = H2.Response.create ~headers h2_status in 922 + let body_writer = H2.Reqd.respond_with_streaming reqd resp in 923 + let rec write_chunks () = 924 + match next () with 925 + | None -> H2.Body.Writer.close body_writer 926 + | Some cs -> 927 + H2.Body.Writer.write_bigstring body_writer ~off:0 928 + ~len:(Cstruct.length cs) (Cstruct.to_bigarray cs); 929 + write_chunks () 930 + in 931 + write_chunks () 932 + in 933 + 934 + let error_handler ?request:_ _error start_response = 935 + let resp_body = start_response H2.Headers.empty in 936 + H2.Body.Writer.write_string resp_body "Internal Server Error"; 937 + H2.Body.Writer.close resp_body 938 + in 939 + 940 + let conn = H2.Server_connection.create ~error_handler request_handler in 941 + let shutdown = ref false in 942 + 943 + let read_loop () = 944 + let rec loop () = 945 + if not !shutdown then 946 + match H2.Server_connection.next_read_operation conn with 947 + | `Read -> 948 + let cs = 949 + Cstruct.of_bigarray read_buffer ~off:0 ~len:read_buffer_size 950 + in 951 + let socket_data = 952 + try 953 + let n = Eio.Flow.single_read flow cs in 954 + Cstruct.to_string (Cstruct.sub cs 0 n) 955 + with End_of_file -> "" 956 + in 957 + let data = 958 + if String.length !pending_data > 0 then begin 959 + let combined = !pending_data ^ socket_data in 960 + pending_data := ""; 961 + combined 962 + end 963 + else socket_data 964 + in 965 + let len = String.length data in 966 + if len = 0 then begin 967 + let _ = 968 + H2.Server_connection.read_eof conn read_buffer ~off:0 ~len:0 969 + in 970 + shutdown := true 971 + end 972 + else begin 973 + Bigstringaf.blit_from_string data ~src_off:0 read_buffer 974 + ~dst_off:0 ~len; 975 + let _ = 976 + H2.Server_connection.read conn read_buffer ~off:0 ~len 977 + in 978 + loop () 979 + end 980 + | `Close -> shutdown := true 981 + in 982 + loop () 983 + in 984 + 985 + let write_loop () = 986 + let rec loop () = 987 + if not !shutdown then 988 + match H2.Server_connection.next_write_operation conn with 989 + | `Write iovecs -> 990 + let cstructs = 991 + List.map 992 + (fun iov -> 993 + Cstruct.of_bigarray ~off:iov.H2.IOVec.off 994 + ~len:iov.H2.IOVec.len iov.H2.IOVec.buffer) 995 + iovecs 996 + in 997 + Eio.Flow.write flow cstructs; 998 + let len = 999 + List.fold_left (fun acc iov -> acc + iov.H2.IOVec.len) 0 iovecs 1000 + in 1001 + H2.Server_connection.report_write_result conn (`Ok len); 1002 + loop () 1003 + | `Yield -> 1004 + let continue = Eio.Promise.create () in 1005 + H2.Server_connection.yield_writer conn (fun () -> 1006 + Eio.Promise.resolve (snd continue) ()); 1007 + Eio.Promise.await (fst continue); 1008 + loop () 1009 + | `Close _ -> shutdown := true 1010 + in 1011 + loop () 1012 + in 1013 + 1014 + Fiber.both read_loop write_loop 1015 + 1016 + (** Direct H2 handler - no protocol detection *) 1017 + let handle_direct ~handler flow = handle ~handler ~initial_data:"" flow 1018 + end 1019 + 1020 + (** {1 Internal: TLS Connection Handler} *) 1021 + 1022 + module Tls_handler = struct 1023 + let handle ~config ~handler ~ws_handler tls_cfg flow = 1024 + try 1025 + let tls_flow = Tls_eio.server_of_flow tls_cfg flow in 1026 + match config.protocol with 1027 + | Http1_only -> 1028 + (* No ALPN check, direct H1 *) 1029 + H1_handler.handle_direct ~handler tls_flow 1030 + | Http2_only -> 1031 + (* No ALPN check, direct H2 *) 1032 + H2_handler.handle_direct ~handler tls_flow 1033 + | Auto | Auto_websocket -> ( 1034 + (* Check ALPN negotiated protocol *) 1035 + match Tls_config.negotiated_protocol tls_flow with 1036 + | Some Tls_config.HTTP_2 -> H2_handler.handle_direct ~handler tls_flow 1037 + | Some Tls_config.HTTP_1_1 | None -> 1038 + if config.protocol = Auto_websocket then 1039 + H1_handler.handle ~handler ~ws_handler:(Some ws_handler) 1040 + ~initial_data:"" tls_flow 1041 + else H1_handler.handle_direct ~handler tls_flow) 1042 + with 1043 + | Tls_eio.Tls_failure failure -> 1044 + traceln "TLS error: %s" (Tls_config.failure_to_string failure) 1045 + | exn -> traceln "Connection error: %s" (Printexc.to_string exn) 1046 + end 1047 + 1048 + (** {1 Internal: Connection Handler} *) 1049 + 1050 + let handle_connection ~config ~handler ~ws_handler flow = 1051 + match config.protocol with 1052 + | Http1_only -> 1053 + (* Fastest path: direct H1, no detection *) 1054 + H1_handler.handle_direct ~handler flow 1055 + | Http2_only -> 1056 + (* Direct H2 (h2c) *) 1057 + H2_handler.handle_direct ~handler flow 1058 + | Auto | Auto_websocket -> ( 1059 + (* Peek to detect protocol *) 1060 + match peek_bytes flow h2_preface_prefix_len with 1061 + | Error `Eof -> () (* Client disconnected immediately *) 1062 + | Error (`Exn exn) -> 1063 + traceln "Connection error: %s" (Printexc.to_string exn) 1064 + | Ok initial_data -> 1065 + if is_h2_preface initial_data then 1066 + H2_handler.handle ~handler ~initial_data flow 1067 + else if config.protocol = Auto_websocket then 1068 + H1_handler.handle ~handler ~ws_handler:(Some ws_handler) 1069 + ~initial_data flow 1070 + else H1_handler.handle ~handler ~ws_handler:None ~initial_data flow) 1071 + 1072 + (** {1 Public API} *) 1073 + 1074 + (** Run an HTTP server. 1075 + 1076 + @param sw Switch for resource management 1077 + @param net Eio network capability 1078 + @param config Server configuration (default: [default_config]) 1079 + @param ws_handler WebSocket handler (required for [Auto_websocket] mode) 1080 + @param handler Request handler *) 1081 + let run ~sw ~net ?(config = default_config) ?ws_handler handler = 1082 + (* Apply GC tuning if configured *) 1083 + (match config.gc_tuning with 1084 + | Some gc_config -> Gc_tune.apply ~config:gc_config () 1085 + | None -> ()); 1086 + 1087 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 1088 + let socket = 1089 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 1090 + ~reuse_port:config.reuse_port net addr 1091 + in 1092 + 1093 + let protocol_str = 1094 + match (config.protocol, config.tls) with 1095 + | Http1_only, None -> "HTTP/1.1" 1096 + | Http1_only, Some _ -> "HTTP/1.1 (TLS)" 1097 + | Http2_only, None -> "HTTP/2 h2c" 1098 + | Http2_only, Some _ -> "HTTP/2 (TLS)" 1099 + | Auto, None -> "HTTP/1.1 + HTTP/2 h2c" 1100 + | Auto, Some _ -> "HTTP/1.1 + HTTP/2 (TLS, ALPN)" 1101 + | Auto_websocket, None -> "HTTP/1.1 + HTTP/2 h2c + WebSocket" 1102 + | Auto_websocket, Some _ -> "HTTP/1.1 + HTTP/2 + WebSocket (TLS, ALPN)" 1103 + in 1104 + traceln "Server listening on port %d (%s)" config.port protocol_str; 1105 + 1106 + (* Validate ws_handler for Auto_websocket mode *) 1107 + let ws_handler = 1108 + match (config.protocol, ws_handler) with 1109 + | Auto_websocket, None -> 1110 + failwith "WebSocket handler required for Auto_websocket mode" 1111 + | Auto_websocket, Some h -> h 1112 + | _, _ -> fun _ -> () (* Dummy handler for non-WS modes *) 1113 + in 1114 + 1115 + let connection_handler flow _addr = 1116 + if config.tcp_nodelay then set_tcp_nodelay flow; 1117 + match config.tls with 1118 + | None -> handle_connection ~config ~handler ~ws_handler flow 1119 + | Some tls_config -> ( 1120 + match Tls_config.Server.to_tls_config tls_config with 1121 + | Error (`Msg msg) -> traceln "TLS config error: %s" msg 1122 + | Ok tls_cfg -> 1123 + Tls_handler.handle ~config ~handler ~ws_handler tls_cfg flow) 1124 + in 1125 + 1126 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 1127 + 1128 + Eio.Net.run_server socket connection_handler 1129 + ~max_connections:config.max_connections ~on_error 1130 + 1131 + (** Run an HTTP server with multi-domain parallelism. 1132 + 1133 + @param sw Switch for resource management 1134 + @param net Eio network capability 1135 + @param domain_mgr Eio domain manager 1136 + @param config Server configuration (default: [default_config]) 1137 + @param ws_handler WebSocket handler (required for [Auto_websocket] mode) 1138 + @param handler Request handler *) 1139 + let run_parallel ~sw ~net ~domain_mgr ?(config = default_config) ?ws_handler 1140 + handler = 1141 + (* Apply GC tuning if configured *) 1142 + (match config.gc_tuning with 1143 + | Some gc_config -> Gc_tune.apply ~config:gc_config () 1144 + | None -> ()); 1145 + 1146 + let domain_count = max 1 config.domain_count in 1147 + let addr = `Tcp (Eio.Net.Ipaddr.V4.any, config.port) in 1148 + let socket = 1149 + Eio.Net.listen ~sw ~backlog:config.backlog ~reuse_addr:config.reuse_addr 1150 + ~reuse_port:config.reuse_port net addr 1151 + in 1152 + 1153 + let protocol_str = 1154 + match (config.protocol, config.tls) with 1155 + | Http1_only, None -> "HTTP/1.1" 1156 + | Http1_only, Some _ -> "HTTP/1.1 (TLS)" 1157 + | Http2_only, None -> "HTTP/2 h2c" 1158 + | Http2_only, Some _ -> "HTTP/2 (TLS)" 1159 + | Auto, None -> "HTTP/1.1 + HTTP/2 h2c" 1160 + | Auto, Some _ -> "HTTP/1.1 + HTTP/2 (TLS, ALPN)" 1161 + | Auto_websocket, None -> "HTTP/1.1 + HTTP/2 h2c + WebSocket" 1162 + | Auto_websocket, Some _ -> "HTTP/1.1 + HTTP/2 + WebSocket (TLS, ALPN)" 1163 + in 1164 + traceln "Server listening on port %d (%s, %d domains)" config.port 1165 + protocol_str domain_count; 1166 + 1167 + (* Validate ws_handler for Auto_websocket mode *) 1168 + let ws_handler = 1169 + match (config.protocol, ws_handler) with 1170 + | Auto_websocket, None -> 1171 + failwith "WebSocket handler required for Auto_websocket mode" 1172 + | Auto_websocket, Some h -> h 1173 + | _, _ -> fun _ -> () 1174 + in 1175 + 1176 + let connection_handler flow _addr = 1177 + if config.tcp_nodelay then set_tcp_nodelay flow; 1178 + match config.tls with 1179 + | None -> handle_connection ~config ~handler ~ws_handler flow 1180 + | Some tls_config -> ( 1181 + match Tls_config.Server.to_tls_config tls_config with 1182 + | Error (`Msg msg) -> traceln "TLS config error: %s" msg 1183 + | Ok tls_cfg -> 1184 + Tls_handler.handle ~config ~handler ~ws_handler tls_cfg flow) 1185 + in 1186 + 1187 + let on_error exn = traceln "Connection error: %s" (Printexc.to_string exn) in 1188 + 1189 + if domain_count <= 1 then 1190 + Eio.Net.run_server socket connection_handler 1191 + ~max_connections:config.max_connections ~on_error 1192 + else 1193 + Eio.Net.run_server socket connection_handler 1194 + ~max_connections:config.max_connections ~on_error 1195 + ~additional_domains:(domain_mgr, domain_count - 1)
+295
lib/stream.ml
··· 1 + (** Streaming abstractions for HCS HTTP library. 2 + 3 + This module provides both synchronous and asynchronous stream types for 4 + handling large payloads efficiently with backpressure support. 5 + 6 + {1 Synchronous Streams} 7 + 8 + For simple, pull-based iteration without runtime dependencies. 9 + 10 + {1 Asynchronous Streams (Eio)} 11 + 12 + For streaming with Eio's structured concurrency, supporting flows, files, 13 + and chunked transfer encoding. *) 14 + 15 + (** {1 Synchronous Stream} 16 + 17 + A simple pull-based stream using OCaml's Seq. *) 18 + module Sync = struct 19 + type 'a t = 'a Seq.t 20 + (** A synchronous stream of values *) 21 + 22 + (** {2 Producers} *) 23 + 24 + let empty : 'a t = Seq.empty 25 + let singleton x : 'a t = Seq.return x 26 + let of_list l : 'a t = List.to_seq l 27 + let of_array a : 'a t = Array.to_seq a 28 + 29 + (** Create a stream from an unfolding function *) 30 + let unfold (f : 's -> ('a * 's) option) (init : 's) : 'a t = Seq.unfold f init 31 + 32 + (** Create a stream that repeats a value n times *) 33 + let repeat n x : 'a t = 34 + unfold (fun i -> if i > 0 then Some (x, i - 1) else None) n 35 + 36 + (** Create a stream from a generator function *) 37 + let generate (f : unit -> 'a option) : 'a t = 38 + let rec next () = 39 + match f () with Some x -> Seq.Cons (x, next) | None -> Seq.Nil 40 + in 41 + next 42 + 43 + (** {2 Transformers} *) 44 + 45 + let map f s : 'a t = Seq.map f s 46 + let filter p s : 'a t = Seq.filter p s 47 + let filter_map f s : 'a t = Seq.filter_map f s 48 + 49 + (** Take the first n elements *) 50 + let take n s : 'a t = Seq.take n s 51 + 52 + (** Drop the first n elements *) 53 + let drop n s : 'a t = Seq.drop n s 54 + 55 + (** Split stream into chunks of size n *) 56 + let chunks n s : 'a list t = 57 + let rec next acc count seq () = 58 + if count >= n then Seq.Cons (List.rev acc, next [] 0 seq) 59 + else 60 + match seq () with 61 + | Seq.Nil -> 62 + if acc = [] then Seq.Nil 63 + else Seq.Cons (List.rev acc, fun () -> Seq.Nil) 64 + | Seq.Cons (x, rest) -> next (x :: acc) (count + 1) rest () 65 + in 66 + next [] 0 s 67 + 68 + (** Flatten a stream of streams *) 69 + let flatten s : 'a t = Seq.flat_map Fun.id s 70 + 71 + (** Append two streams *) 72 + let append s1 s2 : 'a t = Seq.append s1 s2 73 + 74 + (** {2 Consumers} *) 75 + 76 + (** Fold over the stream *) 77 + let fold f init s = Seq.fold_left f init s 78 + 79 + (** Iterate over the stream for side effects *) 80 + let iter f s = Seq.iter f s 81 + 82 + (** Drain the stream, discarding all values *) 83 + let drain s = iter (fun _ -> ()) s 84 + 85 + (** Collect stream into a list *) 86 + let to_list s = List.of_seq s 87 + 88 + (** Collect stream into an array *) 89 + let to_array s = Array.of_seq s 90 + 91 + (** {2 Cstruct-specific operations} *) 92 + 93 + (** Concatenate a stream of Cstructs into a single string *) 94 + let cstructs_to_string (s : Cstruct.t t) : string = 95 + let bufs = to_list s in 96 + let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in 97 + let result = Bytes.create total in 98 + let _ = 99 + List.fold_left 100 + (fun off cs -> 101 + let len = Cstruct.length cs in 102 + Cstruct.blit_to_bytes cs 0 result off len; 103 + off + len) 104 + 0 bufs 105 + in 106 + Bytes.to_string result 107 + 108 + (** Split a string into chunks of Cstructs *) 109 + let string_to_cstructs ?(chunk_size = 4096) (s : string) : Cstruct.t t = 110 + let len = String.length s in 111 + unfold 112 + (fun off -> 113 + if off >= len then None 114 + else 115 + let chunk_len = min chunk_size (len - off) in 116 + Some (Cstruct.of_string ~off ~len:chunk_len s, off + chunk_len)) 117 + 0 118 + end 119 + 120 + (** {1 Asynchronous Stream (Eio)} 121 + 122 + Streams that integrate with Eio's structured concurrency. *) 123 + module Async = struct 124 + type 'a t = unit -> 'a option 125 + (** An asynchronous stream that can be pulled from *) 126 + 127 + (** {2 Producers} *) 128 + 129 + let empty : 'a t = fun () -> None 130 + 131 + let singleton x : 'a t = 132 + let taken = ref false in 133 + fun () -> 134 + if !taken then None 135 + else begin 136 + taken := true; 137 + Some x 138 + end 139 + 140 + let of_list l : 'a t = 141 + let r = ref l in 142 + fun () -> 143 + match !r with 144 + | [] -> None 145 + | x :: rest -> 146 + r := rest; 147 + Some x 148 + 149 + let of_seq s : 'a t = 150 + let r = ref s in 151 + fun () -> 152 + match !r () with 153 + | Seq.Nil -> None 154 + | Seq.Cons (x, rest) -> 155 + r := rest; 156 + Some x 157 + 158 + (** Create a stream from an Eio flow (reads until EOF) *) 159 + let of_flow ?(buf_size = 4096) (flow : _ Eio.Flow.source) : Cstruct.t t = 160 + let buf = Cstruct.create buf_size in 161 + let finished = ref false in 162 + fun () -> 163 + if !finished then None 164 + else 165 + try 166 + let n = Eio.Flow.single_read flow buf in 167 + Some (Cstruct.sub buf 0 n) 168 + with End_of_file -> 169 + finished := true; 170 + None 171 + 172 + (** Create a stream that reads a file in chunks *) 173 + let of_file ?(buf_size = 4096) ~fs path : Cstruct.t t = 174 + let file = Eio.Path.open_in ~sw:(Eio.Switch.run Fun.id) (fs, path) in 175 + of_flow ~buf_size file 176 + 177 + (** {2 Transformers} *) 178 + 179 + let map f (s : 'a t) : 'b t = fun () -> Option.map f (s ()) 180 + 181 + let filter p (s : 'a t) : 'a t = 182 + let rec next () = 183 + match s () with None -> None | Some x -> if p x then Some x else next () 184 + in 185 + next 186 + 187 + let filter_map f (s : 'a t) : 'b t = 188 + let rec next () = 189 + match s () with 190 + | None -> None 191 + | Some x -> ( match f x with Some y -> Some y | None -> next ()) 192 + in 193 + next 194 + 195 + let take n (s : 'a t) : 'a t = 196 + let count = ref 0 in 197 + fun () -> 198 + if !count >= n then None 199 + else begin 200 + incr count; 201 + s () 202 + end 203 + 204 + (** {2 Consumers} *) 205 + 206 + (** Fold over the stream *) 207 + let fold f init (s : 'a t) = 208 + let rec loop acc = 209 + match s () with None -> acc | Some x -> loop (f acc x) 210 + in 211 + loop init 212 + 213 + (** Iterate over the stream *) 214 + let iter f (s : 'a t) = 215 + let rec loop () = 216 + match s () with 217 + | None -> () 218 + | Some x -> 219 + f x; 220 + loop () 221 + in 222 + loop () 223 + 224 + (** Drain the stream *) 225 + let drain s = iter (fun _ -> ()) s 226 + 227 + (** Collect into a list *) 228 + let to_list (s : 'a t) = List.rev (fold (fun acc x -> x :: acc) [] s) 229 + 230 + (** Write stream to an Eio flow *) 231 + let to_flow (flow : _ Eio.Flow.sink) (s : Cstruct.t t) = 232 + iter (fun cs -> Eio.Flow.write flow [ cs ]) s 233 + 234 + (** {2 Cstruct-specific operations} *) 235 + 236 + (** Concatenate a stream of Cstructs into a single string *) 237 + let cstructs_to_string (s : Cstruct.t t) : string = 238 + let bufs = to_list s in 239 + let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in 240 + let result = Bytes.create total in 241 + let _ = 242 + List.fold_left 243 + (fun off cs -> 244 + let len = Cstruct.length cs in 245 + Cstruct.blit_to_bytes cs 0 result off len; 246 + off + len) 247 + 0 bufs 248 + in 249 + Bytes.to_string result 250 + 251 + (** Read entire stream into a Cstruct *) 252 + let cstructs_to_cstruct (s : Cstruct.t t) : Cstruct.t = 253 + let bufs = to_list s in 254 + let total = List.fold_left (fun acc cs -> acc + Cstruct.length cs) 0 bufs in 255 + let result = Cstruct.create total in 256 + let _ = 257 + List.fold_left 258 + (fun off cs -> 259 + let len = Cstruct.length cs in 260 + Cstruct.blit cs 0 result off len; 261 + off + len) 262 + 0 bufs 263 + in 264 + result 265 + end 266 + 267 + (** {1 Chunked Transfer Encoding} *) 268 + 269 + (** Helpers for HTTP chunked transfer encoding *) 270 + module Chunked = struct 271 + (** Encode chunks for chunked transfer encoding *) 272 + let encode (chunks : Cstruct.t Sync.t) : Cstruct.t Sync.t = 273 + let encode_chunk cs = 274 + let len = Cstruct.length cs in 275 + if len = 0 then Cstruct.of_string "0\r\n\r\n" 276 + else 277 + let header = Printf.sprintf "%x\r\n" len in 278 + let trailer = "\r\n" in 279 + let total = String.length header + len + String.length trailer in 280 + let buf = Cstruct.create total in 281 + Cstruct.blit_from_string header 0 buf 0 (String.length header); 282 + Cstruct.blit cs 0 buf (String.length header) len; 283 + Cstruct.blit_from_string trailer 0 buf 284 + (String.length header + len) 285 + (String.length trailer); 286 + buf 287 + in 288 + Seq.append 289 + (Seq.map encode_chunk chunks) 290 + (Seq.return (Cstruct.of_string "0\r\n\r\n")) 291 + 292 + (** Calculate content length from chunks (consumes the stream) *) 293 + let content_length (chunks : Cstruct.t Sync.t) : int = 294 + Sync.fold (fun acc cs -> acc + Cstruct.length cs) 0 chunks 295 + end
+150
lib/tls_config.ml
··· 1 + (** TLS configuration and helpers for HCS. 2 + 3 + This module provides TLS configuration that works with tls-eio and ca-certs 4 + for system certificate loading. *) 5 + 6 + (** {1 ALPN Protocol Identifiers} *) 7 + 8 + (** HTTP/2 over TLS ALPN identifier *) 9 + let alpn_h2 = "h2" 10 + 11 + (** HTTP/1.1 ALPN identifier *) 12 + let alpn_http11 = "http/1.1" 13 + 14 + (** HTTP/2 cleartext (h2c) identifier - used in Upgrade header, not ALPN *) 15 + let alpn_h2c = "h2c" 16 + 17 + (** Protocol type for negotiation results *) 18 + type protocol = HTTP_1_1 | HTTP_2 19 + 20 + (** Convert ALPN string to protocol type *) 21 + let protocol_of_alpn = function 22 + | s when s = alpn_h2 -> Some HTTP_2 23 + | s when s = alpn_http11 -> Some HTTP_1_1 24 + | _ -> None 25 + 26 + (** Convert protocol to ALPN string *) 27 + let alpn_of_protocol = function HTTP_2 -> alpn_h2 | HTTP_1_1 -> alpn_http11 28 + 29 + (** Client TLS configuration *) 30 + module Client = struct 31 + (** Certificate verification mode *) 32 + type verification = 33 + | System_certs (** Use system CA certificates *) 34 + | No_verify (** Disable verification (INSECURE!) *) 35 + 36 + type t = { 37 + verification : verification; 38 + alpn_protocols : string list option; (** ALPN: ["h2"; "http/1.1"] *) 39 + } 40 + 41 + let default = 42 + { verification = System_certs; alpn_protocols = Some [ "http/1.1" ] } 43 + 44 + (** TLS config for HTTP/2 - advertises h2 protocol *) 45 + let h2 = { verification = System_certs; alpn_protocols = Some [ "h2" ] } 46 + 47 + (** TLS config that prefers HTTP/2 but falls back to HTTP/1.1 *) 48 + let h2_or_http11 = 49 + { verification = System_certs; alpn_protocols = Some [ "h2"; "http/1.1" ] } 50 + 51 + let with_alpn protocols config = 52 + { config with alpn_protocols = Some protocols } 53 + 54 + let insecure = 55 + { verification = No_verify; alpn_protocols = Some [ "http/1.1" ] } 56 + 57 + let insecure_h2 = { verification = No_verify; alpn_protocols = Some [ "h2" ] } 58 + 59 + (** Create tls-eio authenticator from config *) 60 + let make_authenticator config = 61 + match config.verification with 62 + | System_certs -> ( 63 + match Ca_certs.authenticator () with 64 + | Ok auth -> Ok auth 65 + | Error (`Msg msg) -> Error msg) 66 + | No_verify -> Ok (fun ?ip:_ ~host:_ _ -> Ok None) 67 + 68 + (** Create Tls.Config.client from our config *) 69 + let to_tls_config config ~host:_ = 70 + match make_authenticator config with 71 + | Error msg -> Error msg 72 + | Ok authenticator -> ( 73 + match 74 + Tls.Config.client ~authenticator ?alpn_protocols:config.alpn_protocols 75 + () 76 + with 77 + | Ok tls_config -> Ok tls_config 78 + | Error (`Msg msg) -> Error msg) 79 + end 80 + 81 + module Server = struct 82 + type t = { 83 + certificate : Tls.Config.own_cert; 84 + alpn_protocols : string list option; 85 + } 86 + 87 + let with_alpn protocols config = 88 + { config with alpn_protocols = Some protocols } 89 + 90 + let h1_only config = with_alpn [ alpn_http11 ] config 91 + let h2_only config = with_alpn [ alpn_h2 ] config 92 + let h2_or_http11 config = with_alpn [ alpn_h2; alpn_http11 ] config 93 + 94 + let of_pem ~cert_file ~key_file = 95 + try 96 + let cert_pem = In_channel.with_open_bin cert_file In_channel.input_all in 97 + let key_pem = In_channel.with_open_bin key_file In_channel.input_all in 98 + let certs = X509.Certificate.decode_pem_multiple cert_pem in 99 + let key = X509.Private_key.decode_pem key_pem in 100 + match (certs, key) with 101 + | Ok certs, Ok key -> 102 + Ok 103 + { 104 + certificate = `Single (certs, key); 105 + alpn_protocols = Some [ alpn_h2; alpn_http11 ]; 106 + } 107 + | Error (`Msg msg), _ -> Error ("Certificate error: " ^ msg) 108 + | _, Error (`Msg msg) -> Error ("Key error: " ^ msg) 109 + with Sys_error msg -> Error ("File error: " ^ msg) 110 + 111 + (** Create Tls.Config.server from our config *) 112 + let to_tls_config config = 113 + Tls.Config.server ~certificates:config.certificate 114 + ?alpn_protocols:config.alpn_protocols () 115 + end 116 + 117 + (** Convert TLS failure to string *) 118 + let failure_to_string failure = Tls.Engine.string_of_failure failure 119 + 120 + (** Wrap an Eio flow with TLS (client side) *) 121 + let client_wrap ~config flow = 122 + match Client.to_tls_config config ~host:"" with 123 + | Error msg -> Error msg 124 + | Ok tls_config -> ( 125 + try 126 + let tls_flow = Tls_eio.client_of_flow tls_config flow in 127 + Ok tls_flow 128 + with 129 + | Tls_eio.Tls_failure failure -> Error (failure_to_string failure) 130 + | exn -> Error (Printexc.to_string exn)) 131 + 132 + (** Wrap an Eio flow with TLS (server side) *) 133 + let server_wrap config flow = 134 + match Server.to_tls_config config with 135 + | Error (`Msg msg) -> Error msg 136 + | Ok tls_config -> ( 137 + try 138 + let tls_flow = Tls_eio.server_of_flow tls_config flow in 139 + Ok tls_flow 140 + with 141 + | Tls_eio.Tls_failure failure -> Error (failure_to_string failure) 142 + | exn -> Error (Printexc.to_string exn)) 143 + 144 + let negotiated_protocol (tls_flow : Tls_eio.t) : protocol option = 145 + match Tls_eio.epoch tls_flow with 146 + | Error () -> None 147 + | Ok epoch_data -> ( 148 + match epoch_data.Tls.Core.alpn_protocol with 149 + | None -> None 150 + | Some alpn -> protocol_of_alpn alpn)
+548
lib/websocket.ml
··· 1 + (** WebSocket implementation for HCS (RFC 6455). 2 + 3 + This module provides WebSocket client and server functionality with Eio for 4 + structured concurrency. *) 5 + 6 + open Eio.Std 7 + 8 + (** {1 Constants} *) 9 + 10 + (** UUID used in WebSocket handshake per RFC 6455 *) 11 + let websocket_uuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 12 + 13 + (** {1 Types} *) 14 + 15 + (** WebSocket frame opcode *) 16 + module Opcode = struct 17 + type t = 18 + | Continuation (** 0x0 *) 19 + | Text (** 0x1 *) 20 + | Binary (** 0x2 *) 21 + | Close (** 0x8 *) 22 + | Ping (** 0x9 *) 23 + | Pong (** 0xA *) 24 + | Ctrl of int (** Other control opcodes *) 25 + | Nonctrl of int (** Other non-control opcodes *) 26 + 27 + let to_int = function 28 + | Continuation -> 0 29 + | Text -> 1 30 + | Binary -> 2 31 + | Close -> 8 32 + | Ping -> 9 33 + | Pong -> 10 34 + | Ctrl n -> n 35 + | Nonctrl n -> n 36 + 37 + let of_int = function 38 + | 0 -> Continuation 39 + | 1 -> Text 40 + | 2 -> Binary 41 + | 8 -> Close 42 + | 9 -> Ping 43 + | 10 -> Pong 44 + | n when n > 7 -> Ctrl n 45 + | n -> Nonctrl n 46 + 47 + let to_string = function 48 + | Continuation -> "continuation" 49 + | Text -> "text" 50 + | Binary -> "binary" 51 + | Close -> "close" 52 + | Ping -> "ping" 53 + | Pong -> "pong" 54 + | Ctrl n -> Printf.sprintf "ctrl(%d)" n 55 + | Nonctrl n -> Printf.sprintf "nonctrl(%d)" n 56 + 57 + let is_control = function Close | Ping | Pong | Ctrl _ -> true | _ -> false 58 + end 59 + 60 + type frame = { 61 + opcode : Opcode.t; 62 + extension : int; 63 + final : bool; 64 + content : string; 65 + } 66 + (** WebSocket frame *) 67 + 68 + let pp_frame fmt frame = 69 + Format.fprintf fmt "{opcode=%s; final=%b; len=%d}" 70 + (Opcode.to_string frame.opcode) 71 + frame.final 72 + (String.length frame.content) 73 + 74 + let make_frame ?(opcode = Opcode.Text) ?(extension = 0) ?(final = true) 75 + ?(content = "") () = 76 + { opcode; extension; final; content } 77 + 78 + let close_frame code = 79 + let content = 80 + if code < 0 then "" 81 + else 82 + let buf = Bytes.create 2 in 83 + Bytes.set buf 0 (Char.chr ((code lsr 8) land 0xff)); 84 + Bytes.set buf 1 (Char.chr (code land 0xff)); 85 + Bytes.to_string buf 86 + in 87 + { opcode = Close; extension = 0; final = true; content } 88 + 89 + type t = { 90 + flow : Eio.Flow.two_way_ty r; 91 + mutable closed : bool; 92 + is_client : bool; (** Client must mask frames *) 93 + read_buf : Buffer.t; 94 + } 95 + (** WebSocket connection *) 96 + 97 + (** Error type *) 98 + type error = Connection_closed | Protocol_error of string | Io_error of string 99 + 100 + (** {1 Cryptographic helpers} *) 101 + 102 + (** Compute SHA-1 hash and base64 encode *) 103 + let b64_encoded_sha1sum s = 104 + let hash = Digestif.SHA1.digest_string s in 105 + Base64.encode_exn (Digestif.SHA1.to_raw_string hash) 106 + 107 + (** Compute the Sec-WebSocket-Accept value *) 108 + let compute_accept_key key = b64_encoded_sha1sum (key ^ websocket_uuid) 109 + 110 + (** {1 Random number generation for masking} *) 111 + 112 + module Rng = struct 113 + let initialized = ref false 114 + 115 + let init () = 116 + if not !initialized then begin 117 + Random.self_init (); 118 + initialized := true 119 + end 120 + 121 + (** Generate n random bytes *) 122 + let generate n = 123 + init (); 124 + let buf = Bytes.create n in 125 + for i = 0 to n - 1 do 126 + Bytes.set buf i (Char.chr (Random.int 256)) 127 + done; 128 + Bytes.to_string buf 129 + end 130 + 131 + (** {1 Frame parsing/serialization} *) 132 + 133 + (** Apply XOR mask to data *) 134 + let xor_mask mask data = 135 + let len = String.length data in 136 + let result = Bytes.create len in 137 + for i = 0 to len - 1 do 138 + let mask_byte = Char.code mask.[i mod 4] in 139 + let data_byte = Char.code data.[i] in 140 + Bytes.set result i (Char.chr (data_byte lxor mask_byte)) 141 + done; 142 + Bytes.to_string result 143 + 144 + (** Serialize a frame to bytes. Client frames must be masked, server frames must 145 + not be masked. *) 146 + let write_frame_to_buf ~is_client buf frame = 147 + let mask = is_client in 148 + let opcode = Opcode.to_int frame.opcode in 149 + let fin = if frame.final then 0x80 else 0 in 150 + let rsv = (frame.extension land 0x7) lsl 4 in 151 + 152 + Buffer.add_char buf (Char.chr (fin lor rsv lor opcode)); 153 + 154 + let len = String.length frame.content in 155 + let mask_bit = if mask then 0x80 else 0 in 156 + 157 + (* Encode payload length *) 158 + if len < 126 then Buffer.add_char buf (Char.chr (mask_bit lor len)) 159 + else if len < 65536 then begin 160 + Buffer.add_char buf (Char.chr (mask_bit lor 126)); 161 + Buffer.add_char buf (Char.chr ((len lsr 8) land 0xff)); 162 + Buffer.add_char buf (Char.chr (len land 0xff)) 163 + end 164 + else begin 165 + Buffer.add_char buf (Char.chr (mask_bit lor 127)); 166 + (* 64-bit length, big-endian *) 167 + for i = 7 downto 0 do 168 + Buffer.add_char buf (Char.chr ((len lsr (i * 8)) land 0xff)) 169 + done 170 + end; 171 + 172 + (* Add mask and payload *) 173 + if mask then begin 174 + let mask_key = Rng.generate 4 in 175 + Buffer.add_string buf mask_key; 176 + Buffer.add_string buf (xor_mask mask_key frame.content) 177 + end 178 + else Buffer.add_string buf frame.content 179 + 180 + (** Read exactly n bytes from flow *) 181 + let read_exactly flow n = 182 + let buf = Cstruct.create n in 183 + let rec loop off = 184 + if off < n then begin 185 + let cs = Cstruct.sub buf off (n - off) in 186 + let read = Eio.Flow.single_read flow cs in 187 + loop (off + read) 188 + end 189 + in 190 + loop 0; 191 + Cstruct.to_string buf 192 + 193 + (** Parse a frame from flow *) 194 + let read_frame ~is_client flow = 195 + try 196 + (* Read first 2 bytes *) 197 + let header = read_exactly flow 2 in 198 + let b0 = Char.code header.[0] in 199 + let b1 = Char.code header.[1] in 200 + 201 + let final = b0 land 0x80 <> 0 in 202 + let extension = (b0 land 0x70) lsr 4 in 203 + let opcode = Opcode.of_int (b0 land 0x0f) in 204 + let masked = b1 land 0x80 <> 0 in 205 + let len0 = b1 land 0x7f in 206 + 207 + (* Server receiving from client: frames must be masked 208 + Client receiving from server: frames must not be masked *) 209 + if (not is_client) && not masked then 210 + Error (Protocol_error "Client frames must be masked") 211 + else if is_client && masked then 212 + Error (Protocol_error "Server frames must not be masked") 213 + else begin 214 + (* Read extended length if needed *) 215 + let len = 216 + if len0 < 126 then len0 217 + else if len0 = 126 then begin 218 + let ext = read_exactly flow 2 in 219 + (Char.code ext.[0] lsl 8) lor Char.code ext.[1] 220 + end 221 + else begin 222 + (* 64-bit length *) 223 + let ext = read_exactly flow 8 in 224 + let len = ref 0 in 225 + for i = 0 to 7 do 226 + len := (!len lsl 8) lor Char.code ext.[i] 227 + done; 228 + !len 229 + end 230 + in 231 + 232 + (* Control frames cannot be fragmented and max 125 bytes *) 233 + if Opcode.is_control opcode && ((not final) || len > 125) then 234 + Error (Protocol_error "Invalid control frame") 235 + else begin 236 + (* Read mask key if present *) 237 + let mask_key = if masked then Some (read_exactly flow 4) else None in 238 + 239 + (* Read payload *) 240 + let content = if len > 0 then read_exactly flow len else "" in 241 + let content = 242 + match mask_key with 243 + | Some key -> xor_mask key content 244 + | None -> content 245 + in 246 + 247 + Ok { opcode; extension; final; content } 248 + end 249 + end 250 + with 251 + | End_of_file -> Error Connection_closed 252 + | exn -> Error (Io_error (Printexc.to_string exn)) 253 + 254 + (** {1 Connection API} *) 255 + 256 + (** Check if connection is open *) 257 + let is_open t = not t.closed 258 + 259 + (** Send a frame *) 260 + let send t frame = 261 + if t.closed then Error Connection_closed 262 + else 263 + try 264 + let buf = Buffer.create 128 in 265 + write_frame_to_buf ~is_client:t.is_client buf frame; 266 + Eio.Flow.write t.flow [ Cstruct.of_string (Buffer.contents buf) ]; 267 + Ok () 268 + with exn -> Error (Io_error (Printexc.to_string exn)) 269 + 270 + (** Send a text message *) 271 + let send_text t content = send t (make_frame ~opcode:Text ~content ()) 272 + 273 + (** Send a binary message *) 274 + let send_binary t content = send t (make_frame ~opcode:Binary ~content ()) 275 + 276 + (** Send a ping *) 277 + let send_ping t ?(content = "") () = 278 + send t (make_frame ~opcode:Ping ~content ()) 279 + 280 + (** Send a pong *) 281 + let send_pong t ?(content = "") () = 282 + send t (make_frame ~opcode:Pong ~content ()) 283 + 284 + (** Receive a frame *) 285 + let recv t = 286 + if t.closed then Error Connection_closed 287 + else 288 + match read_frame ~is_client:t.is_client t.flow with 289 + | Ok frame -> 290 + (* Handle control frames *) 291 + (match frame.opcode with 292 + | Close -> 293 + t.closed <- true; 294 + (* Echo close frame back *) 295 + ignore (send t (close_frame (-1))) 296 + | Ping -> 297 + (* Auto-respond to pings with pong *) 298 + ignore (send_pong t ~content:frame.content ()) 299 + | _ -> ()); 300 + Ok frame 301 + | Error e -> 302 + t.closed <- true; 303 + Error e 304 + 305 + (** Receive a complete message (handles fragmentation) *) 306 + let recv_message t = 307 + let rec collect_fragments first_opcode buf = 308 + match recv t with 309 + | Error e -> Error e 310 + | Ok frame -> ( 311 + Buffer.add_string buf frame.content; 312 + if frame.final then Ok (first_opcode, Buffer.contents buf) 313 + else 314 + match frame.opcode with 315 + | Continuation -> collect_fragments first_opcode buf 316 + | _ -> Error (Protocol_error "Expected continuation frame")) 317 + in 318 + let rec loop () = 319 + match recv t with 320 + | Error e -> Error e 321 + | Ok frame -> ( 322 + match frame.opcode with 323 + | Text | Binary -> 324 + if frame.final then Ok (frame.opcode, frame.content) 325 + else begin 326 + let buf = Buffer.create 256 in 327 + Buffer.add_string buf frame.content; 328 + collect_fragments frame.opcode buf 329 + end 330 + | Close -> Error Connection_closed 331 + | Ping | Pong -> 332 + (* Control frames handled in recv, try again *) 333 + loop () 334 + | Continuation -> Error (Protocol_error "Unexpected continuation") 335 + | _ -> Error (Protocol_error "Unexpected opcode")) 336 + in 337 + loop () 338 + 339 + (** Close the connection *) 340 + let close ?(code = 1000) t = 341 + if not t.closed then begin 342 + t.closed <- true; 343 + ignore (send t (close_frame code)) 344 + end 345 + 346 + (** {1 Handshake helpers} *) 347 + 348 + (** Check if request headers indicate a WebSocket upgrade *) 349 + let is_upgrade_request headers = 350 + let upgrade = H1.Headers.get headers "upgrade" in 351 + let connection = H1.Headers.get headers "connection" in 352 + let key = H1.Headers.get headers "sec-websocket-key" in 353 + match (upgrade, connection, key) with 354 + | Some u, Some c, Some _ -> 355 + let u = String.lowercase_ascii u in 356 + let c = String.lowercase_ascii c in 357 + u = "websocket" && (c = "upgrade" || String.sub c 0 7 = "upgrade") 358 + | _ -> false 359 + 360 + (** Get the Sec-WebSocket-Key from request headers *) 361 + let get_websocket_key headers = H1.Headers.get headers "sec-websocket-key" 362 + 363 + (** Generate random base64-encoded key for client handshake *) 364 + let generate_key () = Base64.encode_exn (Rng.generate 16) 365 + 366 + (** {1 Client API} *) 367 + 368 + (** Connect to a WebSocket server *) 369 + let connect ~sw ~net ?(tls_config = Tls_config.Client.default) ?protocols url = 370 + let uri = Uri.of_string url in 371 + let scheme = Uri.scheme uri |> Option.value ~default:"ws" in 372 + let is_secure = scheme = "wss" in 373 + let host = Uri.host uri |> Option.value ~default:"localhost" in 374 + let default_port = if is_secure then 443 else 80 in 375 + let port = Uri.port uri |> Option.value ~default:default_port in 376 + let path = 377 + let p = Uri.path_and_query uri in 378 + if p = "" then "/" else p 379 + in 380 + 381 + (* Resolve and connect *) 382 + let addrs = Eio.Net.getaddrinfo_stream net host in 383 + match addrs with 384 + | [] -> Error (Io_error ("Cannot resolve host: " ^ host)) 385 + | addr_info :: _ -> ( 386 + let addr = 387 + match addr_info with 388 + | `Tcp (ip, _) -> `Tcp (ip, port) 389 + | `Unix _ -> failwith "Unix sockets not supported" 390 + in 391 + let tcp_flow = Eio.Net.connect ~sw net addr in 392 + 393 + (* Wrap with TLS if secure *) 394 + let flow_result = 395 + if is_secure then 396 + match Tls_config.Client.to_tls_config tls_config ~host with 397 + | Error msg -> Error (Io_error ("TLS error: " ^ msg)) 398 + | Ok tls_cfg -> ( 399 + try 400 + let host_domain = 401 + match Domain_name.of_string host with 402 + | Ok dn -> ( 403 + match Domain_name.host dn with 404 + | Ok h -> Some h 405 + | Error _ -> None) 406 + | Error _ -> None 407 + in 408 + let tls_flow = 409 + Tls_eio.client_of_flow tls_cfg ?host:host_domain tcp_flow 410 + in 411 + Ok (tls_flow :> Eio.Flow.two_way_ty r) 412 + with exn -> Error (Io_error (Printexc.to_string exn))) 413 + else Ok (tcp_flow :> Eio.Flow.two_way_ty r) 414 + in 415 + 416 + match flow_result with 417 + | Error e -> Error e 418 + | Ok flow -> ( 419 + (* Generate key and build upgrade request *) 420 + let key = generate_key () in 421 + let expected_accept = compute_accept_key key in 422 + 423 + let headers = 424 + [ 425 + ("Host", host); 426 + ("Upgrade", "websocket"); 427 + ("Connection", "Upgrade"); 428 + ("Sec-WebSocket-Key", key); 429 + ("Sec-WebSocket-Version", "13"); 430 + ] 431 + in 432 + let headers = 433 + match protocols with 434 + | Some ps -> 435 + ("Sec-WebSocket-Protocol", String.concat ", " ps) :: headers 436 + | None -> headers 437 + in 438 + 439 + (* Send HTTP upgrade request *) 440 + let buf = Buffer.create 256 in 441 + Buffer.add_string buf (Printf.sprintf "GET %s HTTP/1.1\r\n" path); 442 + List.iter 443 + (fun (k, v) -> 444 + Buffer.add_string buf (Printf.sprintf "%s: %s\r\n" k v)) 445 + headers; 446 + Buffer.add_string buf "\r\n"; 447 + 448 + try 449 + Eio.Flow.write flow [ Cstruct.of_string (Buffer.contents buf) ]; 450 + 451 + (* Read response headers *) 452 + let response_buf = Buffer.create 1024 in 453 + let rec read_until_crlf_crlf () = 454 + let byte = read_exactly flow 1 in 455 + Buffer.add_string response_buf byte; 456 + let len = Buffer.length response_buf in 457 + if 458 + len >= 4 459 + && Buffer.nth response_buf (len - 4) = '\r' 460 + && Buffer.nth response_buf (len - 3) = '\n' 461 + && Buffer.nth response_buf (len - 2) = '\r' 462 + && Buffer.nth response_buf (len - 1) = '\n' 463 + then () 464 + else read_until_crlf_crlf () 465 + in 466 + read_until_crlf_crlf (); 467 + 468 + (* Parse status line and headers *) 469 + let response_str = Buffer.contents response_buf in 470 + let lines = String.split_on_char '\n' response_str in 471 + 472 + (* Check status line *) 473 + match lines with 474 + | status_line :: header_lines -> ( 475 + let status_line = String.trim status_line in 476 + if 477 + not 478 + (String.length status_line >= 12 479 + && String.sub status_line 9 3 = "101") 480 + then Error (Protocol_error ("Bad status: " ^ status_line)) 481 + else 482 + (* Parse headers *) 483 + let headers = 484 + List.filter_map 485 + (fun line -> 486 + let line = String.trim line in 487 + if line = "" then None 488 + else 489 + match String.index_opt line ':' with 490 + | Some i -> 491 + let key = 492 + String.lowercase_ascii (String.sub line 0 i) 493 + in 494 + let value = 495 + String.trim 496 + (String.sub line (i + 1) 497 + (String.length line - i - 1)) 498 + in 499 + Some (key, value) 500 + | None -> None) 501 + header_lines 502 + in 503 + 504 + (* Validate accept key *) 505 + let accept = List.assoc_opt "sec-websocket-accept" headers in 506 + match accept with 507 + | Some a when a = expected_accept -> 508 + Ok 509 + { 510 + flow; 511 + closed = false; 512 + is_client = true; 513 + read_buf = Buffer.create 4096; 514 + } 515 + | Some a -> 516 + Error 517 + (Protocol_error 518 + (Printf.sprintf "Bad accept key: %s (expected %s)" a 519 + expected_accept)) 520 + | None -> Error (Protocol_error "Missing accept key")) 521 + | [] -> Error (Protocol_error "Empty response") 522 + with exn -> Error (Io_error (Printexc.to_string exn)))) 523 + 524 + (** {1 Server API} *) 525 + 526 + (** Accept a WebSocket upgrade from an HTTP connection. Returns a WebSocket 527 + connection after sending the upgrade response. *) 528 + let accept ~flow ~key = 529 + let accept = compute_accept_key key in 530 + let response = 531 + Printf.sprintf 532 + "HTTP/1.1 101 Switching Protocols\r\n\ 533 + Upgrade: websocket\r\n\ 534 + Connection: Upgrade\r\n\ 535 + Sec-WebSocket-Accept: %s\r\n\ 536 + \r\n" 537 + accept 538 + in 539 + try 540 + Eio.Flow.write flow [ Cstruct.of_string response ]; 541 + Ok 542 + { 543 + flow :> Eio.Flow.two_way_ty r; 544 + closed = false; 545 + is_client = false; 546 + read_buf = Buffer.create 4096; 547 + } 548 + with exn -> Error (Io_error (Printexc.to_string exn))
+43
opencode.json
··· 1 + { 2 + "$schema": "https://opencode.ai/config.json", 3 + "snapshot": false, 4 + "lsp": { 5 + "ocaml-lsp": { 6 + "command": ["ocamllsp"], 7 + "extensions": [".ml", ".mli"] 8 + }, 9 + "clangd": { 10 + "command": ["clangd"], 11 + "extensions": [".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hh", ".hxx"] 12 + } 13 + }, 14 + "formatter": { 15 + "ocamlformat": { 16 + "command": ["ocamlformat", "--inplace", "$FILE"], 17 + "extensions": [".ml", ".mli"] 18 + }, 19 + "clang-format": { 20 + "command": ["clang-format", "-i", "$FILE"], 21 + "extensions": [".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hh", ".hxx"] 22 + } 23 + }, 24 + "mcp": { 25 + "beads": { 26 + "type": "local", 27 + "command": ["uv", "tool", "run", "beads-mcp"], 28 + "enabled": true 29 + }, 30 + "tod": { 31 + "type": "local", 32 + "command": ["tod", "mcp", "--log-file", "/tmp/tod.log"], 33 + "enabled": true 34 + }, 35 + "jetbrains": { 36 + "type": "local", 37 + "environment": { 38 + "IJ_MCP_SERVER_PORT": "64342" 39 + }, 40 + "command": [ "/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/jbr/bin/java", "-classpath", "/home/gdiazlo/.local/share/JetBrains/CLion2025.3/mcpserver/lib/mcpserver.jar:/home/gdiazlo/.local/share/JetBrains/CLion2025.3/mcpserver/lib/io.modelcontextprotocol.kotlin.sdk.jar:/home/gdiazlo/.local/share/JetBrains/CLion2025.3/mcpserver/lib/io.github.oshai.kotlin.logging.jvm.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/util-8.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.ktor.client.cio.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.ktor.client.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.ktor.network.tls.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.ktor.io.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.ktor.utils.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.kotlinx.io.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.kotlinx.serialization.core.jar:/home/gdiazlo/.local/share/JetBrains/Toolbox/apps/clion/lib/module-intellij.libraries.kotlinx.serialization.json.jar", "com.intellij.mcpserver.stdio.McpStdioRunnerKt" ] 41 + } 42 + } 43 + }
+19
test/certs/cert.pem
··· 1 + -----BEGIN CERTIFICATE----- 2 + MIIDCTCCAfGgAwIBAgIURfGI0GrBt2X78EYcfpuFIogJg1AwDQYJKoZIhvcNAQEL 3 + BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MTIzMDIwNTIyMFoXDTI2MTIz 4 + MDIwNTIyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 + AAOCAQ8AMIIBCgKCAQEA31KLbLy0SawqPU+v3N1fbWDS2/5KVsai65o96pz/ENJA 6 + Y2797qomtd9mX3cSUTr8R0lAINqDoz158NIPRBezonWxiyBR3ZFh+uYEvlGjvJ7V 7 + RXpzOrW0OhmCHQoOJBf3EmC3GlBKPUK2WwPvF6kA/9kf/rFbPDdUKu3kV3np2Var 8 + pyAPQvjWcBvHzAOGMjkFxQHhlSAIY5JJkb5k0BsQsvcIEY1Sj6dqKZKmFhoJY1k2 9 + fysDc8nMto87fJm/OHVJtco6vmgEy4uAqpuUOeqdE9VYF9rdzmKUr19HQVFd4AwN 10 + UEpCNfjEAIlWg2hQqAXbn9xcjrKq41b/O8MXZBuUkwIDAQABo1MwUTAdBgNVHQ4E 11 + FgQUtlOWGEII5hFsFDU9RbLuqZ/1eMgwHwYDVR0jBBgwFoAUtlOWGEII5hFsFDU9 12 + RbLuqZ/1eMgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAbYHg 13 + hbev2BCczOqzoc3AI9rJL8S/m759lG55xcFO2j82ADwi3o8fIQpTBlNQu/HrvOLP 14 + BWvcc8kOyKZyTAzcCqZMreBGxP1B9LgOiRaoc8akoyKuqXg/iJ+RKwjqnUZiq0eQ 15 + nEhHzcVojRzBqLfKTuhLa/FOx++fUDrbr59zVcrJl62FUuNB9cqdFZZIV8xv0xYH 16 + yZ/gN4EceVY/uzC9HL0MCnaSUuJ7ZJ+C5Mv4DC6RoHxc0byjYv9JV0+xArbLMaEC 17 + mhe1vqAIbFw9zhgF+4uwQ1WKCvKiIJZGV1h21MhIXBwg8fZckX0OiTsjC4n1spMp 18 + JZc3Qt/B18/78/vBdg== 19 + -----END CERTIFICATE-----
+28
test/certs/key.pem
··· 1 + -----BEGIN PRIVATE KEY----- 2 + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDfUotsvLRJrCo9 3 + T6/c3V9tYNLb/kpWxqLrmj3qnP8Q0kBjbv3uqia132ZfdxJROvxHSUAg2oOjPXnw 4 + 0g9EF7OidbGLIFHdkWH65gS+UaO8ntVFenM6tbQ6GYIdCg4kF/cSYLcaUEo9QrZb 5 + A+8XqQD/2R/+sVs8N1Qq7eRXeenZVqunIA9C+NZwG8fMA4YyOQXFAeGVIAhjkkmR 6 + vmTQGxCy9wgRjVKPp2opkqYWGgljWTZ/KwNzycy2jzt8mb84dUm1yjq+aATLi4Cq 7 + m5Q56p0T1VgX2t3OYpSvX0dBUV3gDA1QSkI1+MQAiVaDaFCoBduf3FyOsqrjVv87 8 + wxdkG5STAgMBAAECggEAb5vARHK2o48geQ3HNVmJZI+cMggPPPj+2UJ3qTYXH2AE 9 + f+0riUiOGhzw+8r1Y5VYGYRZQVITsdR6iclhicVSqGGrfn2DjHko/4xPWJJSg2TZ 10 + 72Q4DGJdFSBEb6Dj2lQDzbJ6L/Nqdt1NZ4U6y5dSAYuQuugGUbzRGrdThAhQv6Xq 11 + slO366T8XaAaYGpp5Bl8RW0C8H46lnVbvhyBZG+v+XCsvsYyuj8XtUc/FLT3NDBF 12 + 8KSs62SRlhEr7oVc90oZtOd2/Cy05GqKjxn+e2iEuSGJtqmQwSmoc7kUC3rPpVuQ 13 + 8nwVSvztfIO53zmWROJ2Nzq5c5//J5HUgHdDtC+T8QKBgQD9grp8+ZNw2ViHRboT 14 + GnvqbETiJFFpdjJHkuZCuDjYTBcOp9Kgtlj8oQP+v4Bxz8BUWuz4RvfloR1qN3AU 15 + 79rbJQInOReReAAIrQIz8Hht1ldmwn98xB3CZdD4CQx5+yHRxwyEpIOtdwVlEFpO 16 + Y/b7kT0e9V97XABUI+x2voQHGQKBgQDhg+3vxaAkc+g62pW/a6gwJ1qlLziZL1PY 17 + KwDP2lzZ840pVnKMZAcLQkyC3mtqV/zxnDjfcY77SIpKI/EusNCC6SgW/nFDCaPw 18 + 6HvLpzksS9kaU8KiTnX80cc2Wj0ovKUW0tZN5DpYtGKKwx55NQ2ntqMMruTRCSHo 19 + xHlTX6LKiwKBgAKVfokfkh0oWfqm/0qKjpDJIbqcXCgTCFgqayZq8kliHSoNE9yH 20 + f0iz63KBSTHfyDoT4y50qrowdjhK8F2GRFjPe8IiltbovjdYbBlTbibQdHubp+kB 21 + prwAhazBitsDcEjPGpWPh7gQ6oEpNYJvCmluBY13Os3rFiXFZ/ed4YKhAoGAN4jw 22 + sEo/nJp6hvvLQr91RJ58YQ9W5cpU4Hf+lDEXColCqvph7Ra48VnbU6MIWsDeRnWN 23 + P+FML692drmRLElYn9RhDgCMzdBy8eGektFUcAchqK0V0jh2L9NNNqJUS1LhaNR0 24 + 9+uLPsr1NWzFdV3ORg3g6z+qk5unIRY5NoHnqe8CgYEA0ToCHBg/rM2VGaZbAfFp 25 + mTf2TQzbaD9IEQ2J7G3hzuw13FTWCyEbDoXvGtDqZYCnh2Mwj9okusvCLAyOlx29 26 + UUIJwEXhaxwoOqseLvUGYyP8wmD4r/D2aYqM6nKbeY4tdqKxZ18x2p27Dc25XKiP 27 + zdHQJbsvcTFunZsJiVa4DFQ= 28 + -----END PRIVATE KEY-----
+19
test/dune
··· 1 + (test 2 + (name test_hcs) 3 + (package hcs) 4 + (libraries hcs alcotest qcheck qcheck-alcotest eio_main)) 5 + 6 + (executable 7 + (name test_client_integration) 8 + (libraries hcs eio_main mirage-crypto-rng-eio) 9 + (modules test_client_integration)) 10 + 11 + (executable 12 + (name test_alpn_server) 13 + (libraries hcs eio_main mirage-crypto-rng-eio mirage-crypto-rng.unix) 14 + (modules test_alpn_server)) 15 + 16 + (executable 17 + (name test_alpn_client) 18 + (libraries hcs eio_main mirage-crypto-rng-eio mirage-crypto-rng.unix) 19 + (modules test_alpn_client))
+55
test/h2spec_runner.sh
··· 1 + #!/bin/bash 2 + # h2spec compliance testing for HCS HTTP/2 server 3 + # 4 + # Prerequisites: 5 + # - h2spec installed: go install github.com/summerwind/h2spec/cmd/h2spec@latest 6 + # - OR download from: https://github.com/summerwind/h2spec/releases 7 + # 8 + # Usage: 9 + # 1. Start the HCS server: dune exec bin/hcs-demo.exe 10 + # 2. Run this script: ./test/h2spec_runner.sh 11 + # 12 + # Note: The server should be running on localhost:8443 with TLS 13 + # or localhost:8080 without TLS 14 + 15 + set -e 16 + 17 + H2SPEC=${H2SPEC:-h2spec} 18 + HOST=${H2SPEC_HOST:-localhost} 19 + PORT=${H2SPEC_PORT:-8080} 20 + USE_TLS=${H2SPEC_TLS:-false} 21 + 22 + echo "H2Spec HTTP/2 Compliance Tests" 23 + echo "==============================" 24 + echo "Host: $HOST" 25 + echo "Port: $PORT" 26 + echo "TLS: $USE_TLS" 27 + echo "" 28 + 29 + # Check if h2spec is available 30 + if ! command -v "$H2SPEC" &> /dev/null; then 31 + echo "Error: h2spec not found in PATH" 32 + echo "" 33 + echo "Install h2spec:" 34 + echo " go install github.com/summerwind/h2spec/cmd/h2spec@latest" 35 + echo "" 36 + echo "Or download from:" 37 + echo " https://github.com/summerwind/h2spec/releases" 38 + exit 1 39 + fi 40 + 41 + # Build test options 42 + OPTS="-h $HOST -p $PORT" 43 + 44 + if [ "$USE_TLS" = "true" ]; then 45 + OPTS="$OPTS -t -k" # -t for TLS, -k to skip cert verification 46 + fi 47 + 48 + # Run h2spec 49 + echo "Running h2spec..." 50 + echo "" 51 + 52 + $H2SPEC $OPTS --strict 53 + 54 + echo "" 55 + echo "H2Spec tests complete."
+67
test/test_alpn_client.ml
··· 1 + let test_external_servers () = 2 + Eio_main.run @@ fun env -> 3 + let net = Eio.Stdenv.net env in 4 + let clock = Eio.Stdenv.clock env in 5 + Eio.Switch.run @@ fun sw -> 6 + let urls = 7 + [ 8 + ("https://www.google.com", "Google"); 9 + ("https://httpbin.org/get", "httpbin"); 10 + ("https://www.cloudflare.com", "Cloudflare"); 11 + ] 12 + in 13 + List.iter 14 + (fun (url, name) -> 15 + Eio.traceln "\n=== Testing %s ===" name; 16 + Eio.traceln "URL: %s" url; 17 + 18 + Eio.traceln "\n--- HTTP/1.1 mode ---"; 19 + let config = Hcs.Client.default_config |> Hcs.Client.with_http11 in 20 + (match Hcs.Client.get ~sw ~net ~clock ~config url with 21 + | Ok resp -> 22 + let proto = 23 + match resp.protocol with 24 + | Hcs.Client.HTTP_1_1 -> "HTTP/1.1" 25 + | Hcs.Client.HTTP_2 -> "HTTP/2" 26 + in 27 + Eio.traceln "Status: %d, Protocol: %s, Body size: %d" resp.status 28 + proto (String.length resp.body) 29 + | Error e -> 30 + let msg = 31 + match e with 32 + | Hcs.Client.Connection_failed s -> "Connection failed: " ^ s 33 + | Hcs.Client.Tls_error s -> "TLS error: " ^ s 34 + | Hcs.Client.Protocol_error s -> "Protocol error: " ^ s 35 + | Hcs.Client.Timeout -> "Timeout" 36 + | Hcs.Client.Invalid_response s -> "Invalid response: " ^ s 37 + | Hcs.Client.Too_many_redirects -> "Too many redirects" 38 + in 39 + Eio.traceln "Error: %s" msg); 40 + 41 + Eio.traceln "\n--- HTTP/2 mode ---"; 42 + let config = Hcs.Client.default_config |> Hcs.Client.with_http2 in 43 + match Hcs.Client.get ~sw ~net ~clock ~config url with 44 + | Ok resp -> 45 + let proto = 46 + match resp.protocol with 47 + | Hcs.Client.HTTP_1_1 -> "HTTP/1.1" 48 + | Hcs.Client.HTTP_2 -> "HTTP/2" 49 + in 50 + Eio.traceln "Status: %d, Protocol: %s, Body size: %d" resp.status 51 + proto (String.length resp.body) 52 + | Error e -> 53 + let msg = 54 + match e with 55 + | Hcs.Client.Connection_failed s -> "Connection failed: " ^ s 56 + | Hcs.Client.Tls_error s -> "TLS error: " ^ s 57 + | Hcs.Client.Protocol_error s -> "Protocol error: " ^ s 58 + | Hcs.Client.Timeout -> "Timeout" 59 + | Hcs.Client.Invalid_response s -> "Invalid response: " ^ s 60 + | Hcs.Client.Too_many_redirects -> "Too many redirects" 61 + in 62 + Eio.traceln "Error: %s" msg) 63 + urls 64 + 65 + let () = 66 + Mirage_crypto_rng_unix.use_default (); 67 + test_external_servers ()
+28
test/test_alpn_server.ml
··· 1 + let () = 2 + Mirage_crypto_rng_unix.use_default (); 3 + Eio_main.run @@ fun env -> 4 + let net = Eio.Stdenv.net env in 5 + Eio.Switch.run @@ fun sw -> 6 + match 7 + Hcs.Tls_config.Server.of_pem ~cert_file:"test/certs/cert.pem" 8 + ~key_file:"test/certs/key.pem" 9 + with 10 + | Error msg -> 11 + Eio.traceln "TLS config error: %s" msg; 12 + exit 1 13 + | Ok tls_config -> 14 + let handler (req : Hcs.Server.request) = 15 + let protocol_str = 16 + match req.version with 17 + | Hcs.Server.HTTP_1_1 -> "HTTP/1.1" 18 + | Hcs.Server.HTTP_2 -> "HTTP/2" 19 + in 20 + Hcs.Server.respond ~status:`OK 21 + (Printf.sprintf "Hello from %s! Target: %s" protocol_str req.target) 22 + in 23 + let config = 24 + Hcs.Server.default_config |> Hcs.Server.with_port 8443 25 + |> Hcs.Server.with_protocol Hcs.Server.Auto 26 + |> Hcs.Server.with_tls tls_config 27 + in 28 + Hcs.Server.run ~sw ~net ~config handler
+186
test/test_client_integration.ml
··· 1 + (** Integration tests using httpbin.org for HTTP client compliance. 2 + 3 + These tests require network access and use the public httpbin.org service. 4 + They validate that the HCS HTTP client correctly implements HTTP semantics. 5 + 6 + Run with: dune exec test/test_client_integration.exe 7 + 8 + For local testing, you can run httpbin in Docker: docker run -p 8080:80 9 + kennethreitz/httpbin 10 + 11 + Then set HTTPBIN_URL=http://localhost:8080 *) 12 + 13 + let httpbin_url = 14 + match Sys.getenv_opt "HTTPBIN_URL" with 15 + | Some url -> url 16 + | None -> "https://httpbin.org" 17 + 18 + (* Note: These are integration tests that require network access. 19 + They are separate from unit tests and should be run manually or in CI 20 + with network access enabled. *) 21 + 22 + let run_tests env () = 23 + Eio.Switch.run @@ fun sw -> 24 + let net = Eio.Stdenv.net env in 25 + let clock = Eio.Stdenv.clock env in 26 + 27 + (* Test 1: Simple GET request *) 28 + Printf.printf "Test 1: GET /get ... "; 29 + (try 30 + let resp = Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/get") in 31 + match resp with 32 + | Ok resp -> 33 + if resp.status = 200 then begin 34 + Printf.printf "OK (status=%d, body=%d bytes)\n" resp.status 35 + (String.length resp.body) 36 + end 37 + else Printf.printf "FAIL (status=%d)\n" resp.status 38 + | Error e -> 39 + let msg = 40 + match e with 41 + | Hcs.Client.Connection_failed s -> "Connection_failed: " ^ s 42 + | Hcs.Client.Tls_error s -> "Tls_error: " ^ s 43 + | Hcs.Client.Protocol_error s -> "Protocol_error: " ^ s 44 + | Hcs.Client.Timeout -> "Timeout" 45 + | Hcs.Client.Invalid_response s -> "Invalid_response: " ^ s 46 + | Hcs.Client.Too_many_redirects -> "Too_many_redirects" 47 + in 48 + Printf.printf "FAIL (error=%s)\n" msg 49 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 50 + 51 + (* Test 2: GET with query parameters *) 52 + Printf.printf "Test 2: GET /get?foo=bar ... "; 53 + (try 54 + let resp = 55 + Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/get?foo=bar&baz=qux") 56 + in 57 + match resp with 58 + | Ok resp -> 59 + if resp.status = 200 && String.length resp.body > 0 then 60 + Printf.printf "OK\n" 61 + else Printf.printf "FAIL\n" 62 + | Error _ -> Printf.printf "FAIL\n" 63 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 64 + 65 + (* Test 3: POST with body *) 66 + Printf.printf "Test 3: POST /post with body ... "; 67 + (try 68 + let body = "Hello, httpbin!" in 69 + let resp = Hcs.Client.post ~sw ~net ~clock ~body (httpbin_url ^ "/post") in 70 + match resp with 71 + | Ok resp -> 72 + if resp.status = 200 then Printf.printf "OK\n" 73 + else Printf.printf "FAIL (status=%d)\n" resp.status 74 + | Error _ -> Printf.printf "FAIL\n" 75 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 76 + 77 + (* Test 4: Status code 201 *) 78 + Printf.printf "Test 4: GET /status/201 ... "; 79 + (try 80 + let resp = Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/status/201") in 81 + match resp with 82 + | Ok resp -> 83 + if resp.status = 201 then Printf.printf "OK\n" 84 + else Printf.printf "FAIL (status=%d)\n" resp.status 85 + | Error _ -> Printf.printf "FAIL\n" 86 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 87 + 88 + (* Test 5: User-Agent header *) 89 + Printf.printf "Test 5: GET /user-agent ... "; 90 + (try 91 + let resp = Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/user-agent") in 92 + match resp with 93 + | Ok resp -> 94 + if resp.status = 200 && String.length resp.body > 0 then 95 + Printf.printf "OK\n" 96 + else Printf.printf "FAIL\n" 97 + | Error _ -> Printf.printf "FAIL\n" 98 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 99 + 100 + (* Test 6: Response headers *) 101 + Printf.printf "Test 6: GET /response-headers?X-Test=value ... "; 102 + (try 103 + let resp = 104 + Hcs.Client.get ~sw ~net ~clock 105 + (httpbin_url ^ "/response-headers?X-Test=hello") 106 + in 107 + match resp with 108 + | Ok resp -> 109 + let has_header = 110 + List.exists 111 + (fun (k, _) -> String.lowercase_ascii k = "x-test") 112 + resp.headers 113 + in 114 + if resp.status = 200 && has_header then Printf.printf "OK\n" 115 + else Printf.printf "FAIL (missing header or bad status)\n" 116 + | Error _ -> Printf.printf "FAIL\n" 117 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 118 + 119 + (* Test 7: Redirect (we don't follow redirects by default) *) 120 + Printf.printf "Test 7: GET /absolute-redirect/1 ... "; 121 + (try 122 + (* Use config without redirects *) 123 + let config = Hcs.Client.without_redirects Hcs.Client.default_config in 124 + let resp = 125 + Hcs.Client.get ~sw ~net ~clock ~config 126 + (httpbin_url ^ "/absolute-redirect/1") 127 + in 128 + match resp with 129 + | Ok resp -> 130 + (* Should get 302 since we don't follow redirects *) 131 + Printf.printf "OK (status=%d)\n" resp.status 132 + | Error _ -> Printf.printf "FAIL\n" 133 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 134 + 135 + (* Test 8: HTTP/2 if available *) 136 + Printf.printf "Test 8: GET /get with HTTP/2 ... "; 137 + (try 138 + let config = Hcs.Client.with_http2 Hcs.Client.default_config in 139 + let resp = Hcs.Client.get ~sw ~net ~clock ~config (httpbin_url ^ "/get") in 140 + match resp with 141 + | Ok resp -> 142 + let proto = 143 + match resp.protocol with 144 + | Hcs.Client.HTTP_1_1 -> "HTTP/1.1" 145 + | Hcs.Client.HTTP_2 -> "HTTP/2" 146 + in 147 + Printf.printf "OK (status=%d, protocol=%s)\n" resp.status proto 148 + | Error _ -> Printf.printf "FAIL\n" 149 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 150 + 151 + (* Test 9: gzip response *) 152 + Printf.printf "Test 9: GET /gzip ... "; 153 + (try 154 + let resp = Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/gzip") in 155 + match resp with 156 + | Ok resp -> 157 + if resp.status = 200 then Printf.printf "OK\n" 158 + else Printf.printf "FAIL (status=%d)\n" resp.status 159 + | Error _ -> Printf.printf "FAIL\n" 160 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 161 + 162 + (* Test 10: Large response *) 163 + Printf.printf "Test 10: GET /bytes/10000 ... "; 164 + (try 165 + let resp = Hcs.Client.get ~sw ~net ~clock (httpbin_url ^ "/bytes/10000") in 166 + match resp with 167 + | Ok resp -> 168 + if resp.status = 200 && String.length resp.body = 10000 then 169 + Printf.printf "OK (%d bytes)\n" (String.length resp.body) 170 + else 171 + Printf.printf "FAIL (status=%d, body=%d bytes)\n" resp.status 172 + (String.length resp.body) 173 + | Error _ -> Printf.printf "FAIL\n" 174 + with exn -> Printf.printf "FAIL (exception=%s)\n" (Printexc.to_string exn)); 175 + 176 + Printf.printf "\nIntegration tests complete.\n" 177 + 178 + let () = 179 + Printf.printf "HCS HTTP Client Integration Tests\n"; 180 + Printf.printf "Using httpbin at: %s\n\n" httpbin_url; 181 + 182 + Eio_main.run @@ fun env -> 183 + (Mirage_crypto_rng_eio.run [@alert "-deprecated"]) 184 + (module Mirage_crypto_rng.Fortuna) 185 + env 186 + @@ run_tests env
+1030
test/test_hcs.ml
··· 1 + (** HCS Test Suite 2 + 3 + This test suite covers: 4 + - Codec module (identity, string codecs) 5 + - Stream module (sync and async streams) 6 + - Http module (request builder DSL) 7 + - Router module (path parsing, route matching) 8 + - Middleware module (composition, conditional) 9 + - Property-based tests using QCheck *) 10 + 11 + open Alcotest 12 + 13 + (* ================================================================== *) 14 + (* Codec Tests *) 15 + (* ================================================================== *) 16 + 17 + module Test_codec = struct 18 + open Hcs.Codec 19 + 20 + let test_identity_codec_encode () = 21 + let encoder : Cstruct.t Identity_codec.encoder = Fun.id in 22 + let input = Cstruct.of_string "hello world" in 23 + match Identity_codec.encode encoder input with 24 + | Ok result -> 25 + check string "encode" "hello world" (Cstruct.to_string result) 26 + | Error e -> fail e 27 + 28 + let test_identity_codec_decode () = 29 + let decoder : Cstruct.t Identity_codec.decoder = fun cs -> Ok cs in 30 + let input = Cstruct.of_string "hello world" in 31 + match Identity_codec.decode decoder input with 32 + | Ok result -> 33 + check string "decode" "hello world" (Cstruct.to_string result) 34 + | Error e -> fail e 35 + 36 + let test_identity_codec_content_type () = 37 + check string "content_type" "application/octet-stream" 38 + Identity_codec.content_type 39 + 40 + let test_string_codec_encode () = 41 + let encoder : string String_codec.encoder = Fun.id in 42 + match String_codec.encode encoder "hello world" with 43 + | Ok result -> 44 + check string "encode" "hello world" (Cstruct.to_string result) 45 + | Error e -> fail e 46 + 47 + let test_string_codec_decode () = 48 + let decoder : string String_codec.decoder = fun s -> Ok s in 49 + let input = Cstruct.of_string "hello world" in 50 + match String_codec.decode decoder input with 51 + | Ok result -> check string "decode" "hello world" result 52 + | Error e -> fail e 53 + 54 + let test_string_codec_content_type () = 55 + check string "content_type" "text/plain; charset=utf-8" 56 + String_codec.content_type 57 + 58 + let test_codec_error_to_string () = 59 + check string "encode_error" "Encode error: test" 60 + (codec_error_to_string (Encode_error "test")); 61 + check string "decode_error" "Decode error: test" 62 + (codec_error_to_string (Decode_error "test")); 63 + check string "unsupported" "Unsupported body type for codec operation" 64 + (codec_error_to_string Unsupported_body_type) 65 + 66 + let test_with_codec_encode_body () = 67 + let module W = With_codec (String_codec) in 68 + let encoder : string String_codec.encoder = String.uppercase_ascii in 69 + match W.encode_body encoder "hello" with 70 + | Ok result -> check string "encode_body" "HELLO" result 71 + | Error e -> fail (codec_error_to_string e) 72 + 73 + let test_with_codec_decode_body () = 74 + let module W = With_codec (String_codec) in 75 + let decoder : string String_codec.decoder = 76 + fun s -> Ok (String.uppercase_ascii s) 77 + in 78 + match W.decode_body decoder "hello" with 79 + | Ok result -> check string "decode_body" "HELLO" result 80 + | Error e -> fail (codec_error_to_string e) 81 + 82 + let tests = 83 + [ 84 + test_case "identity codec encode" `Quick test_identity_codec_encode; 85 + test_case "identity codec decode" `Quick test_identity_codec_decode; 86 + test_case "identity codec content_type" `Quick 87 + test_identity_codec_content_type; 88 + test_case "string codec encode" `Quick test_string_codec_encode; 89 + test_case "string codec decode" `Quick test_string_codec_decode; 90 + test_case "string codec content_type" `Quick 91 + test_string_codec_content_type; 92 + test_case "codec error to string" `Quick test_codec_error_to_string; 93 + test_case "with_codec encode_body" `Quick test_with_codec_encode_body; 94 + test_case "with_codec decode_body" `Quick test_with_codec_decode_body; 95 + ] 96 + end 97 + 98 + (* ================================================================== *) 99 + (* Stream Tests *) 100 + (* ================================================================== *) 101 + 102 + module Test_stream = struct 103 + open Hcs.Stream 104 + 105 + (* Sync stream tests *) 106 + let test_sync_empty () = 107 + let s = Sync.empty in 108 + check (list int) "empty" [] (Sync.to_list s) 109 + 110 + let test_sync_singleton () = 111 + let s = Sync.singleton 42 in 112 + check (list int) "singleton" [ 42 ] (Sync.to_list s) 113 + 114 + let test_sync_of_list () = 115 + let s = Sync.of_list [ 1; 2; 3 ] in 116 + check (list int) "of_list" [ 1; 2; 3 ] (Sync.to_list s) 117 + 118 + let test_sync_map () = 119 + let s = Sync.of_list [ 1; 2; 3 ] |> Sync.map (fun x -> x * 2) in 120 + check (list int) "map" [ 2; 4; 6 ] (Sync.to_list s) 121 + 122 + let test_sync_filter () = 123 + let s = 124 + Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.filter (fun x -> x mod 2 = 0) 125 + in 126 + check (list int) "filter" [ 2; 4 ] (Sync.to_list s) 127 + 128 + let test_sync_take () = 129 + let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.take 3 in 130 + check (list int) "take" [ 1; 2; 3 ] (Sync.to_list s) 131 + 132 + let test_sync_drop () = 133 + let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.drop 2 in 134 + check (list int) "drop" [ 3; 4; 5 ] (Sync.to_list s) 135 + 136 + let test_sync_fold () = 137 + let result = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.fold ( + ) 0 in 138 + check int "fold" 15 result 139 + 140 + let test_sync_chunks () = 141 + let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.chunks 2 in 142 + check 143 + (list (list int)) 144 + "chunks" 145 + [ [ 1; 2 ]; [ 3; 4 ]; [ 5 ] ] 146 + (Sync.to_list s) 147 + 148 + let test_sync_repeat () = 149 + let s = Sync.repeat 3 "x" in 150 + check (list string) "repeat" [ "x"; "x"; "x" ] (Sync.to_list s) 151 + 152 + let test_sync_unfold () = 153 + let s = Sync.unfold (fun n -> if n > 0 then Some (n, n - 1) else None) 3 in 154 + check (list int) "unfold" [ 3; 2; 1 ] (Sync.to_list s) 155 + 156 + let test_sync_append () = 157 + let s1 = Sync.of_list [ 1; 2 ] in 158 + let s2 = Sync.of_list [ 3; 4 ] in 159 + let s = Sync.append s1 s2 in 160 + check (list int) "append" [ 1; 2; 3; 4 ] (Sync.to_list s) 161 + 162 + let test_sync_cstructs_to_string () = 163 + let s = 164 + Sync.of_list 165 + [ 166 + Cstruct.of_string "hello"; 167 + Cstruct.of_string " "; 168 + Cstruct.of_string "world"; 169 + ] 170 + in 171 + check string "cstructs_to_string" "hello world" (Sync.cstructs_to_string s) 172 + 173 + let test_sync_string_to_cstructs () = 174 + let s = Sync.string_to_cstructs ~chunk_size:5 "hello world" in 175 + let result = 176 + s |> Sync.map Cstruct.to_string |> Sync.to_list |> String.concat "" 177 + in 178 + check string "string_to_cstructs" "hello world" result 179 + 180 + (* Async stream tests *) 181 + let test_async_empty () = 182 + let s = Async.empty in 183 + check (list int) "async empty" [] (Async.to_list s) 184 + 185 + let test_async_singleton () = 186 + let s = Async.singleton 42 in 187 + check (list int) "async singleton" [ 42 ] (Async.to_list s) 188 + 189 + let test_async_of_list () = 190 + let s = Async.of_list [ 1; 2; 3 ] in 191 + check (list int) "async of_list" [ 1; 2; 3 ] (Async.to_list s) 192 + 193 + let test_async_map () = 194 + let s = Async.of_list [ 1; 2; 3 ] in 195 + let mapped = Async.map (fun x -> x * 2) s in 196 + check (list int) "async map" [ 2; 4; 6 ] (Async.to_list mapped) 197 + 198 + let test_async_filter () = 199 + let s = Async.of_list [ 1; 2; 3; 4; 5 ] in 200 + let filtered = Async.filter (fun x -> x mod 2 = 0) s in 201 + check (list int) "async filter" [ 2; 4 ] (Async.to_list filtered) 202 + 203 + let test_async_take () = 204 + let s = Async.of_list [ 1; 2; 3; 4; 5 ] in 205 + let taken = Async.take 3 s in 206 + check (list int) "async take" [ 1; 2; 3 ] (Async.to_list taken) 207 + 208 + let test_async_fold () = 209 + let s = Async.of_list [ 1; 2; 3; 4; 5 ] in 210 + let result = Async.fold ( + ) 0 s in 211 + check int "async fold" 15 result 212 + 213 + (* Chunked encoding tests *) 214 + let test_chunked_encode () = 215 + let chunks = Sync.of_list [ Cstruct.of_string "hello" ] in 216 + let encoded = Chunked.encode chunks in 217 + let result = Sync.cstructs_to_string encoded in 218 + (* 5 in hex is "5", chunk format: "5\r\nhello\r\n" followed by terminator "0\r\n\r\n" *) 219 + check string "chunked encode" "5\r\nhello\r\n0\r\n\r\n" result 220 + 221 + let test_chunked_content_length () = 222 + let chunks = 223 + Sync.of_list [ Cstruct.of_string "hello"; Cstruct.of_string " world" ] 224 + in 225 + let len = Chunked.content_length chunks in 226 + check int "chunked content_length" 11 len 227 + 228 + let tests = 229 + [ 230 + (* Sync tests *) 231 + test_case "sync empty" `Quick test_sync_empty; 232 + test_case "sync singleton" `Quick test_sync_singleton; 233 + test_case "sync of_list" `Quick test_sync_of_list; 234 + test_case "sync map" `Quick test_sync_map; 235 + test_case "sync filter" `Quick test_sync_filter; 236 + test_case "sync take" `Quick test_sync_take; 237 + test_case "sync drop" `Quick test_sync_drop; 238 + test_case "sync fold" `Quick test_sync_fold; 239 + test_case "sync chunks" `Quick test_sync_chunks; 240 + test_case "sync repeat" `Quick test_sync_repeat; 241 + test_case "sync unfold" `Quick test_sync_unfold; 242 + test_case "sync append" `Quick test_sync_append; 243 + test_case "sync cstructs_to_string" `Quick test_sync_cstructs_to_string; 244 + test_case "sync string_to_cstructs" `Quick test_sync_string_to_cstructs; 245 + (* Async tests *) 246 + test_case "async empty" `Quick test_async_empty; 247 + test_case "async singleton" `Quick test_async_singleton; 248 + test_case "async of_list" `Quick test_async_of_list; 249 + test_case "async map" `Quick test_async_map; 250 + test_case "async filter" `Quick test_async_filter; 251 + test_case "async take" `Quick test_async_take; 252 + test_case "async fold" `Quick test_async_fold; 253 + (* Chunked tests *) 254 + test_case "chunked encode" `Quick test_chunked_encode; 255 + test_case "chunked content_length" `Quick test_chunked_content_length; 256 + ] 257 + end 258 + 259 + (* ================================================================== *) 260 + (* HTTP Request Builder Tests *) 261 + (* ================================================================== *) 262 + 263 + module Test_http = struct 264 + open Hcs.Http 265 + 266 + let test_get () = 267 + let req = get "https://example.com/path" |> build in 268 + check string "method" "GET" (meth_to_string req.req_meth); 269 + check string "host" "example.com" (host req); 270 + check string "path" "/path" (path req); 271 + check bool "is_https" true (is_https req) 272 + 273 + let test_post () = 274 + let req = post "http://example.com/api" |> build in 275 + check string "method" "POST" (meth_to_string req.req_meth); 276 + check bool "is_https" false (is_https req) 277 + 278 + let test_put () = 279 + let req = put "http://example.com/resource" |> build in 280 + check string "method" "PUT" (meth_to_string req.req_meth) 281 + 282 + let test_delete () = 283 + let req = delete "http://example.com/resource/1" |> build in 284 + check string "method" "DELETE" (meth_to_string req.req_meth) 285 + 286 + let test_patch () = 287 + let req = patch "http://example.com/resource/1" |> build in 288 + check string "method" "PATCH" (meth_to_string req.req_meth) 289 + 290 + let test_head () = 291 + let req = head "http://example.com/" |> build in 292 + check string "method" "HEAD" (meth_to_string req.req_meth) 293 + 294 + let test_options () = 295 + let req = options "http://example.com/" |> build in 296 + check string "method" "OPTIONS" (meth_to_string req.req_meth) 297 + 298 + let test_header () = 299 + let req = 300 + get "http://example.com" |> header "X-Custom" "value" 301 + |> header "X-Another" "another" 302 + |> build 303 + in 304 + check bool "has X-Custom" 305 + (List.mem ("X-Custom", "value") req.req_headers) 306 + true; 307 + check bool "has X-Another" 308 + (List.mem ("X-Another", "another") req.req_headers) 309 + true 310 + 311 + let test_content_type () = 312 + let req = 313 + post "http://example.com" |> content_type "application/json" |> build 314 + in 315 + check bool "has Content-Type" 316 + (List.mem ("Content-Type", "application/json") req.req_headers) 317 + true 318 + 319 + let test_accept () = 320 + let req = get "http://example.com" |> accept "text/html" |> build in 321 + check bool "has Accept" 322 + (List.mem ("Accept", "text/html") req.req_headers) 323 + true 324 + 325 + let test_user_agent () = 326 + let req = get "http://example.com" |> user_agent "HCS/1.0" |> build in 327 + check bool "has User-Agent" 328 + (List.mem ("User-Agent", "HCS/1.0") req.req_headers) 329 + true 330 + 331 + let test_bearer () = 332 + let req = get "http://example.com" |> bearer "token123" |> build in 333 + check bool "has Authorization" 334 + (List.mem ("Authorization", "Bearer token123") req.req_headers) 335 + true 336 + 337 + let test_query () = 338 + let req = 339 + get "http://example.com/search" 340 + |> query "q" "ocaml" |> query "limit" "10" |> build 341 + in 342 + let pq = path_and_query req in 343 + check bool "has q param" (String.length pq > 0) true 344 + 345 + let test_body_string () = 346 + let req = 347 + post "http://example.com" 348 + |> body_string ~content_type:"text/plain" "hello" 349 + |> build 350 + in 351 + check string "body" "hello" (body_to_string req.req_body); 352 + check bool "has Content-Type" 353 + (List.mem ("Content-Type", "text/plain") req.req_headers) 354 + true 355 + 356 + let test_body_json () = 357 + let req = 358 + post "http://example.com" |> body_json {|{"key": "value"}|} |> build 359 + in 360 + check string "body" {|{"key": "value"}|} (body_to_string req.req_body); 361 + check bool "has Content-Type" 362 + (List.mem ("Content-Type", "application/json") req.req_headers) 363 + true 364 + 365 + let test_form () = 366 + let req = 367 + post "http://example.com" 368 + |> form [ ("name", "Alice"); ("age", "30") ] 369 + |> build 370 + in 371 + let body_str = body_to_string req.req_body in 372 + check bool "has name" (String.length body_str > 0) true; 373 + check bool "has Content-Type" 374 + (List.mem 375 + ("Content-Type", "application/x-www-form-urlencoded") 376 + req.req_headers) 377 + true 378 + 379 + let test_port_default_http () = 380 + let req = get "http://example.com/" |> build in 381 + check int "port" 80 (port req) 382 + 383 + let test_port_default_https () = 384 + let req = get "https://example.com/" |> build in 385 + check int "port" 443 (port req) 386 + 387 + let test_port_explicit () = 388 + let req = get "http://example.com:8080/" |> build in 389 + check int "port" 8080 (port req) 390 + 391 + let test_meth_of_string () = 392 + check string "GET" "GET" (meth_to_string (meth_of_string "GET")); 393 + check string "POST" "POST" (meth_to_string (meth_of_string "POST")); 394 + check string "CUSTOM" "CUSTOM" (meth_to_string (meth_of_string "CUSTOM")) 395 + 396 + let test_body_length () = 397 + check int "empty body" 0 (body_length Empty); 398 + check int "string body" 5 (body_length (String "hello")); 399 + check int "form body" (String.length "a=1&b=2") 400 + (body_length (Form [ ("a", "1"); ("b", "2") ])) 401 + 402 + let test_cookie () = 403 + let req = get "http://example.com" |> cookie "session" "abc123" |> build in 404 + check bool "has Cookie" 405 + (List.exists 406 + (fun (n, v) -> n = "Cookie" && String.length v > 0) 407 + req.req_headers) 408 + true 409 + 410 + let test_cookies_multiple () = 411 + let req = 412 + get "http://example.com" |> cookie "a" "1" |> cookie "b" "2" |> build 413 + in 414 + let cookie_header = 415 + List.find_opt (fun (n, _) -> n = "Cookie") req.req_headers 416 + in 417 + match cookie_header with 418 + | Some (_, v) -> check bool "has a" (String.length v > 0) true 419 + | None -> fail "no Cookie header" 420 + 421 + let tests = 422 + [ 423 + test_case "get" `Quick test_get; 424 + test_case "post" `Quick test_post; 425 + test_case "put" `Quick test_put; 426 + test_case "delete" `Quick test_delete; 427 + test_case "patch" `Quick test_patch; 428 + test_case "head" `Quick test_head; 429 + test_case "options" `Quick test_options; 430 + test_case "header" `Quick test_header; 431 + test_case "content_type" `Quick test_content_type; 432 + test_case "accept" `Quick test_accept; 433 + test_case "user_agent" `Quick test_user_agent; 434 + test_case "bearer" `Quick test_bearer; 435 + test_case "query" `Quick test_query; 436 + test_case "body_string" `Quick test_body_string; 437 + test_case "body_json" `Quick test_body_json; 438 + test_case "form" `Quick test_form; 439 + test_case "port default http" `Quick test_port_default_http; 440 + test_case "port default https" `Quick test_port_default_https; 441 + test_case "port explicit" `Quick test_port_explicit; 442 + test_case "meth_of_string" `Quick test_meth_of_string; 443 + test_case "body_length" `Quick test_body_length; 444 + test_case "cookie" `Quick test_cookie; 445 + test_case "cookies multiple" `Quick test_cookies_multiple; 446 + ] 447 + end 448 + 449 + (* ================================================================== *) 450 + (* Router Tests *) 451 + (* ================================================================== *) 452 + 453 + module Test_router = struct 454 + open Hcs.Router 455 + 456 + let test_parse_path_empty () = 457 + check (list string) "empty" [] 458 + (List.map 459 + (function Literal s -> s | Param s -> ":" ^ s | Wildcard -> "*") 460 + (parse_path "")) 461 + 462 + let test_parse_path_root () = 463 + check (list string) "root" [] 464 + (List.map 465 + (function Literal s -> s | Param s -> ":" ^ s | Wildcard -> "*") 466 + (parse_path "/")) 467 + 468 + let test_parse_path_simple () = 469 + let segments = parse_path "/users/list" in 470 + check int "length" 2 (List.length segments); 471 + match segments with 472 + | [ Literal "users"; Literal "list" ] -> () 473 + | _ -> fail "unexpected segments" 474 + 475 + let test_parse_path_with_params () = 476 + let segments = parse_path "/users/:id/posts/:post_id" in 477 + check int "length" 4 (List.length segments); 478 + match segments with 479 + | [ Literal "users"; Param "id"; Literal "posts"; Param "post_id" ] -> () 480 + | _ -> fail "unexpected segments" 481 + 482 + let test_parse_path_with_wildcard () = 483 + let segments = parse_path "/files/*" in 484 + check int "length" 2 (List.length segments); 485 + match segments with 486 + | [ Literal "files"; Wildcard ] -> () 487 + | _ -> fail "unexpected segments" 488 + 489 + let test_router_literal_match () = 490 + let router = empty () in 491 + add_route router ~method_:(Some `GET) ~path:"/users" 492 + ~handler:"users_handler"; 493 + match lookup router ~method_:`GET ~path:"/users" with 494 + | Some (handler, _) -> check string "handler" "users_handler" handler 495 + | None -> fail "no match" 496 + 497 + let test_router_param_match () = 498 + let router = empty () in 499 + add_route router ~method_:(Some `GET) ~path:"/users/:id" 500 + ~handler:"user_handler"; 501 + match lookup router ~method_:`GET ~path:"/users/123" with 502 + | Some (handler, params) -> 503 + check string "handler" "user_handler" handler; 504 + check (option string) "id param" (Some "123") (param "id" params) 505 + | None -> fail "no match" 506 + 507 + let test_router_multiple_params () = 508 + let router = empty () in 509 + add_route router ~method_:(Some `GET) ~path:"/users/:user_id/posts/:post_id" 510 + ~handler:"post_handler"; 511 + match lookup router ~method_:`GET ~path:"/users/42/posts/100" with 512 + | Some (handler, params) -> 513 + check string "handler" "post_handler" handler; 514 + check (option string) "user_id" (Some "42") (param "user_id" params); 515 + check (option string) "post_id" (Some "100") (param "post_id" params) 516 + | None -> fail "no match" 517 + 518 + let test_router_wildcard_match () = 519 + let router = empty () in 520 + add_route router ~method_:(Some `GET) ~path:"/files/*" 521 + ~handler:"files_handler"; 522 + match lookup router ~method_:`GET ~path:"/files/path/to/file.txt" with 523 + | Some (handler, params) -> 524 + check string "handler" "files_handler" handler; 525 + check (option string) "wildcard" (Some "path/to/file.txt") 526 + (param "*" params) 527 + | None -> fail "no match" 528 + 529 + let test_router_method_match () = 530 + let router = empty () in 531 + add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"get_handler"; 532 + add_route router ~method_:(Some `POST) ~path:"/users" 533 + ~handler:"post_handler"; 534 + (match lookup router ~method_:`GET ~path:"/users" with 535 + | Some (handler, _) -> check string "GET handler" "get_handler" handler 536 + | None -> fail "no GET match"); 537 + match lookup router ~method_:`POST ~path:"/users" with 538 + | Some (handler, _) -> check string "POST handler" "post_handler" handler 539 + | None -> fail "no POST match" 540 + 541 + let test_router_any_method () = 542 + let router = empty () in 543 + add_route router ~method_:None ~path:"/health" ~handler:"health_handler"; 544 + (match lookup router ~method_:`GET ~path:"/health" with 545 + | Some (handler, _) -> check string "GET" "health_handler" handler 546 + | None -> fail "no GET match"); 547 + match lookup router ~method_:`POST ~path:"/health" with 548 + | Some (handler, _) -> check string "POST" "health_handler" handler 549 + | None -> fail "no POST match" 550 + 551 + let test_router_no_match () = 552 + let router = empty () in 553 + add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"handler"; 554 + match lookup router ~method_:`GET ~path:"/posts" with 555 + | Some _ -> fail "unexpected match" 556 + | None -> () 557 + 558 + let test_router_compile () = 559 + let routes = 560 + [ 561 + Route.get "/users" "list_users"; 562 + Route.post "/users" "create_user"; 563 + Route.get "/users/:id" "get_user"; 564 + Route.delete "/users/:id" "delete_user"; 565 + ] 566 + in 567 + let router = compile routes in 568 + (match lookup router ~method_:`GET ~path:"/users" with 569 + | Some (h, _) -> check string "list" "list_users" h 570 + | None -> fail "no list match"); 571 + match lookup router ~method_:`GET ~path:"/users/42" with 572 + | Some (h, params) -> 573 + check string "get" "get_user" h; 574 + check (option string) "id" (Some "42") (param "id" params) 575 + | None -> fail "no get match" 576 + 577 + let test_param_helpers () = 578 + let params = [ ("id", "42"); ("name", "alice") ] in 579 + check (option string) "param" (Some "42") (param "id" params); 580 + check (option string) "param missing" None (param "foo" params); 581 + check string "param_or" "default" (param_or "foo" ~default:"default" params); 582 + check (option int) "param_int" (Some 42) (param_int "id" params); 583 + check int "param_int_or" 0 (param_int_or "foo" ~default:0 params) 584 + 585 + let tests = 586 + [ 587 + test_case "parse_path empty" `Quick test_parse_path_empty; 588 + test_case "parse_path root" `Quick test_parse_path_root; 589 + test_case "parse_path simple" `Quick test_parse_path_simple; 590 + test_case "parse_path with params" `Quick test_parse_path_with_params; 591 + test_case "parse_path with wildcard" `Quick test_parse_path_with_wildcard; 592 + test_case "router literal match" `Quick test_router_literal_match; 593 + test_case "router param match" `Quick test_router_param_match; 594 + test_case "router multiple params" `Quick test_router_multiple_params; 595 + test_case "router wildcard match" `Quick test_router_wildcard_match; 596 + test_case "router method match" `Quick test_router_method_match; 597 + test_case "router any method" `Quick test_router_any_method; 598 + test_case "router no match" `Quick test_router_no_match; 599 + test_case "router compile" `Quick test_router_compile; 600 + test_case "param helpers" `Quick test_param_helpers; 601 + ] 602 + end 603 + 604 + (* ================================================================== *) 605 + (* Middleware Tests *) 606 + (* ================================================================== *) 607 + 608 + module Test_middleware = struct 609 + open Hcs.Middleware 610 + 611 + let test_identity () = 612 + let handler x = x + 1 in 613 + let wrapped = apply identity handler in 614 + check int "identity" 2 (wrapped 1) 615 + 616 + let test_compose () = 617 + let m1 : (int, int) t = fun next x -> next (x * 2) in 618 + let m2 : (int, int) t = fun next x -> next (x + 1) in 619 + let composed = compose m1 m2 in 620 + let handler x = x in 621 + let result = apply composed handler 5 in 622 + (* m1 runs first: 5 * 2 = 10, then m2: 10 + 1 = 11 *) 623 + check int "compose" 11 result 624 + 625 + let test_compose_all () = 626 + let m1 : (int, int) t = fun next x -> next (x * 2) in 627 + let m2 : (int, int) t = fun next x -> next (x + 1) in 628 + let m3 : (int, int) t = fun next x -> next (x - 3) in 629 + let composed = compose_all [ m1; m2; m3 ] in 630 + let handler x = x in 631 + let result = apply composed handler 5 in 632 + (* m1: 5 * 2 = 10, m2: 10 + 1 = 11, m3: 11 - 3 = 8 *) 633 + check int "compose_all" 8 result 634 + 635 + let test_infix_compose () = 636 + let m1 : (int, int) t = fun next x -> next (x * 2) in 637 + let m2 : (int, int) t = fun next x -> next (x + 1) in 638 + let composed = m1 @> m2 in 639 + let handler x = x in 640 + check int "infix" 11 (apply composed handler 5) 641 + 642 + let test_logging () = 643 + let logs = ref [] in 644 + let log msg = logs := msg :: !logs in 645 + let m = logging ~log in 646 + let handler _ = "response" in 647 + let _ = apply m handler () in 648 + check int "log count" 2 (List.length !logs); 649 + check bool "has request log" (List.mem "Request received" !logs) true; 650 + check bool "has response log" (List.mem "Response sent" !logs) true 651 + 652 + let test_timing () = 653 + let duration_ref = ref 0.0 in 654 + let on_complete d = duration_ref := d in 655 + let m = timing ~on_complete in 656 + let handler _ = 657 + Unix.sleepf 0.01; 658 + "response" 659 + in 660 + let _ = apply m handler () in 661 + check bool "duration > 0" (!duration_ref > 0.0) true 662 + 663 + let test_recover () = 664 + let m = recover ~on_error:(fun _ -> "recovered") in 665 + let handler _ = failwith "error" in 666 + let result = apply m handler () in 667 + check string "recover" "recovered" result 668 + 669 + let test_when_true () = 670 + let m : (int, int) t = fun next x -> next (x * 2) in 671 + let conditional = when_ (fun x -> x > 0) m in 672 + let handler x = x in 673 + check int "when true" 10 (apply conditional handler 5); 674 + check int "when false" (-5) (apply conditional handler (-5)) 675 + 676 + let test_unless () = 677 + let m : (int, int) t = fun next x -> next (x * 2) in 678 + let conditional = unless (fun x -> x < 0) m in 679 + let handler x = x in 680 + check int "unless true" 10 (apply conditional handler 5); 681 + check int "unless false" (-5) (apply conditional handler (-5)) 682 + 683 + let tests = 684 + [ 685 + test_case "identity" `Quick test_identity; 686 + test_case "compose" `Quick test_compose; 687 + test_case "compose_all" `Quick test_compose_all; 688 + test_case "infix compose" `Quick test_infix_compose; 689 + test_case "logging" `Quick test_logging; 690 + test_case "timing" `Quick test_timing; 691 + test_case "recover" `Quick test_recover; 692 + test_case "when_ true/false" `Quick test_when_true; 693 + test_case "unless" `Quick test_unless; 694 + ] 695 + end 696 + 697 + (* ================================================================== *) 698 + (* Log Tests *) 699 + (* ================================================================== *) 700 + 701 + module Test_log = struct 702 + open Hcs.Log 703 + 704 + let test_level_to_string () = 705 + check string "debug" "DEBUG" (level_to_string Debug); 706 + check string "info" "INFO" (level_to_string Info); 707 + check string "warn" "WARN" (level_to_string Warn); 708 + check string "error" "ERROR" (level_to_string Error) 709 + 710 + let test_level_of_string () = 711 + check bool "DEBUG" true (level_of_string "DEBUG" = Some Debug); 712 + check bool "debug" true (level_of_string "debug" = Some Debug); 713 + check bool "INFO" true (level_of_string "INFO" = Some Info); 714 + check bool "WARN" true (level_of_string "WARN" = Some Warn); 715 + check bool "unknown" true (level_of_string "unknown" = None) 716 + 717 + let test_level_gte () = 718 + check bool "error >= debug" true (level_gte Error Debug); 719 + check bool "error >= error" true (level_gte Error Error); 720 + check bool "debug >= error" false (level_gte Debug Error); 721 + check bool "warn >= info" true (level_gte Warn Info) 722 + 723 + let test_method_to_string () = 724 + check string "GET" "GET" (method_to_string GET); 725 + check string "POST" "POST" (method_to_string POST); 726 + check string "Other" "CUSTOM" (method_to_string (Other "CUSTOM")) 727 + 728 + let test_event_to_string_request_start () = 729 + let e = 730 + Request_start { id = "req-1"; meth = GET; uri = "/test"; headers = [] } 731 + in 732 + let s = event_to_string e in 733 + check bool "contains id" (String.length s > 0) true; 734 + check bool "contains GET" true (String.length s > 0) 735 + 736 + let test_event_to_string_request_end () = 737 + let e = 738 + Request_end 739 + { 740 + id = "req-1"; 741 + status = 200; 742 + duration_ms = 42.5; 743 + body_size = Some 1024; 744 + } 745 + in 746 + let s = event_to_string e in 747 + check bool "contains status" (String.length s > 0) true 748 + 749 + let test_event_to_string_error () = 750 + let e = 751 + Error 752 + { id = Some "req-1"; error = "connection failed"; context = Some "TLS" } 753 + in 754 + let s = event_to_string e in 755 + check bool "contains error" (String.length s > 0) true 756 + 757 + let test_event_to_json_request_start () = 758 + let e = 759 + Request_start { id = "req-1"; meth = GET; uri = "/test"; headers = [] } 760 + in 761 + let j = event_to_json e in 762 + check bool "is json" true (String.sub j 0 1 = "{"); 763 + check bool "contains event" true (String.length j > 10) 764 + 765 + let test_event_to_json_custom () = 766 + let e = Custom { name = "test_event"; data = [ ("key", "value") ] } in 767 + let j = event_to_json e in 768 + check bool "is json" true (String.sub j 0 1 = "{"); 769 + check bool "contains name" true (String.length j > 10) 770 + 771 + let test_null_logger () = 772 + (* Should not raise *) 773 + null Info 774 + (Request_start { id = "req-1"; meth = GET; uri = "/"; headers = [] }); 775 + null Error (Error { id = None; error = "test"; context = None }) 776 + 777 + let test_custom_logger () = 778 + let logged = ref [] in 779 + let logger = custom (fun level msg -> logged := (level, msg) :: !logged) in 780 + logger Info 781 + (Request_start { id = "req-1"; meth = GET; uri = "/"; headers = [] }); 782 + check int "logged count" 1 (List.length !logged) 783 + 784 + let test_combine_loggers () = 785 + let count1 = ref 0 in 786 + let count2 = ref 0 in 787 + let logger1 = custom (fun _ _ -> incr count1) in 788 + let logger2 = custom (fun _ _ -> incr count2) in 789 + let combined = combine [ logger1; logger2 ] in 790 + combined Info (Custom { name = "test"; data = [] }); 791 + check int "logger1 count" 1 !count1; 792 + check int "logger2 count" 1 !count2 793 + 794 + let test_with_min_level () = 795 + let logged = ref [] in 796 + let base = custom (fun level msg -> logged := (level, msg) :: !logged) in 797 + let filtered = with_min_level Warn base in 798 + filtered Debug (Custom { name = "debug"; data = [] }); 799 + filtered Info (Custom { name = "info"; data = [] }); 800 + filtered Warn (Custom { name = "warn"; data = [] }); 801 + filtered Error (Custom { name = "error"; data = [] }); 802 + check int "logged count" 2 (List.length !logged) 803 + 804 + let test_generate_request_id () = 805 + let id1 = generate_request_id () in 806 + let id2 = generate_request_id () in 807 + check bool "starts with req-" true (String.sub id1 0 4 = "req-"); 808 + check bool "unique" true (id1 <> id2) 809 + 810 + let tests = 811 + [ 812 + test_case "level_to_string" `Quick test_level_to_string; 813 + test_case "level_of_string" `Quick test_level_of_string; 814 + test_case "level_gte" `Quick test_level_gte; 815 + test_case "method_to_string" `Quick test_method_to_string; 816 + test_case "event_to_string request_start" `Quick 817 + test_event_to_string_request_start; 818 + test_case "event_to_string request_end" `Quick 819 + test_event_to_string_request_end; 820 + test_case "event_to_string error" `Quick test_event_to_string_error; 821 + test_case "event_to_json request_start" `Quick 822 + test_event_to_json_request_start; 823 + test_case "event_to_json custom" `Quick test_event_to_json_custom; 824 + test_case "null logger" `Quick test_null_logger; 825 + test_case "custom logger" `Quick test_custom_logger; 826 + test_case "combine loggers" `Quick test_combine_loggers; 827 + test_case "with_min_level" `Quick test_with_min_level; 828 + test_case "generate_request_id" `Quick test_generate_request_id; 829 + ] 830 + end 831 + 832 + module Test_tls_config = struct 833 + open Hcs.Tls_config 834 + 835 + let test_alpn_constants () = 836 + check string "h2" "h2" alpn_h2; 837 + check string "http/1.1" "http/1.1" alpn_http11; 838 + check string "h2c" "h2c" alpn_h2c 839 + 840 + let test_protocol_of_alpn () = 841 + check (option string) "h2 -> HTTP_2" (Some "HTTP_2") 842 + (Option.map 843 + (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1") 844 + (protocol_of_alpn "h2")); 845 + check (option string) "http/1.1 -> HTTP_1_1" (Some "HTTP_1_1") 846 + (Option.map 847 + (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1") 848 + (protocol_of_alpn "http/1.1")); 849 + check (option string) "unknown -> None" None 850 + (Option.map 851 + (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1") 852 + (protocol_of_alpn "unknown")) 853 + 854 + let test_alpn_of_protocol () = 855 + check string "HTTP_2 -> h2" "h2" (alpn_of_protocol HTTP_2); 856 + check string "HTTP_1_1 -> http/1.1" "http/1.1" (alpn_of_protocol HTTP_1_1) 857 + 858 + let test_client_configs () = 859 + check 860 + (option (list string)) 861 + "default has http/1.1" (Some [ "http/1.1" ]) Client.default.alpn_protocols; 862 + check 863 + (option (list string)) 864 + "h2 has h2" (Some [ "h2" ]) Client.h2.alpn_protocols; 865 + check 866 + (option (list string)) 867 + "h2_or_http11 has both" 868 + (Some [ "h2"; "http/1.1" ]) 869 + Client.h2_or_http11.alpn_protocols 870 + 871 + let tests = 872 + [ 873 + test_case "alpn constants" `Quick test_alpn_constants; 874 + test_case "protocol_of_alpn" `Quick test_protocol_of_alpn; 875 + test_case "alpn_of_protocol" `Quick test_alpn_of_protocol; 876 + test_case "client configs" `Quick test_client_configs; 877 + ] 878 + end 879 + 880 + (* ================================================================== *) 881 + (* Property-Based Tests (QCheck) *) 882 + (* ================================================================== *) 883 + 884 + module Test_properties = struct 885 + (* Stream properties *) 886 + let prop_sync_map_identity = 887 + QCheck.Test.make ~name:"sync map identity" ~count:100 888 + QCheck.(list int) 889 + (fun l -> 890 + let open Hcs.Stream.Sync in 891 + let s = of_list l in 892 + to_list (map Fun.id s) = l) 893 + 894 + let prop_sync_map_composition = 895 + QCheck.Test.make ~name:"sync map composition" ~count:100 896 + QCheck.(list nat_small) 897 + (fun l -> 898 + let open Hcs.Stream.Sync in 899 + let f x = x + 1 in 900 + let g x = x * 2 in 901 + let s = of_list l in 902 + to_list (map (fun x -> g (f x)) s) = to_list (map g (map f (of_list l)))) 903 + 904 + let prop_sync_filter_all = 905 + QCheck.Test.make ~name:"sync filter true keeps all" ~count:100 906 + QCheck.(list int) 907 + (fun l -> 908 + let open Hcs.Stream.Sync in 909 + to_list (filter (fun _ -> true) (of_list l)) = l) 910 + 911 + let prop_sync_filter_none = 912 + QCheck.Test.make ~name:"sync filter false removes all" ~count:100 913 + QCheck.(list int) 914 + (fun l -> 915 + let open Hcs.Stream.Sync in 916 + to_list (filter (fun _ -> false) (of_list l)) = []) 917 + 918 + let prop_sync_take_drop = 919 + QCheck.Test.make ~name:"sync take n ++ drop n = original" ~count:100 920 + QCheck.(pair (list int) nat_small) 921 + (fun (l, n) -> 922 + let open Hcs.Stream.Sync in 923 + let s1 = of_list l in 924 + let s2 = of_list l in 925 + let taken = to_list (take n s1) in 926 + let dropped = to_list (drop n s2) in 927 + taken @ dropped = l) 928 + 929 + let prop_sync_fold_sum = 930 + QCheck.Test.make ~name:"sync fold sum = List.fold_left sum" ~count:100 931 + QCheck.(list nat_small) 932 + (fun l -> 933 + let open Hcs.Stream.Sync in 934 + fold ( + ) 0 (of_list l) = List.fold_left ( + ) 0 l) 935 + 936 + (* Router properties *) 937 + let prop_router_param_extraction = 938 + QCheck.Test.make ~name:"router extracts all params" ~count:100 939 + QCheck.(pair string_small string_small) 940 + (fun (id, name) -> 941 + (* Skip edge cases: empty strings or strings containing / *) 942 + if 943 + id = "" || name = "" || String.contains id '/' 944 + || String.contains name '/' 945 + then true (* vacuously true for invalid inputs *) 946 + else 947 + let open Hcs.Router in 948 + let router = empty () in 949 + add_route router ~method_:(Some `GET) ~path:"/users/:id/items/:name" 950 + ~handler:"handler"; 951 + match 952 + lookup router ~method_:`GET 953 + ~path:(Printf.sprintf "/users/%s/items/%s" id name) 954 + with 955 + | Some (_, params) -> 956 + param "id" params = Some id && param "name" params = Some name 957 + | None -> false) 958 + 959 + (* HTTP builder properties *) 960 + let prop_http_method_roundtrip = 961 + QCheck.Test.make ~name:"http method roundtrip" ~count:20 962 + QCheck.( 963 + make 964 + Gen.( 965 + oneof 966 + [ 967 + return "GET"; 968 + return "POST"; 969 + return "PUT"; 970 + return "DELETE"; 971 + return "PATCH"; 972 + return "HEAD"; 973 + return "OPTIONS"; 974 + ])) 975 + (fun m -> 976 + let open Hcs.Http in 977 + meth_to_string (meth_of_string m) = m) 978 + 979 + (* Middleware properties *) 980 + let prop_middleware_identity = 981 + QCheck.Test.make ~name:"middleware identity is neutral" ~count:100 982 + QCheck.int (fun x -> 983 + let open Hcs.Middleware in 984 + let handler n = n + 1 in 985 + apply identity handler x = handler x) 986 + 987 + let prop_middleware_compose_associative = 988 + QCheck.Test.make ~name:"middleware compose is associative" ~count:100 989 + QCheck.nat_small (fun x -> 990 + let open Hcs.Middleware in 991 + let m1 : (int, int) t = fun next n -> next (n + 1) in 992 + let m2 : (int, int) t = fun next n -> next (n * 2) in 993 + let m3 : (int, int) t = fun next n -> next (n - 3) in 994 + let handler n = n in 995 + let left = apply (compose (compose m1 m2) m3) handler x in 996 + let right = apply (compose m1 (compose m2 m3)) handler x in 997 + left = right) 998 + 999 + let tests = 1000 + List.map QCheck_alcotest.to_alcotest 1001 + [ 1002 + prop_sync_map_identity; 1003 + prop_sync_map_composition; 1004 + prop_sync_filter_all; 1005 + prop_sync_filter_none; 1006 + prop_sync_take_drop; 1007 + prop_sync_fold_sum; 1008 + prop_router_param_extraction; 1009 + prop_http_method_roundtrip; 1010 + prop_middleware_identity; 1011 + prop_middleware_compose_associative; 1012 + ] 1013 + end 1014 + 1015 + (* ================================================================== *) 1016 + (* Main *) 1017 + (* ================================================================== *) 1018 + 1019 + let () = 1020 + run "HCS" 1021 + [ 1022 + ("Codec", Test_codec.tests); 1023 + ("Stream", Test_stream.tests); 1024 + ("Http", Test_http.tests); 1025 + ("Router", Test_router.tests); 1026 + ("Middleware", Test_middleware.tests); 1027 + ("Log", Test_log.tests); 1028 + ("Tls_config", Test_tls_config.tests); 1029 + ("Properties", Test_properties.tests); 1030 + ]