try-catch-FAIL

Failure is inevitable

NAVIGATION - SEARCH

Handling Environment-Specific Configuration with Visual Studio Tools for Apache Cordova

I would wager that just about every app you work on is actually deployed in multiple environments. Those environments might just be "my machine" and "production", but still: multiple environments! We have good tools for dealing with that in .NET, but what about in our Cordova applications? That's exactly the question that was asked on my new VS TACO course by one viewer. Let's talk about our options!

The Goal

So, what are we trying to accomplish here? We've got a Cordova application (the sample app from my course), and we'd like to be able to run it against different environments. Let's assume each environment has its own backend services. When running locally, we want it to call APIs running on http://localhost. When we're testing it against our staging environment, we want it to use http://staging.fakeapp. And in production, we want to use http://api.fakeapp.

We're going to be building our app before we run it against an environment, so it makes sense to define solution configurations for each environment. Here's what my Visual Studio solution configuration looks like now:


Note: I typically leave the "Release" configuration as a reference, but feel free to delete it if it bothers you.

If this was a web application, we'd define web.config transforms, one per environment, that would swap out our settings. But we don't have a web.config file, because this is a Cordova application! Instead, I like to store my configuration in a simple "config.js" file, which looks like this in our sample AngularJS app:

(function() {
    angular.module('app').constant('config', {
        environment: 'local',
        apiUrl: 'http://localhost/api/'
    });
})();

How do we transform this file based on the environment? Well, transforming it is actually harder, and pointless, so instead, we'll replace it with an environment-specific file.

Our Environment-Specific Config Files

I follow a very simple convention for my configuration files: one config file per environment, named by the environment, like so:

//config.debug.js (my default for local development and testing)
(function() {
    angular.module('app').constant('config', {
        environment: 'local',
        apiUrl: 'http://localhost/api/'
    });
})();

//config.staging.js
(function() {
    angular.module('app').constant('config', {
        environment: 'staging',
        apiUrl: 'http://staging.fakeapp/api'
    });
})();

//config.production.js
(function() {
    angular.module('app').constant('config', {
        environment: 'production',
        apiUrl: 'http://fakeapp/api'
    });
})();

Each is fully self-contained. We just need to make sure our app loads the right file at runtime. We'll do that by copying in the correct environment-specific config file at build time. We won't change our HTML file, which will always load whatever configuration file is deployed:

<!-- This is what our script section should look like now, with our config at the end. -->
<script type="text/javascript" src="cordova.js"></script>
<script type="text/javascript" src="scripts/platformOverrides.js"></script>
<script type="text/javascript" src="js/vendor.min.js"></script>
<script type="text/javascript" src="js/app-templates.min.js"></script>
<script type="text/javascript" src="js/app.min.js"></script>
<script type="text/javascript" src="config.js"></script>

One important thing to note: we must keep our config files outside of our normal "app" folder so that they won't be bundled and minified with the rest of our app code. We load it directly from the config.js file that's copied in to our www folder.

So, that's what our config files look like. But how do we copy in the right config file for our current build configuration?

Option 1: Use a Hook!

Cordova's build process is flexible and extensible via "hooks." A hook is just a script that can be used to alter or extend the behavior of Cordova's build system.

There are a ton of places we can hook in to (see the docs for a full list). For swapping out configuration files, hooking in to 'after_prepare' makes sense. This will allow us to copy in the correct config file that matches the active build configuration before Corodva starts to actually build our application package.

Let's create our hook! We'll need to add a new JS file, which I'll place in our project's hooks folder:

var fs = require('fs');

module.exports = function (context) {

    var environment = getEnvironment(context.cmdLine);

    if (!environment) {
        console.log('Unable to determine build environment!');
        return;
    }

    var sourceFile = 'config/config.' + environment + '.js';

    fs.createReadStream(sourceFile).pipe(fs.createWriteStream('www/config.js'));
}

function getEnvironment(cmdLine) {

    var matches = /--configuration (\w+)/.exec(cmdLine);

    if (matches && matches.length > 1) {
        return matches[1];
    }

    return null;
}

Let's talk about the high-points of this simple hook.

First, we're parsing the build configuration from the command line that was used to run Cordova.

For VS TACO, that command line will look something like this:

C:\Users\YOUR_USER\AppData\Roaming\Microsoft\VisualStudio\MDA\vs-npm\2.14.9\node.exe 
    C:\Users\YOUR_USER\AppData\Roaming\npm\node_modules\vs-tac\app.js build 
    --platform Android 
    --configuration Debug 
    --projectDir . 
    --projectName IntroToCordova 
    --npmInstallDir

We need to parse that command line and extract the value of the "configuration" argument, which is exactly what the getEnvironment function does with a little bit of regex.

Once we know the environment, all we need to do is copy in the right one to our www folder.

var sourceFile = 'config/config.' + environment + '.js';
fs.createReadStream(sourceFile).pipe(fs.createWriteStream('www/config.js'));

That's it! All that's left is to wire up our hook in our config.xml file so that Cordova will execute it! Just add a <hook> element at the end of the file, like so:

<?xml version="1.0" encoding="utf-8"?>
<widget ...> 
  <!-- SNIP -->
  <plugin name="cordova-plugin-inappbrowser" version="1.5.0" />
  <plugin name="cordova-plugin-geolocation" version="2.4.0" />
  <plugin name="cordova-plugin-compat" version="1.1.0" />
  <plugin name="cordova-plugin-camera" version="2.3.0" />

  <hook src="hooks/copyConfig.js" type="after_prepare"/>
</widget>

Now we can execute a build, and we should see this in our www folder:


Great. But....

Other Matt: "We're not finished, are we?"

No we're not, Other Matt.


The Problem With Hooks

Hooks work great for Cordova, BUT, there's currently a bug (or feature?) in VS TACO itself: when you build for Ripple, your hooks will only be executed on the first build! If you change your configuration, then build again, nothing will be updated, because your hooks won't fire.

The work-around is to nuke your platforms folder when you change configuration. This will cause the underlying vs-taco-cli tooling to correctly execute your hooks again. In my opinion, this isn't a very workable solution. It's too prone to human error.

Still, it's worth remembering that this only affects you if you are running in Ripple and wish to change your configuration to target a different environment. If you are building for a device or an emulator, your hooks will fire correctly on each build. If you never use Ripple for anything but local development, this may be all you need.

For the rest of us...

An Alternative: Use Gulp

Instead of relying on Cordova's build system to copy the configuration file for us, we can instead rely on our client-side build tool: Gulp!

I like to set things up so that my default "I'm developing locally!" task copies in my debug/localhost configuration. Since I'm using Heroic Gulp, I can add a step to my "build" task, like so:

//in gulpfile.js, before:
exports.build = gulp.series(clean, gulp.parallel(exports.js, exports.css, htmlTemplates, staticAssets));

//in gulpfile.js, after: 
exports.build = gulp.series(clean, gulp.parallel(exports.js, exports.css, htmlTemplates, staticAssets), copyDebugConfig);

This adds in a new copyDebugConfig step, which we'll now need to implement:

//Don't forget to require fs!
//const fs = require('fs');

function copyDebugConfig(done) {
    fs.createReadStream('config/config.debug.js').pipe(fs.createWriteStream('www/config.js'));
    done();
}
exports.copyDebugConfig = copyDebugConfig;

Now when my default buildAndServe task executes, my debug config is copied in to my www folder. But what about other configurations?

For that, I just add a couple more tasks to my gulp file:

function copyStagingConfig(done) {
    fs.createReadStream('config/config.staging.js').pipe(fs.createWriteStream('www/config.js'));
    done();
}
exports.copyStagingConfig = copyStagingConfig;

function copyProductionConfig(done) {
    fs.createReadStream('config/config.production.js').pipe(fs.createWriteStream('www/config.js'));
    done();
}
exports.copyProductionConfig = copyProductionConfig;

If I want to run my app in Ripple against staging, I just manually execute my copyStagingConfig task. And if I want to run against production, I execute my copyProductionConfig task.

This works well enough, BUT, this option isn't perfect, either. You have to manually run the appropriate gulp task to change your environment before building a release package, which could be dangerous. Again, we're leaving the door wide-open for human error.

What will happen if I push out a production release, but I forget to run my copyProductionConfig task first?

Bad things, Other Matt. Bad things.

(although this animation is actually awesome)

Cover All Your Bases: Use Both

Since there are gaps left by each approach, the safest thing to do is to use both approaches together.

By using a hook, we'll ensure that the correct configuration is used when we build our app for a device.

By using Gulp, we can easily switch which environment we are targeting during our day-to-day development.

Together, this gives us the flexibility to run against any environment we want when working locally, and it ensures that we use the correct configuration when we go to create a package.

The Final Code

You can check out the final, combined solution in action in my sample project. Let me know what you think!

And if there are other VS TACO-related topics you want to learn about, let me know in the comments!

About Matt Honeycutt...

Matt Honeycutt is a software architect specializing in ASP.NET web applications, particularly ASP.NET MVC. He has over a decade of experience in building (and testing!) web applications. He’s an avid practitioner of Test-Driven Development, creating both the SpecsFor and SpecsFor.Mvc frameworks.

He's also an author for Pluralsight, where he publishes courses on everything from web applications to testing!

blog comments powered by Disqus