ssereload(1) introduction

TL;DR:

$ ssereload 1333 &; find -f build | entr -s 'pkill -HUP ssereload'

Live reload, that is, automatic reload of a browser tab when the source files had changed, is a nice thing. It shortens the feedback loop, whether you are iterating on layout, styles, or the content.

Usually this feature is built-in in the SSG, but if you’re building your own generator, then, unsurprisingly, you’d have to build it too. It’s not that critical a feature that you can’t live without, a nice to have, and it’s not that hard to build.

I’ve been nerdsniped^W inspired by a few comments on Lobsters to try to separate this feature from website generator proper. I really like using entr for watching the file changes: it does one thing and it fits in my head. So I’ve even gone one step further than separating reload from SSG: ssereload doesn’t watch the files, it only listens to a SIGHUP.

The plan was simple: set up signal handler, in which notify current connections about the signal. I’ve decided to try writing it in Scheme, in order to learn it as well.

Source code

Long story short, here is the source code:

(import (chicken io) (chicken tcp) (chicken process signal) (chicken process-context) (chicken condition)
  (srfi-1))

(define port-for-sse (string->number (car (append (command-line-arguments) '("1330")))))
(define (hup-handler _) (notify-listeners))
(define (int-handler _) (set! should-quit #t) (notify-listeners) (exit))
(define should-quit? #f)
(define listeners (list)) ; list of thunks
(define (notify-listeners)
  (write-line "ssereload: reloading listeners")
  (set! listeners (filter (lambda (x) (x)) listeners)))

; output-port -> bool (whether to keep port in listeners or not)
(define (write-single-update out)
  (condition-case
    (begin
      (write-line "data: reload\r\n\r\n" out)
      (flush-output out)
      (if should-quit? (begin (close-output-port out) #f) #t))
    ((exn i/o net) #f)))

; tcp-listener -> void (recursion)
(define (accept-loop listener)
  (unless should-quit?
    (let-values (((i o) (tcp-accept listener)))
      (let ((inp (read-line i)))
        (begin
          (close-input-port i)
          (if (string=? inp "GET /sse HTTP/1.1")
            (set-up-listener o)
            (begin
              (write-line "HTTP/1.1 404 Not Found\r\n\r\nNot found" o)
              (close-output-port o)))))
    (accept-loop listener))))

; output-port -> void, mutates listeners
(define (set-up-listener o)
  (write-preamble o)
  (set! listeners (cons (lambda () (write-single-update o)) listeners)))
(define preamble
  (apply string-append
   (intersperse (list
      "HTTP/1.1 200 OK"
      "content-type: text/event-stream"
      "connection: keep-alive"
      "access-control-allow-origin: *"
      "") "\r\n")))

; output-port -> void, writes to output port
(define (write-preamble o) (write-line preamble o) (flush-output o) (print "ssereload: client connected"))
(set-signal-handler! signal/hup hup-handler)
(set-signal-handler! signal/int int-handler)

(let ((listener (tcp-listen port-for-sse)))
  (write-line (string-append "ssereload: listening on port " (number->string port-for-sse)))
  (accept-loop listener))

(curl it as curl -O https://timmarinin.net/2026/ssereload.scm)

And you’d need a script tag on your page to connect to the ssereload:

<script>
const sse = new EventSource('http://localhost:1333/sse')
sse.onmessage = () => {
    window.location.reload();
}
sse.onopen = () => {
    console.log('ssereload activated')
}
</script>

Don't forget to remove the script before going to production, though.

Random thoughts

And now, this: a jumble of thoughts, in no particular order.

Decoupling ssereload from the specific website allows me to use a single process to reload all websites I’m working on simultaneously (yes, I do have ADHD, why do you ask?)

If you have LAN between your devices (something tailscale-ish, probably), you can also create EventSource as "http://your.machine.test:1333/sse", and get live reloading while testing from a smartphone (and you do test your websites from a real device, I hope).

Writing in Scheme felt weirdly fun. I don’t have a “proper” REPL workflow set up yet, so I mostly edited code in Helix and then restarted the whole process. The Scheme implementation I’ve used is Chicken Scheme, which allowed me to build static binary to drop in ~/bin. I didn’t read through neither R6RS nor R7RS yet, so I mostly relied on my vague memory of reading bits of SICP and HtDP, and browsing through Chicken Manual and SRFIs.

One unanswered question for me is whether it’s possible to write Scheme code that’s usable across implementations (not as in “you can very easily port it”, but running the same code). Can I depend only on SRFIs? From what I’ve gathered, the syntax for imports is different: (import (chicken ...)) in Chicken, (use-modules ...) in Guile.

Chicken Scheme has green threads, and the mighty web server called Spiffy, but using the whole shebang for this felt as overkill. I tried to rewrite the code to use threads properly, but after a few minutes of trying to figure out how to notify all “listener” threads that an event occurred, I reverted to the simple code above. (Email me at not-afraid-of-mutexes@timmarinin.net, if you do know how to do that). Also, I'm sure that my Scheme code is not idiomatic: if you have tips, don't hesitate to point out the proper style.

The scope of this tool is so small that any language would do. I didn’t experience the purported benefits of flexibility on such scale. On my MacBook, the process is sitting around 2.8 MB of memory. One future idea is to rebuild the same in thing in C to compare memory usage.

Unix philosophy might be dead, but splitting the feature into not two, but three separate pieces (usual HTTP serving, file-watching, SSE server) was fun as well. Now I’m thinking for what else can I use the ssereload binary.

Another thing I noticed is how little I needed to pretend to be a proper HTTP server, mostly a few line breaks. But then I've also realized that SSE in this particular case is just a long-polling request in a stylish coat, the browser drops SSE connection on hard reload.

There is also a place to play with different kinds of reload, and maybe even stuff some proper data (e.g., which pages should reload?) into that SSE connection.

Anyways, that was fun.

Published at by mt