Shared Asset Engine

A Technique for Serving Common Assets Among Sub-Apps

It’s difficult trying to create a consistent look and feel across a large website that hosts modern and legacy apps. We needed our own Twitter Bootstrap that would work with a variety of different languages and frameworks like Rails, Wordpress and PHP.

This article explains how we accomplished that.

Goals

In approaching the problem, we laid out the following goals, not knowing if they were all possible:

Ease of implementation

The solution should require no more than a single line of code to implement in a sub-app

Simplicity of updates

Pushing changes to the shared assets should not require any updates to the sub-apps. These sub-apps will be maintained by different teams and will be hosted on multiple subdomains, so it would be a huge pain to have to update each app.

Appropriate caching

The assets should take advantage of browser caching for performance, but when an update is pushed, the browser should know to fetch the latest version. This way, we never serve stale assets.

Potential for future integration with CDN

Although it wasn’t necessary to implement CDN caching right away, we wanted to make sure there would be a straightforward way to integrate with CloudFront (or something comparable) in the future.

Solution: Overview

First, we gathered the common styles such as the header, footer, grid system, typography and other reusable components. Once we extracted the necessary styles and scripts, we needed a way to host and serve them that would meet our goals.

A Sinatra app hosted at assets.example.com serves 3 routes: /css, /js and /loader.

At its most basic level, the solution looks like this:

The /css and /js routes serve the compiled and minified assets using the sinatra-asset-pipeline gem. This allows for Sass and Sprockets support. We get asset fingerprinting for free when Sprockets precompiles the assets. By creating a unique hash of the file contents and appending it to the filename, when the contents of our CSS or JS file changes, the filename will change as well. As a result, the browser will request a fresh copy of that file. Cache busted!

The /loader route serves a small script that rewrites HTML in the section, loading the other assets in turn.
Sinatra App

/Gemfile

source 'https://rubygems.org'

gem 'bundler'  
gem 'foreman'  
gem 'thin'  
gem 'sinatra'  
gem 'sinatra-asset-pipeline'  
gem 'uglifier'  

/app.rb

require 'bundler'  
Bundler.require  
require 'sinatra/asset_pipeline'

class App < Sinatra::Base

  # Configure Asset Pipeline
  set :assets_precompile, %w(application.css application.js)
  set :assets_prefix, %w(assets vendor/assets)
  set :assets_css_compressor, :sass
  set :assets_js_compressor, :uglifier

  register Sinatra::AssetPipeline

  # Routes
  get '/css' do
    response.headers['Content-Type'] = 'text/css'
    File.read( File.join('public', asset_path('application.css') ) )
  end

  get '/js' do
    response.headers['Content-Type'] = 'application/javascript'
    File.read( File.join('public', asset_path('application.js') ) )
  end

  get '/loader' do
    response.headers['Content-Type'] = 'application/javascript'
    @css_path = path_for( 'application.css' )
    @js_path = path_for( 'application.js' )
    erb :'loader.js'
  end

  private

  # Helper Functions
  def path_for( asset_name )
    if production?
      asset_type = asset_name.split('.').last
      "#{host}/#{asset_type}?v=#{thumbprint(asset_name)}"
    else
      asset_path(asset_name)
    end
  end

  def production?
    ENV['RACK_ENV'] == 'production'
  end

  def host
    ENV['host'] || 'http://localhost:5000'
  end

  def thumbprint( asset_name )
    asset_path(asset_name)[/#{Regexp.escape("-")}(.*?)#{Regexp.escape(".")}/m, 1]
  end  
end  

Core Assets

The core application.css and application.js files reside in /assets/stylesheets/application.css.scss and /assets/stylesheets/application.js respectively. These manifest files are responsible for importing the components that make up the shared styles and scripts following Sprockets’ require notation. The content of these files will be unique to each application so I won’t go into further detail here.

One thing to keep in mind when writing these styles and scripts is that proper scoping is of the utmost importance. We need to build these files such that they can be included in any project without conflicting with any pre-existing code or styles.

Loader.js file

The loader.js file is also served by the Sinatra app. It acts like a 3rd-party JavaScript widget, rewriting the HTML to load additional stylesheets, JavaScript files and other dependencies.

/views/loader.js.erb

(function () {
  function loadStylesheet(url) {
    var link = document.createElement('link');
    link.href = url;
    link.rel = "stylesheet";
    link.type = "text/css";
    link.async = false;
    document.head.appendChild(link);
  }

  function loadScript(url) {
    var script = document.createElement('script');
    script.src = url;
    script.async = false;
    document.head.appendChild(script);
  }

  // Load Stylesheets
  loadStylesheet('//fonts.googleapis.com/css?family=Open+Sans:400,600');
  loadStylesheet("<%= @css_path %>");

  // Load Scripts
  loadScript("//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js");
  loadScript("<%= @js_path %>");
}());

Items of note:

  • The logic is wrapped in an anonymous function call to avoid polluting the global scope.
  • The order these files are loaded is important. Stylesheets are loaded before JavaScript
  • We set async false to block execution until these files load because we had some dependencies in these files that could cause load-time errors. To improve performance, you might try enabling async on some or all of these files.
  • Besides the CSS and JS files, we’re also loading a font stylesheet from and jQuery from Google’s CDN.

Rack config for hosting

Finally, we need to host our application. We chose to host it on Heroku because we already host a number of applications there and everyone on the team is familiar with it. Because Sinatra is a Rack-based application, it’s easy enough to host anywhere, but Heroku makes it trivial. The following two files are all that’s necessary to get the shared asset engine application running on Heroku.

/config.ru

require './app.rb'  
run App.run!  

/Rakefile

require 'sinatra/asset_pipeline/task'  
require './app'

Sinatra::AssetPipeline::Task.define! App  

Conclusion

With this solution, we fulfilled all the goals we set out to accomplish.

  • It’s dead simple to implement in any sub-app. The only requirement is a <script> tag that points to our loader.js file.
  • Updates are automatic. The assets get re-compiled when a new version is pushed to Heroku.
  • At the same time, we make use of the browser’s cache, leveraging Sprockets’ file fingerprinting to bust the cache when the files change.
  • Because loader.js writes the asset URLs on-the-fly, it will be a straightforward process to build CloudFront caching into our app when we need it.

So far, everyone on our team is happy with this solution. We’ve even expanded on it to serve a common favicon as well. If anyone has any suggestions for improvement, I’d love to hear them.

Andrew Allen

Author of EfficientRails.com, Software Engineer @Munchery, Former startup founder.

comments powered by Disqus