Notes to self

Removing assets dependencies from Rails applications for runtime

Rails provides a smooth assets:precompile task to prepare application assets but keeps all required gems for assets generation as a standard part of the generated Gemfile. Let’s see if we can avoid these dependencies for runtime.

A new Rails application comes with various gems concerning assets compilation and minification:

$ cat Gemfile
...
# Use SCSS for stylesheets
gem 'sass-rails', '>= 6'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 5.0'

We might see other gems in older versions of Rails, like uglifier or coffee-rails.

It makes sense since the Rails’s assets:precompile tasks is usually run within the PRODUCTION environment, where the CSS concerns are defined:

$ cat config/environment/production.rb
...
  # Compress CSS using a preprocessor.
  # config.assets.css_compressor = :sass

  # Do not fallback to assets pipeline if a precompiled asset is missed.
  config.assets.compile = false

Applications without Webpacker might configure a JavaScript processor for the Asset Pipeline:

  # example of older applications
  config.assets.css_compressor = :sass
  config.assets.js_compressor = :uglifier

All of this works but includes extra gems and might have implications about system dependencies. For example, both webpacker and uglifier would need a JavaScript runtime like Node.js for running assets:precompile and possibly for starting the Rails server (Webpacker shouldn’t complain, but uglifier/execjs would).

But what if we want to handle assets outside the Rails application’s deployment, thus removing these dependencies? What if we’re going to build an optimized Docker container using the multi-stage build and not provide Node.js in the final image?

Well, we can do something we already do with development and test dependencies – omit these gems for production. We can move them into a new assets group in the Gemfile:

gem 'webpacker', '~> 5.0'

group :assets do
  gem 'sass-rails', '>= 6'
  ...
end

# or
group :assets do
  gem 'sass-rails', '>= 6'
  gem 'uglifier'
  ...
end

We could be ommiting Webpacker for production only if we don’t depend on javascript_pack_tag and other related helpers. But Webpacker doesn’t break if Node.js is not found, so it’s not an issue.

Rails determines which groups to require by calling Rails.groups. By default, that’s the :default group (anything ungrouped), the environment group coming from Rails.env, and anything added to the RAILS_GROUPS environment variable. With RAILS_GROUPS we can add the assets group to our development and testing environments.

If you are building a Docker image, set RAILS_GROUPS to assets with ARG during build (while avoiding it in the final image):

ARG RAILS_GROUPS="assets"

Once that’s done, let’s also instruct Bundler to load the configuration to set the right groups for the task at hand:

$ export RAILS_ENV=production
$ export RAILS_GROUPS=assets
$ bundle config set --local without development:test
$ rails assets:precompile

The assets:precompile task needs to include assets, but a production start later doesn’t:

$ export RAILS_ENV=production
$ export RAILS_GROUPS=
$ bundle config set --local without development:test:assets
$ rails s

Setting the without option won’t load these assets gems but will fail whenever you try to use them in the configuration directly. This shouldn’t be an issue for a brand new Rails 6.1 application with Webpacker, but if your js_compressor is set to :uglifier, then omitting the gem ends up not starting the Rails server:

/home/rails-user/.rubies/ruby-2.6.5/lib/ruby/gems/2.6.0/gems/execjs-2.8.1/lib/execjs/runtimes.rb:58:in `autodetect': Could not find a JavaScript runtime. See https://github.com/rails/execjs for a list of available runtimes. (ExecJS::RuntimeUnavailable)

That happens because uglifier relies on execjs which tries to autodetect a JavaScript processor. With a RubyGems’ Gem.loaded_specs, we can check if we are loading a specific gem and not set these configuration options:

...
  if Gem.loaded_specs.has_key?('uglifier')
    config.assets.js_compressor = :uglifier
  end
...

Now the uglifier is used only if we are loading it – and we only load it while running assets:precompile.

With the new assets group we could omit certain gems while running the Rails application server and leave out Node.js from the production server or a final container image. That saves memory and removes a possible attack vector. Not bad.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS