Creating a simple live reload server for my blog
2020-07-12
This post describes how I added a hot-reload feature to my blogging workflow.
This blog is made up of
- Markdown files in a “posts/” directory,
- compiled to individual HTML files by Pandoc with a custom template,
- and an index page generated by a shell script (awk and sed).
Commands to build and deploy is codified in a Makefile:
make
to build everythingmake deploy
to deploymake watch
to watch for file changes and do a make (this was a new feature added recently)
This workflow has been working well, but I felt that things can be improved by adding some sort of live preview. I would like to be able to open my Markdown files alongside the HTML page, edit my post, and see changes live.
Hot reload
The simplest way would be to insert some sort of JS that does a page refresh every 5s. It might seem sort of a waste, but it will work. The problem is that 5s might be too long - Pandoc compilation takes much less than 1s, and it would take less than 5s for me to manually refresh.
So instead, we need a way for a local server to inform the webpage that something changed, and should be reloaded.
This is a very common feature present in many development environments, Flutter, React Native, and many JS front-end environments. This usually goes by the name hot reload. Note that these are much more complicated, in that they hot-swap specific parts (components) that changed, without reloading the entire page. For my blog, reloading the entire page is just fine.
WebSockets
For this, we can use WebSockets. We will need:
- a local server that understands WebSocket
- some small JS snippet that will be included in a webpage
- this JS snippet will establish a connection with the server
- when files are modified locally, the local server will inform the web page via this connection
I chose to use uWebSockets 1, which has an example EchoServer built in - all it does is echo whatever the client sent:
# make sure to do recursive, it has a submodule which you need to pull in
git clone --recursive [email protected]:uNetworking/uWebSockets.git
make examples
./EchoServer
With the server up at “localhost:9001”, we now need a client to do a test. I wrote up a simple HTML page, following instructions on MDN’s Writing WebSocket client applications.
<html>
<head>
<script>
var s = new WebSocket("ws://localhost:9001");
s.onopen = function(event) {
s.send("Hello world!");
}
</script>
</head>
</html>
The important part here is to call s.send
inside the callback s.onopen
, since establishing a WebSocket connection is asynchronous. I missed that initially and got no response from the server.
You should then be able to see “Hello world!” sent from the page to the server, and back 2.
Listening to file changes
We can now communicate from the server to our webpage, but the echo server can only do so via a message from the client (hence echo). What we need to do now is to change our Make+Pandoc build to notify the WebSocket server that it should send a message to the client.
There are many ways to do this. While browsing the examples in uWebSockets, I found misc/main.cpp
, which showed how you can define HTTP endpoints and WebSocket endpoints listening on the same port. This makes sense, because a WebSocket connection always starts with a HTTP connection with special headers, and then that get upgraded into a WebSocket connection.
So that’s what we will use:
- when Pandoc is done building HTML, curl localhost:9001
- set up the local server to listen on 9001 for a HTTP request (without the upgrade headers)
- send a message to connected WebSocket clients (that’s the web page)
- have the JS snippet listen to this message, and refresh the page
The tricky part is here to store the WebSocket object in the server. For simplicity, I chose a single global pointer (not exactly global, since it’s still within main
, but funtions as a global). This means that I can only have a single active WebSocket connection that I can ask to refresh the page, but is fine for my use case now.
int main() {
uWS::WebSocket<false, true> *global_ws = nullptr;
uWS::App()
.get("/", [&global_ws](auto *res, auto *req) {
if (global_ws != nullptr) {
global_ws->send("refresh", uWS::OpCode::TEXT);
}
res->end("");
})
.ws<PerSocketData>("/*", {
// more configuration
.open = [&global_ws](auto *ws) {
/* Open event here, you may access ws->getUserData() which points to a PerSocketData struct */
global_ws = ws;
},
.close = [&global_ws](auto *ws, int code, std::string_view message) {
global_ws = nullptr;
}
}).listen(9001, [](auto *token) {
if (token) {
std::cout << "Listening on port " << 9001 << std::endl;
}
}).run();
}
The second part is that I wouldn’t want this JS snippet to be in my final HTML that goes live, only for development. So I will need to have a separate template that Pandoc uses for local builds. That’s not too hard, just a bit of duplication. I chose to create a separate “dev” directory and will recompile all my posts, then add “dev” to .gitignore.
Keepalive
Aka “why does my websocket connection close automatically?” (because I literally Googled this). This is a somewhat nasty problem I faced before, while working on a school project that used WebSockets to build a chat application.
Browsers will terminate WebSocket connections after a period of inactivity (probably to save resources). So, I was seeing my hotreload work for a couple of saves, then it stopped working. The fix here is to register a function using setInterval
to send keepalive messages to the server every 5 seconds:
var intervalId;
// Ping every 5s to keep this WS connection alive.
// Otherwise the browser will terminate this connection.
function keepalive(s) {
intervalId = setInterval(function() {
s.send("keepalive");
}, 5000);
}
var s = new WebSocket("ws://localhost:9001");
s.onopen = function(even) {
console.log("WebSocket connection open.");
keepalive(s);
};
s.onclose = function(even) {
console.log("WebSocket connection closed.");
clearInterval(intervalId);
};
s.onerror = function(event) {
console.error("WebSocket error observed:", event);
clearInterval(intervalId);
};
s.onmessage = function(event) {
if (event.data === "refresh") {
location.reload();
}
};
Clearing intervalId
is not required for this small project, but is good habit anyway.
Stopping the hotreload server
The problem with the current setup is that, I’m starting the server as a backgrounded task, which means that when I Ctrl-C on my terminal, it is left in the background.
It is not a huge deal, since when I terminate the shell session it goes away, but it is a bit annoying.
I remember reading about trap
in bash, which is a way to catch signals and respond to it. I found a useful guide, which suggests using a special EXIT
signal that can be use to do some cleanup right before a bash script exits.
So the simple fix is to create a helper script that:
- sets a trap on
EXIT
, - runs
hotreload
and backgrounds it, - set up fswatch to automatically build changed pages,
- when I hit
Ctrl-C
on the terminal, it stops the script, and is caught by the trap, and kills the backgroundedhotreload
.
You can see the changes on Github. “hotreload.cpp” is the server, lightly modified from the example “EchoServer”. The Makefile contains some hardcoded paths to the headers and object files for uWebSockets, so if you want to use it, you’ll need to modify it.
I’ve been doing C++ development recently, and wanted to expose myself to more C++ projects.↩︎
You can observe WebSocket messages in the Chrome DevTools.↩︎