(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-serverprocedure

HTTP Server Record

http-server?procedure

Test if a value is a http-server struct.

Get the handler field of a http-server struct.

Get the port field of a http-server struct.

Get the host field of a http-server struct.

Get the backlog field of a http-server struct.

Get the timeout field of a http-server struct.

Get the max-request-size field of a http-server struct.

Get the gzip field of a http-server struct.

Get the socket field of a http-server struct.

Get the running field of a http-server struct.

Get the clients field of a http-server struct.

Get the stop-box field of a http-server struct.

The 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.

The 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-clientprocedure

Client connection state

http-client?procedure

Test if a value is a http-client struct.

Get the socket field of a http-client struct.

Get the buffer field of a http-client struct.

Get the headers-complete field of a http-client struct.

Get the content-length field of a http-client struct.

Get the body-bytes-read field of a http-client struct.

Get the request field of a http-client struct.

Get the response-started field of a http-client struct.

Get the created-at field of a http-client struct.

Create 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's Accept-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.

Check 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)  ; => #f

Start 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 the guarded server loop, restarting on any escaping exception. Assumes an async scheduler is already active (see http-server-start).

Stop 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).

Close 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.

Process one round of I/O (for custom event loops).

How 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-loopprocedure

Main 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 connections using socket-select

Accept a new client connection

Process clients that have readable data

Handle data from a client Returns updated client or #f to remove

Try to parse request from buffer and handle it Returns updated client, or #f to remove client

Continue reading body data

Finalize and handle the complete request

Parse just the headers, returning (method path query version . headers-dict) or #f on error

Find end of headers (rnrn)

Parse 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.

Normalize 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 lines into dict with lowercase keyword keys

Handle a complete request

send-responseprocedure

Send 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 an error response

http-servevariable

Convenience 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).