An HTTP/1.1 client and server in risp, a zero-dependency Lisp in Rust

risp — a zero-dependency Lisp in Rust, not the 2019 tutorial — gains a full HTTP/1.1 client and server written almost entirely in Lisp, with zero external crates.

Source: github.com/kissishka/risp

To be clear about which Lisp this is: risp is a small, homoiconic, std-only Lisp interpreter written in Rust — not the 2019 toy “Risp” blog-post calculator, and not a Common Lisp anything. It has a single Value enum that is both AST and runtime value, three execution engines, and a charter that a default cargo build resolves zero external crates (source on GitHub). This post is about the week it learned to speak HTTP: a real HTTP/1.1 client and server, with chunked decoding and a router, written almost entirely in risp itself — and the small, deliberate amount of Rust that made it possible without breaking the zero-dependency promise.

Two walls: no transport, no text

The honest first answer to “could you build HTTP in this Lisp?” was no, and not for one reason but two. risp’s entire connection to the outside world was three builtins — print, display, newline. No file I/O, no read-line, no sockets, no FFI to bind a C library, no process-spawn to shell out to curl. The interpreter could compute beautifully and tell you the answer, but it could not reach anything. That’s wall one: no transport. You can’t speak HTTP if you can’t open a TCP connection, and there was no primitive and no escape hatch to open one.

Wall two was subtler. Suppose bytes did start arriving. risp’s strings were Rc<str> — immutable and opaque: you could print one or compare two for equality, and that was the entire API. No substring, no string-index, no way to turn a string into bytes or back, and no byte-buffer type at all. So even handed a populated socket you couldn’t assemble a request line or take a response apart. Two walls, each sufficient on its own to block HTTP. The feature wasn’t missing — the substrate was.

The lazy strategy: smallest Rust surface, protocol in Lisp

The move that made HTTP tractable is the same instinct that puts map and filter in a risp prelude rather than in Rust: add the smallest possible native surface, then write everything above it in the language itself. The new substrate was two Value variants — Bytes(Rc<[u8]>) and Tcp — plus about twenty plain fn(&[Value]) -> RispResult builtins: six sockets (tcp-connect, tcp-read, tcp-write, tcp-close, tcp-listen, tcp-accept), a handful of bytes operations (bytes-ref, subbytes, bytes-concat, bytes-index, bytes->string, string->bytes), and the string operations a parser actually needs (substring, string-index, string->number, string-downcase).

The byte buffer is the foundation, and Rc<[u8]> is chosen for the same reason every other payload sits behind an Rc: one indirection and an O(1) clone.

pub enum Value {
    // ... Nil, Bool, Int, Float, Str, Symbol, Pair, Builtin, Closure, Macro, Compiled ...

    /// An immutable byte buffer (`Rc<[u8]>` gives one indirection and O(1) clone).
    Bytes(Rc<[u8]>),
    /// A TCP socket handle (stream or listener), opaque and self-evaluating.
    Tcp(Rc<TcpKind>),
}

pub enum TcpKind {
    Stream(std::net::TcpStream),
    Listener(std::net::TcpListener),
}

The whole charter rides on one fact: std::net is part of std. Sockets cost zero dependencies — the same offline cargo build that resolved no crates before still resolves none after. (TLS is the one capability that genuinely can’t be std-only, since there is no std::tls; it lives behind a --features tls flag, exactly mirroring how risp’s Cranelift JIT is quarantined, so the default artifact stays pure. That story — and the broader hardening work the HTTP stack needed — is covered in hardening a hobby interpreter’s HTTP stack.)

Adding a Value variant is a finite, compiler-checked edit

Adding a variant to the central Value enum sounds terrifying across three engines — a tree-walker, a register-cached bytecode VM, and an optional JIT. In practice it’s a disciplined, bounded edit. There are exactly four sites in value.rs you must touch by hand: the enum itself, type_name, the shallow-equality function risp_eq, and write_atom (the printer).

fn type_name(&self) -> &'static str {
    match self {
        // ...
        Value::Bytes(_) => "bytes",
        Value::Tcp(_) => "tcp",
    }
}

Everything else is the compiler’s problem. This is the underrated joy of exhaustive match in Rust: you don’t have to remember where a new variant needs handling. You add it to the enum, run cargo build, and the exhaustiveness checker hands you a punch-list of every site across the tree-walker, the VM, the bytecode layer, and the JIT that now needs a decision. “Build to green” stops being a hope and becomes a hard correctness gate. The iterative Drop that risp uses to reclaim million-deep structures needed no change at all, because Bytes and Tcp hold no nested Values — the existing catch-all reclaims them as leaves.

All the protocol lives in http.lisp

With the substrate in place, the genuinely fiddly part — request framing, status-line parsing, header alists, Content-Length and chunked decoding — was written in risp, in a file called http.lisp that’s include_str!’d and evaluated at interpreter startup right after prelude.lisp:

const PRELUDE: &str = include_str!("prelude.lisp");
self.run_source(PRELUDE).expect("prelude.lisp must evaluate cleanly");
const HTTP: &str = include_str!("http.lisp");
self.run_source(HTTP).expect("http.lisp must evaluate cleanly");

The order matters: the prelude installs fold/map/filter, and http.lisp is built on top of them. The payoff of this split is that the Rust side stays tiny and auditable while the protocol logic lives in a high-level language where it’s easy to read and, crucially, to change — and faster than a native-compiling Lisp in some real measurements. Every line of protocol kept out of Rust is a line that doesn’t need a recompile to fix. URL parsing, case-folded header lookup, the read-until-delimiter loop — all of it is readable Lisp.

A GET request, satisfyingly, is just string concatenation written through string-append, then a socket write of the bytes. Here is the real request-builder from http.lisp:

(def http--build-request
  (lambda (method path host extra body)
    (let ((header-lines
            (map (lambda (kv) (string-append (car kv) ": " (cdr kv) "\r\n")) extra)))
      (let ((clen (if (> (string-length body) 0)
                      (string-append "Content-Length: "
                                     (number->string (bytes-length (string->bytes body)))
                                     "\r\n")
                      "")))
        (string-append
          method " " path " HTTP/1.1\r\n"
          "Host: " host "\r\n"
          "Connection: close\r\n"
          (http--apply-concat header-lines)
          clen
          "\r\n"
          body)))))

That string then goes out as (tcp-write sock (string->bytes req)). Notice the \r\ns. HTTP is a CRLF-framed protocol, and getting those carriage returns onto the wire turned out to be the project’s one genuine war story — the lexer didn’t recognize \r as a string escape and silently lexed "\r" to a literal r, a dormant bug that no test had ever exercised because, until you’re speaking a CRLF protocol, who types \r into a string? A round-trip integration test caught in seconds what years of unit tests never thought to assert.

A response is an alist; chunked decoding is a Lisp loop

On the way back, a response is parsed into an alist and read with five accessors — http-status, http-reason, http-body, http-header, and http-final-url:

(def resp (http-get "http://example.com/"))
(http-status resp)                   ; => 200
(http-reason resp)                   ; => "OK"
(http-body resp)                     ; => "<!doctype html>…"
(http-header resp "content-type")    ; => "text/html; charset=UTF-8"  (case-insensitive)

The interesting framing case is Transfer-Encoding: chunked. Each chunk is a hex size line, then that many data bytes, then a CRLF, repeated until a zero-size chunk ends the body. http--decode-chunked parses the sizes with parse-hex and assembles the body over the O(n) socket primitives, ignoring trailers after the final chunk:

(def http--decode-chunked
  (lambda (sock buf max out total)
    (let ((b (http--ensure-crlf sock buf)))
      (let ((nl (bytes-index b "\r\n")))
        (if (< nl 0)
            (error "malformed chunked body: no size line") ; truncated
            (let ((size (parse-hex (bytes->string (subbytes b 0 nl)))))
              (if (= size 0)
                  (bytes-concat (reverse out)) ; last chunk; done
                  ;; ... read `size` data bytes + CRLF, recurse with a bigger `out`
                  )))))))

Two things make this safe rather than naïve. The size-line parse leans on parse-hex, which stops at the first non-hex character, so a chunk-size line carrying a ;ext parameter or a trailing \r still parses correctly. And the decoder threads a running total against max, raising "response body exceeded max-body" the moment a hostile server’s chunk sizes would push the assembled body past the cap. The header scans are deliberately naïve O(n·m) string searches — but they only ever run over header-sized data; the body framing is the part that has to be O(n), and it is, because the actual read loops (tcp-read-until, tcp-read-exact) live in Rust where they scan only newly-arrived bytes instead of re-concatenating the whole buffer per chunk.

The server is single-threaded by the interpreter’s nature

The server side — http-serve, http-serve-once, and make-router — is shaped entirely by the fact that risp is single-threaded. There is no thread pool to hand a connection to, so the accept loop serves exactly one connection at a time and every response carries Connection: close; there is no keep-alive. This is a hard ceiling of the single-threaded interpreter, and the docs name it as a design choice rather than an oversight. http-serve is literally a bind, then a tail-recursive accept-and-serve loop:

(def http-serve
  (lambda (port handler)
    (let ((listener (tcp-listen "127.0.0.1" port)))
      (def loop
        (lambda ()
          (let ((sock (tcp-accept listener)))
            (http--serve-connection sock handler)
            (loop))))
      (loop))))

Routing is just as small. make-router takes a list of (method path handler) triples and returns a handler that matches on method plus the exact path, with an automatic 404 for anything that doesn’t match:

(def make-router
  (lambda (routes)
    (lambda (req)
      (let ((method (http--get req 'method ""))
            (path (http--get req 'path "/")))
        (let ((route (http--match-route routes method path)))
          (if (null? route)
              (list (cons 'status 404)
                    (cons 'headers (list (cons "Content-Type" "text/plain")))
                    (cons 'body "Not Found"))
              ;; route is (method path handler); call the handler with the req.
              ((car (cdr (cdr route))) req)))))))

Exact-match only — no /users/:id params, no wildcards, no trailing-slash normalization in v1. A handler returns either a plain string (which becomes a 200 text/plain) or a full response alist with status, headers, and body for any status code. The whole per-connection path is wrapped in error-guard, so a malformed client yields a best-effort 400 or 500 instead of throwing out of the accept loop and killing the server.

Testing without the network

If the interpreter internals are new to you, learn Rust by reading a Lisp interpreter is a good primer on how the engine is structured before diving into the networking layer.

A networking library you can only test against the live internet is a flaky one, so the tests are hermetic — no external hosts, deterministic, safe in CI. For the client that’s easy: spin up a canned server on a background std::thread bound to 127.0.0.1:0 (so the OS hands you a free port), have it accept one connection and write a fixed HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello, then drive risp’s http-get against it and assert on the parsed status and body.

The server is the harder direction, because it runs straight into the single-threaded limit: you can’t have risp’s http-serve-once block on accept while also driving a client from the same interpreter. The trick is to pick a free port by binding a probe listener and immediately dropping it, spawn a Rust-thread client that connects with retry — looping briefly until the connect succeeds — and let risp bind that same port and serve. The retry-connect loop closes the tiny race between dropping the probe and risp re-binding. It keeps the whole thing single-interpreter and deterministic.

Frequently asked questions

Can you write an HTTP server without any dependencies?
Yes. risp's HTTP/1.1 client and server are written almost entirely in Lisp, on top of only a Bytes value type and roughly 20 socket and string builtins added to its Rust core. Because std::net is part of Rust's standard library, a default cargo build still resolves zero external crates.
How much Rust did risp need to add to support HTTP?
Two Value enum variants (Bytes and Tcp) touching exactly four sites in value.rs, plus about 20 plain fn(&[Value]) -> RispResult builtins: six TCP sockets, a handful of bytes operations, and the string operations a parser needs. All the protocol logic — request framing, header parsing, chunked decoding — lives in http.lisp, not Rust.
Is the risp HTTP server concurrent?
No. The server is single-threaded and serves exactly one connection at a time, sending Connection: close on every response with no keep-alive. That is a hard ceiling of the single-threaded interpreter, acknowledged as a design choice rather than an oversight.

← All posts