exupero's blog
RSSApps

Live reloading HTML that didn't ask for it

In the previous post I showed how to tweak the example Babashka file server to hold a request for a file until the file is changed. That's not much use if nothing ever asks for updates, so in this post we'll inject some JavaScript into HTML pages that causes them to long poll the server for new content.

To support this behavior, let's add a second arity to the file function so it can accept a handler that rewrites the contents of a file:

(defn file
  ([path] (file path identity))
  ([path body-handler]
   (let [mime-type (ext-mime-type (fs/file-name path))]
     {:headers {"Content-Type" mime-type
                "Access-Control-Allow-Headers" "*"
                "Access-Control-Allow-Origin" "*"}
      :body (body-handler (fs/file path))})))

In the main server handler, add a check for HTML files and supply a body handler function:

(cond
  ...
  (= "text/html" (ext-mime-type (fs/file-name path)))
  , (file f inject-live-reload-js)
  ...)

I've included this condition after the check for "on-update" in the query string since we don't need to inject the long-polling code after the first page load.

Here's the function to inject the needed JavaScript by inserting it before the closing <head> tag:

(defn inject-live-reload-js [f]
  (string/replace-first
    (slurp f)
    #"</head>"
    "<script>
function longPoll() {
  fetch(window.location.href + '?on-update')
    .then(response => response.text())
    .then(text => {
      const html = document.createElement('html');
      html.innerHTML = text;
      document.body.innerHTML = html.querySelector('body').innerHTML;
      Array.from(document.body.querySelectorAll('script')).forEach(oldScript => {
        const newScript = document.createElement('script');
        Array.from(oldScript.attributes)
          .forEach(attr => newScript.setAttribute(attr.name, attr.value));
        newScript.appendChild(document.createTextNode(oldScript.innerHTML));
        oldScript.parentNode.replaceChild(newScript, oldScript);
      });
      Array.from(document.body.querySelectorAll('img')).forEach(img => {
        const t = new Date().getTime();
        const i = img.src.indexOf('?');
        img.src = img.src.substring(0, i > -1 ? i : img.src.length) + '?t=' + t;
      });
      longPoll();
    })
}
longPoll();
    </script></head>"))

The JavaScript fetches the current page with the ?on-update query parameter so the server only returns when the file changes. When it gets a response, it creates a new html element from the new file contents and replaces the current document body with the new body. The two Array.from(...).forEach(...) loops re-run other JavaScript on the page and reload any images. Finally, the JavaScript function triggers itself again and waits for another file update.

This code has served me well for more than a year of writing this blog, though I'm sure it has shortcomings for other use cases. If you have suggestions for improvements, feel free to email me.