Frontend configuration with macros!?
Since I spend the majority of my time working in the backend, my excursions to the frontend usually come with a surprise in one form or another. I've worked on several frontend applications before, but these were rather large, well-established code bases where I worked on particular components in isolation, which meant I never really went into too much detail concerning the project setup or its configuration aspects before.
Lately, it's a different story though, I was recently at a loss setting up a ClojureScript project from scratch, which means I also had to take care of this initial setup work. While juggling with the bits and pieces and trying to understand how they fit together, I discovered a topic that I had never given much thought to before: frontend configuration.
The need for frontend configuration might be rather limited in general, but in my case, the bare minimum of configuration that was required was the ability to configure environment-specific backend URLs. I tried to learn by example and searched for implementations by people with experience who know what they are doing and I got under the impression that frontend configuration is more of a dark art with no obvious best practices.
One specifically interesting implementation I came across that puzzled me looked similar to the following:
(ns main.playground.config #?(:clj (:require [aero.core :as aero]) :cljs (:require-macros [main.playground.config :refer [config]]))) #?(:clj (defmacro config [& ks] (get-in (aero/read-config "config.edn") ks)) :cljs (do (def api-url (let [{:keys [tls? host port]} (config :backend)] (str "http" (when tls? "s") "://" host ":" port))) (def debug (config :debug)) (def nested (config :somewhere :deep :inside :lives :an))))
The obvious purpose of this configuration snippet is to read the config.edn file and write its contents to a bunch of variables, but it seemed a little odd to me at first glance for multiple reasons:
- The main.playground.config namespace is defined inside a .cljc file
- There are reader conditionals used within the ns form, and also on the top level of the namespace
- It uses the aero configuration library, which I have only seen in Clojure and not in Clojurescript context so far.
- The namespace seems to require itself using the :require-macro keyword. Definitely something I hadn't seen before.
The following snippet shows my dummy aero config.edn that I used to explore how the configuration gets loaded here. The #merge & #include constructs are aero specific tagged literals, which are particularly useful for building layered configurations. For this, we would define an additional environment-specific configuration file called local-config.edn that would get loaded and merged into our default config.edn by aero.
#merge [{:backend {:tls? true :host "dev.some-nice-backend.com" :port 8042} :debug true :somewhere {:deep {:inside {:lives {:an "interesting-value"}}}}} #include "local-config.edn"]
What is going on here? Let's look at the different parts of this config.cljc namespace in more detail.
At first, I did a little more research on the aero library and it turned out it actually is possible to use it in ClojureScript context out of the box, without applying any macro wizardry. This works only for the Node.js runtime (by using process.env) currently and not in the browser, however.
The use of reader conditionals indicates that we are not only defining a single namespace called main.playground.config here, but in fact, we are defining the namespace twice, once as a clj and once as a cljs version. This could also be done in two separate files as well, which is what Thomas Heller recommends in his blog post, to improve readability and avoid some subtle mistakes and pitfalls that the cljc version has in store.
To understand how these two parts play together it's important to know that the ClojureScript compiler is a Clojure process running on the JVM. So the clj and cljs bits of this namespace are running at different times.
There is nothing extraordinary about the :clj part of the ns form at the very top: we are requiring the aero.core namespace like we would do in any other Clojure project. The second :clj reader conditional on the top level is more interesting though:
#?(:clj (defmacro config [& ks] (get-in (aero/read-config "config.edn") ks)) ...)
What we want to achieve here is to read our aero specific config.edn file. But why is this read-config call wrapped inside a macro and not just a simple function? Aero's core functionality to read and parse a file from disk is only available within the JVM runtime environment since it is calling JVM-specific code. The macro is expanded at compile time as part of the cljs compilation process on the JVM and here aero is available and can find and load the config.edn file by calling into clojure.java.io. In addition to that, if we would simply replace the defmacro with a defn form, this function wouldn't even exist at runtime, because it is defined inside the :clj reader conditional
Why is the macro called with a sequence of keys as argument? Since every macro invocation will be replaced with the value returned by the macro at the call site, we want to avoid redundancy and only include each configuration section once. Filtering the desired keys on cljs side would mean the full configuration gets baked in for every single variable definition. Another important reason: security! Depending on the content of the configuration, we might only want to share a selected portion with the frontend and not leak the full configuration.
Why is this "self-require" via :require-macros necessary here?
(ns main.playground.config #?(:clj (:require [aero.core :as aero]) :cljs (:require-macros [main.playground.config :refer [config]])))
The :require-macros keyword, which is ClojureScript specific, tells the ClojureScript compiler that in addition to other cljs namespaces, we also want to include macros here, which need to live in their own separate clj macro namespace to differentiate between compile time and runtime code as described in the ClojureScript API docs in more detail. Note that this is only true for the JVM-based ClojureScript compilation and not for the self-hosted ClojureScript compilation. This is the point in time during compilation when the macro expansion happens and each (config ...) call from within the cljs namespace, e.g.
(def debug (config :debug))
is replaced with the resulting value of the macro expansion. In our case, these are the aero configuration values we are interested in.
This means when the final compiled JS file is loaded by the browser, all the magic has already happened and these configuration values are baked in as ordinary JS variables at this point.
In the end, the key to demystifying this configuration implementation for me was to read more about the ClojureScript compiler(s) and explore the different phases of ClojureScript compilation. This configuration approach still seems a little exotic and heavyweight to me, but I could imagine scenarios where it might come in handy. One example might be a large mono repo setup with a single large configuration file. I'm curious to hear what others think about it.
My personal takeaways from this exploration are:
- If I absolutely want to use the configuration goodies that the aero library has to offer for the frontend as well - there is a way!
- Beware of what you are sharing with the frontend, it might be more than you think.
- Using cljs macros already adds quite a bit of cognitive load, it's better to avoid adding even more to that by using reader conditionals in a single namespace - split into separate clj and cljs namespaces.
Thank you for reading and I wish you safe and happy macroing!