- Server-Sent Events.
(SSE "/path" handler)indefserver, orApp.SSE, answers a GET with atext/event-streamresponse that stays open. The handler is called withConnectwhen a client subscribes, withTickeveryApp.sse-tick-intervalseconds, and withClosewhen the client goes away; it queues events on the stream handle withSSEStream.sendandSSEStream.send-event. A reconnecting client'sLast-Event-IDis handed to the handler. Idle streams are not closed by the HTTP timeouts, and a tick that queues nothing sends a comment line so proxies keep the stream open. TheSSEmodule encodes the wire format on its own for handlers that need anid, aretrytime, or a comment.
- A conditional request is matched the way
If-None-Matchis defined. The header used to be compared as one whole string against the response'sETag, soIf-None-Match: *never matched, a client holding several cached variants ("a", "b") re-downloaded the full body every time, and a weak validator (W/"x") never matched its strong spelling. All three now match, as RFC 9110 §13.1.2 asks for. A matchingIf-None-Matchon a method other thanGETorHEADis answered with412 Precondition Failedinstead of a304 Not Modified, andIf-Modified-Sinceis ignored on those methods. A response that is not a2xxignores both headers, so a404stays a404. The412, like the304, keeps every header the response had built up — aSet-Cookiethe handler issued, anAccess-Control-Allow-Originan after-hook added — and drops only the ones that described the body it no longer carries. A responseETagorLast-Modifiedis found whatever its capitalisation, the way the request headers are already read. - A file whose extension is uppercase gets its real content type. A path
ending in
.JPG,.PNG,.HTMLor any other spelling of a known extension that is not all lower case was served asapplication/octet-stream, so a browser offeredIMG_1234.JPGas a download instead of showing it. Extensions now match case-insensitively, the way nginx and Apache do. - A WebSocket close frame is checked before it is answered. A close payload holding a single byte, a status code an endpoint must never receive (0-999, 1004, 1005, 1006, 1012-2999, and anything above 4999), or a reason that is not valid UTF-8 was accepted silently; such a frame now fails the connection with 1002 or 1007. A well-formed close is answered with the client's own status code echoed back, as RFC 6455 §5.5.1 asks for, and a close carrying no payload is still answered with an empty close.
- A
304 Not Modifiedkeeps the headers the200would have sent. The revalidation response was built from an empty header map holding nothing butETag, soCache-Control,Vary,Last-Modified,Expiresand theAccess-Control-*headers a hook had added were thrown away: a browser revalidating a cross-origin resource got a 304 with noAccess-Control-Allow-Originand failed the CORS check, and a shared cache lost theVary: Origintelling it to key the entry by origin. The 304 now carries everything the200did apart from the body and the headers that describe it.
httpbumped to 0.4.2.
- Dependencies bumped:
http0.4.1,json0.6.0,log0.2.0,utf8.carp0.2.0, andorm0.5.1 in the todo example.
- A
Rangeheader, aContent-Typeheader, a form body, or a static path whose byte length ran past its character count no longer kills the server. One such request aborted the whole process. Every stringwebmatches itself measures and slices in bytes now. The one remaining upstream path (a hostilemultipart/form-databoundary parameter aborting whilehttpparsed the media type) is fixed by thehttp0.4.1 bump above. - A file extension is recognised on a non-ASCII path, so such a file is
served with its real
Content-Typerather thanapplication/octet-stream, and a non-ASCII directory path finds its index file. Response.jsonno longer rounds numbers to six significant digits. A database rowid of1234567890went out as1.23457e+09,1000000as1e+06and3.141592653589793as3.14159; each of those is now written in full. Numbers that already fitted in six digits, such as42and3.14, are unchanged.Response.jsonno longer drops the character after an escape. A string value or object key containing"or\, or a control character such as a newline or a tab, lost whatever code point came next if that code point was one of a large set including Greek, Cyrillic,©,°,µand»: a body built from"\nα"was serialised as"\n". Bytes that are not valid UTF-8 were dropped or replaced too, and now reach the client untouched.
jsonis pinned to 0.6.0 andutf8.carpto 0.2.0. Thejsonrelease drops its own, older copy ofutf8.carp, so only one version of that module is compiled into a server now.JSONalso gains RFC 6901 pointers, RFC 6902 patch, RFC 7386 merge patch and a parser recursion limit, all of which are available to handlers.Rangerequest headers follow RFC 9110 §14.1. The range unit is matched case-insensitively, whitespace and empty elements in the range-set are ignored, and a request for several ranges is answered with a206carrying the first satisfiable one instead of the whole representation.httpis pinned to 0.4.0.
fileis pinned to 0.3.0. Its read path is all-or-nothing now, andread-allreports an error for an input whose length it cannot determine instead of reading into a buffer sized from a failedftell. The static file server callsread-all, so a path that resolves to a pipe or a device is a clean404rather than a corrupted response.
- The bundled todo example compiles again.
examples/todo/server.carphad not built since 0.6.0: it pinned long-supersededwebandormreleases, andItem.insertnow yields aLongrowid where the example still expected anInt. Itsidis aLongnow, it loads the repository's ownweb.carpthe way the other bundled servers do, and a failing insert, update, or delete comes back as a500with the database's message instead of being silently discarded.test/smoke.shcompiles the example, so it cannot rot unnoticed again.
Form.decode-multipart/decode-multipart-requestreturn(Result (Array FormPart) String)and delegate to http's multipart parser instead of shipping a second one.FormPart.content-typeis now a(Maybe String),Nothingwhen the part carries noContent-Typeheader (previouslytext/plain).
- HTTP date headers. Every response now carries a
Dateheader (an RFC 9110 MUST for an origin server), and static-file responses add aLast-Modifiedheader from the file's modification time, so caches and conditional requests have the freshness metadata they need. If-Modified-Sinceconditional requests. A static-file request whoseIf-Modified-Sinceis not older than the resource's modification time gets a304 Not Modified; an older or malformed value serves the full200.If-None-Matchstill takes precedence when both are present.- HTTP
Rangerequests on static files. The static-file server answers a single byte range (bytes=A-B,bytes=A-,bytes=-N) with206 Partial Contentand aContent-Rangeheader, seeking directly viasendfile(2), so clients can resume interrupted downloads and seek within large assets. An unsatisfiable range gets416 Range Not Satisfiable; every static response now advertisesAccept-Ranges: bytes. Multiple ranges and malformed specs fall back to the full200. App.request-timeout(60s) bounds first byte to complete request, so a slow-dripping body can no longer hold a connection open.Expect: 100-continueis answered with the interim response once the headers are complete.- An end-to-end smoke test (
test/smoke.sh, in CI) drives a real server with curl: large, chunked, and 100-continue uploads, keep-alive, 400s.
SHA1is correct on platforms where Carp'sLongis 32-bit. Every digest came out wrong there, so the WebSocket opening handshake failed (Sec-WebSocket-Acceptis a SHA1 hash), andHMAC-SHA1and signed cookies were wrong too. 64-bit-Longplatforms — including the macOS CI — were unaffected, which is why it went unnoticed.- Handlers no longer receive truncated request bodies. The server
dispatched at the end of the headers, so any body not in the same 4KB read
arrived cut short, and the leftover bytes were misread as a second request.
It now waits for the whole body (
Content-Length, or the terminating chunk) and answers400for ambiguous framing (RFC 7230 §3.3.3). - Chunked bodies reach handlers decoded and normalized: body dechunked,
Transfer-Encodingremoved,Content-Lengthset, trailers discarded. Connection: closeis honoured whatever its case, and alongside other connection options. The field value was compared verbatim againstclose, so a client sendingCLOSE,Close, orclose, TEkept the socket open and gotConnection: keep-aliveback. The value is now read as a list of case-insensitive comma-separated tokens (RFC 9110 §7.6.1).Connection: closeis honoured when it arrives on its own header line. A client sendingConnection: keep-aliveandConnection: closeas two separate field lines kept the socket open, because only the first line was read. EveryConnectionline is now considered — whatever the case of its field name, soconnection:from an HTTP/2 downgrading proxy counts too — as RFC 9110 §5.3 requires of repeated field lines.
WebSocket.send-now/send-binary-nownow return aBool.truemeans the whole frame was written;falsemeans the connection is dead or the client stopped draining, and the handler should stop sending. Callers that discarded the old()result need anignore.
send-nowno longer tears frames under backpressure. Connection sockets are non-blocking, so when the kernel send buffer filled mid-frame, the old write loop dropped the rest of the frame (and silently discarded whole frames). The remainder desynchronized the client's WebSocket parser: later frames (including server pings) were swallowed as payload bytes of the incomplete frame, the client could never answer a ping again, and the server eventually closed the connection as dead. The write loop now polls for writability onEAGAINand resumes (bounded by a 30s stall timeout), retries onEINTR, and suppressesSIGPIPE(MSG_NOSIGNAL/SO_NOSIGPIPE), so a streaming handler gets natural backpressure and a frame on the wire is always complete.- WebSocket text frames with invalid UTF-8 now close with 1007. Per RFC 6455 §8.1, incoming text payloads (and reassembled fragmented text messages) are validated as UTF-8; malformed data fails the connection with close code 1007 instead of being passed to the handler.
- WebSocket control frames are now fully validated per RFC 6455 §5.2/§5.5. A control frame that is fragmented (FIN=0), carries more than 125 payload bytes, or uses a reserved opcode (0xB–0xF) fails the connection with close code 1002. The 0.6.0 "unknown opcodes close 1002" change reached the reserved data opcodes (0x3–0x7) but not the control range: the ping/pong/close fast path (opcode ≥ 8) still silently consumed 0xB–0xF. The FIN and payload-size checks are new.
-
Cookie module. Request-side helpers for reading cookies by name:
Request.cookies-mapreturns all cookies as aMap String String,Request.cookielooks up a single cookie value. -
Cookie signing with HMAC-SHA1.
Cookie.set-secretconfigures a signing key;Cookie.signandCookie.verifyproduce and validatevalue.signaturetokens.Request.signed-cookiecombines lookup and verification. A top-levelcookie-secretfunction provides cleandefserverintegration. -
Response.set-cookie-with-max-age. Adds aSet-Cookieheader with aMax-Ageattribute (in seconds), complementing the existingset-cookiewhich uses thehttplibrary'sExpires-only format. -
Response.clear-cookie. Expires a cookie by name, settingMax-Age=0and an empty value. -
HMAC-SHA1 module (
HMAC.sha1,HMAC.sha1-hex) implementing RFC 2104 on top of the existing SHA-1 module, for cookie signing.
Response.chunkeduses StringBuf for O(n) chunk encoding. Previously each loop iteration allocated a new string viaString.append, making chunked encoding quadratic in the number of chunks. Now usesStringBuffor amortized O(1) appends.
-
Configurable CORS middleware.
CORS.setupconfigures origin, methods, headers, and max-age.CORS.set-credentials!andCORS.set-expose-headers!control additional headers. A(cors ...)form indefserverregisters both hooks automatically. -
StaticFilemodule for serving static files.StaticFile.handlerandStaticFile.mountserve files from a directory with directory index support (index.html by default, configurable viahandler-with), path traversal prevention, and zero-copy transfer viaResponse.sendfile.
-
Multi-core serving via SO_REUSEPORT.
App.serve-with-workersforksnworker processes, each running its own event loop bound to the same port. The kernel distributes incoming connections across workers. Falls back to single-processservewhennis 1 or less.defservergains a(workers N)form for declarative configuration. -
WebSocket fragment timeout and size limits. Fragment accumulation now tracks per-connection timestamps via
ConnState.ws-frag-start.sweep-idlecloses connections where fragments have been accumulating longer thanApp.ws-frag-ttlseconds (default 30), preventing memory exhaustion from incomplete messages. A separateApp.ws-max-frag-sizeconstant (default 1 MiB) governs the maximum accumulated fragment size, independent ofApp.max-request-size. -
Slow-client timeout (slow loris protection). New connections must complete HTTP headers within
App.header-timeoutseconds (default 15). Connections that trickle bytes without completing the request line and headers are closed, regardless of how frequently data arrives. The per-request timer is tracked viaConnState.read-startand checked insweep-idle. Keep-alive connections reset the timer between requests. -
Request line validation. Before full parsing, the server validates that the HTTP method is a recognized token (GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS, TRACE, CONNECT), that the version is HTTP/1.0 or HTTP/1.1, and that the request line fits within
App.max-header-linebytes (default 8192). Malformed requests receive an immediate 400 Bad Request response.web-valid-method?andweb-validate-request-lineare available as public helpers. -
HEAD method support (RFC 7231). HEAD requests automatically match GET routes and return the same headers (including
Content-Length) but with an empty body. Forsendfileresponses, the file size is computed forContent-Lengthwithout transferring the file data. -
ETag-based conditional responses for static files.
Response.filecomputes anETagfrom the SHA-1 hash of the file contents.Response.sendfilecomputes anETagfrom the file's modification time and size, preserving its zero-copy design by avoiding a full file read. When a request includes anIf-None-Matchheader that matches the response'sETag, the server returns304 Not Modifiedwith no body, eliminating redundant file transfers. -
SHA1.hex-digestcomputes the SHA-1 digest of a byte array and returns it as a 40-character lowercase hex string. -
WebSocket subprotocol negotiation (RFC 6455 §4.2.2).
App.WSPregisters a WebSocket route with a list of supported subprotocols. During the upgrade handshake, the server selects the first client-requested protocol that appears in the route's list and includesSec-WebSocket-Protocolin the 101 response. The negotiated protocol is available to handlers via(WebSocket.protocol ws).App.WSis unchanged and does not negotiate subprotocols.
-
WebSocket
decode-frame64-bit payload length truncation.decode-framesilently ignored the high 4 bytes (offsets 2–5) of 64-bit extended payload lengths (RFC 6455 §5.2), reading only the low 32 bits. A remote peer sending a frame header with non-zero high bytes would cause the decoder to compute a wrong payload length, potentially desynchronizing the frame parser. The decoder now checks the high bytes and rejects frames whose payload length exceeds 32-bitIntrange. -
Response.chunkedhardcoded status text.Response.chunkedalways set the reason phrase to"OK"regardless of the status code passed. A(Response.chunked 404 ...)would produceHTTP/1.1 404 OKon the wire. Now usesStatus.reasonfrom the http library to derive the correct phrase. -
WebSocket RFC 6455 protocol compliance.
- Protocol error paths (unexpected fragments, unknown opcodes) now send a 1002 close frame before disconnecting, as required by RFC 6455 §7.2.
- Unknown opcodes (3–7, 11–15) trigger a 1002 protocol error close instead of being silently skipped (RFC 6455 §5.2).
- Upgrade header matching is now case-insensitive for both the header name and value, per RFC 7230 §3.2 and RFC 6455 §4.2.1.
- Oversized WebSocket messages now send a 1009 (Message Too Big) close frame before disconnecting, as required by RFC 6455 §7.4.1. Previously, the three size-limit code paths (single frame too large, first fragment too large, accumulated fragments too large) closed the connection silently without a close frame.
-
web-finalize-responsepreserves explicit Content-Length. When a response already has aContent-Lengthheader (e.g. HEAD responses), finalization no longer overrides it with the body length.
WSRoutegains aprotocolsfield ((Array String)) listing supported subprotocols. ExistingApp.WScalls pass an empty array for backward compatibility.WebSocketgains aprotocolfield ((Maybe String)) holding the negotiated subprotocol, orNothingif none was negotiated.ConnStategains aws-protocolmap for tracking the negotiated subprotocol per WebSocket connection.web-try-ws-upgradereturn type gains a(Maybe String)for the negotiated protocol.handle-ws-upgradeincludesSec-WebSocket-Protocolin the 101 response when a protocol was negotiated.
-
WebSocket server-initiated ping with dead client detection.
WebSocket.encode-pingencodes a ping frame. The server automatically sends ping frames to idle WebSocket connections (afterApp.ws-ping-intervalseconds, default 30) and closes connections that missApp.ws-max-missed-pongsconsecutive pongs (default 3). Pong responses from clients reset the counter. HTTP connections still use the simple idle timeout. Settingws-ping-intervalto 0 or negative disables pinging.App.ws-ping-actionexposes the pure decision function for testing. -
Multipart form-data parsing.
FormParttype represents a single part from amultipart/form-datarequest, withname, optionalfilename,content-type, andbodyfields.Form.decode-multipartparses a multipart body given a boundary string.Form.decode-multipart-requestextracts the boundary from the Content-Type header and parses automatically.Form.multipart?checks whether a request has multipart content type. Header matching is case-insensitive. -
Binary WebSocket frame support.
WSEvent.Binaryvariant for receiving binary frames (opcode 0x2).WebSocket.encode-binaryencodes byte arrays as binary frames.WebSocket.send-binaryandWebSocket.send-binary-nowmirror their text counterparts for binary data. -
WebSocket message fragmentation reassembly (RFC 6455 §5.4). The server now checks the FIN bit, accumulates continuation frames (opcode 0) in a per-connection buffer, and dispatches the complete message once the final fragment arrives. Interleaved control frames (ping, pong, close) are processed immediately during fragmentation. Protocol violations (e.g. continuation without a start frame) close the connection.
-
WebSocket RFC 6455 compliance checks. The server now validates reserved bits (RSV1-3) on incoming frames and closes the connection with status 1002 if any are set (§5.2). Unmasked client frames are rejected with status 1002 (§5.1). The upgrade handshake validates
Sec-WebSocket-Version: 13and responds with 426 Upgrade Required if the version is missing or wrong (§4.2.1).
- Content-Length no longer sent with chunked encoding.
web-finalize-responsenow skips theContent-Lengthheader whenTransfer-Encodingis already set, fixing an RFC 7230 §3.3.2 violation. log-afterno longer crashes when_startis missing. The after-hook usedMaybe.unsafe-fromon the parsed start time, which panicked iflog-beforewas not registered. Now falls back to0l.- File descriptor leak on
fstatfailure. Whensendfileopened a file butfstatfailed, the fd was stored inConnStatebut never closed. The fd is now closed immediately onfstatfailure.
ConnStategainsws-ping-countandws-last-pingmaps for tracking server-initiated ping state per WebSocket connection.sweep-idlenow takes apollparameter and sends ping frames to idle WebSocket connections instead of closing them immediately.WSFramegainsrsv(Int) andmasked(Bool) fields in addition to the existingfinfield.WSFramegains afinfield (Bool) for the FIN bit.ConnStategainsws-frag-bufsandws-frag-opcodesmaps for tracking in-progress fragmented messages per connection.
- WebSocket support (#11). RFC 6455 upgrade handshake, text frames,
ping/pong, and close frames over the existing non-blocking event loop.
(WS "/path" handler)indefserverregisters a WebSocket route.WSEventsumtype:Connect,(Message String),Close. Handlers receive one event at a time with path params and aWebSockethandle.WebSocket.sendqueues outgoing text frames; the event loop drains the outbox after the handler returns.WebSocket.send-nowwrites a frame directly to the socket, bypassing the outbox. For handlers that block (e.g. LLM token streaming) so each message reaches the client immediately.- Frame codec:
WebSocket.encode-text,encode-pong,encode-close,decode-frame. Supports 7-bit, 16-bit, and 64-bit payload lengths. - Max frame size enforcement: frames exceeding
App.max-request-sizeclose the connection.
- SHA-1 (
SHA1.digest) in pure Carp (~75 lines, Long-based 32-bit ops). Used only for the WebSocket handshake, not for security. - Base64 (
Base64.encode) in pure Carp. No external dependency. - Coverage harness (
test/cov.carp) usingCoverage.carpfrom core.
Apptype gains aws-routesfield ((Array WSRoute)).ConnStategainsws-route-idxandws-paramsmaps for tracking active WebSocket connections.conn-done-writingskips clearing the read buffer for WebSocket connections so partial frames survive across write cycles.conn-cleanupremoves WebSocket state on disconnect.handle-readablechecks for WebSocket upgrade before HTTP routing, and dispatches active WebSocket connections tohandle-ws-readable. The upgrade check parses the request once and passes the result through to avoid a double parse.defserverrecognizes(WS pattern handler)forms.- Extracted
web-routable-pathhelper (was inlined 3 times). - Extracted
ws-flatten-outboxhelper (was inlined 2 times). .gitignorenow excludes gcov artifacts.
- Form body parsing (#4).
Form.decodedecodesapplication/x-www-form-urlencodedbodies into(Map String String), handling+as space and percent-encoding.Form.decode-requestchecks the Content-Type header first. - sendfile() (#9).
Response.sendfileserves files viasendfile(2)(zero-copy kernel-to-socket transfer).App.static-diruses it automatically. RequiresIO.Raw.openandIO.Raw.fstat-sizefrom the Carp core. - Chunked responses (#6).
Response.chunkedencodes an array of chunks withTransfer-Encoding: chunkedframing. ConnStatedeftype for per-connection state. Passed by reference to named helper functions (handle-accept,handle-writable,handle-readable,conn-cleanup,sweep-idle,flush-closed). Thread-safe: eachservecall creates its own state.
- Refactored event loop from a monolithic function into named helpers
operating on
&ConnState. The serve function's main loop is now ~30 lines. - Bumped
socketdependency to 0.1.4 (sendfile-chunk). - Static file serving (
App.static-dir) now usessendfile(2)instead of reading files into memory. - File operations moved from socket library to Carp core (
IO.Raw.open,IO.Raw.close-fd,IO.Raw.fstat-size,IO.Raw.fileno).
- Middleware via
beforeandafterhooks (#1). Before-hooks run before route dispatch and can short-circuit with an early response. After-hooks run after the handler and can modify the response. Both receive the params map, so hooks can annotate it for downstream use. Use(before fn)and(after fn)indefserver. - CORS middleware (#2).
CORS.before-hookhandles OPTIONS preflight,CORS.after-hookaddsAccess-Control-Allow-Origin. Configure withCORS.configure. - Cookie response helpers (#3).
Response.set-cookietakes a fullCookievalue.Response.set-simple-cookietakes a name and value with sensible defaults (Path=/, HttpOnly, SameSite=Lax). - Prefix glob routes (#5). A
*as the last segment of a pattern captures the remaining path./api/*matches/api/foo/barwith* = foo/bar. Works with named captures:/users/:id/*. - Request logging middleware (#7).
log-before/log-afterprint method, path, status code, and response time. Uses thelogpackage, so any backend (simplelog, filelog, custom) works. - Custom error pages (#12).
App.set-error(or(errors fn)indefserver) registers a handler(Fn [&Request Int String] Response)that replaces the default plain-text error responses. - Form body parsing (#4).
Form.decodedecodesapplication/x-www-form-urlencodedbodies into(Map String String), handling+as space and percent-encoding.Form.decode-requestchecks the Content-Type header first. - sendfile() (#9).
Response.sendfileserves files viasendfile(2)(zero-copy kernel-to-socket transfer).App.static-diruses it automatically. - Chunked responses (#6).
Response.chunkedencodes an array of chunks withTransfer-Encoding: chunkedframing.
App.servenow takesbefore-hooksandafter-hooksarrays as extra parameters (betweenappandhost). Pass empty arrays if you have no middleware.defserverrecognizes(before fn),(after fn),(errors fn)forms alongside route forms.- Added
log@0.1.1dependency. - Bumped
socketdependency to 0.1.4 (sendfile, open-file, file-size). - Refactored event loop. Per-connection state moved to module globals,
event handlers extracted into
handle-accept,handle-writable,handle-readable,conn-cleanup,sweep-idle,flush-closed.
Initial release.
- Routing with named captures (
:paramsegments) and wildcard (*) pattern. defservermacro for concise server definitions.- Response helpers:
text,html,json,file,not-found,bad-request,redirect,with-header,with-status,content-type-for. - Static file serving via
App.static-dir/(static dir)with content-type detection and directory-traversal protection. - Non-blocking kqueue/epoll event loop with HTTP keep-alive. Large responses drain across writable events without stalling other connections.
- URL decoding on request paths.
- Request body size limit (
App.max-request-size, default 1 MiB). - Idle connection timeout (
App.idle-timeout, default 60s). - Graceful shutdown on SIGINT/SIGTERM.
- JSON integration via
carpentry-org/json. - Dependencies:
http@0.1.3,socket@0.1.2,json@0.2.1,file@0.1.2.