Prerendering a re-frame app with Chrome Headless
Hi, I’m currently developing ventas, a project to develop an ecommerce platform using an all-clojure stack (re-frame, http-kit, Datomic).
Some days ago I added prerendering capabilities to the project. I started by looking for a Webdriver client for Clojure. I found etaoin, so I added it to my
ventas uses mount for managing application state, so I added a state for the Chrome driver:
This takes care of starting and stopping the driver when we reload the namespace from the REPL, ensuring no driver is started without ever being stopped.
As you may have noticed, in that code I’m calling
chrome and not
chrome-headless , because I want to see what’s going on.
After setting that up and reloading my code (with
clojure.tools.namespace.repl/refresh ), I got a Chrome instance running, controlled from Clojure. I did a quick test:
That worked too: my app was loading correctly in the Chrome instance, so my next task was to write some ClojureScript to change the Bidi route:
routes/go-to is basically
(accountant/navigate! (apply bidi/path-for app-routes args)). Notice that I’m adding
:export metadata to that function: it’s important to do it if we want to call it from outside of CLJS (it will be available as
ventas.seo.go_to in the JS world).
When I saved this code, it was loaded into the Chrome instance by Figwheel, so it was time to test it:
route would be something like
[:frontend.category :id "women"] , so I needed to pass it as a string (as it’s not valid JS). This is why I used
cljs.reader/read-string in the CLJS side to parse the incoming string as CLJS data.
With that out of the way, I needed a new CLJS function to know when the page was loaded:
This will depend a lot on your app, but for ventas I needed to check:
- Whether there was any pending websocket request.
- Whether the page was rendered (just checks for the existence of any child of the React root node)
- Whether all page resources were loaded (images, for example)
ready?, I called it from the server:
(<! (wait-for-frontend driver)) is executed, the function will call
ventas.seo.ready? every 400 ms, until it returns
true . If that never happens, it times out (at 4000ms in this example).
Time to put that to use:
Step by step:
- Wait for the frontend to be ready using
- Get the current URL
- Calculate the path where the HTML file will be saved and make the parent directories (
- Finally, save to that path the innerHTML of the node where React renders the app.
So that’s it, we have an HTML file! Now we should serve that file whenever we get a request for that route. This is going to depend a lot on your app, but this is what ventas does: there’s an
index.html file that gets served on every SPA request, and it contains this inside
When serving this file, the server does a simple
clojure.string/replace on that tag:
Going further — messing with the DB
So, that took care of basic prerendering, but we now need more:
- We need to save re-frame’s DB contents and mutate them on app start (if they’re available) to avoid flickering
- We need to change the contents of the DB just before prerendering because we want things to be shown in a certain way (or be hidden)
For the first part, we’re going to need a new function in CLJS land:
At least in ventas, that will dump the whole database as an EDN string (
::events/db refers to an universal subscription that basically does a
get-in on the DB).
Back in Clojure land, we need to extend
prerender to call this new function and save the results to an edn file (last line in the snippet):
Now we serve it as we did with the HTML file:
Finally we mutate the db on app init:
Let’s do the other part: changing the db before prerendering. I did it like this:
Now any namespace that has something to object to the prerendering of the app, can do so:
Don’t forget to add a call before getting the
The ventas demo uses docker, so I had to do some tweaks to get this whole prerendering thing going.
The first one was adding Chrome to my
privileged: true seems to be necessary for now because of an issue with the image.
Then I modified my driver defstate to connect to the host and port set in the production configuration file:
Lastly, I had to add a
docker-host option to allow the Chrome container to visit the URL without using a domain or public IP (using the container name):
So that’s it! Thanks for reading!