Daniel Doubrovkine bio photo

Daniel Doubrovkine

aka dB., @awscloud, former CTO @artsy, +@vestris, NYC

Email Twitter LinkedIn Github Strava
Creative Commons License

I wrote about how we do asset packaging with Rails, how we Jammit and push them to S3 in this post. We’ve had a few surprises since then, one that had to do with compressed assets.

If a browser sends an Accept-Encoding: gzip _header for a resource that has both an uncompressed and a compressed copy (eg. _client.js and client.js.gz), the server can respond with the compressed version of the file along with the original Content-Type (eg. text/css) and Content-Encoding: gzip headers. This is called content negotiation. Unfortunately S3 has very poor negotiation skills. CloudFront CDN does better, but only if the server behind it supports it. So putting a CloudFront in front of S3 produces the same effect and Amazon recommends using a custom origin server (a fancy word for another web server), other than S3 if you want to enable content negotiation. We’d rather not serve static assets from Heroku because it requires checking them into source control. And we’d rather not add a web server that we have to maintain – too much infrastructure when multiplied by the number of developers.

The solution is, as usual, to monkey-patch rails. We’re going to tell our Rails application to serve a compressed file if the browser includes the right headers by rendering an URL to the compressed version.

In Rails 3.0 (your mileage will vary for 3.1) paths to assets are written via ActionView::Helpers::AssetTagHelper’s path_to_javascript and path_to_stylesheet. We can figure out browser capabilities by examining request.env[‘HTTP_ACCEPT_ENCODING’] and rewrite those URLs to our liking.

module ActionView
  module Helpers
    module AssetTagHelper
      def accept_encoding?(encoding)
        (request.env['HTTP_ACCEPT_ENCODING'] || '').split(',').include?(encoding)
      def rewrite_path_to_gzip?(source)
        (! config.asset_host.blank?) and (source =~ /assets\//) and accept_encoding?('gzip')
      def path_to_javascript(source)
        source = rewrite_path_to_gzip(source) if rewrite_path_to_gzip?(source)
        compute_public_path(source, 'javascripts', 'js')
      def path_to_stylesheet(source)
        source = rewrite_path_to_gzip(source) if rewrite_path_to_gzip?(source)
        compute_public_path(source, 'stylesheets', 'css')
      def rewrite_path_to_gzip(source)
        source + ".cgz"

config/initializers/asset_tag_helper.rb and spec/initializers/rails/asset_tag_helper_spec.rb_

The .cgz extension replaces the .gz extension to workaround a bug in Safari. The rewrite_path_to_gzip? check ensures that we’re rendering something under assets and that we’re using an external server (not in development). Your condition may be different.

Finally, we must set the proper content encoding headers when pushing the assets to S3. Here’s the meat of our Rake task.

File.open(entry) do |entry_file|
  content_options = {}
  content_type = MIME::Types.type_for(entry)[0]
  content_options['x-amz-acl'] = 'public-read'
  if entry.ends_with?('.gz')
    uncompressed_entry = entry[0..-4]
    entry = "#{uncompressed_entry}.cgz"
    content_type = MIME::Types.type_for(uncompressed_entry)[0]
    content_options['content-encoding'] = 'gzip'
  content_options['content-type'] = content_type.to_s
  key = 'assets/'
  key += entry.slice(from.length + 1, entry.length - from.length - 1)
  s3i.put(to, key, entry_file, content_options)

Note that the Content-Type for a .css.gz file is the same as for a .css file (text/css) and that Content-Encoding is set to gzip.

To verify that your implementation worked, examine the page source and make sure you got a .css.cgz link for stylesheets. Then, navigate to the .css.cgz URL – it should display an uncompressed CSS_._

For purists, this implementation is really bad. A browser may request a web page with Accept-Encoding: gzip, but then request the CSS from another server without it. I am going to decide that this never happens in real life.