Programming

12-factoring Single Page Applications

How to dynamically configure Single Page Applications through environment variables in a 12-factor app fashion
April 30, 2016
--
User avatar
Adrian Perez
@blackxored

In an era where the cloud has become our main deployment platform, containers are everywhere, and we're structuring our applications as a set of microservices, we're hearing a lot about 12-factor and cloud-native applications, perhaps even too much (don't scream yet, I'm a fan, haven't you heard?).

But wait... 12 factor what

I'm tempted to say "Consider yourself lucky. There was a time, I used to know where my stuff was running, and this contributed to that loss". On a serious note, you're probably familiar with at least some of the concepts if you've used any PaaS (such as Heroku), and you can get up to speed quickly by visiting the official site.

One of the points seems to be particularly difficult when it comes to Single Page Applications (SPAs from now on), and it's being able to dynamically configure them (see the relevant section of the 12-factor "manifesto" if you're not familiar with the concept), since they're static by definition.

Let's revisit the concept of "static" for a moment before we dive in. Some of these apps are, granted, served by a dynamic server such as Express on NodeJS, or Rails on Ruby (see what I did there?) and we don't encounter this problem (or at least it's easy to "inject" dynamic values into the markup), but what about completely static apps, comprised only of HTML and JS files served solely by Nginx, for example? This is the type of app this post describes, and is a good opportunity to apologize for not naming this "...static Single Page Applications", since the static/application combo in the same sentence doesn't make a lot of sense in my head, and from what I'm aware unless you're doing isomorphic/server-side-rendering (i.e. React, and even then), most SPAs would fit into this description.

Back to the issue that's about to be tackled in this post, while many solutions and workarounds exist, I'm going to describe how to solve it by leveraging a build process, alongside several plugins to achieve this effect. My main advocacy for this solution over others you might be familiar with is based on the fact that it backs the strict separation of build, release and run cycles, and it's as dynamic as it can get (i.e code modification, so use with care).

Our app, our problem

So, I'm a bit lost, what is it again we're trying to do exactly?

Don't you worry, child, it will be become clear in a second.

We have a JavaScript application that needs some configuration values to operate. In addition, those values need to be different depending on the environment (let's say development vs staging vs production). As good developers, we've extracted this configuration to a separate file, and refactored out the common/static parts, but we still have the problem with the dynamic part.

To keep this example simple, we'll pretend that the dynamic "configuration" relates to how to talk to our API server, a very common scenario, and the only part we'll need is the API server's address, which again, would be different on development/staging/etc.

Let's start by taking a look at the common part of our configuration:

const config = {
  apiVersion: "v1",
  apiVendor: "myawesomespa",
  tokenHeader: "Authorization"
  // ...
};

This is a very simple configuration, it tells the particular implementation of the app, which header it will use for authorization, and how to request a specific version of the API (in this case we'll provide an Accept HTTP header of application/vnd.myawesomespa-v1+json). Diving into details of API versioning and authorization is outside the scope of this post, but you're welcome to find my other articles that dive into that topic in depth.

This is where it gets interesting. The recommended 12-factor method of configuration is by injecting configuration as environment variables. In that fashion, for example, we would have an environment variable that stores the address of our API server, and use that variable in the code that needs it. This would have effectively not only decoupled but introduced a strict separation between our configuration and our code.

Let's take a step back and see how to access these in other runtimes, just for the sake of illustration, and appreciation to how easy it is on those:

# Ruby
api_server = ENV['MY_AWESOME_SPA_API_HOST']
// Scala
val apiServer = sys.env("MY_AWESOME_SPA_API_HOST")
// Golang
api_server := os.Getenv("MY_AWESOME_SPA_API_HOST")
// Server-side javascript (i.e NodeJS)
const apiServer = process.env.MY_AWESOME_SPA_API_HOST;

In any of these runtimes, we would have direct access to these variables, or in the worst case scenario indirectly through a templating engine. However, we don't have that luxury in static apps. So how do we solve this?

const config = {
  apiVersion: 'v1',
  apiVendor: 'myawesomespa',
  tokenHeader: 'Authorization',
  apiServer: ?????????
};

This is where the build process kicks in. And we need one 1. The examples for other runtimes earlier made clear that we need a way of accessing environment variables from a language that could evaluate external input, which is not the case for HTML, or statically-served (i.e client-side) JavaScript. It is the case, however, for whatever tool we're using to streamline our build 2.

For this tutorial, we're going to leverage good ol' Gulp, although it can certainly be done with Webpack, Grunt, and maybe even JSPM. I'd love to hear in the comments if you try this approach under these or other build systems/bundlers.

The final config

I'm going to spoil it for you, as some of you avid readers will instantly realize what's happening here (and hopefully still don't stop reading) by taking a look at the finished configuration, since that's the part you're probably most familiar with so far.

const config = {
  apiVersion: "v1",
  apiVendor: "myawesomespa",
  tokenHeader: "Authorization",
  apiServer: "/* @echo MY_AWESOME_SPA_API_HOST */"
};

Now, this bit of wizard-y comes from a popular library called preprocess, specifically using its Gulp plugin in our build.

Let's create a Gulp task that will process our JavaScript files under our app directory, and invoke preprocess in the (no pun intended) process. If you're familiar with JavaScript builds in general, this is the step where you'd normally do concatenation, minification, etc. We're also going to fetch the environment variables themselves since Gulp is running, you guessed, under Node, so nothing stops us from retrieving the ones we need there. You could in theory pass your entire environment, but I'd advice against it, and prefer to keep it more concise, secure and limited, even as it makes the code bigger (admittedly isn't that bad thanks to ES2015, which I've been using exclusively throughout this post).

import gulp from 'gulp';
import preprocess from 'gulp-preprocess';
import path from 'path';

const src = path.join(__dirname, 'src');
const dist = path.join(__dirname, 'dist/');
const scripts = path.join(src, '/**/*.js');

const {NODE_ENV, MY_AWESOME_SPA_API_HOST} = process.env;

gulp.task('build', () => {
  return gulp.src(scripts)
    .pipe(preprocess({
      context: {
        NODE_ENV,
        MY_AWESOME_SPA_API_HOST,
      },
    })
    .pipe(gulp.dest(dist));
});

So, now this should start to make some sense. We're running Gulp (under NodeJS), which if you remember from above, it's able to access environment variables through process.env, and then we're passing those variables along to the preprocess step's "context".

This context would then be available to the preprocess task, and this is where the weird echo syntax shown above starts to be clear, it's part of the preprocess syntax, and the sky is kind of the limit for what you can do with it, I've used it to exclude code from production, modify configuration as described here, inject code based on a particular environment, etc.

As a result, as long as we specify those environment variables when invoking our Gulp task, these variables will get forwarded back to our preprocess task, which would in turn inject them into our final JS/HTML, and thus achieving our initial goal.

Let's test it:

$ MY_AWESOME_SPA_API_HOST=127.0.0.1 gulp build

Now if we take a look at the file that gets produced in the dist dir, it would look like:

const config = {
  apiVersion: "v1",
  apiVendor: "myawesomespa",
  tokenHeader: "Authorization",
  apiServer: "127.0.0.1"
};

Now, it would be tedious to have to specify these environment variables every time we invoke our build, but that's where sane defaults come in:

const config = {
  // ...
  apiServer: "/* @echo MY_AWESOME_SPA_API_HOST */" || "localhost"
};

And also your build process itself, whether it's you manually running the command on your computer (some people wouldn't call that a build "process", but hey, who am I to judge ;)), a shell script, another build task for "deploy", a CI/CD service, a containerized build image, you name it: all of those are places where you would specify those variables.

Now our little app is "12-factored" for configuration. Hope this post can help you with yours and stay tuned for more 12-factor articles, possibly "Build, Release, Run" aided by Docker.


  1. In the interest of full disclosure, I have to note that technically web servers are not completely "static" and there's some black magic you can do with it, some requiring a built-in or compiled scripting engine, but it's out of scope for this post, considerably more complicated, and perhaps even more limiting. Take a look at OpenResty if you're interested in looking down this path.

  2. You're not limited to JavaScript tools for a build system, although it's certainly the most popular option for JavaScript apps. You can still take away the concept from this post, and find or create a preprocess equivalent for the language of your build tool of choice.

~ EOF ~

Craftmanship Journey
λ
Software Engineering Blog

Stay in Touch


© 2020 Adrian Perez