Middleware providing live-reload capabilities for Go web development
This works beautifully for web applications with server-side rendering. Including those using hypermedia frameworks like HTMX or Datastar.
This is not a good fit for SPAs. Those front-end frameworks typically have better solutions in their ecosystems with hot-module reloading.
I always try to keep dependencies to a minimum. E.g., the library has its own websocket implementation. It's not a full implementation, only enough for this problem (e.g., this cannot handle messages larger than a single frame can hold)
The only dependency is fsnotify used for monitoring file system events.
I may refactor this in a way that fsnotify-dependent code is moved to its own module, and you need to inject a watcher. Thus leaving the main livereload module free of dependencies. But I don't have plans for this yet.
The one tool ubiquitously mentioned to solve this problem in Go is Air. But I
find Air to be unintuitive, overly complex to configure, builds unnecessary
artefacts (when you can launch with go run), and starts an unnecessary proxy
server, adding one extra potential port conflict.
IMHO, this build on much simpler principles.
The library has an HTTP middleware, you use to wrap your existing http Handler.
The middleware automatically injects a <script src="..."></script> element
into responses with a Content-Type: text/html header. The middleware also
serves the necessary script, and accepts websocket connections initiated by the
script.
The application must call WatchDir for each directory with content to watch;
they are not watched recursively. The watcher will push a reload message to
the client when changes are detected; and the script will reload the current URL.
The script also handles server restarts. When the websocket is broken, the script goes into a reconnect phase. Once a connection is established again, it reloads. The reconnect phase prevents reloading before the web server is ready to accept incoming connections, resulting in a "connection failed" state in the browser (requiring manual reload)
An example dev-server for the module exampleapp could look like this:
// ./cmd/dev-server
package main
import (
// .. necessary std imports
"exampleapp/internal/web"
"github.com/gost-dom/livereload"
)
func main() {
var reloader livereload.Middleware
err1 := reloader.WatchDir("static/css")
err2 := reloader.WatchDir("static/js")
if err := errors.Join(err1, err2); err != nil {
slog.Error("Livereload initialization failed", "error", err)
}
rootHandler := web.CreateRootHttpHandler()
http.ListenAndServe(":8080", reloader.Middleware(rootHandler))
}If directories aren't absolute, they are resolved from the current working directory (I've only tested on MacOS).
The previous example adds live reload behaviour into server executable. There are at least two strategies to avoid this:
- Use build tags, so livereload functionality is only added if a dev-tag is used.
- Create separate
./cmd/serverand./cmd/dev-serverpackages, each being amainpackage, launching an HTTP server.
Whether the latter may not be a good fit for all projects, but works well for projects that follow the the Server Project module layout.
By decoupling application logic (including HTTP handlers) from startup code,
package main can be a very thin package with minimal responsibilities, e.g.
starting the HTTP listener and handling environment-specific configuration.
./internal/web # root http handler
./cmd/server # production server
./cmd/dev-server # development serverProduction specific code in ./cmd/server could include configuring logs to be
routed to an APM, and ellaborate server configuration settings. But the http
handler itself is from ./internal/web/.
Now ./cmd/dev-server is another thin package, wrapping the handler in a live
reload middleware, and just calling http.ListenAndServe.
The livereload is not the only reason for a dedicated dev-server; I often have test-specific endpoints for regression testinging templates.
Refactoring CSS files can have unintended effects. To prevent this, I often have regression tests rendering pages in a browser, taking screenshots, and comparing them against a golden sample.
If there are changes in the rendered screenshot, the test breaks. If the changes are intended, the current screenshots are elevated to a new golden sample.
To have identical output for each run, special endpoints render templates with static viewmodels. They also render for unauthenticated users, no matter the intended use for the templates.
Those endpoints, as well as the static viewmodels, only exist in the dev-server.
nodemon is a common tool for this problem in the node.js community. You can
install it with your favourite JS package manager, e.g., npm i -g nodemon
nodemon -e "go" -i "*_test.go" -x go --signal SIGTERM -- run ./cmd/dev-serverTip
You can of course place this in a Taskfile or similar.
Note
This basically tells nodemon to run go run ./cmd/dev-server, and restart it
whenever a .go file changes, but not when a test file changes. To shutdown
the running server, it should send a SIGTERM signal. That signal is used
here, as http.ListenAndServe terminates on that signal. If you have custom
signal handling, you may want to adapt.
Warning
This only watches .go files, so content embedded with the embed package will
not be taken into account. Adjust accordingly.
Building on top of the previous, automatically reloading when a .go file changes, if you use templ, integrating live reload requires the following steps.
- Save a .templ file
- Run
templ generate -f {filename}.templto produce to.gofile. - Nodemon (or your tool of choice) reruns
go run ...
Templ itself provides a watcher for building templ files into .go files, but I prefer to just let my editor run templ automatically.
Pros of this approach is
- It's not another process running in another shell
- I rarely notice templ build errors, as the shell isn't normally visible on screen.
- When the editor runs
templ generate, it can detect non-zero exit codes, and present error messages to the user.
The following neovim script will implement that behaviour (adapt if you don't
use go tool for templ):
vim.api.nvim_create_autocmd("BufWritePost", {
group = group,
pattern = "*.templ",
callback = function(event)
local stdout = {}
local stderr = {}
local jobId = vim.fn.jobstart({ "go", "tool", "templ", "generate", "-f", event.file }, {
stderr_buffered = true,
stdout_buffered = true,
on_stdout = function(_, data)
stdout = data
end,
on_stderr = function(_, data)
stderr = data
end,
on_exit = function(_, exitcode)
if exitcode > 0 then
print(vim.inspect({
"Templ error",
exitcode = exitcode,
stdout = stdout,
stderr = stderr,
}))
end
end,
})
end,
})