(sigil http server)
HTTP Server Implementation
Provides the HTTP server with support for:
- Non-blocking I/O via coroutines
- Multiple concurrent connections
- Streaming responses
Exports
http-serverprocedureHTTP Server Record
http-server?procedureTest if a value is a http-server struct.
http-server-handlerprocedureGet the handler field of a http-server struct.
http-server-portprocedureGet the port field of a http-server struct.
http-server-hostprocedureGet the host field of a http-server struct.
http-server-backlogprocedureGet the backlog field of a http-server struct.
http-server-timeoutprocedureGet the timeout field of a http-server struct.
http-server-max-request-sizeprocedureGet the max-request-size field of a http-server struct.
http-server-gzipprocedureGet the gzip field of a http-server struct.
http-server-socketprocedureGet the socket field of a http-server struct.
http-server-runningprocedureGet the running field of a http-server struct.
http-server-clientsprocedureGet the clients field of a http-server struct.
http-server-stop-boxprocedureGet the stop-box field of a http-server struct.
server-stop-requested?procedureThe server record is immutable: http-server-start and the serve loop derive updated COPIES, so a caller that holds the record it passed to http-server-start never shares state with the loop's current record — a running field flip on one copy is invisible to the others. The stop signal therefore lives in a one-slot vector created once per make-http-server and carried BY REFERENCE through every derived copy: http-server-stop sets it, and the loop (which wakes at least every drain-sweep-interval-ms now that its waits are deadline-bounded) checks it and shuts down. #f (no box) keeps records constructed directly usable — stop then only affects the record it was called on.
%http-client--typevariableThe server record is immutable: http-server-start and the serve loop derive updated COPIES, so a caller that holds the record it passed to http-server-start never shares state with the loop's current record — a running field flip on one copy is invisible to the others. The stop signal therefore lives in a one-slot vector created once per make-http-server and carried BY REFERENCE through every derived copy: http-server-stop sets it, and the loop (which wakes at least every drain-sweep-interval-ms now that its waits are deadline-bounded) checks it and shuts down. #f (no box) keeps records constructed directly usable — stop then only affects the record it was called on.
http-clientprocedureClient connection state
http-client?procedureTest if a value is a http-client struct.
http-client-socketprocedureGet the socket field of a http-client struct.
http-client-bufferprocedureGet the buffer field of a http-client struct.
http-client-headers-completeprocedureGet the headers-complete field of a http-client struct.
http-client-content-lengthprocedureGet the content-length field of a http-client struct.
http-client-body-bytes-readprocedureGet the body-bytes-read field of a http-client struct.
http-client-requestprocedureGet the request field of a http-client struct.
http-client-response-startedprocedureGet the response-started field of a http-client struct.
http-client-created-atprocedureGet the created-at field of a http-client struct.
make-http-servervariableCreate a new HTTP server.
The handler is called for each request and should return a response.
(define server
(make-http-server
(lambda (req) (response body: "Hello!"))
port: 3000
host: "127.0.0.1"))
(http-server-start server)Options:
port:- Port to listen on (default: 8080)host:- Address to bind to (default: "0.0.0.0")backlog:- Listen queue size (default: 128)timeout:- Request timeout in milliseconds (default: 30000)max-request-size:- Maximum request body size in bytes (default: 10MB)gzip:- Opt-in automatic gzip. When #t, non-streaming responses are gzip-encoded per the request'sAccept-Encoding(only when the client accepts gzip, the type is compressible, and the body is large enough). Default #f — the default server behaves exactly as before.
http-server-running?procedureCheck if server is currently running.
Returns #t if the server has been started and not stopped.
(define srv (make-http-server handler))
(http-server-running? srv) ; => #fhttp-server-startprocedureStart the server (blocking - runs event loop).
Binds to the configured host and port, then enters the event loop. Does not return until the server is stopped.
Streaming responses (http-response/file, SSE) spawn a goroutine via go, which requires a live async scheduler. To make streaming work uniformly whether or not the caller wrapped the call in with-async, http-server-start establishes its OWN scheduler (via with-async) when none is currently active. When a scheduler is already active — e.g. a caller ran (go (http-server-start …)) inside with-async — that scheduler is reused so we don't nest a second one and break the caller's concurrent goroutines. Either way the serve loop cooperates with streaming goroutines through the same scheduler.
An outer guard wraps server-loop so that any exception escaping the inner per-operation guards (e.g. from await-readable, scheduler tick internals, or other code paths not covered by process-connections guards) is caught, logged, and the loop is restarted with the current server state. This prevents a single runtime error in the loop machinery from killing the whole process — the cost is a log line and one restart tick rather than a crash + external supervisor respawn.
run-server-resume-loopprocedureRun the guarded server loop, restarting on any escaping exception. Assumes an async scheduler is already active (see http-server-start).
http-server-stopprocedureStop the server gracefully.
Signals the serve loop through the shared stop box — the loop wakes within drain-sweep-interval-ms, closes the sockets IT owns, and http-server-start returns. This works from a sibling task holding ANY record derived from the same make-http-server (including the pre-start one), which is the goroutine-mode stop path. Sockets reachable from THIS record are also closed directly (the embedded/tick mode, where the caller drives the loop and holds the current record).
server-loop-shutdownprocedureClose the sockets the serve loop currently owns and return the stopped record. Runs inside the loop when the shared stop signal is seen; tolerates sockets an embedded-mode http-server-stop already closed.
http-server-tickprocedureProcess one round of I/O (for custom event loops).
drain-sweep-interval-msvariableHow long the drain loop may sleep between connection sweeps while clients are connected but idle (ms). This bounds how late a request timeout (408 reaping) can fire; readable data always wakes the loop immediately regardless of this value. Servers configured with a sub-second timeout: sweep at that finer interval instead (see drain-sweep-ms) so reaping latency stays proportional.
server-loopprocedureMain server loop
Cooperates with other tasks (including streaming-response goroutines) via await-readable/await-readable-any, which yield to the async scheduler that http-server-start always establishes before entering the loop (it reuses the caller's scheduler, or self-installs one via with-async). Both degrade to a blocking socket-select on their own if ever run without a scheduler, so the loop stays correct either way.
While clients are connected, the drain suspends on the WHOLE socket set (listen + clients) through the scheduler rather than busy-spinning a short-timeout native select. The busy-spin variant kept this task perpetually runnable, and the scheduler only polls socket io-waiters when its run-queue is empty — so one connected client (e.g. a browser holding an SSE stream) starved every OTHER task's socket I/O in the process (first reads on outbound connections were delayed for as long as the client stayed connected). Suspending folds this loop's sockets into the scheduler's own select, so sibling io-waiters are serviced promptly. The wait is deadline-bounded (drain-sweep-interval-ms) so idle connections are still swept for request timeouts (408).
process-connectionsprocedureProcess connections using socket-select
accept-new-clientprocedureAccept a new client connection
process-readable-clientsprocedureProcess clients that have readable data
handle-client-dataprocedureHandle data from a client Returns updated client or #f to remove
try-parse-and-handleprocedureTry to parse request from buffer and handle it Returns updated client, or #f to remove client
initialize-body-readprocedureContinue reading body data
finalize-requestprocedureFinalize and handle the complete request
parse-headers-onlyprocedureParse just the headers, returning (method path query version . headers-dict) or #f on error
find-header-endprocedureFind end of headers (rnrn)
http-parse-request-bufferprocedureParse a complete HTTP request buffer using the same byte-counted framing path as the live server. Returns #f for incomplete or invalid requests. This is intentionally small and useful for regression tests around Content-Length byte accounting.
buffer->bytevectorprocedureNormalize request buffers to bytevectors so Content-Length is compared against bytes, not decoded string characters. HTTP headers are ASCII, so Latin-1 conversion preserves old string handler behavior.
parse-header-linesprocedureParse header lines into dict with lowercase keyword keys
handle-requestprocedureHandle a complete request
send-responseprocedureSend a response to socket.
Uses a write loop to handle partial writes from non-blocking sockets. socket-write may return fewer bytes than requested, so we loop until the full data is sent. Converts strings to bytevectors for correct byte-offset tracking (socket-write returns bytes written, not characters).
send-error-responseprocedureSend an error response
http-servevariableConvenience function: create and start server in one call.
This is a blocking call — it runs the event loop until the server is stopped. It establishes its own async scheduler internally, so streaming responses (http-response/file, http-response/sse) work without wrapping the call in with-async:
;; Plain response:
(http-serve (lambda (req) (response body: "Hello!")) port: 8080)
;; Streaming a file works too — no with-async needed:
(http-serve (lambda (req) (http-response/file "/path/to/image.png"))
port: 8080)If you already run inside with-async (e.g. you spawn the server with (go (http-server-start …)) alongside other goroutines), that scheduler is reused — no nested scheduler is created.
Pass gzip: #t to opt into automatic gzip of non-streaming responses per the request's Accept-Encoding (off by default).