Or how to speed up the deployments by avoiding building your application for every environment separately
In one of my posts, I described how to Configure Nuxt.js to consume ENV variables during run-time. Nuxt.js is for Vue what Next.js is for React. Both of these frameworks are used for server-rendered or statically-exported apps. Because of their isomorphic nature, managing ENV variables is more involved than in a strictly speaking front-end, client-rendered application. Unfortunately, I haven't had a chance to play with Next.js yet, but I got an idea for writing this post from my previous post on How to use Google Analytics with React Router v4 where I am using window._env_
to access my ENV variables instead of more commonly used process.env
using Webpack's EnvironmentPlugin
. Hope this is useful for others as much as it was for my team and me.
Why do this at all?
If you want to "build once, deploy many", you generally have to re-invent the wheel in JavaScript (front-end) world since the trend seems to be built for each environment separately and "bake" everything inside your bundle(s).
What does the "build once, deploy many" strategy even mean? Imagine that you have your shiny application that you and your team work on and you want to deploy it to many different environments - test, dev, staging, production,... Most likely you will always be running a production build for all of them. Yet, if you are using CRA out of the box none of them are going to result in the exact same bundles. Why? Because Webpack will "bake in" process.env
variables depending on the environment where the build was run. This is unnecessary and slow. The approach demonstrated here keeps the entire application build intact and runs a script to generate a separate file that will declare a global window variable (object) that contains all ENV variables specific to that environment only.
Dive right in
It turns out this is also quite easy to accomplish since we will take advantage of the heavy lifting that CRA already does to prepare the variables for the already mentioned EnvironmentPlugin
. Keep in mind that CRA uses convention to expose any variable to the client that starts with REACT_APP
(ENV
and PUBLIC_URL
being exceptions).
Writing the script.
// scripts/client-env.js
require('dotenv').config()
const fs = require('fs')
const clientEnv = require('react-scripts/config/env.js')(process.env.PUBLIC_URL || '')
const pathToWrite='./public'
const fileToWrite = `${pathToWrite}/client-env.js`
const globalVarName = '_env_'
const content = `window.${globalVarName} = ${JSON.stringify(clientEnv.raw)}`
try {
fs.writeFileSync(fileToWrite, content, 'utf8')
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error while writing client-env file:', err.message)
process.exit(1)
}
First we require dotenv
so that we have access to our ENV variables through process.env
. Require fs
for writing the file and then the most important line - declaring clientEnv
. Here we require a script from CRA (react-scripts
in node_modules
directory inside your project) and execute it in the same line. The function that config/env.js
file exports takes only one argument (publicUrl
). We read this from the current environment and if not defined, pass an empty string.
The rest of it should be self-explanatory.
Depending on your needs, you could allow the script to take command line arguments. Instead of hard-coding the pathtoWrite
, fileToWrite
, globalVarName
, you could do something like this:
/**
* Remove first two arguments
* 1st arg: path to nodejs
* 2nd arg: location of this script
*/
const args = process.argv.slice(2)
const DIR = /DIR=/i
const pathToWrite = args
.filter(key => DIR.test(key))
.slice(0, 1)
.reduce((prev, curr) => prev + curr.replace(DIR, ''), '')
if (!pathToWrite) {
throw new Error('DIR argument is required.')
}
Plug it in
Now that we have our script, how/when do we run it and how do we consume it? Consuming it is more straight forward so let's address that first.
This file needs to be loaded before the bundle file so head to your public/index.html
file and add <script src="%PUBLIC_URL%/client-env.js"></script>
just before the closing </body>
tag.
Now that we got that out of the way, let's think about where will this need to be executed. For starters, development. So let's add the following line to our package.json
file inside "scripts" object:
"build:env": "node scripts/client-env.js DIR='./public'"
Now for development, instead of "start": "react-scripts start"
, we will have "start": "npm run build:env && react-scripts start"
which will generate the file every time we execute npm run start
.
For other environments, it depends on your use-case. In most projects we use, there is also a server so we have something like this:
"start": "npm run build:env && NODE_PATH=server-build node server-build/index.js"
which generates the file just before the server (re)starts. So your mileage may vary. You could, for example, modify this approach for every client-env.js
to be generated centrally in one location for every environment you need it for.
There are, of course, drawbacks to this approach:
- It is an extra file so your browser may choke sooner if it hits the maximum concurrent connection limit to the same domain
- Your ENV variables are more exposed. Given that this is a browser, you should not be too worried about this since you should not expose anything sensitive there anyway (if you are, please remove the sensitive info ASAP)
- Extra configuration. While this reuses the functionality already built-in inside CRA (and just exposes it differently), it is still an extra factor to worry about when it comes to maintainability/portability
- ENV variables are still going to be baked inside
process.env
, depending on where the production build was run. If you don't consume that, it should not be a problem (aside from the unnecessary bloat in your bundle file(s)). This can be avoided but requireseject
-ing.
We deploy often and having a finished deployment in most cases under 30 seconds makes this approach worth its drawbacks for my team and me.
In and out. Ciao!