Asset Versioning using Grunt

What is Asset Versioning? This is sometimes referred to as cache busting. Assets for your website are CSS files, JavaScript files, images and other things that are used by all the pages in a website. These are files that should not change often, so for better performance, you want the browser to just download the file the first time it accesses your site and then use cached copies going forward. After the initial page load, the rest of the pages on your website should load quicker because of this. This is accomplished by having your web server set expiration dates on those files far in the future. You can do this in your Apache configuration like this:

<FilesMatch "\.(gif|jpg|png|js|css)$">
  ExpiresActive On
  ExpiresDefault "access plus 10 years"
</FilesMatch>

However, what happens when you make a change to a CSS or JS file. Since the expiration date is set in the future, the browser will continue to use the original version. Fixing this problem is what Asset Versioning or Cache Busting is meant to handle.

I know about 3 methods of doing asset versioning:

  1. Query string parameter: /js/main.js?v=1.2.0
  2. Folder: /1.2.0/js/main.js
  3. File name: /js/main.1.2.0.js

I had always used the query string parameter method for asset versioning, but I have learned that there are problems with that method. I found an article by Steve Souders that explained some proxy servers will not cache items with query strings, so using this technique will cause your files to not be cached at all which will result in slower performance which is the opposite of what we want. (FYI – Steve Souders wrote the book High Performance Web Sites.)

I don’t like the folder method, because not all your assets will change with every release. This will result in the cache changing every time you have a release even though some items do not need to be changed.

This leads me to the file name method. There are several methods of versioning file names. You can use a version number (like 1.2.0), but that generally means maintaining the file names by hand. Another option is using a date stamp in the file name. One problem I have run into with this method was related to running multiple servers in a cluster. We use 5 web servers in our load balancer. We would deploy the code to each server and run the versioning code and it would add a date stamp to each file. However, the date stamp also included the hour. Not all of the boxes would be running the versioning code at the same time so some of the servers would use a string like “2016031114”, but others would be running a few minutes later and have a string that ended in 15. This would end up with occasional 404 pages when an asset would be requested but the request would get sent to a different server in the load balancer. The final way I know of creates a hash (like a md5 hash) based on the file contents and adds that to the file name. I have found this method works best. I have found a grunt plugin for doing this.

The plugin that I chose is grunt-assets-versioning. There are several npm modules available, but this is the one I like best. As well as versioning your assets, it will output a mapping file of the assets versioned that can be used in your backend assets controller. To install it, do npm install grunt-assets-versioning --save-dev. This will automatically add it to your package.json file during the install. After installing, you activate it by updating your Gruntfile with grunt.loadNpmTasks('grunt-assets-versioning'); and defining a grunt task to do the versioning.

This is the grunt task I setup:

assets_versioning: {
    options: {
      tag: 'hash',
      post: true,
      versionsMapFile: 'app/helpers/AssetHelper.php',
      versionsMapTemplate: 'app/helpers/AssetHelper.php.tpl',
      versionsMapTrimPath: 'public'
    },
    files: {
      options: {
        tasks: ['uglify:global','uglify:advert','cssmin:target']
      }
    }
}

Here is a quick overview of the options I use. The “tag” option is using “hash” which means take an MD5 hash of the file contents to use. There is an option for doing a date stamp, but that can cause problems when deploying across a web cluster, so I recommend using the “hash” value. The option “post” is set true so it will process the files after running a task which we want since we also want to minify our files.

The remaining options deal with creating a version map so we can identify the files created during the asset versioning task. The “versionsMapFile” option is the file name to create. If you do not specify a template, it will create a JSON file with the name specified. Here is an example of the JSON file it would generate:

[
  { "originalPath": "public/js/global.min.js", "versionedPath": "public/js/global.min.xxxxxxxxx.js", "version": "xxxxxxxxx" },
  { "originalPath": "public/js/advert.min.js", "versionedPath": "public/js/advert.min.xxxxxxxxx.js", "version": "xxxxxxxxx" },
  { "originalPath": "public/styles/global.min.css", "versionedPath": "public/styles/global.min.yyyyyyyy.css", "version": "yyyyyyyy" }
]

The next option I want to review is “versionsMapTrimPath”. This will remove portions from the versionedPath that you do not want. I the case above, the docRoot for the website is the public directory. So the path will be correct when referencing it in a src in a script tag, we want to remove the leading public so it would be a valid path. You can see that is what I do in the options used above. This would give us a path of “/js/global.min.xxxxxxxx.js”.

However the key to making this useful is creating a template for creating a more useful file for us than the JSON format above. This plugin uses underscore.js for its templating engine. Here is the template I use for creating a PHP file:

<?php
namespace app\helpers;

use \app\lib\Helper;
class AssetHelper extends Helper
{
  public static $dict = array(
<% _.forEach(files, function(file) { %>
    "<%= file.originalPath %>" => "<%= file.versionedPath %>",
<% }); %>
  );
}

This template would result in this file being created:

<?php
namespace app\helpers;
use \app\lib\Helper;
class AssetHelper extends Helper
{
  public static $dict = array(
    "/js/global.min.js" => "/js/global.min.5e0677e7.js",
    "/js/advert.min.js" => "/js/advert.min.49317176.js",
    "/styles/global.min.css" => "/styles/global.min.3d71d72b.css",
  );
}

In our PHP application, we use Twig for templating. Here is how I created a Twig function to use the data in the PHP file created by the template.

$twig->addFunction(new Twig_SimpleFunction(
    'assetStylesheet',
    function ($asset) use ($app) {
        if (isset(AssetHelper::$dict[$asset])) {
            $asset = AssetHelper::$dict[$asset];
        }
        return '';
    },
    array('is_safe' => array('html'))
));

$twig->addFunction(new Twig_SimpleFunction(
    'assetScript',
    function ($asset) use ($app) {
        if (isset(AssetHelper::$dict[$asset])) {
            $asset = AssetHelper::$dict[$asset];
        }
        return '';
    },
    array('is_safe' => array('html'))
));

These functions work on the basis of if I find the string replace it with the versioned string otherwise use the original string. I would then use these functions like this:

{{ assetScript('/js/global.min.js') }}
{{ assetScript('/js/nonversioned.js') }}
{{ assetStylesheet("/styles/global.min.css") }}

This would output the following on what gets served to the client:

<script type="text/javascript" src="/js/global.min.5e0677e7.js"></script>
<script type="text/javascript" src="/js/nonversioned.js"></script>
<link href="/styles/global.min.3d71d72b.css" rel="stylesheet">

The last part of the grunt task is defining what files to process. In my case, I told it to process the output of tasks defined in the Gruntfile. The other tasks are for uglifying javascript and minifying css files. You can also list specific file names, but I find it more effective for performance to also uglify and minify assets.

This has been effective for me for versioning assets. If you have other ideas, please leave a comment.

Leave a Reply

Your email address will not be published. Required fields are marked *