lthms' avatar, a hand drawing looking person, wearing a headset, close to a window on a raining night
Thomas Letan
lthms · he/him

Did you come across something which caught your attention? Don’t hesitate to shoot me an email in my public inbox.

 Published on  Modified on

Serving This Article from RAM for Fun and No Real Benefit

 ocaml  meta

In 2022, Xe Iaso published a transcript of their talk on how their website was working at the time . In a nutshell, their approach consisted of a server preprocessing the website from its source at startup, then serving its contents from memory. If you have not already, I can only encourage you to read the article or watch the talk, as the story they tell is very interesting. For me personally, it sparked a question: what if, instead of preprocessing the website at startup, one decided to embed the already preprocessed website within the program of the HTTP server tasked to serve it?

Fast-forward today, and this question has finally been answered. The webpage you are currently reading has been served to you by an ad hoc HTTP server built with Dream , whose binary is the only file I need to push to my server to deploy the latest version of my website. I have actually deployed it, and it’s been serving the contents of this website for more than a week now.

What did I learn from this fun, little experiment? Basically, that this approach changes nothing, as far as Lighthouse  and my monitoring is concerned. I couldn’t find any meaningful differences between a static website served by Nginx, a piece of software with thousands and thousands of engineering work behind it, and my little toy web server pieced together in an hour or so. Still. It was fun, so why not write about it?

This article is a kind of experience report. I’ll dive into what I have done to turn my website into a single, static binary. Not only does it mean writing some OCaml, which is always fun, but it also requires understanding a little some key HTTP headers, as well as using Docker to build easily deployable binaries. All in all, I hope it will be an interesting read for the curious minds.

Embedding My Website in a Binary

Not much had changed much since I stopped using cleopatra to generate this website, and the article I published in 2023 still stands. In a nutshell, I work in the site/ directory, and soupault  generates my website in the out/~lthms directory, thanks to a collection of built-in and ad hoc pluginsFor instance, Markdown footnotes are turned into side notes with a soupault plugin. . To deploy the website, I was relying on rsync to sync the contents of the out/~lthms directory with the directory statically served by a Nginx instance on my personal server.

The first step of my little toy project is to actually embed the output of soupault into an OCaml program.

That’s where ocaml-crunch  comes in handy. It is a MirageOS  project, whose only job is to generate an OCaml module from a file system directory. It is straightforward to use it from Dune.

; file: out/dune
(rule
 (target website_content.ml)
 (deps (source_tree ~lthms))
 (action
  (run ocaml-crunch -m plain -o %{target} -s ~lthms)))

This snippet generates the website_content.ml module, which we can then expose through a library with the library stanza.

; file: out/dune
(library
 (name website_content))

And we are basically done. Excluding an Internal module, the signature of Website_content is pretty straightforward.

val file_list : string list
val read : string -> string option
val hash : string -> string option
val size : string -> int option

Serving the content with Dream

Dream  is a cool project, and provides a straightforward API that we can leverage to turn our list of in-memory files into an HTTP server.

Naive Approach

Our goal now is to create a Dream.handler for each item in file_list. Done naively (as was my first attempt), it gives you something of the form:

let make_handler ~content path =
  Dream.get path (fun req ->
    Lwt.return (Dream.response content)))

Which we can use to build the main route we will then pass to Dream.router.

let website_route =
  Dream.scope "~lthms" []
  @@ List.map
       (fun path ->
         let content = Option.get (Website_content.read path) in
         make_handler ~content path)
       Website_content.file_list

With this approach, we build our handlers once, and then the lookup is done by Dream’s router. It could be an interesting experiment to see if doing the lookup ourselves is more performant (since Dream’s router is very generic, while in our case we don’t really need to parse anything). I remember Xe routing is basically going through a linked list, which seems strange at first, but works very well in practice because they have ordered said list with the most recent articles up front, and everybody comes to their website to read the latest article anyway.

It does not take an extensive QA process to figure out that this approach is far from being enough. To name a few things:

This is a gentle reminder of all the things Nginx can do for you with very little configuration.

Handling index.html Synonyms

This one is rather simple. For files named index.html, we need 3 handers, not just one. We can achieve this with an additional helper make_handler_remove_suffix.

let make_handler_remove_suffix ~content path suffix
    =
  if String.ends_with ~suffix path then
    let alt_path =
      String.sub path 0 (String.length path - String.length suffix)
    in
    [ make_handler ~content alt_path ]
  else []

Updating the website_route definition to use make_handler_remove_suffix is quite easy as well.

 let website_route =
   Dream.scope "~lthms" []
-  @@ List.map
+  @@ List.concat_map
        (fun path ->
          let content = Option.get (Website_content.read path) in
-         make_handler ~content path)
+         if path = "index.html" then
+           (* Special case to deal with "index.html" which needs to be
+              recognized by the route "/" *)
+           [
+             make_handler ~content "/";
+             make_handler ~content "";
+             make_handler ~content "index.html";
+           ]
+         else
+           make_handler_remove_suffix ~content path
+             "/index.html"
+           @ make_handler_remove_suffix ~content
+               path "index.html"
+           @ [ make_handler ~content path ])
        Website_content.file_list

With that, https://soap.coffee/~lthms/posts/index.html returns the same pages as https://soap.coffee/~lthms/posts or https://soap.coffee/~lthms/posts. Check.

Supporting Content-Type

Content-Type is an HTTP header which is used by the receiver of the HTTP message (whether it is a request or a response) to interpret its content.

For instance, when building a RPC API, Content-Type is used by the server to know how to parse the request body (Content-Type: application/json or Content-Type: application/octet-stream being two popular choices, for JSON or binary encoding, respectively).

In our case, the Content-Type header is used by the HTTP server to communicate the nature of the content to browsers. For my website, I can just use the file extensions to infer the correct header to set. First, we list the extensions that are actually used.

let content_types =
  [
    (".html", "text/html");
    (".css", "text/css");
    (".xml", "text/xml");
    (".png", "image/png");
    (".svg", "image/svg+xml");
    (".gz", "application/gzip");
    (".pub", "text/plain");
  ]

A header in Dream is encoded as a string * string value, with the first string being the header name and the second being the header value.

let content_type_header path =
  List.filter_map
    (fun (ext, content_type) ->
      if String.ends_with ~suffix:ext path then
        Some ("Content-Type", content_type)
      else None)
    content_types
  |> assert_f
       ~error_msg:Format.(sprintf "Unsupported file type %s" path)
       (( <> ) [])

with assert_f being defined as follows.

let assert_f ~error_msg f v =
  if f v then v else failwith error_msg

assert_f is used to enforce that I don’t deploy a website which contains route lacking a Content-Type header. For instance, if I remove the "html" entry of the content_type list, I get this error when I try to execute the server.

Fatal error: exception Failure("Unsupported file type index.html")

This is because the headers are only computed once, when each route are defined. This is a key principle of this project: compute once, serve many timeI would love to get a compilation error instead (considering there are no runtime values involved), but have not looked into this just yet. .

 let website_route =
   Dream.scope "~lthms" []
   @@ List.concat_map
        (fun path ->
          let content = Option.get (Website_content.read path) in
+         let headers = content_type_header path in
          if path = "index.html" then
            (* Special case to deal with "index.html" which needs to be
               recognized by the route "/" *)
            [
-             make_handler ~content "/";
-             make_handler ~content "";
-             make_handler ~content "index.html";
+             make_handler ~headers ~content "/";
+             make_handler ~headers ~content "";
+             make_handler ~headers ~content "index.html";
            ]
          else
-           make_handler_remove_suffix ~content path
+           make_handler_remove_suffix ~headers ~content path
              "/index.html"
-           @ make_handler_remove_suffix ~content
+           @ make_handler_remove_suffix ~headers ~content
                path "index.html"
-           @ [ make_handler ~content path ])
+           @ [ make_handler ~headers ~content path ])
        Website_content.file_list

(The changes in make_handler and make_handler_remove_prefix are left as an exercise to enthusiast readers)

Compressing if Requested

Nowadays, computations are cheap, while downloading data costs time (and sometimes money). As a consequence, it is often a good idea for a server to compress a large HTTP response, and browsers do ask them to do so, by setting the Accept-Encoding  header of their requests.

The value of the Accept-Encoding header is a comma-separated list of supported compression algorithms, optionally ordered with a priority value q.

For instance, Accept-Encoding: gzip;q=0.5, deflate;q=0.3, identity tells you that the browser supports three encoding methods: gzip, deflate and identity (no compression), and the browser prefers gzip over deflate. Besides, the request can provide several Accept-Encoding headers instead of just one, so we can have

Accept-Encoding: gzip;q=0.5
Accept-Encoding: deflate;q=0.3
Accept-Encoding: identity

The String module provides everything we need to check if a browser supports gzip as an encoding methodSpoiler: they do. I was even wondering at some point if I could just always return GZIP-compressed values, ignoring the Accept-Encoding header altogether. If you do that, though, curl becomes annoying to use (it does not uncompress the response automatically, and instead complains about being about to write binary to the standard output). .

(* For [method(; q=val)?], returns [method], except if
   [q=0]. *)
let to_directive str =
  match String.split_on_char ';' str |> List.map String.trim with
  | [ x ] -> Some x
  | [ x; y ] -> (
      match String.split_on_char '=' y |> List.map String.trim with
      | [ "q"; "0" ] -> None
      | [ "q"; _ ] -> Some x
      | _ -> None)
  | _ -> None

(* [contains ~value:v header] returns [true] if [v] is a
   supported method listed in [header]. *)
let contains ~value header =
  String.split_on_char ',' header
  |> List.to_seq |> Seq.map String.trim
  |> Seq.filter_map to_directive
  |> Seq.exists (( = ) value)

We use contains to tell us if we can return a compressed response, which leaves us with one final question: how to compress said response?

The OCaml ecosystem seems to have picked camlzip  library when GZIP is involvedYou know it is a legitimate OCaml library when one of the top-level modules is not documented at all . . What is surprising with this library is that it does not support in-memory compression: the functions expect channels, not bytes. That is quite annoying, because we are specifically doing this not to use files.

The Internet is helpful here, and quickly suggests using pipes. It works when you remember –or figure out– that pipes are a blocking mechanism: one does not just write a buffer of arbitrary size in a pipe, because after something like 4KBytes, writing becomes blocking until a read happens to free some space. That’s not a big problem: we can read and write concurrently to the pipe using threads, and OCaml 5 makes it quite easy to do so with the Domain module.

let gzip content =
  let inc, ouc = Unix.pipe () in
  let ouc = Gzip.open_out_chan ~level:6 Unix.(out_channel_of_descr ouc) in
  let _writer =
    Domain.spawn (fun () ->
        Gzip.output_substring ouc content 0 String.(length content);
        Gzip.close_out ouc)
  in
  let res = In_channel.input_all Unix.(in_channel_of_descr inc) in
  Unix.close inc;
  res

Caution

As rightfully pointed out  by Daniel Bünzli , the gzip function presented in this article is full of shortcomings. To quote the message, the function can leak fds in case of errors and domains are not meant to be used that way (it’s rather spawn one long running domain per CPU you have). It’s not necessarily more complicated to correct it to use Fun.protect invocations to make sure all your fds get closed even if the function blows up and use Thread.create so that the netizens cut and paste correct code.

In my opinion, this implementation is “good enough” for my use case, which is compressing arbitrary strings before the HTTP server is even started. If it were to be called in the handlers themselves, then definitely, it would not be suitable.

Keep that in mind if you want to borrow this code.

We know how to decide whether to compress or not, and how to compress. The next step is to modify make_handler accordingly.

-let make_handler ~headers ~content path =
+let make_handler ~headers ~gzip_content ~content path =
+  let gzip_headers = ("Content-Encoding", "gzip") :: headers in
   Dream.get path (fun req ->
-    Lwt.return (Dream.response content)))
+      match Dream.headers req "Accept-Encoding" with
+      | accepted_encodings
+        when List.exists (contains ~value:"gzip") accepted_encodings ->
+          Lwt.return @@ Dream.response ~headers:gzip_headers gzip_content
+      | _ -> Lwt.return @@ Dream.response ~headers content)

gzip_content is computed only once (using our gzip function), and passed to make_handler. This way, the only computation the handler needs to do is to “parse” Accept-Encoding.

Caching

Compressing a page to reduce the number of bytes a browser needs to download is fine. Letting the browser know it does not need to download anything because it's previous version is still accurate is better.

This is achieved through two complementary mechanisms: entity tags  (ETag)My first encounter with entity tags was around the time GDPR was a hot topic, because you can use them as a cheap replacement for cookies to track your users . I remained at the surface level at the time, it was fun learning more about them through this little project. , and cache policies  (Cache-Control).

Entity tags are used to identify a resource, and are expected to change every time the resource is updated. The general workflow goes like this: the first time a browser requests https://soap.coffee/~lthms/index.html, it caches the result along with the value of the ETag header. The next time it needs the page, it adds the header If-None-Match, with the ETag as its value. For such requests, the server is expected to return an empty response with HTTP code 304 (Not Modified).

I decided to use the sha256 hash algorithm to compute the entity tag of each resource of my website. The sha  OCaml library looked like a good enough candidate.

(* in `website_route` *)
let etag = Sha256.(string content |> to_hex) in

Interestingly, one question I had to answer was whether the entity tag of a page needed to be different whether it was compressed or not. Internet almost unanimously answered yes. So be it. We just need to keep in mind ETag values are expected to be surrounded by quotes, and we are good to go. It is just a matter of suffixing the ETag with +gzip in the compressed case.

-let make_handler ~headers ~gzip_content ~content path =
-  let gzip_headers = ("Content-Encoding", "gzip") :: headers in
+let make_handler ~headers ~etag ~gzip_content ~content path =
+  let etag_gzip = Format.sprintf "\"%s+gzip\"" etag in
+  let etag = Format.sprintf "\"%s\"" etag in
+  let gzip_headers =
+    ("Content-Encoding", "gzip") :: ("ETag", etag_gzip) :: headers in
+  let identity_headers = ("ETag", etag) :: headers in
   Dream.get path (fun req ->
-      match Dream.headers req "Accept-Encoding" with
-      | accepted_encodings
-        when List.exists (contains ~value:"gzip") accepted_encodings ->
-          Lwt.return @@ Dream.response ~headers:gzip_headers gzip_content
-      | _ -> Lwt.return @@ Dream.response ~headers content)
+      match Dream.headers req "If-None-Match" with
+      | [ previous_etag ] when previous_etag = etag || previous_etag = etag_gzip
+        ->
+          Lwt.return
+          @@ Dream.response
+               ~headers:(("ETag", previous_etag) :: headers)
+               ~code:304 ""
+      | _ -> (
+          match Dream.headers req "Accept-Encoding" with
+          | accepted_encodings
+            when List.exists (contains ~value:"gzip") accepted_encodings ->
+              Lwt.return @@ Dream.response ~headers:gzip_headers gzip_content
+          | _ -> Lwt.return @@ Dream.response ~headers:identity_headers content))

Entity tags are useful, but you still need the browser to make an HTTP request every single time you visit the website. By setting a cache policy, we can remove even remove the need for this request most of the time. The Cache-Control header is used to set a number of parameters, including the max-age value (in seconds).

In my Nginx configuration, I had set max-age to a year for images. I did the same thing here. Besides, I decided to set max-age for other resources to 5 minutes. This seems like a good compromise: since my website does not change very often, it is very unlikely that you happen to visit it when I publish new content. Setting a 5-minute cache policy should let my readers download each resource only once, yet get the freshest version at their next visitDealing with the Cache-Control header is basically the same exercise as setting the correct Content-Type header, and this article is already long enough, which is why there is no diff or snippet in this section. .

And with this, we are done. We get a standalone library to server our website in a browser-friendly manner, which I can theoretically use to replace my current Nginx-powered setup. Although… is it that simple?

Building and Deploying the Website

Files are quite easy to share and deploy. As I mentioned earlier in this article, you just need to rsync them and be done with it. Binaries, on the other hand… One cannot just assume a binary build on a machine X will work on another machine Y. Some additional works need to be done.

The most straightforward solution I know is to rely on static binaries. I have already written about how to generate static binaries for OCaml projects, so I had a pretty strong head start, but one drawback of the approach I’ve described there (and that I have been using for Spatial Shell’s releases Not that there were many of them lately. ) is that it is rather slow (I have a script creating a new local switch each time) and requires static libraries to be installed (which Arch Linux does not provide).

And so, I figured, why not build the static binary in Docker? This allows me to use Alpine to get a static version of my system dependencies, and can be quite fast thanks to Docker build cache . The Dockerfile is quite simple: one stage for building the system and OCaml dependencies, and one stage for building the static binary.

FROM alpine:3.21 AS build_environment

# Use alpine /bin/ash and set shell options
# See https://docs.docker.com/build/building/best-practices/#using-pipes
SHELL ["/bin/ash", "-euo", "pipefail", "-c"]

USER root
WORKDIR /root

RUN apk add autoconf automake bash build-base ca-certificates opam gcc \ 
  git rsync gmp-dev libev-dev openssl-libs-static pkgconf zlib-static \
  openssl-dev zlib-dev
RUN opam init --bare --yes --disable-sandboxing
COPY makefile dune-project .
RUN make _opam/.init OCAML="ocaml-option-static,ocaml-option-no-compression,ocaml.5.2.1"
RUN eval $(opam env) && make server-deps

FROM build_environment AS builder

COPY server ./server
COPY out ./out
COPY dune .
RUN eval $(opam env) && dune build server/main.exe --profile=static

FROM alpine:3.21 AS soap.coffee

COPY --from=builder /root/_build/default/server/main.exe /bin/soap.coffee

Then, building my static binary becomes as simple as:

docker build . -f ./build.Dockerfile \
  --target soap.coffee \
  -t soap.coffee:latest
docker create --name soap-coffee-build soap.coffee:latest
docker cp soap-coffee-build:/bin/soap.coffee .
docker rm -f soap-coffee-build

docker cp does not work on an image, but on a container, so we need to create one which can be destroyed shortly after.

This little binary weights 38MBytes, which seems relatively reasonable to me, considering my website weights 20MBytes. I guess it could be easy to reduce this size by embedding the compressed version of my articles and images, instead of the uncompressed one. But really, for my website, I’m not really interested in investing the extra effort.

Conclusion

I would not recommend anyone to use this in production for anything remotely important, but from my perspective, it was both fun and insightful. I was able to refresh my memories about HTTP “internal,” among other things. Again, as far as I can tell, deploying my website this way did not bring me any benefit, performance-wise; even worse, I am pretty sure the Dream server will not behave as well as Nginx when it comes to handling the load (since it is limited to one core, instead of several with Nginx).

That being said, I am way too invested now… which is why, yes, you are reading a blog post served to you directly from memory 🎉.