Env Vars in under 100 Lines of Code — (.env)
Like many projects that run on Node.js our apps use the dotenv
package from npm to load environment variables from a local file which is kept out of git.
This lets our developers keep important secrets out of source control while our apps maintain a consistent way to read from the environment.
But I like to break things and decided to rewrite the dotenv
entirely in ClojureScript, the result has been added to our degree9/enterprise
repo.
Let’s get started!
Ok, so the Node.js environment is mutable within the running process. Which means we can override the js/process.env
object with variables we load from our local file, this is basically the same process that dotenv
uses.
Let’s start with some simple helper functions, these will make working with the .env
file and js/process.env
a bit easier.
First up is read-file
we want to make sure the environment is populated prior to anything trying to read from it, so we are using the synchronous operation. By providing an encoding the function will return a string instead of a buffer.
(defn- read-file [path]
(.readFileSync fs path #js{:encoding "utf8"}))(defn- env-file [dir]
(.resolve path dir ".env"))
The env-file
is a simple path resolver which will allow us to provide an optional path to look for the .env
file.
(defn- split-kv [kvstr]
(cstr/split kvstr #"=" 2))(defn- split-config [config]
(->> (cstr/split-lines config)
(map split-kv)
(into {})))
Next we have the split-kv
helper, this produces a key/value pair from MY_ENV_VAR=foobar
.
While split-config
takes the result of our synchronous file read then splits each line and applies our previous split-kv
to each.
(defn- dot-env [path]
(-> (env-file path)
(read-file)
(split-config)))
The result of our existing helpers is the dot-env
function, a convenient way to load our .env
files as a hash-map config.
(defn- node-env [env]
(->> (js-keys env)
(map (fn [key] [key (obj/get env key)]))
(into {})))
It has a counter part, the node-env
function. This converts a js/process.env
object into a clojure hash-map.
Our last helper is populate-env!
which takes a clojure map and sets each key value pair to the js/process.env
object.
(defn- populate-env! [env]
(doseq [[k v] env]
(obj/set js/process.env k v)))
Now that we have all our helper functions ready to go, we move on to the public API we will be using to interact with the environment variables.
(defn init!
"Initialize environment with variables from .env file."
([] (init! {:path (.cwd js/process) :env js/process.env}))
([{:keys [path env]}]
(populate-env!
(merge (dot-env path)
(node-env env)))))
Starting up our application we want to initialize the environment, we do this by populating it with the result of merging our .env
and js/process.env
maps. We want to call this init!
function as early as possible in our code so that the environment is ready before anything tries to read from it.
In our application we simply read from the environment using our other public function get
(defn get
"Return the value for `key` in environment or `default`."
([key] (get key nil))
([key default] (get js/process.env key default))
([env key default] (obj/get env key default)))
Put it all together!
(require [degree9.env :as env])(env/init!)(env/get "MY_ENV_VAR")