{"id":48441915,"url":"https://github.com/artalis-io/keel","last_synced_at":"2026-04-06T16:01:21.550Z","repository":{"id":340176159,"uuid":"1164845721","full_name":"artalis-io/keel","owner":"artalis-io","description":"Minimal C11 HTTP client/server library built on raw epoll/kqueue/io_uring. Pluggable allocator, pluggable parser, pluggable TLS library, streaming responses, multipart uploads, 101K req/s on a single thread.","archived":false,"fork":false,"pushed_at":"2026-03-16T13:08:08.000Z","size":870,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-16T21:54:04.518Z","etag":null,"topics":["async","c","c11","epoll","event-loop","http","http-server","io-uring","kqueue","multipart","sendfile","threadpool","tls","zero-copy"],"latest_commit_sha":null,"homepage":"https://keel.artalis.io","language":"C","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/artalis-io.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":"docs/roadmap.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-23T14:50:30.000Z","updated_at":"2026-03-16T13:08:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/artalis-io/keel","commit_stats":null,"previous_names":["artalis-io/keel"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/artalis-io/keel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artalis-io%2Fkeel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artalis-io%2Fkeel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artalis-io%2Fkeel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artalis-io%2Fkeel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/artalis-io","download_url":"https://codeload.github.com/artalis-io/keel/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artalis-io%2Fkeel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31479006,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-06T14:34:32.243Z","status":"ssl_error","status_checked_at":"2026-04-06T14:34:31.723Z","response_time":112,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["async","c","c11","epoll","event-loop","http","http-server","io-uring","kqueue","multipart","sendfile","threadpool","tls","zero-copy"],"created_at":"2026-04-06T16:01:20.221Z","updated_at":"2026-04-06T16:01:21.539Z","avatar_url":"https://github.com/artalis-io.png","language":"C","funding_links":[],"categories":[],"sub_categories":[],"readme":"# KEEL — Kernel Event Engine, Lightweight\n\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/artalis-io/keel/badge)](https://scorecard.dev/viewer/?uri=github.com/artalis-io/keel)\n[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12186/badge)](https://www.bestpractices.dev/projects/12186)\n\nMinimal C11 HTTP client/server library built on raw epoll/kqueue/io_uring/poll. Both the server and client support sync and async operation — sync handlers return immediately, async handlers suspend and resume via the event loop; the client offers both a blocking API and an event-driven API. Pluggable allocator, pluggable HTTP parser, pluggable TLS, pluggable body readers, per-route middleware, streaming responses, multipart uploads, connection timeouts, thread pool, zero forced buffering.\n\n**101K req/s** on a single thread. **671 tests** (40 suites) with ASan/UBSan. **One vendored dependency** (llhttp).\n\n## Build\n\n```bash\nmake                    # build libkeel.a (epoll on Linux, kqueue on macOS)\nmake BACKEND=iouring    # build with io_uring backend (Linux 5.6+, requires liburing-dev)\nmake BACKEND=poll       # build with poll() backend (universal POSIX fallback)\nmake CC=cosmocc         # build with Cosmopolitan C (Actually Portable Executable)\nmake test               # run unit tests\nmake examples           # build all example programs\nmake debug              # debug build with ASan + UBSan\nmake analyze            # Clang static analyzer (scan-build)\nmake cppcheck           # cppcheck static analysis\nmake fuzz               # build libFuzzer fuzz targets (requires clang)\nmake clean              # remove artifacts\n```\n\n## Hello World\n\n```c\n#include \u003ckeel/keel.h\u003e\n\nvoid handle_hello(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)req; (void)ctx;\n    kl_response_json(res, 200, \"{\\\"msg\\\":\\\"hello\\\"}\", 15);\n}\n\nint main(void) {\n    KlServer s;\n    KlConfig cfg = {.port = 8080};\n    kl_server_init(\u0026s, \u0026cfg);\n    kl_server_route(\u0026s, \"GET\", \"/hello\", handle_hello, NULL, NULL);\n    kl_server_run(\u0026s);\n    kl_server_free(\u0026s);\n}\n```\n\n## Features\n\n- **Four event loop backends** — epoll (edge-triggered), kqueue (edge-triggered), io_uring (POLL_ADD), poll (universal POSIX fallback)\n- **Pluggable HTTP parser** — ships with llhttp, swap via `KlConfig.parser`\n- **Pluggable TLS** — bring your own BearSSL/LibreSSL/OpenSSL via vtable, zero vendored TLS code\n- **Pluggable body readers** — vtable interface for request body processing\n- **Per-route middleware** — pattern-matched middleware chain with short-circuit support\n- **Built-in CORS middleware** — configurable origins, methods, headers, preflight handling\n- **Multipart form-data** — RFC 2046 parser with configurable size limits\n- **Three response modes** — buffered (writev), file (sendfile zero-copy), stream (chunked transfer encoding)\n- **WebSocket** — RFC 6455 server + client with frame encoding/decoding\n- **Route parameters** — `:param` capture, no allocation, pointers into read buffer\n- **Connection timeouts** — monotonic clock sweep, automatic 408 responses, slow-loris protection\n- **Access logging** — pluggable callback after each response, zero overhead when disabled\n- **Async server handlers** — suspend connections via `KlAsyncOp`, resume later from watchers or thread pool workers — no stalling the event loop\n- **HTTP client (sync + async)** — blocking `kl_client_request()` for simple use cases, event-driven `kl_client_start()` for non-blocking I/O, both with TLS support\n- **Composable event context** — `KlEventCtx` decouples the event loop from the server, enabling standalone clients and thread pools without a `KlServer`\n- **Thread pool** — submit blocking work from event loop, execute on workers, resume via pipe wakeup\n- **Generic FD watchers** — register any file descriptor for event-driven callbacks via `KlWatcher`\n- **Server-Sent Events** — zero-alloc SSE framing (`kl_sse_event`, `kl_sse_comment`) over chunked streaming\n- **Response compression** — pluggable compression vtable; ships with miniz gzip backend\n- **Client decompression** — automatic `Content-Encoding: gzip` response decompression\n- **Client connection pool** — keep-alive reuse keyed by (host, port, is_tls, proxy) with idle timers\n- **Redirect following** — automatic 3xx redirect with RFC 7231/7538 method transformation\n- **HTTP proxy** — HTTP forwarding (absolute-form URL) and HTTPS CONNECT tunneling through proxies\n- **Backpressure write buffer** — `KlDrain` buffers unsent data on would-block, flushes on write-readiness\n- **Timer scheduling** — one-shot timers on the event loop via min-heap\n- **Error diagnostics** — sqlite3-style per-struct error codes with `kl_strerror()`\n- **Pluggable DNS resolver** — bring your own async DNS (c-ares, thread-pool wrapper), with caching decorator\n- **Server load introspection** — `kl_server_stats()` for connection counts, enabling user-space load-shedding middleware\n- **Pre-allocated connection pool** — no per-request malloc, no fragmentation under load\n- **Pluggable allocator** — bring your own arena/pool/tracking allocator\n- **pledge/unveil sandboxing** — init/run split makes syscall lockdown natural\n- **Zero-copy techniques** — header pointers into read buffer, sendfile, writev batching\n\n## Architecture\n\n31 orthogonal modules, each independently testable:\n\n| Module | Header | Description |\n|--------|--------|-------------|\n| **allocator** | `allocator.h` | Bring-your-own allocator interface |\n| **event** | `event.h` | epoll / kqueue / io_uring / poll abstraction |\n| **event_ctx** | `event_ctx.h` | Composable event loop context (watchers + allocator) |\n| **request** | `request.h` | Parsed HTTP request struct (header-only, zero alloc) |\n| **parser** | `parser.h` | Pluggable request/response parser vtables |\n| **response** | `response.h` | Response builder: buffered, sendfile, or streaming chunked |\n| **router** | `router.h` | Route matching with `:param` capture + middleware chain |\n| **connection** | `connection.h` | Pre-allocated connection pool + state machine |\n| **server** | `server.h` | Top-level glue: init, bind, async event loop, stop |\n| **body_reader** | `body_reader.h` | Pluggable body reader vtable + buffer reader |\n| **body_reader_multipart** | `body_reader_multipart.h` | RFC 2046 multipart/form-data parser |\n| **chunked** | `chunked.h` | Parser-agnostic chunked transfer-encoding decoder |\n| **cors** | `cors.h` | Built-in CORS middleware with configurable origins |\n| **tls** | `tls.h` | Pluggable TLS transport vtable (bring-your-own backend) |\n| **async** | `async.h` | Connection suspension for async operations |\n| **thread_pool** | `thread_pool.h` | Worker thread pool with pipe-based event loop wakeup |\n| **url** | `url.h` | URL parser (http/https/ws/wss, IPv6, CRLF injection guard) |\n| **client** | `client.h` | HTTP/1.1 client (sync blocking + async event-driven) |\n| **websocket** | `websocket.h` + `websocket_server.h` | RFC 6455 WebSocket server (shared frame parser + server API) |\n| **websocket_client** | `websocket_client.h` | RFC 6455 WebSocket client (masked frames, async handshake) |\n| **h2** | `h2.h` + `h2_server.h` | HTTP/2 server (pluggable session vtable) |\n| **h2_client** | `h2_client.h` | HTTP/2 client (multiplexed streams, pluggable session) |\n| **resolver** | `resolver.h` | Pluggable async DNS resolver vtable |\n| **sse** | `sse.h` | Server-Sent Events: line framing over chunked streaming (zero alloc) |\n| **error** | `error.h` | Diagnostic error codes (KlError enum) + kl_strerror() |\n| **timer** | `timer.h` | One-shot timer scheduling on KlEventCtx (min-heap) |\n| **client_pool** | `client_pool.h` | HTTP client connection pool with keep-alive reuse |\n| **redirect** | `redirect.h` | Automatic 3xx redirect following (RFC 7231/7538) |\n| **compress** | `compress.h` | Pluggable response compression vtable (buffer + streaming) |\n| **decompress** | `decompress.h` | Pluggable response decompression vtable (client-side) |\n| **drain** | `drain.h` | Backpressure write buffer with on_drain callback |\n| **file_io** | `file_io.h` | Pluggable async file I/O vtable (io_uring backend) |\n| **resolver_cache** | `resolver_cache.h` | Caching DNS resolver decorator with configurable TTL/capacity |\n\n**Deliberate design choices:**\n\n- **Single-threaded event loop** — Same model as Node.js, Redis, Nginx (per-worker), and Python asyncio. No mutexes, no lock contention, no data races — the entire connection state machine is lock-free by construction. `KlThreadPool` offloads blocking work to workers; multi-core scaling is horizontal via `SO_REUSEPORT` with multiple processes.\n- **O(n) router** — Linear scan over routes per request. A `memcmp` scan over even hundreds of routes costs nanoseconds, invisible next to network I/O syscalls. A trie or radix tree would add complexity to param extraction and middleware pattern matching for no measurable gain.\n- **O(n) timeout sweep** — Iterates all connection slots once per event loop tick. At `max_connections` = 256 (default), this is a tight loop over a contiguous array well within L1 cache.\n- **No built-in 503 / load shedding** — `kl_server_stats()` exposes connection counts for user-space middleware to make load-shedding decisions. Thresholds and `Retry-After` policy belong in application code, not the framework.\n- **No global memory monitoring** — The allocator is pluggable, so the framework can't reliably track total memory. Existing per-resource caps (`max_body_size`, `max_header_size`, `KlDrain.max_size`) bound the main vectors; OS-level OOM handling covers the rest.\n\n## Request Body Handling\n\nKEEL uses a vtable-based body reader interface. Register a body reader factory per-route — the connection layer creates the reader after headers are parsed, feeds it data as it arrives, and makes the finished reader available in the handler via `req-\u003ebody_reader`.\n\n**Built-in buffer reader** — accumulates the body into a growable buffer:\n\n```c\nvoid handle_post(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)ctx;\n    KlBufReader *br = (KlBufReader *)req-\u003ebody_reader;\n    if (!br || br-\u003elen == 0) {\n        kl_response_error(res, 400, \"Request body required\");\n        return;\n    }\n    kl_response_status(res, 200);\n    kl_response_header(res, \"Content-Type\", \"application/octet-stream\");\n    kl_response_body_borrow(res, br-\u003edata, br-\u003elen);\n}\n\n/* Register with size limit (1 MB) */\nkl_server_route(\u0026s, \"POST\", \"/api/data\", handle_post,\n                (void *)(size_t)(1 \u003c\u003c 20), kl_body_reader_buffer);\n```\n\nPass `NULL` as the body reader factory for routes that don't accept a body. If a request with a body arrives on a route with no reader, KEEL discards the body. If the reader factory returns NULL, KEEL sends 415 Unsupported Media Type.\n\n**Custom readers** — implement the `KlBodyReader` vtable (`on_data`, `on_complete`, `on_error`, `destroy`) and provide a factory function.\n\n## Middleware\n\nRegister middleware that runs before handlers. Middleware can inspect/modify the request and response, or short-circuit the chain by returning a non-zero value (e.g., to reject unauthenticated requests).\n\n### Built-in CORS middleware\n\n```c\nKlCorsConfig cors;\nkl_cors_init(\u0026cors);\nkl_cors_add_origin(\u0026cors, \"https://app.example.com\");\nkl_cors_add_origin(\u0026cors, \"https://staging.example.com\");\n// Or parse from an environment variable:\n// kl_cors_parse_origins(\u0026cors, getenv(\"ALLOWED_ORIGINS\"));\n\nkl_server_use(\u0026s, \"*\", \"/*\", kl_cors_middleware, \u0026cors);\n```\n\nHandles `Access-Control-Allow-Origin`, `Allow-Credentials`, and automatically responds to OPTIONS preflight requests with 204 + all required CORS headers.\n\n### Writing custom middleware {#writing-custom-middleware}\n\nMiddleware uses the same `(KlRequest *, KlResponse *, void *)` signature. Return 0 to continue, non-zero to short-circuit:\n\n**Logging middleware:**\n\n```c\nint log_middleware(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)res; (void)ctx;\n    fprintf(stderr, \"[req] %.*s %.*s\\n\",\n            (int)req-\u003emethod_len, req-\u003emethod,\n            (int)req-\u003epath_len, req-\u003epath);\n    return 0;  /* continue to next middleware / handler */\n}\n\nkl_server_use(\u0026s, \"*\", \"/*\", log_middleware, NULL);\n```\n\n**Auth middleware:**\n\n```c\ntypedef struct { const char *api_key; } AuthConfig;\n\nint auth_middleware(KlRequest *req, KlResponse *res, void *ctx) {\n    AuthConfig *cfg = ctx;\n    size_t key_len;\n    const char *key = kl_request_header_len(req, \"X-API-Key\", \u0026key_len);\n    size_t expect_len = strlen(cfg-\u003eapi_key);\n    if (!key || key_len != expect_len ||\n        memcmp(key, cfg-\u003eapi_key, expect_len) != 0) {\n        kl_response_error(res, 401, \"Unauthorized\");\n        return 1;  /* short-circuit — response already written */\n    }\n    return 0;  /* continue */\n}\n\nAuthConfig auth = {.api_key = \"secret-key-123\"};\nkl_server_use(\u0026s, \"*\", \"/api/*\", auth_middleware, \u0026auth);\n```\n\n**Request context passing (middleware → handler):**\n\n```c\nint user_middleware(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)res; (void)ctx;\n    /* Validate token, look up user, set context for handler */\n    req-\u003ectx = my_user_lookup(req);\n    return 0;\n}\n\nvoid handle_profile(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)ctx;\n    User *user = req-\u003ectx;  /* set by middleware */\n    /* ... */\n}\n```\n\n### Middleware patterns\n\n- Patterns ending with `/*` are prefix matches: `/api/*` matches `/api`, `/api/users`, `/api/users/123`\n- Patterns without `/*` are exact matches: `/health` matches only `/health`\n- Method `\"*\"` matches any HTTP method; `\"GET\"` also matches HEAD requests\n- Middleware runs in registration order, before body reading\n- Short-circuiting disables keep-alive (unread body can't be drained)\n\n## Multipart Uploads\n\n```c\nstatic KlMultipartConfig mp_config = {\n    .max_part_size  = 4 \u003c\u003c 20,   /* 4 MB per part */\n    .max_total_size = 16 \u003c\u003c 20,  /* 16 MB total */\n    .max_parts      = 8,\n};\n\nvoid handle_upload(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)ctx;\n    KlMultipartReader *mr = (KlMultipartReader *)req-\u003ebody_reader;\n    if (!mr || mr-\u003enum_parts == 0) {\n        kl_response_error(res, 400, \"No parts received\");\n        return;\n    }\n    for (int i = 0; i \u003c mr-\u003enum_parts; i++) {\n        KlMultipartPart *p = \u0026mr-\u003eparts[i];\n        printf(\"  %s: %zu bytes (filename=%s)\\n\",\n               p-\u003ename, p-\u003edata_len, p-\u003efilename ? p-\u003efilename : \"n/a\");\n    }\n    kl_response_json(res, 200, \"{\\\"ok\\\":true}\", 11);\n}\n\nkl_server_route(\u0026s, \"POST\", \"/upload\", handle_upload,\n                \u0026mp_config, kl_body_reader_multipart);\n```\n\n```bash\ncurl -F \"name=Alice\" -F \"file=@photo.jpg\" localhost:8080/upload\n```\n\n## WebSocket\n\nRegister a WebSocket endpoint and get bidirectional communication:\n\n```c\nstatic int ws_on_message(KlWsServerConn *ws, KlWsOpcode opcode,\n                         const char *data, size_t len, void *ctx) {\n    (void)ctx;\n    if (opcode == KL_WS_TEXT) {\n        kl_ws_server_send_text(ws, data, len);  /* echo back */\n    }\n    return 0;\n}\n\nstatic void ws_on_close(KlWsServerConn *ws, void *ctx) {\n    (void)ws; (void)ctx;\n    printf(\"WebSocket closed\\n\");\n}\n\nstatic KlWsServerConfig ws_config = {\n    .on_message = ws_on_message,\n    .on_close = ws_on_close,\n};\n\nint main(void) {\n    KlServer s;\n    KlConfig cfg = {.port = 8080};\n    kl_server_init(\u0026s, \u0026cfg);\n    kl_server_ws(\u0026s, \"/ws\", \u0026ws_config);\n    kl_server_run(\u0026s);\n}\n```\n\nThe WebSocket server module handles frame parsing, masking, and protocol details. The handler receives callbacks for each message — use `kl_ws_server_send_text()` or `kl_ws_server_send_binary()` to reply.\n\n## Async Operations\n\nServer handlers can be **sync** (return immediately with a response set) or **async** (suspend the connection for later resumption). KEEL provides two primitives for async handlers: **KlWatcher** (generic FD callbacks) and **KlAsyncOp** (connection suspension). Together they allow handlers to park a connection, perform work asynchronously, and resume when done — without stalling the event loop.\n\n```c\nvoid handle_async(KlRequest *req, KlResponse *res, void *user_data) {\n    KlServer *srv = user_data;\n    KlConn *conn = kl_request_conn(req);\n\n    /* Allocate context for the async operation */\n    MyCtx *ctx = ...;\n    ctx-\u003eop.on_resume = my_resume_cb;\n    ctx-\u003eop.on_cancel = my_cancel_cb;\n\n    /* Create a pipe — watcher fires when the pipe is written to */\n    socketpair(AF_UNIX, SOCK_STREAM, 0, ctx-\u003epipe_fds);\n    kl_watcher_add(\u0026srv-\u003eev, ctx-\u003epipe_fds[0], KL_EVENT_READ, my_watcher, ctx);\n\n    /* Suspend the connection (removes it from event loop, exempt from timeouts) */\n    kl_async_suspend(srv, conn, \u0026ctx-\u003eop);\n\n    /* Later: write to pipe → watcher fires → kl_async_complete → connection resumes */\n}\n```\n\nThe watcher callback runs on the event loop thread, making it safe to call `kl_async_complete()` which re-registers the connection FD and drives the state machine forward.\n\n## Thread Pool\n\n`KlThreadPool` bridges blocking work (SQLite queries, file I/O, DNS, crypto) and the single-threaded event loop. Submit work items from the event loop, execute on worker threads, resume connections via pipe wakeup.\n\n```c\n/* Create pool (auto-detects CPU count, 64-item queue) */\nKlThreadPool *pool = kl_thread_pool_create(\u0026server.ev, NULL);\n\n/* Work callbacks */\nstatic void do_query(void *ud) {\n    MyWork *w = ud;\n    w-\u003estatus = sqlite3_exec(w-\u003edb, w-\u003esql, ...);  /* runs on worker thread */\n}\nstatic void query_done(void *ud) {\n    MyWork *w = ud;\n    kl_async_complete(w-\u003eserver, \u0026w-\u003eop);           /* runs on event loop thread */\n}\n\n/* In handler: suspend connection, submit blocking work */\nkl_async_suspend(srv, conn, \u0026work-\u003eop);\nKlWorkItem item = { .work_fn = do_query, .done_fn = query_done, .user_data = work };\nkl_thread_pool_submit(pool, \u0026item);\n```\n\nEach `KlWorkItem` has three callbacks:\n\n| Callback | Thread | Purpose |\n|----------|--------|---------|\n| `work_fn` | Worker | Execute blocking work |\n| `done_fn` | Event loop | Resume connection (called via pipe watcher) |\n| `cancel_fn` | Event loop | Cleanup for items still queued at shutdown (may be NULL) |\n\nThread safety is guaranteed by construction — workers never touch the event loop directly. They push completed items to a done queue and write a byte to a pipe; the pipe watcher fires on the event loop thread and calls `done_fn`. Backpressure: `submit()` returns `-1` when the queue is full.\n\n## HTTP Client\n\nKEEL includes both **sync** (blocking) and **async** (event-driven) HTTP/1.1 clients with TLS support. The sync client is a single function call for simple use cases. The async client uses `KlEventCtx` (not `KlServer`), so it works standalone — no server required.\n\n**Sync (blocking):**\n\n```c\nKlAllocator alloc = kl_allocator_default();\nKlClientResponse resp;\nif (kl_client_request(\u0026alloc, NULL, \"GET\", \"http://api.example.com/data\",\n                       NULL, 0, NULL, 0, \u0026resp) == 0) {\n    printf(\"status=%d body=%.*s\\n\", resp.status, (int)resp.body_len, resp.body);\n    kl_client_response_free(\u0026resp);\n}\n```\n\n**Async (event-driven):**\n\n```c\nstatic void on_done(KlClient *client, void *user_data) {\n    if (kl_client_error(client) == 0) {\n        const KlClientResponse *r = kl_client_response(client);\n        printf(\"status=%d\\n\", r-\u003estatus);\n    }\n    kl_client_free(client);\n}\n\n/* Works with standalone KlEventCtx — no KlServer needed */\nKlAllocator alloc = kl_allocator_default();\nKlEventCtx ev;\nkl_event_ctx_init(\u0026ev, \u0026alloc);\nkl_client_start(\u0026ev, \u0026alloc, NULL, \"GET\", \"http://example.com/\", NULL, 0, NULL, 0, on_done, NULL);\n/* pump ev.loop ... */\nkl_event_ctx_free(\u0026ev);\n```\n\nThe URL parser (`kl_url_parse`) handles `http://`, `https://`, `ws://`, `wss://`, IPv6 `[::1]:port`, default ports, and rejects CRLF injection.\n\n## Static File Serving\n\nZero-copy file responses via `sendfile(2)`:\n\n```c\nvoid handle_static(KlRequest *req, KlResponse *res, void *ctx) {\n    (void)ctx;\n    if (memmem(req-\u003epath, req-\u003epath_len, \"..\", 2) != NULL) {\n        kl_response_error(res, 403, \"Forbidden\");\n        return;\n    }\n    char filepath[512];\n    snprintf(filepath, sizeof(filepath), \"./public%.*s\",\n             (int)req-\u003epath_len, req-\u003epath);\n    int fd = open(filepath, O_RDONLY);\n    if (fd \u003c 0) { kl_response_error(res, 404, \"Not Found\"); return; }\n    struct stat st;\n    fstat(fd, \u0026st);\n    kl_response_status(res, 200);\n    kl_response_header(res, \"Content-Type\", \"text/html\");\n    kl_response_file(res, fd, st.st_size);  /* zero-copy sendfile */\n}\n```\n\nUses `sendfile(2)` on Linux and macOS, with TCP_CORK coalescing on Linux for optimal throughput.\n\n## Streaming Responses\n\nWrite directly to the socket via chunked transfer encoding — zero intermediate buffering:\n\n```c\nvoid handle_stream(KlRequest *req, KlResponse *res, void *ctx) {\n    kl_response_header(res, \"Content-Type\", \"application/json\");\n\n    KlWriteFn write_fn;\n    void *write_ctx;\n    kl_response_begin_stream(res, 200, \u0026write_fn, \u0026write_ctx);\n\n    write_fn(write_ctx, \"{\\\"data\\\":\", 8);\n    // ... write as much as you want, each call becomes a chunk ...\n    write_fn(write_ctx, \"}\", 1);\n\n    kl_response_end_stream(res);\n}\n```\n\nThe `KlWriteFn` signature (`int (*)(void *ctx, const char *data, size_t len)`) is designed to be compatible with streaming JSON writers.\n\n## Route Parameters\n\n```c\nkl_server_route(\u0026s, \"GET\", \"/users/:id/posts/:pid\", handler, NULL, NULL);\n// Params extracted from path — no allocation, pointers into read buffer\n```\n\nThe router returns 200 (match), 405 (path matched, wrong method), or 404 (not found).\n\n## Connection Timeouts\n\n```c\nKlConfig cfg = {\n    .port = 8080,\n    .read_timeout_ms = 15000,   /* 15 seconds (default: 30000) */\n};\n```\n\nKEEL stamps each connection with a monotonic clock on every I/O event. A periodic sweep (every ~400ms) closes connections that have been idle longer than `read_timeout_ms` and sends a 408 Request Timeout response. This protects against slow-loris attacks and abandoned connections without affecting active transfers.\n\n## Access Logging\n\n```c\nvoid my_logger(const KlRequest *req, int status,\n               size_t body_bytes, double duration_ms, void *user_data) {\n    fprintf(stderr, \"%.*s %.*s %d %zu %.1fms\\n\",\n            (int)req-\u003emethod_len, req-\u003emethod,\n            (int)req-\u003epath_len, req-\u003epath,\n            status, body_bytes, duration_ms);\n}\n\nKlConfig cfg = {\n    .port = 8080,\n    .access_log = my_logger,       /* NULL = disabled (default) */\n    .access_log_data = NULL,       /* passed as user_data */\n};\n```\n\nSet a callback in `KlConfig` and KEEL calls it after each response is fully sent. The callback receives the full request (method, path, headers), response status, body size, and wall-clock duration in milliseconds. Users implement their own formatting (JSON, CLF, custom). `NULL` = no logging, zero overhead.\n\n## Custom Allocator\n\n```c\nKlAllocator arena = my_arena_allocator();\nKlConfig cfg = {\n    .port = 8080,\n    .alloc = \u0026arena,\n};\n```\n\nThe allocator interface passes `size` to `free` and `old_size` to `realloc` — enabling arena and pool allocators that don't store per-allocation metadata.\n\n## Pluggable Parser\n\nShips with llhttp (default). Swap by setting `KlConfig.parser`:\n\n```c\nKlConfig cfg = {\n    .port = 8080,\n    .parser = kl_parser_pico,  // use picohttpparser instead\n};\n```\n\nImplement the 3-function `KlRequestParser` vtable (`parse`, `reset`, `destroy`) for any backend. The response parser (`KlResponseParser`) uses the same pattern for the HTTP client.\n\n## Pluggable TLS\n\nKEEL doesn't vendor any TLS library. Bring your own backend (BearSSL, LibreSSL, OpenSSL, rustls-ffi) by implementing the 7-function `KlTls` vtable:\n\n```c\nKlTlsCtx *ctx = my_bearssl_ctx_new(\"cert.pem\", \"key.pem\");\nKlTlsConfig tls = {\n    .ctx = ctx,\n    .factory = my_bearssl_factory,\n    .ctx_destroy = my_bearssl_ctx_free,\n};\nKlConfig cfg = {\n    .port = 8443,\n    .tls = \u0026tls,\n};\n```\n\nThe vtable interface (`handshake`, `read`, `write`, `shutdown`, `pending`, `reset`, `destroy`) wraps the transport layer. Everything above it — parser, router, middleware, body readers, handlers — works identically on plaintext and TLS connections.\n\nWhen TLS is active, `sendfile(2)` falls back to `pread` + TLS write (encryption requires userspace access to plaintext). All other response modes (buffered, streaming) work transparently.\n\n## Sandboxing with pledge/unveil\n\nKEEL deliberately does **not** own your sandbox policy — that's an application concern. The server separates initialization (bind/listen) from the event loop (accept/read/write), so you can lock down syscalls and filesystem access between the two:\n\n```c\n#include \u003ckeel/keel.h\u003e\n\nint main(void) {\n    KlServer s;\n    KlConfig cfg = {.port = 8080};\n    kl_server_init(\u0026s, \u0026cfg);    // binds socket — needs inet, rpath\n    kl_server_route(\u0026s, \"GET\", \"/hello\", handle_hello, NULL, NULL);\n\n    // --- Sandbox boundary ---\n    unveil(\"/var/www\", \"r\");     // only serve files from here\n    unveil(NULL, NULL);          // lock it down\n    pledge(\"stdio inet rpath\", NULL);\n\n    kl_server_run(\u0026s);           // enters event loop — sandboxed\n    kl_server_free(\u0026s);\n}\n```\n\nOn Linux, use the [pledge polyfill](https://github.com/jart/pledge) (seccomp-bpf + Landlock) for the same API. The key insight: KEEL's `init`/`run` split makes this natural — no library changes needed.\n\n## Benchmark\n\n```bash\nmake bench              # build bench server, run 4 wrk benchmarks with latency\nJSON=1 make bench       # JSON output for CI/tooling\n```\n\nThe benchmark suite runs 4 endpoints against a dedicated bench server:\n\n| Endpoint | What it measures |\n|----------|-----------------|\n| `GET /hello` | **Baseline** — minimal JSON, no routing params, no middleware |\n| `GET /users/:id` | **Router** — param extraction + snprintf response |\n| `GET /mw/hello` | **Middleware** — same response through 2 pass-through middleware |\n| `POST /echo` | **Body reading** — KlBufReader + echo body back |\n\nSample results (Apple M1 Max, single thread, 100 connections, kqueue):\n\n| Endpoint | Req/sec | Avg Latency | p99 |\n|----------|---------|-------------|-----|\n| `GET /hello` (baseline) | 111,650 | 0.89ms | 1.13ms |\n| `GET /users/42` (route params) | 109,112 | 0.91ms | 1.15ms |\n| `GET /mw/hello` (middleware chain) | 111,247 | 0.89ms | 1.14ms |\n| `POST /echo` (body reading) | 109,370 | 0.90ms | 1.15ms |\n\nRoute params, middleware, and body reading add no measurable overhead — all within ~2% of the baseline. No GC pauses. No goroutine scheduling. No async runtime overhead. Just `kqueue` → `read` → `write`.\n\n## Platform Support\n\n| Platform | Backend | Build |\n|----------|---------|-------|\n| macOS / BSD | kqueue (edge-triggered) | `make` |\n| Linux | epoll (edge-triggered) | `make` |\n| Linux 5.6+ | io_uring (POLL_ADD) | `make BACKEND=iouring` |\n| Any POSIX | poll (level-triggered) | `make BACKEND=poll` |\n| Linux (musl/Alpine) | epoll (edge-triggered) | `make` |\n| Cosmopolitan (APE) | poll (auto-selected) | `make CC=cosmocc` |\n| Bare-metal + lwIP | poll (via lwIP sockets) | `make BACKEND=poll` + `-DKL_NO_SIGNAL` |\n\nThe io_uring backend uses `IORING_OP_POLL_ADD` for readiness notification — a drop-in replacement for epoll with io_uring's batched submission advantage. Requires `liburing-dev`.\n\nThe poll backend is a universal POSIX fallback that works on any platform with `poll(2)`. It enables Cosmopolitan C support (Actually Portable Executables that run on Linux, macOS, Windows, FreeBSD, OpenBSD, NetBSD from a single binary). When `CC=cosmocc` is detected, the Makefile automatically selects the poll backend.\n\nFor bare-metal targets (STM32, ESP32, etc.), link against lwIP or picoTCP — their BSD socket compatibility layers provide all the POSIX functions Keel uses (`accept`, `read`, `write`, `close`, `poll`, `getaddrinfo`). Compile with `-DKL_NO_SIGNAL` to disable POSIX signal handling, and exclude `thread_pool.c` from the build if no RTOS is available. See [docs/comparison.md](docs/comparison.md#bare-metal--mcu-support) for details.\n\n## Testing\n\n671 tests across 40 test suites, covering every module (678 on io_uring builds):\n\n| Suite | Tests | Covers |\n|-------|-------|--------|\n| `test_allocator` | 4 | Default + custom tracking allocators |\n| `test_async` | 14 | Watchers (KlEventCtx), suspend/resume, deadlines, cancel, e2e async handler |\n| `test_body_reader` | 30 | Buffer + multipart: limits, spanning, binary, edge cases |\n| `test_chunked` | 17 | Chunked decoder: single/multi chunk, hex, extensions, trailers, errors |\n| `test_client` | 18 | Sync/async client, response free, TLS config, error handling |\n| `test_client_pool` | 24 | Connection pool: acquire/release, per-host limits, idle expiry, stale detection |\n| `test_client_stream` | 27 | Response streaming (push), request streaming (pull), chunked body production |\n| `test_compress` | 16 | Compression vtable, buffer + streaming, miniz gzip backend |\n| `test_connection` | 12 | Pool init, acquire/release, exhaustion, active count, state machine, monotonic clock |\n| `test_cors` | 17 | Config, origin whitelist, wildcard, preflight, credentials, middleware |\n| `test_cross_module` | 7 | Cross-module integration: compress+drain, TLS+async, middleware+body+async, resolver cache, TLS+middleware+compress, stats during load |\n| `test_decompress` | 14 | Decompression vtable, gzip one-shot + streaming, CRC/ISIZE verification |\n| `test_drain` | 28 | Backpressure buffer: passthrough, partial, EAGAIN, flush, on_drain, max_size, overreport |\n| `test_error` | 11 | Error codes, kl_strerror, per-struct error storage |\n| `test_event` | 8 | Event loop init/close, add/wait, del, multiple FDs, timeout, mod mask |\n| `test_event_ctx` | 7 | Standalone event context init/free, watcher lifecycle, dispatch helpers |\n| `test_file_io` | 14 | Async file I/O vtable: mock submit/cancel/tick, state machine, EAGAIN, TLS fallback |\n| `test_file_io_iouring` | 7 | io_uring integration: real IORING_OP_READ submissions, CQE routing, offset/EOF (io_uring builds only) |\n| `test_h2` | 29 | HTTP/2 sessions, streams, routing, ALPN, goaway, body limits |\n| `test_h2_client` | 18 | Mock session vtable, stream tracking, response free, API validation |\n| `test_integration` | 27 | Full server: hello, POST, keepalive, multipart, chunked, middleware |\n| `test_overflow` | 20 | Integer overflow guards across all modules |\n| `test_parser` | 9 | GET, POST, query strings, incomplete, reset, chunked TE |\n| `test_proxy` | 11 | HTTP proxy: forwarding, CONNECT tunnel, auth, async proxy states, pool keying |\n| `test_redirect` | 33 | 3xx redirect following, method transform, cross-origin auth strip, pooled |\n| `test_request` | 14 | Header case-insensitive lookup, params, query strings, empty/missing values |\n| `test_response` | 24 | Status, headers, body, JSON, error, streaming, sendfile, compression |\n| `test_response_parser` | 10 | HTTP response parsing, chunked, headers, body limits, malformed |\n| `test_router` | 27 | Exact match, params, 404, 405, wildcard, middleware chain |\n| `test_server_integration` | 6 | Pool exhaustion, backpressure recovery, concurrent requests, drain |\n| `test_server_stats` | 4 | Server stats: initial, active count, max connections, null safety |\n| `test_resolver_cache` | 13 | DNS cache: hit/miss, TTL expiry, eviction, cancel, error non-caching |\n| `test_sse` | 7 | SSE framing: event, data, id, comment, multiline, begin/end |\n| `test_thread_pool` | 12 | Create/free, submit, backpressure, FIFO ordering, multi-worker, shutdown, stress |\n| `test_timeout` | 8 | Idle, partial headers, partial body, active connections, body timeout, keepalive idle, concurrent |\n| `test_timer` | 10 | Min-heap scheduling, cancellation, callback safety, next-timeout |\n| `test_tls` | 20 | TLS vtable, handshake FSM, response send/stream/file via mock, shutdown retry, pool teardown |\n| `test_tls_integration` | 3 | Passthrough TLS mock: full handshake→read→write path |\n| `test_url` | 20 | URL parsing, IPv6, CRLF rejection, default ports, ws/wss schemes |\n| `test_websocket` | 48 | Frame parsing, masking, opcode, fragments, close, echo, unmasked rejection |\n| `test_websocket_client` | 30 | Client frame encoding, mask XOR, handshake, parser, API, config, auto-ping |\n\n```bash\nmake test               # run all tests\nmake debug \u0026\u0026 make test  # run under ASan + UBSan\nmake analyze            # Clang static analyzer\nmake cppcheck           # cppcheck static analysis\n```\n\n## Why C\n\nAn HTTP server at this level is mostly syscalls, pointer arithmetic, and state machines. C is a natural fit: direct `writev`/`sendfile`/`epoll_wait` access, zero-copy pointers into read buffers, explicit memory layout, no runtime. One vendored dependency (llhttp), 2-second clean builds, runs on everything from io_uring to bare-metal MCUs.\n\nThe tradeoff is real — C has no borrow checker, no bounds-checked slices, no RAII. We compensate with defense-in-depth:\n\n- Pre-allocated connection pool (no per-request `malloc`, no fragmentation)\n- `SIZE_MAX/2` overflow guards on all arithmetic, bounds checks at system boundaries\n- ASan + UBSan in debug builds, Clang static analyzer + cppcheck in CI\n- libFuzzer on the HTTP parser and multipart parser (the primary attack surface)\n- `pledge()`/`unveil()` sandboxing, `-D_FORTIFY_SOURCE=2 -fstack-protector-strong`\n- Pluggable allocator for arena/pool strategies with deterministic cleanup\n\nThis is adequate for a focused ~14K LOC library with thorough testing, but it's not a language-level guarantee. If you're evaluating Keel and memory safety is your primary concern, that's a legitimate reason to look elsewhere.\n\n## Not in Scope\n\nKEEL is a transport library — it handles sockets, parsing, routing, and response serialization. Everything above the HTTP layer is an application concern:\n\n- **Authentication / authorization** — Token validation, session management, OAuth flows, RBAC. These are policy decisions that vary per application. KEEL's middleware interface makes auth trivial to implement (see [Auth middleware example](#writing-custom-middleware)) but deliberately doesn't ship one. *[Hull](https://github.com/artalis-io/hull) provides session, JWT, and RBAC middleware.*\n\n- **Request logging** — Log format (JSON, CLF, custom), destination (stderr, file, syslog), and filtering are application choices. KEEL provides a pluggable `access_log` callback with method, path, status, body size, and duration — you bring the formatter. *Hull provides a structured JSON logger middleware.*\n\n- **Request IDs / correlation IDs** — These are application-generated identifiers for tracing requests through your system. Use middleware to read/generate X-Request-ID headers and pass them to your logging/observability. *Hull generates and propagates request IDs automatically.*\n\n- **Rate limiting** — Rate limits depend on your authentication layer, your billing tiers, your abuse patterns. Implement as middleware with whatever backing store (in-memory, Redis, database) fits your architecture. *Hull provides configurable rate limiting middleware.*\n\n- **Request validation** — Schema validation, content-type negotiation, input sanitization. These are application-level concerns that depend on your data model. *Hull provides a validation module with schema-based input checking.*\n\n- **ETag / Last-Modified** — These are application-specific (KEEL doesn't know when your data changes). Use existing `kl_request_header()` / `kl_response_header()` for the headers; your application handles 304 logic. *Hull handles conditional responses for static assets.*\n\n- **Static file serving** — MIME types, path traversal protection, directory listing are application decisions. See `examples/static_files.c` for the pattern. *Hull auto-serves embedded or filesystem static assets with MIME detection.*\n\n- **CSRF protection** — Cross-site request forgery tokens for form-based applications. *Hull provides `hull.middleware.csrf` with automatic token generation and validation.*\n\n- **Idempotency keys** — Safe POST retry via `Idempotency-Key` header. *Hull provides `hull.middleware.idempotency` with configurable TTL and response caching.*\n\nThe general principle: if it requires policy decisions that vary between applications, it belongs in application code, not in the transport library. KEEL provides the hooks (middleware, body readers, access log callback) — you provide the policy.\n\n## Comparison with Alternatives\n\nThree embedded C HTTP libraries compared. See [docs/comparison.md](docs/comparison.md) for full details with API examples.\n\n| | Keel | [Mongoose](https://github.com/cesanta/mongoose) | [GNU libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/) |\n|---|------|----------|---------------|\n| **License** | MIT | GPLv2 / Commercial | LGPLv2.1+ |\n| **LOC** | ~14K | ~33K | ~19K |\n| **Architecture** | 31 independent modules | Monolithic amalgam | Monolithic |\n| **Maturity** | New (2025–2026) | 20+ years (NASA, Siemens, Samsung) | GNU project, 18+ years (NASA, Sony, systemd) |\n| **HTTP/2** | Server + client | No | No |\n| **Event backends** | epoll, kqueue, io_uring, poll | select/poll only | select, poll, epoll |\n| **Router + middleware** | Built-in with `:param` capture | None (DIY if/else) | None (single callback) |\n| **HTTP client** | Sync + async + streaming + H2 | Basic client | Server only |\n| **Allocator** | Runtime vtable (bring-your-own) | Compile-time macros | None (raw malloc) |\n| **TLS** | Pluggable vtable — any backend | Built-in TLS 1.3 + pluggable | GnuTLS only |\n| **Compression** | Pluggable vtable (gzip + extensible) | No | No |\n| **Threading** | Single-threaded + thread pool | Single-threaded | 4 modes incl. thread-per-connection |\n| **Bare-metal MCU** | Via lwIP/picoTCP (BSD sockets) | Built-in TCP/IP stack | Requires OS networking |\n| **Cosmopolitan C** | Supported (APE binaries) | No | No |\n| **Tests** | 671 (40 suites) | ~4K LOC tests | Fewer relative to size |\n\n**Choose Keel** when you want MIT licensing, HTTP/2, a built-in router/middleware/client, and pluggable everything. **Choose Mongoose** when you're targeting bare-metal MCUs with no OS, need a built-in TCP/IP stack, or need battle-tested maturity. **Choose libmicrohttpd** when you need multi-threaded request handling, independently audited security, or wide distro packaging.\n\n## CI\n\nGitHub Actions runs on every push and PR against `main`:\n\n- **Linux (epoll)** — build, test, examples, smoke test\n- **Linux (io_uring)** — build, test, examples, smoke test\n- **Linux (poll fallback)** — build, test, examples, smoke test\n- **macOS (kqueue)** — build, test, examples, smoke test\n- **Linux (musl/Alpine)** — build, test, examples\n- **Cosmopolitan (APE)** — build, examples, smoke test\n- **ASan + UBSan** — build and test with sanitizers\n- **Static Analysis** — scan-build + cppcheck\n- **Fuzz Testing** — libFuzzer on HTTP parser + multipart (60s each)\n\nA separate benchmark workflow runs on push to `main` (informational, not gating).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartalis-io%2Fkeel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fartalis-io%2Fkeel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartalis-io%2Fkeel/lists"}