jon.chrt.dev

Adding Live Reload to a Static Site Generator Written in Go

Jon Charter

Over the past few years, I have been toying around with my static site generator, Lumaca.

What sets Lumaca apart from the plethora of other static site generators loose in the world? Very little, actually. I didn’t build it to compete with Hugo, Astro or Zola; which are more full featured products.

Lumaca is minimal and opinionated. It doesn’t support custom styles or templates. It produces simple and clean looking blog websites.

So far, I’ve built features important to me1, such as code syntax highlighting that is pre-rendered on the server, as opposed to relying on JavaScript2 post-processing. But a feature it has always lacked is live reloading.

What is live reloading? It is a quality of life feature that watches for file changes then automatically rebuilds the output and refreshes the browser.

So, what is required to implement this?

  1. A file watcher that responds to events such as creation and modification.
  2. A debouncing mechanism to reduce redundant or conflicting rebuilds.
  3. A Server-Sent Events (SSE) endpoint to establish a persistent connection from browser to server.
  4. Middleware that injects a client-side script that connects to the prior endpoint and triggers a reload on event receipt

With all that, a rebuild triggers each time a file is created, updated or deleted and browser refreshes immediately.

File Watcher

The concept: Given a set of directories, monitor them for any file changes and listen to the events that come back, reacting accordingly. In practice, it’s harder than it sounds. File systems work differently across the various operating systems and there are many pitfalls.

Luckily, Go has an excellent library for this: fsnotify. With it, you instantiate a new Watcher and pass the files, or better, the directories you want to watch.

func watchFiles(root string) error {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	defer watcher.Close()

	err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			err := watcher.Add(path)
			if err != nil {
				return err
			}
		}
		return nil
	})
	if err != nil {
		return err
	}

	// Event loop...
}

In this example a file path is passed in as an argument, and it walks that path to find all sub directories, adding them to the watcher. You can imagine a content directory being passed in with sub directories like: posts, pages, static/css, etc.

We must then listen for watcher events. fsnotify provides two channels we can consume from: One for events and another for errors.

  ...
	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return nil
			}
			if event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) {
  			builder.Build()
			}
			if event.Has(fsnotify.Create) {
				if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
					if err := watcher.Add(event.Name); err != nil {
            return err
					}
				}
			}
		case err, ok := <-watcher.Errors:
			if !ok {
				return nil
			}
      return err
  	}
  }

The above snippet also includes the logic to handle new directories being created, which are added to the watcher. Any new files within will be observed.

Note that I’ve been selective of the events to watch for. There are plenty of others, like CHMOD, that we don’t want to react to.

With that, we have something working. It will rebuild on every change. All of them.

Sadly, if we were to throw some logs in just before build step, we would see that each time we save a file, two to six rebuilds will have triggered.

Debouncing

Why do we see so many rebuilds?

It comes down to how text editors save files. Most editors do not atomically write a file. They do something like:

There can be more or fewer events, but the point is clear: We don’t want to rebuild for each of these steps.

The solution? Debouncing. Instead of triggering a rebuild on a change event, we set a short delay period of 50 - 300 ms. Any subsequent events reset the delay, and only on completion of the delay do we run the rebuild.

func watchFiles(root string) error {
	timer := time.NewTimer(math.MaxInt64)
	timer.Stop() // Prevent ticking until event received
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	defer watcher.Close()

	// Walk and add directories to watcher...

	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return nil
			}
			if event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) {
				timer.Reset(200 * time.Millisecond)
			}
			if event.Has(fsnotify.Create) {
				if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
					if err := watcher.Add(event.Name); err != nil {
						return err
					}
				}
			}

		// Handle errors...
	
		case <-timer.C:
			if err := builder.Build(); err != nil {
				return err
			}
		}
	}
}

Server-Sent Events

We’re auto-rebuilding and that’s great, but now we need a way to tell the browser to refresh.

How can the browser know when we’ve made changes to our files? One solution is to set up an endpoint that responds with either “Nothing to see here. As you were” or “Things have changed. Refresh!”

We could poll that endpoint on a recurring timer, but a better option is to establish a persistent connection. We hit the endpoint once, the connection stays open and the server pushes data whenever it likes.

Initially, I turned to WebSockets, because it’s all I knew. But a little reading made me reconsider. Server-Sent Events (SSE) is a lighter solution that fits our needs. Both are similar, but WebSockets is a complex protocol designed for bi-directional communications, while SSE only handles one-way communication: Server -> Client. It’s less complex and it works over plain HTTP.

Before we start building, let’s consider: Should we support multiple “clients”? i.e. multiple browser tabs. Perhaps over-engineering for a dev server, but it’s just a little more work to support it. And then we support having additional tabs set to responsive design mode.

To support multiple clients, we start with the concept of a broker.

type SSEBroker struct {
	mu      sync.Mutex
	clients map[chan string]struct{}
}

The broker is a set of clients (A ‘set’ in Go being typically represented with a map with values of struct{}), and a mutex to synchronize access. If you’re unfamiliar with concurrency in Go, we use a Mutex to ensure the map isn’t written to and read from at the same time, which would cause a panic.

Let’s add some methods to instantiate the broker, register clients and broadcast messages.

func NewSSEBroker() *SSEBroker {
	return &SSEBroker{
		clients: make(map[chan string]struct{}),
	}
}

func (b *SSEBroker) Subscribe() chan string {
	ch := make(chan string, 10) // A small buffer to absorb brief delays
	b.mu.Lock()
	b.clients[ch] = struct{}{}
	b.mu.Unlock()
	return ch
}

func (b *SSEBroker) Unsubscribe(ch chan string) {
	b.mu.Lock()
	delete(b.clients, ch)
	close(ch)
	b.mu.Unlock()
}

func (b *SSEBroker) Broadcast(data string) {
	b.mu.Lock()
	defer b.mu.Unlock()

	for ch := range b.clients {
		select {
		case ch <- data:
		default: // Buffer is full, drop the message
			slog.Info("dropped message for slow client")
		}
	}
}

Note the buffer on the channel. This means a client can “fall behind” by not consuming up to ten messages, at which point messages will just be dropped. It’s unlikely given all clients are local browser tabs, but this prevents a slow one from grounding everything to a halt.

That covers the core broker logic. We’ll want to pass this to our dev server as an http handler. To fulfill that interface, we’ll need a ServeHTTP method.

func (b *SSEBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher) // Assert writer implements Flusher

	if !ok {
		http.Error(w, "Streaming not supported", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/event-stream")

	// Register client with the broker
	ch := b.Subscribe()
	defer b.Unsubscribe(ch)

	// Listen for events
	for {
		select {
		case msg, ok := <-ch:
			if !ok {
				// Channel has been closed
				return
			}
			if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
				return
			}
			flusher.Flush()
		case <-r.Context().Done():
			return
		}
	}
}

First, we do a type assertion to ensure the ResponseWriter implements http.Flusher. This is key to SSE. http.ResponseWriter buffers the response by default. Fine for usual http responses, but for SSE we need the response to be sent immediately, or “Flushed”.

Next, we set the relevant SSE headers. Well, header - just Content-Type: text/event-stream.

You’ll often see the following headers included in SSE examples:

w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")

The first two have been omitted, as they’re redundant. HTTP/1.1 defaults to Connection: keep-alive. It’s effectively always redundant. Cache-Control is worth setting if you’re serving behind a CDN or a proxy, but we’re only serving locally - and that also makes setting CORS redundant for our usecase. So just the one header required.

Finally we register the client and listen for events. When we receive something from a client channel we write it to the response writer, conforming to the event stream format and flush it through.

Wire this up with Mux, and we’ve got everything we need server-side.

func serveStaticContent(port int) error {
	broker := NewSSEBroker()
	go watchFiles("content/", broker)

	mux := http.NewServeMux()
	mux.Handle("/", http.FileServer(http.Dir("dist")))
	mux.Handle("/events", broker)

	addr := fmt.Sprintf(":%d", port)
	fmt.Printf("Serving on http://localhost%s\n", addr)
	return http.ListenAndServe(addr, mux)
}

watchFiles needs a small change. It needs to accept a pointer to an SSEBroker as a parameter and call Broadcast after reloading.

func watchFiles(root string, broker *SSEBroker) error {
	// Initiate timer and watcher...
	// Walk and add directories to watcher...

	for {
		select {

		// Handle events...
		// Handle new directory...
		// Handle errors...
	
		case <-timer.C:
			if err := builder.Build(); err != nil {
				return err
			}
			broker.Broadcast("reload")
		}
	}
}

“reload” is the event we’ll listen for on the client side.

JavaScript Injection Middleware

The final piece of the puzzle.

We have everything set up on the backend to automatically rebuild and an SSE endpoint we’re broadcasting a refresh instruction to. But how do we get the browser to listen?

Of course, we could hardcode a script into our HTML templates, but we’d be poisoning our production site for the benefit of development.

The solution is to create some intercepting middleware to inject our script when pages are served through our dev server.

If you’re unfamiliar with middleware and relevant Go patterns for it, here is an excellent post you should read.

The concept here is that we want to inject our own ResponseWriter, which will buffer the content rather than send it. We can then edit it, before sending it on as originally intended.

type bufferedHTTPWriter struct {
	http.ResponseWriter
	buf    bytes.Buffer
	status int
}

func (b *bufferedHTTPWriter) Write(p []byte) (int, error) {
	return b.buf.Write(p)
}

func (b *bufferedHTTPWriter) WriteHeader(code int) {
	b.status = code
}

func withLiveReload(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		buf := &bufferedHTTPWriter{
			ResponseWriter: w,
			status:         200,
		}
		next.ServeHTTP(buf, r)

		body := buf.buf.String()
		contentType := buf.Header().Get("Content-Type")

		if strings.Contains(contentType, "text/html") {
			script := `<script>new EventSource("/events").onmessage = () => location.reload();</script>`
			body = strings.Replace(body, "</body>", script+"</body>", 1)
		}

		w.Header().Set("Content-Length", strconv.Itoa(len(body)))
		w.WriteHeader(buf.status)
		if _, err := w.Write([]byte(body)); err != nil {
			slog.Error("failed to write response", "error", err)
		}
	})
}

The JavaScript we’re injecting looks like this:

new EventSource("/events").onmessage = () => location.reload();

Essentially we’re opening a persistent connection with the /events endpoints, and reloading when we receive any message.

Finish this off by wiring in the middleware.

	mux.Handle("/", withLiveReload(http.FileServer(http.Dir("dist"))))

Conclusion

This is how I have implemented live-reloading for Lumaca. The examples here are simplified, but if you’d like to see how I’ve implemented it in full, the source is available on Codeberg here.

Lumaca is a WIP that I’m dogfooding with this blog. Feel free to give it a try if you’re looking for something minimal to write your own blog. I’d be keen to hear any feedback on this post, Lumaca, or this site - find me on the links in the footer.


  1. I’ll likely add more features in the future as they appeal to me. WebMention support and auto-linking to, for example, a related BlueSky post are features I find appealing.
  2. Lumaca doesn’t output any JavaScript at all. Just plain HTML and CSS. Hopefully all that is needed for a simple static site on modern browsers. The hamburger menu is handled with Invoker Commands