Cache Busting with Angular CLI

Update 3/20/2017:

Since publishing Angular CLI has transitioned to be based on Webpack instead of Broccoli so the article below is now useless :). As a side affect it also now supports fingerprinting so all caching use cases should be handled.

So it turns out that Angular CLI, while pretty great, doesn’t really fulfill the whole “going to production” thing. While it does do bundling it falls completely short when it comes to other prod-like behavior like cache busting. When I say cache busting I mean: making sure that your users’ browsers never use old code, html or css from it’s cache. Since, I really love Angular and I want to support its development and community I decided to grit my teeth and figure it out. Short story: I figured it out... but yikes.

Fingerprinting

As far as I’ve read/heard/been-prosthelytized-to-about the best way to accomplish cache busting is to simply rename your files every time you deploy anew. That usually takes the form of appending a hash of the contents of the file to the end of your file name. This makes sure that any files that have changed will be fetched freshly from the server and any files that haven’t changed can be loaded safely from the cache. An obvious complication to this is that you have to replace any references to these assets (within the files) with their hashed names.

Angular CLI

So to use Angular CLI as our build tool and to accomplish cache-busting we need to extend the build process of our project. Angular CLI is nice in this way because it is really just a broccoli plugin (because the whole project is forked from Ember CLI) and we have a file called angular-cli-build.js where we can make those extensions. There we need to use another broccoli plugin to modify the files that are produced by Angular2App plugin. And it just so happens that there is just such a plugin: broccoli-asset-rev. So we end up with a build file that looks something like

var Angular2App = require('angular-cli/lib/broccoli/angular2-app');
var AssetRev = require('broccoli-asset-rev');

module.exports = function(defaults) {
var app = new Angular2App(defaults, {
vendorNpmFiles: [
'systemjs/dist/system-polyfills.js',
'systemjs/dist/system.src.js',
'zone.js/dist/**/*.+(js|js.map)',
'es6-shim/es6-shim.js',
'reflect-metadata/**/*.+(ts|js|js.map)',
'rxjs/**/*.+(js|js.map)',
'@angular/**/*.+(js|js.map)'
]
});

if(process.env.EMBER_ENV === 'production'){
// Does fingerprinting
app = new AssetRev(app, {
extensions: ['js', 'css', 'html'],
replaceExtensions: ['html','css','js'],
//exclude: ['index.html'],
generateAssetMap: true
});
}

return app;
};

This will enable your build to produce cache-busting assets when you do a production build and regular ones when in development.

Complications

This is a nice neat story until you try to actually use it… and what you find is that even though Angular CLI is forked from Ember CLI it is incompatible with Ember CLI Addons (which broccoli-asset-rev is). So even by having broccoli-asset-rev in your node_modules directory it will break your build. This happens because Ember CLI has this feature called Addon Discovery where it looks through your node_modules looking for modules that it thinks are addons and then tries to use them automatically. Well this doesn’t work with Angular CLI… so the build breaks. In order to even let the two projects be in our node_modules together I needed to amend the Angular2App broccoli plugin to add a public method to expose the options member (apparently this is part of the contract between Ember CLI and it’s addons…?). So change node_modules/angular-cli/lib/broccoli/angular2-app.js like:

// node_modules/angular-cli/lib/broccoli/angular2-app.js
...
class
Angular2App extends BroccoliPlugin {
...
  /**
* For compatibility with Ember addons
*
@returns {*|{}}
*/
get options(){
return this._options;
}
...
}
module.exports = Angular2App;

Don’t worry I’ve already submitted a bug and PR to address this issue. I’ve also forked the project if you want to just use my fork (I’ll keep it up to date until my PR gets merged).

Relative Assets

Ok, once you patch Angular CLI like above your build should at least execute and you should get fingerprinted assets… and your app should run… as long as you are using absolute asset references… which Angular CLI doesn’t produce in it’s component generation. Your relative assets won’t work because they did not get replaced properly by broccoli-asset-rev. So now you have the option to either make all of your templateUrls and styleUrls absolute (which will break your tests) or come up with some other way to deal with it. My choice was to build another broccoli plugin. This plugin makes some assumptions about which files should be considered relative and then manually replaces relative references to them in the main bundled javascript file. WARNING: this is not a generic javascript or even Angular2 approach…it highly depends on the ng-cli project structure and build conventions. I’ve published it as a project for others to fork an use for their own needs: https://github.com/jonnysamps/ng-cli-relative-assset-plugin.

So now add one more plugin to the build file and you’ll be there:

var Angular2App = require('angular-cli/lib/broccoli/angular2-app');
var AssetRev = require('broccoli-asset-rev');
var AngularCLIRelativeAssetPlugin = require('ng-cli-relative-assset-plugin');
module.exports = function(defaults) {
var app = new Angular2App(defaults, {
vendorNpmFiles: [
'systemjs/dist/system-polyfills.js',
'systemjs/dist/system.src.js',
'zone.js/dist/**/*.+(js|js.map)',
'es6-shim/es6-shim.js',
'reflect-metadata/**/*.+(ts|js|js.map)',
'rxjs/**/*.+(js|js.map)',
'@angular/**/*.+(js|js.map)'
]
});

if(process.env.EMBER_ENV === 'production'){
// Does fingerprinting
app = new AssetRev(app, {
extensions: ['js', 'css', 'html'],
replaceExtensions: ['html','css','js'],
//exclude: ['index.html'],
generateAssetMap: true
});
    // Does relative asset hash replacement
app = new AngularCLIRelativeAssetPlugin([app]);
}

return app;
};

This new plugin will parse the assetMap.json generated by broccoli-asset-rev, find the assets it thinks should be considered relative (hardcoded to be [*.css, *.template]. Why *.template? Because I use rails as a backend and it likes to look for .html files in the asset pipeline. I suppose I should make that configurable. If you’d like that let me know.). It then searches through the main file to replace those references with the hashed file names.

Whew! What a pain!

If anyone has any better approach or suggestions I’d be very happy to hear them.

Side Note: there are a few more details concerning getting this to work well when served by rails. If anyone cares about those, ping me and I’ll add them here.