All you needed to know about Rails Engines

How to bundle assets in a Rails engine

January 10, 2023 18:28

During Rails' lifetime, we had a lot of ways to load, parse, and process assets. The "recommended" way is to hook into the asset pipeline (whichever that is) and, on deployment, let the assets:precompile task to take over and... compile them so they can be used in your app.

But, if you built an engine that you want to distribute to others, there might be a few things that might get in the way.

Asset pipeline cons

Let's say you're using jsbundling-rails and/or tailwindcss-rails with your distributable engine. To you, it's common to have all you need to compile those assets.
You'll have Node.js and all other external dependencies installed and ready to compile everything together, but the parent app that's using your gem might not. They might use importmaps, and they might not have all that Node.js infrastructure.

When the user pushes the Rails app with your gem installed, they might get the gem without any assets (because they haven't been built yet), or they might even get a crash that sprockets (or propshaft) can't find those assets.

But there's a better way!

Compile assets on publish-time

One approach we had with avo-hq/avo was to precompile the assets before we publish a new version and serve them as static assets to the parent app.

How do we do that?

The overview is this:

  1. Set up build commands
  2. Provide a way for the parent app to reach those assets
  3. Run all the compilation commands
  4. Package the gem up with those static assets

1. Set up build commands

You first install your asset handlers as you need them for your project. They can be anything from rails/jsbundling-rails and rails/tailwindcss-rails to webpacker or something custom.

Add some commands to build up those assets.
We have the following scripts inside the package.json file:

  "scripts": {
    "prod:build:js": "esbuild app/javascript/*.js --bundle --sourcemap --minify --outdir=public/avo-assets",
    "prod:build:css": "tailwindcss -i ./app/assets/stylesheets/avo.base.css -o ./public/avo-assets/avo.base.css --postcss",
  }

They take the source JS and CSS files, compile them, and move them to a public/assets directory.

2. Provide a way for the parent app to reach those assets

When you ship your gem, that public directory will not be public at all. It will be hosted somewhere hidden on that machine, so the browser will not have a way to reach them.

Let's use a Rack::Static middleware to the engine.rb file.

module Avo
  class Engine < ::Rails::Engine
    config.app_middleware.use(
      Rack::Static,
      urls: ["/avo-assets"],
      root: Avo::Engine.root.join("public")
    )
  end
end

Now, the parent app will re-route all the traffic from /avo-assets to that "hidden" directory where our compiled assets are.

3. Run all the compilation commands

When we're ready to ship our gem to RubyGems (or any other gems server), we should run the compilation commands to ensure all the JS and CSS files are compiled, minified, and packaged up how we need them to be in production.

$ yarn prod:build:js
$ yarn prod:build:css

4. Package the gem up with those static assets

We need to instruct the gem utility to add the compiled files to the packaged gem.

Gem::Specification.new do |spec|
  spec.name = "avo"
  spec.version = Avo::VERSION
  # more spec properties

  spec.files = Dir["{bin,app,config,db,lib,public}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "avo.gemspec", "Gemfile", "Gemfile.lock"]

  spec.add_dependency "activerecord", ">= 6.0"
  # other dependencies here
end

The spec.files property knows which files should be bundled up. Finally, you'll see the Dir["{public}/**/*"] will add the whole public directory to the gem, including the avo-assets one.

Run bundle exec rails build to package everything up and profit 🙌

All you wanted to know about Rails engines

This post is part of a series I'm writing to share some of my learnings while building my own distributed engine, Avo.