Improving the Writing Experience
As with writing code or documentation that is going to be viewed, it is desired to be able to view it as it is being iterated on. Currently for this site the processes to do so is as follows:
- Run
mix run --no-halt
in a terminal - Then:
- Make a change
- Run
mix build
- Refresh page
- Repeat
This process is kind of annoying, more so when trying to do HTML and CSS stuff. However, being able to watch for file system changes is pretty straight forward and we already have mix build
that will do most of what we want on a change. So with that, I went down the path of extending mix build
to re-build on file changes
File System Watching
I started off by searching for a library that can do file system watching. First result lead me to FileSystem. Quick look at this readme showed me everything I needed. So I started by writing a GenServer to hook into the file system events.
lib/tasks/file_system_watcher.ex
defmodule SourShark.FileSystemWatcher do
use GenServer
@root_dir File.cwd!()
@watch_dir ["#{@root_dir}/lib/sour_shark/templates", "#{@root_dir}/priv/posts"]
def start_link(args) do
GenServer.start_link(__MODULE__, args)
end
def init(_args) do
Mix.Tasks.Build.build_output(true)
{:ok, watcher_pid} = FileSystem.start_link(dirs: @watch_dir, latency: 0)
FileSystem.subscribe(watcher_pid)
IO.puts("Watching for file changes at:\n\t- #{Enum.join(@watch_dir, "\n\t- ")}")
{:ok, %{watcher_pid: watcher_pid}}
end
def handle_info({:file_event, watcher_pid, {path, events}}, %{watcher_pid: watcher_pid} = state) do
IO.puts("File event: #{inspect(path)} -> #{inspect(events)}")
IEx.Helpers.recompile()
Mix.Tasks.Build.build_output(false)
{:noreply, state}
end
def handle_info({:file_event, watcher_pid, :stop}, %{watcher_pid: watcher_pid} = state) do
IO.puts("File watcher stopped")
{:noreply, state}
end
end
This also meant adding a new API to Mix.Tasks.Build
to leverage this.
Note the usage of IEx.Helpers.recompile()
, I did this because I could not find a way to invoke a new build of NimblePublisher. As far as I can tell there is no API provided, as it only runs on compilation.
Now I needed a host for this file system watcher GenServer, which I only have one good option for that. The Bandit server that runs when doing mix run --no-halt
.
With that I updated the application.ex to start it up on its supervisor.
lib/sour_shark/application.ex
defmodule SourShark.Application do
use Application
@impl true
def start(_type, _args) do
children = [
Registry.child_spec(
keys: :duplicate,
name: SourShark.EventRegistry.FileChange
),
{Bandit, scheme: :http, plug: SourShark.Router, port: 8080},
SourShark.FileSystemWatcher
]
opts = [strategy: :one_for_one, name: SourShark.Supervisor]
Supervisor.start_link(children, opts)
end
end
What remains is updating the API on the mix build
module.
lib/mix/tasks/build.ex
def run(_args) do
build_output()
end
def build_output(should_clean? \\ false) do
{micro, _} =
:timer.tc(fn ->
if should_clean?, do: clean_output_dir()
download_highlight_js_assets()
build_pages()
build_blog()
build_assets()
end)
ms = micro / 1000
IO.puts("Finished building site in #{ms}ms")
end
I added a should_clean?
flag as well, this definitely exposes us to defects when it comes to lifecycle events of files. Such as one of them being deleted. However, since this product is a user of one, I'm not going to bother improving it. What spurred the should_clean?
flag is the download_highlight_js_assets()
function. I didn't want to keep going to the CDNs and downloading the JavaScript files on every change, so I updated that function to check if what it was downloading already existed. But since clean_output_dir()
was always being ran, it would always download. Hence the should_clean?
flag.
Now when running mix run --no-halt
a file system watcher is started and a new build is ran when changes are detected.
Live Reloading
The remaining thing to solve is the live reloading. At this point I have it so whenever there is a change to the markdown files or the HTML files a new build will be invoked then upon refresh on the web page the new content will load. Now I just need a way to force a refresh.
This can be done by opening a web socket connection to the Bandit server and when an event is sent on this connection, use JavaScript to refresh the page.
To begin I updated the Bandit server to have a web socket connection endpoint via the WebSockAdapter package. Then added a handler for the web socket connections:
lib/sour_shark/router.ex
get "/events/reload" do
conn
|> WebSockAdapter.upgrade(SourShark.HotReloader, [], timeout: 60_000)
|> halt()
end
lib/sour_shark/hot_reloader.ex
defmodule SourShark.HotReloader do
def init(_) do
Process.send_after(self(), :register_event, 1000)
{:ok, %{}}
end
def handle_in(_, state) do
{:ok, state}
end
def handle_info(:broadcast, state) do
{:reply, :ok, {:text, "reload"}, state}
end
def handle_info(:register_event, state) do
Registry.register(SourShark.EventRegistry.FileChange, :file_change, %{})
{:ok, state}
end
def handle_info({:EXIT, _pid, _reason}, state) do
{:stop, :normal, state}
end
def terminate(_reason, state) do
{:ok, state}
end
end
Make note of the Register.register
API call. This is because we need a way to connect the file system watcher process with the open web socket connection handler process.
This register call makes it so whenever something is dispatched it'll invoke a function. The dispatch exists on the file system watcher
lib/sour_shark/file_system_watcher
def handle_info({:file_event, watcher_pid, {path, events}}, %{watcher_pid: watcher_pid} = state) do
IO.puts("File event: #{inspect(path)} -> #{inspect(events)}")
IEx.Helpers.recompile()
Mix.Tasks.Build.build_output(true, false)
Registry.dispatch(SourShark.EventRegistry.FileChange, :file_change, fn entries ->
for {pid, _} <- entries, do: send(pid, :broadcast)
end)
{:noreply, state}
end
The important part here is Registry.dispatch
. This will result in whenever there is a file event anything registered for the :file_change key on SourShark.EventRegistry.FileChange
registry will then invoke sending a message to :broadcast
Now what remains is injecting HTML when running locally that will have JavaScript code to open a connection to this new web socket endpoint.
lib/sour_shark/templates/root.html.eex
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= Application.get_env(:sour_shark, :product_name) %></title>
<link href="/css/app.css" rel="stylesheet">
<%= @extra_head %>
<%= @hot_reload %>
</head>
lib/sour_shark/templates/hot_reload.html.eex
<script>
const socket = new WebSocket("ws://localhost:8080/events/reload");
socket.onmessage = function(event) {
window.location.reload();
};
socket.onopen = function(e) {
socket.onclose = function(event) {
if (!event.wasClean)
window.location.reload();
};
};
</script>
Now need to dynamically inject this HTML into the built HTML files when running locally. To do this I updated the mix build
module once more
lib/mix/tasks/build.ex
def build_output(add_hot_reload? \\ false, should_clean? \\ false) do
{micro, _} =
:timer.tc(fn ->
if should_clean?, do: clean_output_dir()
download_highlight_js_assets()
build_pages(add_hot_reload?)
build_blog(add_hot_reload?)
build_assets()
end)
ms = micro / 1000
IO.puts("Finished building site in #{ms}ms")
end
defp build_pages(add_hot_reload?) do
hot_reload =
if add_hot_reload?,
do: File.read!("lib/sour_shark/templates/hot_reload.html.eex"),
else: nil
for source <- Path.wildcard("lib/sour_shark/templates/pages/*.html.eex") do
target = source |> Path.basename() |> String.replace(".eex", "")
IO.puts("Building #{source} -> #{target}")
File.write!(
"#{@output_dir}/#{target}",
eval_file("lib/sour_shark/templates/root.html.eex",
assigns: [
hot_reload: hot_reload,
extra_head: nil,
content: eval_file(source, assigns: [])
]
)
)
end
end
The add_hot_reload?
is there because I didn't know of a better way to pass down the context to include the web socket connection HTML. Given that we don't want this HTML injected on non file system watch invocations of build. As in, we don't want deployed code at jack-develops.com to be trying to open a web socket connection to localhost.
At this point hot reloading works. Whenever there is a file change a web socket is sent to the client (browser page) and a page reload is done.