{"id":47795355,"url":"https://github.com/cvik/coap","last_synced_at":"2026-04-03T16:15:09.066Z","repository":{"id":343735133,"uuid":"1175667091","full_name":"cvik/coap","owner":"cvik","description":"High-performance CoAP server and client library for Zig, built on Linux io_uring","archived":false,"fork":false,"pushed_at":"2026-03-18T13:35:13.000Z","size":394,"stargazers_count":0,"open_issues_count":6,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-19T04:29:57.844Z","etag":null,"topics":["coap","io-uring","iot","rfc7252","udp","zig"],"latest_commit_sha":null,"homepage":"","language":"Zig","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/cvik.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-08T02:15:55.000Z","updated_at":"2026-03-18T13:35:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/cvik/coap","commit_stats":null,"previous_names":["cvik/coap"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/cvik/coap","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cvik%2Fcoap","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cvik%2Fcoap/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cvik%2Fcoap/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cvik%2Fcoap/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cvik","download_url":"https://codeload.github.com/cvik/coap/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cvik%2Fcoap/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31362716,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-03T15:19:21.178Z","status":"ssl_error","status_checked_at":"2026-04-03T15:19:20.670Z","response_time":107,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: 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":["coap","io-uring","iot","rfc7252","udp","zig"],"created_at":"2026-04-03T16:15:08.436Z","updated_at":"2026-04-03T16:15:09.053Z","avatar_url":"https://github.com/cvik.png","language":"Zig","funding_links":[],"categories":[],"sub_categories":[],"readme":"# coap\n\nHigh-performance CoAP server and client library for Zig, built on Linux io_uring.\n\n### Highlights\n\n- **Simple handler interface** — `fn(Request) ?Response`, with context handlers and error wrappers\n- **Zero allocations in the hot path** — pre-allocated pools, arena resets per batch\n- **Multi-threaded** — SO_REUSEPORT, no shared state between threads\n- **DTLS 1.2 PSK** — pure Zig AES-128-CCM-8, stateless cookies, anti-replay\n- **IPv4 and IPv6** with dual-stack support\n\n### RFC compliance\n\nRFC compliance:\n\n| RFC | Feature | Coverage |\n|-----|---------|----------|\n| [7252](https://datatracker.ietf.org/doc/html/rfc7252) | CoAP core, separate responses, critical options | Full |\n| [7641](https://datatracker.ietf.org/doc/html/rfc7641) | Observe (client subscribe + server push) | Full |\n| [7959](https://datatracker.ietf.org/doc/html/rfc7959) | Block-wise transfers (client + server) | Full |\n| [6347](https://datatracker.ietf.org/doc/html/rfc6347) | DTLS 1.2 (PSK, flight retransmit) | Full |\n| [9175](https://datatracker.ietf.org/doc/html/rfc9175) | Echo option, Request-Tag | Partial |\n| [6690](https://datatracker.ietf.org/doc/html/rfc6690) | .well-known/core discovery | Full |\n| [4279](https://datatracker.ietf.org/doc/html/rfc4279) | PSK key exchange | Full |\n\nRFC 9175: server-side Echo response and Block1 Request-Tag disambiguation\nare implemented. Client-side automatic Echo retry on 4.01 and client\nRequest-Tag on Block1 uploads are not yet implemented.\n\nSee the [protocol compliance roadmap](docs/ROADMAP.md) for planned features.\n\n## Quick Start\n\n### Server\n\n```zig\nconst std = @import(\"std\");\nconst coap = @import(\"coap\");\n\npub fn main() !void {\n    var gpa = std.heap.GeneralPurposeAllocator(.{}){};\n    defer _ = gpa.deinit();\n\n    var server = try coap.Server.init(gpa.allocator(), .{}, echo);\n    defer server.deinit();\n\n    try server.run();\n}\n\nfn echo(request: coap.Request) ?coap.Response {\n    return coap.Response.ok(request.payload());\n}\n```\n\n### Server with Router\n\n```zig\nconst coap = @import(\"coap\");\n\nconst router = coap.Router(.{\n    .{ .get,  \"/temperature\",    getTemp },\n    .{ .put,  \"/temperature\",    setTemp },\n    .{ .get,  \"/sensor/:id\",     getSensor },\n    .{ .post, \"/led\",            toggleLed },\n});\n\npub fn main() !void {\n    var gpa = std.heap.GeneralPurposeAllocator(.{}){};\n    var server = try coap.Server.init(gpa.allocator(), .{}, router.handler());\n    defer server.deinit();\n    try server.run();\n}\n\nfn getTemp(_: coap.Request) ?coap.Response {\n    return coap.Response.ok(\"22.5\");\n}\n\nfn setTemp(req: coap.Request) ?coap.Response {\n    _ = req.payload(); // new temperature value\n    return coap.Response.changed();\n}\n\nfn getSensor(req: coap.Request) ?coap.Response {\n    const id = req.param(\"id\") orelse return coap.Response.badRequest();\n    _ = id; // look up sensor by id\n    return coap.Response.ok(\"sensor data\");\n}\n\nfn toggleLed(_: coap.Request) ?coap.Response {\n    return coap.Response.changed();\n}\n```\n\n### Client\n\n```zig\nconst std = @import(\"std\");\nconst coap = @import(\"coap\");\n\npub fn main() !void {\n    var gpa = std.heap.GeneralPurposeAllocator(.{}){};\n    defer _ = gpa.deinit();\n    const allocator = gpa.allocator();\n\n    var client = try coap.Client.init(allocator, .{\n        .host = \"127.0.0.1\",\n        .port = 5683,\n    });\n    defer client.deinit();\n\n    // Fire-and-forget NON request.\n    try client.cast(.get, \u0026.{}, \"ping\");\n\n    // Blocking CON request/response with retransmission.\n    const result = try client.get(allocator, \"/temperature\");\n    defer result.deinit(allocator);\n\n    std.debug.print(\"response: {s}\\n\", .{result.payload});\n}\n```\n\n### Server with DTLS\n\nPass PSK credentials to enable DTLS automatically. The server binds on port\n5684 (CoAPS) and requires a valid DTLS handshake before accepting requests.\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .psk = .{ .identity = \"device1\", .key = \"supersecretkey1!\" },\n}, handler);\ndefer server.deinit();\ntry server.run();\n```\n\n### Client with DTLS\n\n```zig\nvar client = try coap.Client.init(allocator, .{\n    .host = \"10.0.0.1\",\n    .psk = .{ .identity = \"device1\", .key = \"supersecretkey1!\" },\n});\ndefer client.deinit();\n\ntry client.handshake();\n\nconst result = try client.get(allocator, \"/temperature\");\ndefer result.deinit(allocator);\n```\n\nAll send/recv methods automatically encrypt/decrypt after `handshake()`.\nHandlers can check `request.is_secure` to distinguish DTLS from plain requests.\n\n## Installation\n\nAdd to your `build.zig.zon`:\n\n```zig\n.coap = .{\n    .url = \"git+https://github.com/cvik/coap#v0.7.2\",\n    .hash = \"...\",  // zig build will tell you the expected hash\n},\n```\n\nThen in `build.zig`:\n\n```zig\nconst coap_dep = b.dependency(\"coap\", .{ .target = target, .optimize = optimize });\nexe.root_module.addImport(\"coap\", coap_dep.module(\"coap\"));\n```\n\n## Handler Interface\n\nThe handler is a function pointer with the signature:\n\n```zig\nfn(coap.Request) ?coap.Response\n```\n\n### Request\n\nThe request provides convenience accessors and the underlying packet:\n\n- `method()` — request method (`.get`, `.post`, `.put`, `.delete`, …)\n- `payload()` — request payload bytes\n- `param(\"name\")` — route parameter captured by the router (e.g. `:id`)\n- `pathSegments()` — iterator over URI-Path option segments\n- `querySegments()` — iterator over URI-Query option values\n- `findOptions(kind)` / `findOption(kind)` — option lookup by kind\n- `ifMatch()` / `ifNoneMatch()` / `etags()` — conditional request accessors\n- `echoOption()` — reflected Echo value for freshness verification\n- `deferResponse()` — defer the response for async handling (separate response)\n- `observeResource(rid)` / `removeObserver(rid)` — observe registration\n- `packet` — the full parsed CoAP packet for advanced use\n- `peer_address` — source address of the client (`std.net.Address`)\n- `arena` — per-request arena allocator (resets after handler returns)\n\n### Response\n\nReturn a `coap.Response` to send a reply, or `null` for no response.\n\nConvenience constructors for common responses:\n\n```zig\nResponse.ok(\"hello\")                         // 2.05 Content with payload\nResponse.content(arena, .json, \"{}\")         // 2.05 with Content-Format option\nResponse.created()                           // 2.01 Created\nResponse.valid()                             // 2.03 Valid\nResponse.deleted()                           // 2.02 Deleted\nResponse.changed()                           // 2.04 Changed\nResponse.notFound()                          // 4.04 Not Found\nResponse.badRequest()                        // 4.00 Bad Request\nResponse.methodNotAllowed()                  // 4.05 Method Not Allowed\nResponse.unauthorized()                      // 4.01 Unauthorized\nResponse.forbidden()                         // 4.03 Forbidden\nResponse.badOption()                         // 4.02 Bad Option\nResponse.preconditionFailed()                // 4.12 Precondition Failed\nResponse.withCode(.gateway_timeout)          // arbitrary code\nResponse.withEcho(arena)                     // add Echo option for freshness\n```\n\nOr construct directly:\n\n```zig\nreturn .{ .code = .content, .options = opts, .payload = data };\n```\n\n### Context Handlers\n\nUse `Server.initContext` to pass state to the handler without globals:\n\n```zig\nconst State = struct { counter: u64 = 0 };\n\nvar state = State{};\nvar server = try coap.Server.initContext(allocator, .{}, handle, \u0026state);\n\nfn handle(ctx: *State, request: coap.Request) ?coap.Response {\n    _ = @atomicRmw(u64, \u0026ctx.counter, .Add, 1, .monotonic);\n    return coap.Response.ok(request.payload());\n}\n```\n\nThe context pointer is type-erased internally and passed to the handler on\nevery invocation. When `thread_count \u003e 1`, the context is shared across worker\nthreads — use atomic operations, mutexes, or thread-local state.\n\n### Error Handling Wrappers\n\n`safeWrap` converts a handler that returns `!?Response` into a\n`SimpleHandlerFn`. Errors are logged and converted to 5.00 Internal Server\nError:\n\n```zig\nfn handler(request: coap.Request) !?coap.Response {\n    const data = try fetchData(request.arena);\n    return .{ .payload = data };\n}\n\nvar server = try coap.Server.init(allocator, .{}, coap.safeWrap(handler));\n```\n\n`safeWrapContext` does the same for context handlers:\n\n```zig\nfn handler(ctx: *State, request: coap.Request) !?coap.Response {\n    const data = try ctx.lookup(request.arena);\n    return .{ .payload = data };\n}\n\nvar server = try coap.Server.initContext(\n    allocator, .{}, coap.safeWrapContext(*State, handler), \u0026state,\n);\n```\n\n### Message Types\n\nThe server handles CoAP message types automatically:\n\n- **CON** (confirmable) — response is sent as ACK with the matching message ID.\n  If the handler returns `null`, an empty ACK is sent. Duplicate CON messages\n  are detected and the cached response is retransmitted without calling the\n  handler again.\n- **NON** (non-confirmable) — response is sent as NON. If the handler returns\n  `null`, no response is sent.\n- **RST** (reset) — cancels the matching exchange (removes cached response).\n\n### Panic Behavior\n\nHandler functions must not panic. A panic in any handler terminates the\nentire process (Zig panics are not recoverable). Worker threads are\nautomatically restarted up to `max_worker_restarts` times (default: 5),\nbut this only covers normal thread exits (e.g. init failures, transient\nI/O errors), not panics. Use `catch` to convert errors into CoAP error\nresponses, or use `safeWrap` for automatic error conversion.\n\n### Routing\n\nUse the comptime `Router` for multi-resource servers (see [Server with Router](#server-with-router)).\nRoutes support parameterized segments:\n\n```zig\nconst router = coap.Router(.{\n    .{ .get, \"/sensor/:id\", getSensor },\n});\n\nfn getSensor(req: coap.Request) ?coap.Response {\n    const id = req.param(\"id\") orelse return coap.Response.badRequest();\n    _ = id; // look up sensor\n    return coap.Response.ok(\"data\");\n}\n```\n\nFor simple servers, manual routing with request accessors also works:\n\n```zig\nfn handler(request: coap.Request) ?coap.Response {\n    var it = request.pathSegments();\n    const seg1 = it.next() orelse return coap.Response.notFound();\n\n    if (request.method() == .get and std.mem.eql(u8, seg1.value, \"temperature\")) {\n        return coap.Response.ok(\"22.5\");\n    }\n\n    return coap.Response.notFound();\n}\n```\n\n### Response Options\n\nUse `Response.content()` to set Content-Format automatically:\n\n```zig\nfn handler(request: coap.Request) ?coap.Response {\n    return coap.Response.content(request.arena, .json, \"{\\\"temp\\\": 22.5}\");\n}\n```\n\nFor custom options, use the arena allocator directly:\n\n```zig\nfn handler(request: coap.Request) ?coap.Response {\n    var cf_buf: [2]u8 = undefined;\n    const cf = coap.Option.content_format(.json, \u0026cf_buf);\n    const opts = request.arena.dupe(coap.Option, \u0026.{cf}) catch\n        return coap.Response.withCode(.internal_server_error);\n\n    return .{ .code = .content, .options = opts, .payload = \"{\\\"temp\\\": 22.5}\" };\n}\n```\n\n## Client API\n\nA `Client` connects to a single server via a connected UDP socket. Create\nmultiple instances for multiple servers.\n\n### init / deinit\n\n```zig\nvar client = try coap.Client.init(allocator, .{\n    .host = \"127.0.0.1\",\n    .port = 5683,\n    .max_in_flight = 32,       // max concurrent CON requests\n    .token_len = 2,            // token length in bytes (1-8)\n    .default_szx = 6,          // block size exponent (6 = 1024 bytes)\n});\ndefer client.deinit();\n```\n\n### get / post / put / delete — path convenience\n\nCON request/response by URI string with automatic retransmission.\nPaths and query strings are parsed automatically:\n\n```zig\nconst result = try client.get(allocator, \"/sensor/temperature\");\ndefer result.deinit(allocator);\n// result.code, result.payload, result.options\n\n// Query strings work too:\nconst r2 = try client.get(allocator, \"/sensors?type=temp\u0026floor=2\");\ndefer r2.deinit(allocator);\n\nconst r3 = try client.post(allocator, \"/log\", \"event happened\");\ndefer r3.deinit(allocator);\n```\n\nReturns `error.Timeout` after max retransmissions, `error.Reset` if the\nserver sends RST. Transparently reassembles Block2 multi-block responses.\n\n### URI helpers\n\nFor building options manually (e.g. with `call` or `submit`), use the\n`coap.uri` helpers. All stack-allocated, no heap:\n\n```zig\nconst uri = coap.uri;\n\n// Parse a full URI into CoAP options:\nvar buf: [uri.max_options]coapz.Option = undefined;\nconst opts = uri.fromUri(\"/sensors/temp?unit=celsius\u0026fmt=json\", \u0026buf);\nconst result = try client.call(allocator, .get, opts, \u0026.{});\n\n// Or build path and query separately:\nvar path_buf: [8]coapz.Option = undefined;\nconst path = uri.fromPath(\"sensors/temp\", \u0026path_buf);\n\nvar query_buf: [8]coapz.Option = undefined;\nconst query = uri.fromQuery(\"unit=celsius\u0026fmt=json\", \u0026query_buf);\n```\n\n### cast — NON fire-and-forget\n\nSends a NON request with no response expected:\n\n```zig\nvar buf: [coap.uri.max_options]coapz.Option = undefined;\ntry client.cast(.post, coap.uri.fromUri(\"/log\", \u0026buf), \"event happened\");\n```\n\n### call — CON request/response\n\nLower-level CON method accepting raw options. Use `get`/`post`/`put`/`delete`\nfor simpler path-based requests.\n\n```zig\nvar buf: [coap.uri.max_options]coapz.Option = undefined;\nconst result = try client.call(allocator, .get, coap.uri.fromUri(\"/sensor\", \u0026buf), \u0026.{});\ndefer result.deinit(allocator);\n```\n\n### submit / poll — pipelined async\n\nFor high-throughput workloads, use `submit` to send CON requests without\nblocking, then `poll` to drive the event loop and collect completions:\n\n```zig\nvar client = try coap.Client.init(allocator, .{\n    .host = \"10.0.0.1\",\n    .max_in_flight = 64,\n});\ndefer client.deinit();\n\n// Submit multiple requests — returns immediately.\nconst h1 = try client.submit(.get, \u0026.{\n    .{ .kind = .uri_path, .value = \"temperature\" },\n}, \u0026.{});\nconst h2 = try client.submit(.get, \u0026.{\n    .{ .kind = .uri_path, .value = \"humidity\" },\n}, \u0026.{});\n\n// Poll for completions (handles retransmission, Block2 reassembly).\nwhile (try client.poll(allocator, 100)) |completion| {\n    defer completion.result.deinit(allocator);\n    if (completion.handle == h1) {\n        std.debug.print(\"temp: {s}\\n\", .{completion.result.payload});\n    } else if (completion.handle == h2) {\n        std.debug.print(\"humidity: {s}\\n\", .{completion.result.payload});\n    }\n}\n```\n\n`poll` returns `null` when the timeout expires with no completion. Check\n`completion.result._timeout` or `._reset` for error conditions. Option\n`value` memory passed to `submit` must remain valid until the corresponding\ncompletion.\n\nThe blocking `call`/`get`/`post`/`put`/`delete` methods are implemented as\n`submit` + `poll` internally — both APIs share the same slot infrastructure\nand can be mixed freely.\n\n### sendRaw / recvRaw — low-level\n\nSend and receive raw CoAP packets without protocol automation:\n\n```zig\ntry client.sendRaw(packet);\nconst response = try client.recvRaw(allocator, 2000) orelse return; // 2s timeout\ndefer response.deinit(allocator);\n```\n\n### observe — RFC 7641\n\n**Client — subscribe to resource notifications:**\n\n```zig\nvar stream = try client.observe(\u0026.{\n    .{ .kind = .uri_path, .value = \"temperature\" },\n});\n\nwhile (try stream.next(allocator)) |notification| {\n    defer notification.deinit(allocator);\n    std.debug.print(\"update: {s}\\n\", .{notification.payload});\n}\n\ntry stream.cancel();\n```\n\nCON notifications are automatically ACKed. For zero-allocation processing,\nuse `nextBuf` with a caller-provided buffer:\n\n```zig\nvar buf: [1500]u8 = undefined;\nwhile (try stream.nextBuf(\u0026buf)) |notification| {\n    // notification.payload and options live in buf — no deinit needed\n    std.debug.print(\"update: {s}\\n\", .{notification.payload});\n}\n```\n\n**Server — push notifications to subscribers:**\n\n```zig\n// Allocate a resource ID at startup.\nconst temp_rid = server.allocateResource() orelse return error.Full;\n\n// In the handler, register the client as an observer.\nfn handler(req: coap.Request) ?coap.Response {\n    if (req.method() == .get) {\n        _ = req.observeResource(temp_rid);\n        return coap.Response.ok(\"22.5\");\n    }\n    return coap.Response.methodNotAllowed();\n}\n\n// From any thread, push an update to all observers.\nserver.notify(temp_rid, coap.Response.ok(\"23.1\"));\n```\n\n`notify()` is thread-safe — call it from sensor loops, worker threads,\nor interrupt handlers. Notifications are queued and sent on the next\nserver tick.\n\n**Reliability — CON notifications:**\n\nThe server periodically sends CON (confirmable) notifications to verify\nthat observers are still reachable. The interval is configurable:\n\n```zig\n.observe_con_interval = 20,  // every 20th notification is CON (default)\n```\n\nWhen a CON notification goes unacknowledged after retransmission (exponential\nbackoff, up to 4 retries per RFC 7252 §4.2), the observer is automatically\nremoved. This is how the server detects that a client has gone away.\n\nSet `observe_con_interval = 0` to disable CON notifications entirely\n(all notifications sent as NON). Set to `1` for maximum reliability\n(every notification is CON, higher overhead).\n\n**Client-side cancellation and failure detection:**\n\n- Call `stream.cancel()` to unsubscribe (sends Observe=1 deregister).\n- If the server sends a RST to any notification, the subscription is cancelled.\n- `stream.next()` returns `null` when cancelled.\n\n**Server-side cancellation:**\n\nTo tell clients a resource is gone (sensor disconnected, resource deleted),\nsend a non-2.xx notification. Per RFC 7641 §3.2, clients must deregister\non error codes:\n\n```zig\nserver.notify(temp_rid, coap.Response.notFound());  // 4.04 — resource gone\n```\n\n**Server-side observer eviction:**\n\nObservers are removed when:\n- The client sends RST to a notification (explicit rejection).\n- A CON notification times out after max retransmissions (client unreachable).\n- The handler calls `req.removeObserver(rid)` explicitly.\n\n### upload — RFC 7959 Block1\n\nUpload large payloads using Block1 segmentation:\n\n```zig\nconst result = try client.upload(allocator, .put, \u0026.{\n    .{ .kind = .uri_path, .value = \"firmware\" },\n}, large_payload);\ndefer result.deinit(allocator);\n```\n\nThe server's preferred block size is honored if it responds with a\nsmaller SZX value.\n\n## Server Configuration\n\nAll fields have sensible defaults. Pass `.{}` for a standard server on port 5683.\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .port = 5683,                     // UDP listen port\n    .bind_address = \"0.0.0.0\",        // IPv4/IPv6 bind address\n    .buffer_count = 512,              // io_uring provided buffers\n    .buffer_size = 1280,              // max UDP datagram size (bytes)\n    .exchange_count = 256,            // max concurrent CON exchanges\n    .max_deferred = 16,               // max concurrent separate responses\n    .max_block_transfers = 32,        // max concurrent Block1/Block2 transfers\n    .max_block_payload = 64 * 1024,   // max block transfer payload (bytes)\n    .max_observers = 256,             // max total observer entries\n    .max_observe_resources = 64,      // max observed resources\n    .observe_con_interval = 20,       // CON every Nth notification (0 = NON only)\n    .well_known_core = null,          // RFC 6690 discovery payload\n    .recognized_options = \u0026.{},       // extra critical options to allow\n    .thread_count = 1,                // server threads (SO_REUSEPORT)\n    .max_arena_size = 256 * 1024,     // arena trim threshold (bytes)\n    .rate_limit_ip_count = 1024,      // max tracked IPs (0 = disabled)\n    .rate_limit_tokens_per_sec = 100, // tokens refilled per second\n    .rate_limit_burst = 200,          // max bucket capacity\n    .load_shed_throttle_pct = 75,     // % utilization to start throttling\n    .load_shed_critical_pct = 90,     // % utilization to start shedding\n    .load_shed_recover_pct = 50,      // % utilization to recover\n    .handler_warn_ns = 0,             // slow handler warning threshold (ns)\n    .max_worker_restarts = 5,         // max worker restart attempts\n    .cpu_affinity = \u0026.{ 0, 1, 2, 3 }, // pin threads to CPU cores\n}, handler);\n```\n\n### `port`\n\nUDP port to bind. Default: `5683` (CoAP standard port per RFC 7252).\n\n### `bind_address`\n\nAddress to bind. Use `\"0.0.0.0\"` for all IPv4 interfaces, `\"::\"` for\ndual-stack IPv6 (accepts both v4 and v6 clients via `IPV6_V6ONLY=0`),\n`\"127.0.0.1\"` or `\"::1\"` for loopback only. Default: `\"0.0.0.0\"`.\n\n### `buffer_count`\n\nNumber of provided buffers in the io_uring buffer pool. The kernel consumes\none buffer per incoming packet. Buffers are returned after each packet is\nprocessed, but during bursts the pool must absorb all arrivals between\nprocessing cycles. Set this to at least 2x your expected concurrent clients'\nsend window. Default: `512`.\n\nHigher values require more kernel memory per io_uring instance.\n\n### `buffer_size`\n\nMaximum size of a single CoAP UDP datagram in bytes. Must be at least 64.\nDefault: `1280` (IPv6 minimum MTU, recommended by RFC 7252).\n\nThis also sets the maximum cached response size for CON deduplication.\n\n### `exchange_count`\n\nMaximum number of concurrent CON message exchanges tracked for duplicate\ndetection and response caching. Each exchange holds the peer address,\nmessage ID, and a copy of the encoded response (up to `buffer_size` bytes).\nExchanges expire automatically per RFC 7252 section 4.8.2 (every ~247\nseconds). Default: `256`.\n\nMemory per exchange: `~8 + buffer_size` bytes. With defaults: `256 * 1288 ≈ 322 KB`.\n\nIf the pool is exhausted, new CON responses are sent but not cached — the\nserver logs a warning and duplicate detection is unavailable for those\nexchanges.\n\n### `max_deferred`\n\nMaximum concurrent separate (delayed) responses. When a handler calls\n`request.deferResponse()`, the server sends an empty ACK and tracks the\npending response in this pool. Set to `0` to disable. Default: `16`.\n\n### `max_block_transfers`\n\nMaximum concurrent Block1 upload reassembly and Block2 large response\nfragmentation transfers (shared pool). Set to `0` to disable block transfer\nsupport. Default: `32`.\n\n### `max_block_payload`\n\nMaximum payload size for block transfers in bytes. Block1 uploads exceeding\nthis are rejected with 4.13. Block2 responses are capped at this size.\nDefault: `65536` (64 KB).\n\n### `max_observers` / `max_observe_resources`\n\nMaximum total observer entries and maximum observed resources for server-side\nObserve (RFC 7641). The observer list is partitioned evenly across resources.\nSet `max_observers` to `0` to disable. Defaults: `256` / `64`.\n\n### `observe_con_interval`\n\nSend a CON (confirmable) notification every N notifications per observer.\nCON notifications require client acknowledgement — unresponsive observers\nare removed after retransmission timeout. Set to `0` to send NON only.\nDefault: `20`.\n\n### `well_known_core`\n\nStatic link-format string returned for `GET /.well-known/core` requests\n(RFC 6690 resource discovery). When set, matching requests are intercepted\nbefore reaching the handler. The response includes `Content-Format: 40`\n(application/link-format).\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .well_known_core = \"\u003c/temperature\u003e;rt=\\\"temperature\\\";if=\\\"sensor\\\",\" ++\n                       \"\u003c/led\u003e;rt=\\\"light\\\";if=\\\"actuator\\\"\",\n}, handler);\n```\n\nWhen `null` (default), `/.well-known/core` requests pass through to the\nhandler like any other request.\n\n### `recognized_options`\n\nAdditional critical option numbers the application understands. The server\nautomatically rejects unrecognized critical options (odd-numbered) with 4.02\nBad Option per RFC 7252 §5.4.1. All standard CoAP options are recognized by\ndefault. Use this field to whitelist application-specific critical options:\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .recognized_options = \u0026.{ 2049, 2051 },  // application-specific critical options\n}, handler);\n```\n\nDefault: `\u0026.{}` (only standard options recognized).\n\n### `thread_count`\n\nNumber of server threads. Each thread gets its own io_uring instance, UDP\nsocket, and exchange pool — there is no shared state between threads. The\nkernel distributes incoming packets across sockets via `SO_REUSEPORT`\n(4-tuple hash).\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .thread_count = 4,\n}, handler);\n```\n\nNote: the kernel distributes packets by 4-tuple hash (src/dst IP + port).\nA single client socket always hashes to one server thread. Throughput scales\nwith the number of distinct client connections — multiple clients (different\nsource ports) spread across all threads. Even on loopback, the bench shows\nmulti-thread throughput scales linearly because it uses one socket per client thread.\n\n### `max_arena_size`\n\nMaximum arena size in bytes before trimming. The per-tick arena is trimmed\nback to this size after each batch of completions to prevent unbounded growth\nfrom handler allocations. Default: `256 * 1024` (256 KB).\n\n### `handler_warn_ns`\n\nLog a warning when a handler invocation takes longer than this threshold in\nnanoseconds. When enabled, adds a `nanoTimestamp()` call per handler\ninvocation. Set to `0` to disable (default). Useful for detecting slow\nhandlers in production.\n\n### `max_worker_restarts`\n\nMaximum number of times a crashed worker thread is automatically restarted.\nAfter this limit, the worker is not respawned and a log error is emitted.\nDefault: `5`.\n\n### `cpu_affinity`\n\nPin server threads to specific CPU cores. Thread *i* is pinned to\n`cpu_affinity[i % len]` — the main thread uses index 0, workers use\nindices 1..N-1. This keeps each thread's io_uring buffers hot in L1/L2\ncache and reduces latency jitter from OS thread migration.\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .thread_count = 4,\n    .cpu_affinity = \u0026.{ 0, 2, 4, 6 },  // pin to even cores\n}, handler);\n```\n\nWhen `null` (default), no affinity is set — the OS schedules threads\nfreely. If pinning fails (e.g., core ID out of range or insufficient\npermissions), a warning is logged and the thread continues unpinned.\n\n### `psk`\n\nPSK credentials for DTLS 1.2 (RFC 6347). When set, the server requires a\nDTLS handshake before accepting CoAP requests. Uses\n`TLS_PSK_WITH_AES_128_CCM_8` (the mandatory cipher suite for CoAP, per\nRFC 7252 §9). The port auto-switches to 5684 (CoAPS) if the default 5683\nwas configured.\n\n```zig\nvar server = try coap.Server.init(allocator, .{\n    .psk = .{ .identity = \"device1\", .key = \"supersecretkey1!\" },\n}, handler);\n```\n\nWhen `null` (default), no DTLS — plain CoAP over UDP.\n\n### `dtls_session_count`\n\nMaximum concurrent DTLS sessions. Each session holds handshake state and\nencryption keys. Sessions are evicted LRU when the table is full.\nDefault: `65536`.\n\n### `dtls_session_timeout_s`\n\nIdle DTLS session timeout in seconds. Sessions with no activity for this\nduration are evicted. Default: `300` (5 minutes).\n\n## Rate Limiting\n\ncoap includes per-IP token bucket rate limiting, activated when the server\nenters the `throttled` load level (see [Load Shedding](#load-shedding)).\n\nConfiguration:\n\n- `rate_limit_ip_count` — max tracked IPs. Set to `0` to disable rate\n  limiting entirely. Default: `1024`.\n- `rate_limit_tokens_per_sec` — token refill rate per IP. Default: `100`.\n- `rate_limit_burst` — maximum bucket capacity per IP. Default: `200`.\n\nWhen a client exceeds its rate limit:\n\n- **CON** messages receive a RST (from a pre-allocated buffer).\n- **NON** messages are silently dropped.\n\n## Load Shedding\n\nThe server monitors buffer pool and exchange pool utilization and\ntransitions between three load levels:\n\n| Level | Trigger | Behavior |\n|-------|---------|----------|\n| **normal** | utilization \u003c `throttle_pct` | All requests processed normally |\n| **throttled** | any pool \u003e= `throttle_pct` | Per-IP rate limiting applied |\n| **shedding** | any pool \u003e= `critical_pct` | New packets dropped; CONs get RST |\n\nRecovery occurs when both pools drop below `load_shed_recover_pct`. The\nhysteresis gap between trigger and recovery thresholds prevents oscillation.\n\nDuring shedding, cached CON retransmissions are still served — only new\nrequests are dropped.\n\n## Server Lifecycle\n\n```zig\n// 1. Init — pre-allocates all memory.\nvar server = try coap.Server.init(allocator, config, handler);\ndefer server.deinit();\n\n// 2a. Run (blocking) — binds, spawns threads, loops until stop().\ntry server.run();\n\n// 2b. Or manual control:\ntry server.listen();       // bind socket, arm io_uring\nwhile (running) {\n    try server.tick();     // process one batch of completions\n}\n\n// 3. Graceful shutdown — call from another thread or signal handler.\nserver.stop();             // signals run() and all workers to exit\n```\n\nThe `tick()` method processes up to 256 completion events, calls the handler\nfor each request, and submits responses. The arena allocator resets after\neach tick. Use `listen()` + `tick()` when you need control over the event\nloop (e.g., graceful shutdown, integration with other I/O).\n\n## Logging\n\ncoap uses `std.log` with the `.coap` scope. Control verbosity via:\n\n```zig\npub const std_options: std.Options = .{\n    .log_level = .warn,  // suppress info/debug from coap\n};\n```\n\nLog messages:\n- **info**: server started (port, thread count), worker start/stop\n- **warn**: multishot recv re-armed, exchange pool full, slow handler\n  (when `handler_warn_ns` enabled), rate-limited clients\n- **debug**: malformed packets, exchange eviction counts, load level changes\n- **err**: buffer release failures, worker crash/restart exhaustion\n\n## Benchmarks\n\nThe included bench suite runs a matrix of scenarios grouped by transport\n(Plain/DTLS) × type (NON/CON) × threads × payload size. NON throughput\nis measured server-side via shared-memory counters; CON measures echo\nround-trip latency. Results vary by hardware — run on your own system:\n\n```bash\nzig build bench -Doptimize=ReleaseFast\n```\n\nFilter flags: `--plain-only`, `--dtls-only`, `--con-only`, `--non-only`,\n`--single-only`, `--multi-only`, `--ipv6`. Use `--help` for all options.\n\n## Requirements\n\n- Linux (io_uring support, kernel 5.13+ for multishot recvmsg)\n- Zig 0.15.1+\n\n## Roadmap\n\n- [x] CON/ACK reliability (duplicate detection, piggybacked ACK, response caching)\n- [x] RST message handling\n- [x] Pipelined benchmark client with embedded server\n- [x] Multi-threading with SO_REUSEPORT\n- [x] .well-known/core resource discovery (RFC 6690)\n- [x] Per-IP rate limiting and load shedding\n- [x] Client library (cast, call, observe, block transfer)\n- [x] DTLS 1.2 PSK security (RFC 6347)\n- [x] Pipelined async client API (submit/poll)\n- [x] Auto-clamp buffer_count to fit RLIMIT_MEMLOCK\n- [x] Parallel AES-CTR via AES-NI xorWide\n- [x] Critical option rejection (RFC 7252 §5.4.1)\n- [x] NSTART congestion control (RFC 7252 §4.7)\n- [x] IPv6 with dual-stack support\n- [x] Separate (delayed) responses (RFC 7252 §5.2.2)\n- [x] Server-side Observe with thread-safe notify (RFC 7641)\n- [x] Server-side Block1/Block2 transfers (RFC 7959)\n- [x] Client observe sequence freshness check (RFC 7641 §3.4)\n\nSee [docs/ROADMAP.md](docs/ROADMAP.md) for the full protocol compliance roadmap.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcvik%2Fcoap","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcvik%2Fcoap","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcvik%2Fcoap/lists"}