exupero's blog
RSSApps

HTTP server that watches files

Like many software developers, I've occasionally needed an HTTP server for accessing files in a local directory. If you have Python installed, possibly the easiest way is to use the command python -m SimpleHTTPServer, which I used as my default option for many years. When I started this blog, however, I wanted a server I could tweak easily, and I found this Babashka script. To provide a live-reload workflow, I tweaked it to hold onto a request until the file updated. Here are the essential changes.

The workhorse is a loop that checks the last time a file was updated, and if it's earlier than when the request was made, it waits and checks again:

(defn wait-for-update [f interval stop?]
  (let [cutoff (.toEpochMilli (java.time.Instant/now))]
    (loop []
      (cond
        @stop?
        , false
        (< cutoff (.lastModified (fs/file f)))
        , true
        :else
        , (do
            (Thread/sleep (* 1000 interval))
            (recur))))))

The stop? atom is used to quit looping if the client connection is closed. That's tripped by http-kit's as-channel in the main request handler, using the :on-close event:

(let [stop? (atom false)]
  (server/as-channel request
    {:on-close (fn [ch status]
                 (reset! stop? true))}))

By default, the server doesn't use wait-for-update unless asked to. I trigger holding the request by adding the query parameter on-update to requests:

(cond
  ...
  (some->> (request :query-string)
           (re-find #"on-update"))
  , (do (wait-for-update f interval stop?)
        (file f))
  ...)

It would be more appropriate to use an HTTP header, but when I tried I ran into Chrome's cache locking, which pauses requests made to the same URL as a pending request, and while Chrome does cancel the pending request after 20 seconds and let the subsequent request finish, that's a long time to wait for a page load that's otherwise instantaneous. The two proposed solutions don't seem to be practical for requests triggered by a browser refresh (adding a Cache-Control: no-store header or including a unique query parameter). It's much easier to use a different URL for the long-polling request, which adding a query parameter accomplishes. Basic file servers like this don't typically need query parameters, so using ?on-update hasn't interfered with anything I've used this script for.

The full script is here.

Beyond writing this blog, I've used this live-reload script for a couple other workflows, one of which I'll describe in a subsequent post.