Why Rails Engines ?

First, let me define my problem. I want to write a rails engine for my customer, this engine is to provide several rails type features such as helpers, components etc… But, it also needs to package its assets in a modern webpack type way.

However, unlike sprockets, webpacker knows nothing about rails engines so your application assets live in app/javascripts and thats it. So, how do I write my engine in such a way that the application can easily use its assets ?

The 2 options I thought of are :-

  1. Have the engine’s source code as both a rubygem and a node module so it can be included into the application as a gem for the rails features and as a node module for the assets.
  2. Configure the engine to have webpack installed and produce its own packs that can be used by the application.

I liked option 1 for a while but when I started to use it, I was hitting issues when requiring jsx files from the gem as this was not going through webpacker first.

So, this article is going to focus on option 2

Where To Start

My plan is to setup a rails engine with a dummy app for testing. I will configure this dummy app so it can easily be used in development mode as well as in the test environment. This gives me a ‘rails like environment’.

After that, I will follow the instructions in https://github.com/rails/webpacker/blob/master/docs/engines.md to install webpacker.

Creating The Engine

Looking at the help for the ‘rails plugin’ command, I have decided on the following command line to generate the engine.

rails plugin new my_fancy_engine --database=sqlite3 --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-record --skip-active-storage --skip-action-cable --skip-sprockets --skip-spring --skip-turbolinks --skip-bootsnap --full

This produced a load of files with no errors – so looks good so far. Im now going to commit to my github repo so that I can see my steps again later.

Running A Development Server

Ok, lets give this a go – run a rails server like this

./bin/rails s

Boom – we get a massive error saying

The gemspec at /home/garytaylor/projects/my_fancy_engine/my_fancy_engine.gemspec is not valid. Please fix this gemspec. (Gem::InvalidSpecificationException

Ok, lets fix this – editing the gemspec file putting in some valid values and then try again

garytaylor2@garyslaptop:~/projects/my_fancy_engine$ ./bin/rails s
 => Booting WEBrick
 => Rails 6.0.2.2 application starting in development http://localhost:3000
 => Run rails server --help for more startup options
 [2020-04-23 07:06:33] INFO  WEBrick 1.4.2
 [2020-04-23 07:06:33] INFO  ruby 2.5.1 (2018-03-29) [x86_64-linux]
 [2020-04-23 07:06:33] INFO  WEBrick::HTTPServer#start: pid=7370 port=3000

Perfect, we now have a web server running. If I go to http://localhost:3000 – I get the normal welcome to rails page.

Installing Webpacker

Following the instructions in https://github.com/rails/webpacker/blob/master/docs/engines.md starting at step 2 (as we have already created the engine), I add 8 files as follows

config/webpacker.yml

# Note: You must restart bin/webpack-dev-server for changes to take effect

default: &default
  source_path: app/javascript
  source_entry_path: packs
  public_root_path: public
  public_output_path: packs
  cache_path: tmp/cache/webpacker
  check_yarn_integrity: false
  webpack_compile_output: true

  # Additional paths webpack should lookup modules
  # ['app/assets', 'engine/foo/app/assets']
  resolved_paths: []

  # Reload manifest.json on all requests so we reload latest compiled packs
  cache_manifest: false

  # Extract and emit a css file
  extract_css: false

  static_assets_extensions:
    - .jpg
    - .jpeg
    - .png
    - .gif
    - .tiff
    - .ico
    - .svg
    - .eot
    - .otf
    - .ttf
    - .woff
    - .woff2

  extensions:
    - .jsx
    - .mjs
    - .js
    - .sass
    - .scss
    - .css
    - .module.sass
    - .module.scss
    - .module.css
    - .png
    - .svg
    - .gif
    - .jpeg
    - .jpg

development:
  <<: *default
  compile: true

  # Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules
  check_yarn_integrity: true

  # Reference: https://webpack.js.org/configuration/dev-server/
  dev_server:
    https: false
    host: localhost
    port: 3035
    public: localhost:3035
    hmr: false
    # Inline should be set to true if using HMR
    inline: true
    overlay: true
    compress: true
    disable_host_check: true
    use_local_ip: false
    quiet: false
    pretty: false
    headers:
      'Access-Control-Allow-Origin': '*'
    watch_options:
      ignored: '**/node_modules/**'


test:
  <<: *default
  compile: true

  # Compile test packs to a separate directory
  public_output_path: packs-test

production:
  <<: *default

  # Production depends on precompilation of packs prior to booting for performance.
  compile: false

  # Extract and emit a css file
  extract_css: true

  # Cache manifest.json for performance
  cache_manifest: true

One thing I noticed (literally as I pasted it into this blog) are these lines

# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
resolved_paths: []

I wish I had noticed them before when I was experimenting with option 1 – it might have solved my problem. However, I much prefer the route I am following as it will give me a typical rails engine experience where I just add it to my gemfile and everything is available – I dont want to force the projects using this gem to have to modify the webpack config etc..
When you think about it further, taking this approach doesnt even force the application to use webpack – just anything that can require javascript modules really.

config/webpack/environment.js

const { environment } = require('@rails/webpacker')

module.exports = environment

config/webpack/development.js

process.env.NODE_ENV = process.env.NODE_ENV || 'development'

const environment = require('./environment')

module.exports = environment.toWebpackConfig()

config/webpack/production.js

process.env.NODE_ENV = process.env.NODE_ENV || 'production'

const environment = require('./environment')

module.exports = environment.toWebpackConfig()

config/webpack/test.js

process.env.NODE_ENV = process.env.NODE_ENV || 'development'

const environment = require('./environment')

module.exports = environment.toWebpackConfig()

bin/webpack

#!/usr/bin/env ruby

ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"

require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)

require "bundler/setup"

require "webpacker"
require "webpacker/webpack_runner"

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::WebpackRunner.run(ARGV)
end

bin/webpack-dev-server

#!/usr/bin/env ruby

ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"

require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)

require "bundler/setup"

require "webpacker"
require "webpacker/dev_server_runner"

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::DevServerRunner.run(ARGV)
end

package.json

{
  "name": "my_fancy_engine",
  "private": true,
  "dependencies": {
    "@babel/preset-react": "^7.9.4",
    "@rails/actioncable": "^6.0.0",
    "@rails/activestorage": "^6.0.0",
    "@rails/ujs": "^6.0.0",
    "@rails/webpacker": "4.2.2",
    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
    "prop-types": "^15.7.2",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "turbolinks": "^5.2.0"
  },
  "version": "0.1.0",
  "devDependencies": {
    "webpack-dev-server": "^3.10.3"
  }
}

Now make sure the bins are executable – Im doing this in linux – the same command also applies for mac OSX but I havent touched windows for 10 years so I cannot remember the command for windows.

chmod +x bin/webpack
chmod +x bin/webpack-dev-server

I am probably not the most up to date with front end tools as the majority of my work for the last 2 years has been backend work – but I seem to find that anyone using react (which I will be doing) uses ‘yarn’ instead of ‘npm’ – I dont pretend to know why, but I am going to use yarn to install everything now.

yarn install

and I get back

success Saved lockfile.
Done in 9.86s.

Cool – everything installed – as I have completed a logical step I will commit to git repo after adding node_modules to my .gitignore file

Now onto steps 3 to 6 in https://github.com/rails/webpacker/blob/master/docs/engines.md – not forgetting to replace MyEngine with the name of your engine – and in the rake task, ‘my_engine’ needs replacing with the underscored name of your engine.

Step 7 gives us some decisions – it talks about putting the engine’s assets into the public dir of the main application OR using a separate middleware.

I have chosen not to use a separate middleware as I would think this would mean that the packs that I produce in the gem cannot be bundled together with the application’s packs – meaning more http requests for the browser. I would much prefer that the application require’s the module inside one of its own packs and webpacker will automatically pack the engine’s packs into the applications. We will see how that works out, I might be re visiting this bit.

At the same time putting the engine’s assets into the public dir of the main application means knowing the application’s public dir path relative to the engine’s gem – which of course when we are done will be where the rest of the gems are kept on your system – it wont have a chance of knowing where the application is – and this is all just yaml so we cant put any ruby code in there.

So, where do we go from here.

Well, when the engine is finished, I will setup a build pipeline to build all of the packs and publish them to npm meaning the application can do something like

require("my_fancy_engine")

after adding my_fancy_engine to the package.json of course

But, this is going away from the simple ‘add the gem and all done’ approach that we expect from engines.

But, saying that – it must be the best way as this is how rails does it with the actioncable stuff for example. You dont see their assets coming straight from the gem, I guess the reason is we are trying to use decent well established front end tools rather than defining a ‘rails way’ of doing things like we had with sprockets.

So, thats it – decision made – I will publish to npm.

But, what about development ? we dont want to be having to publish to npm every time we make a change to the gem do we ?

This is where npm link comes in, we can tell npm to use a local version of the module whilst developing. We wont go into that here, but that will be our strategy hopefully.

The next worry is css and images – how do they get handled ? After doing a bit of research, I believe that when webpacker sees something like this

import "./styles.css";

It works from within the main app, but if we were to change this to

import "my_fancy_engine/style.css";

Then this should work

Also, images seem to be supported – all of this theory needs trying though – in part 2.


1 Comment

Tim C · November 5, 2020 at 5:39 pm

Thanks for posting on this Gary – very helpful to hear your thought processes. Have you got any further with this and is there a Part 2 coming..?! Over the past year or so I have had several aborted attempts do the same thing with a large engine that has lots of assets and JavaScript. Like you I have experimented with recommended approaches, and tbh I have not really found one yet that works and feels right (my current solution is a bit of a Frankenstein, so no better). Today I started investigating the npm route again – inspired like you by the approach actioncable/actiontext etc take – but quickly hit various issues which will require me to refactor some (poorly written I confess) JavaScript before I can go any further with it. Hey ho. Engines are on the one hand great, on the other a total pain!

Leave a Reply

Avatar placeholder

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