Javascript Dependency Management for Rails Devs

12 Oct 2017

I wrote this to help a new dev at Fire Hazard get up to speed, but it might be useful for anyone looking to add advanced js to a rails application. This is one potential implementation of the stack - there are plenty of other ways to to it.

The 'player' sections of Citydash are a single-page javascript app, served by a Rails application. The actual javascript we write is several layers removed from what executes, because:

  1. We want to use lots of external libraries (which themselves have dependencies).

  2. Javascript's syntax is evolving all the time, and we want to use more recent language features now even though they may not be supported in all browsers yet.

  3. We want to compress the js before it goes down the wire, and use cache-busting to ensure we have the latest version.

Here's how we go from frontend/*.js in the repo to a running js app on the client:

The chain

Looks like this:

* deploy
    * before_assets_precompile
        * yarn install
            * package.json
        * yarn run
            * package.json
                * cross-env
                    * browserify
                        * envify
                        * babelify
                            * .babelrc
    * assets:precompile
* web request
    * ...

bundle exec rake production deploy

Most of the magic happens when we deploy code to the server. This causes 'rake', the Rails automation tool, to execute the task 'deploy'. Before it does that, it loads:

lib/tasks/before_assets_precompile.rake

This fragment hooks into rake's existing 'deploy' task, and adds a new step that executes two new shell commands on the server.

task :before_assets_precompile do
  system('yarn install && yarn run build_players')
end
Rake::Task['assets:precompile'].enhance ['before_assets_precompile']

yarn install

'yarn' is an improved equivalent to 'npm', the node package manager - a javascript dependency management system that performs roughly the same function as ruby's 'gem'. 'install' causes yarn to read 'package.json' to get a list of dependencies, figure out all of their dependencies, then download everything and place the source in node_modules/ (where we'll be able to find it in a minute).

package.json

In 'dependencies', we're listing all of the node modules our javascript loads (using 'import' statements). Like ruby's Gemfile, we also specify a version.

yarn will create yarn.lock, the equivalent of Gemfile.lock. This means we can safely specify versions as "^1.2.3" (meaning "compatible with 1.2.3") - we'll only get new versions if we do 'yarn upgrade', rather than 'yarn install'.

{
  "name": "app",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "axios": "0.15.2",
    "vue": "2.4.4",
    "vue-router": "2.7.0", 
    ...
  },
}

yarn run build_players

'yarn run' is a loose analogue of ruby's 'bundle exec', except that it runs scripts which are defined in package.json rather than shell commands.

package.json

So we go back to a different section of package.json, where we define the scripts:

{
  "name": "frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build_players": "yarn install && cross-env NODE_ENV=production browserify -g envify frontend/app.js > app/assets/javascripts/app.js",
  },

cross-env

cross-env just sets up environment variables that are passed to it (here, NODE_ENV = production), and then runs the command passed to it (in this case, 'browserify').

browserify

browserify handles converting the javacript we write into javascript that can actually execute in browsers. It takes the 'entry point' for the app (frontend/app.js), finds everything else it needs either in the referenced app code, or in node_modules/, and outputs a single combined file to app/assets/javascripts/app.js.

This translates calls like

import VueRouter from 'vue-router'

into something the browser can execute, since 'import' isn't implemented by browsers.

It also does a couple of additional transformations:

envify

the -g envify adds an additional transform that replaces references to process.env.NODE_ENV with the environment we set up using cross-env.

babelify

Another transformation is documented in package.json:

...
"browserify": {
    "transform": [
      "babelify"
    ]
  }
...

Babel is a tool that translates javascript to javascript. Here we're using it to enable using newer syntax (eg arrow functions) than browsers support. In package.json we require the dependency 'babel-preset-env' in order to get a preset.

.babelrc

Babel reads .babelrc to figure out which of the available presets to apply:

{
  "presets": ["env"]
}

Digression: Javascript versions

ES5 - old school, implemented everywhere. Babel is translating into this for us.

ES6 == ECMAScript 2015, standardised in 2015. Lots of new useful stuff.

ES7 == ECMAScript 2016. Very little added from ES6.

ES8 == ECMAScript 2017. In development.

assets:precompile

When this all finishes, Rake returns to its normal deployment process, executing the task 'assets:precompile'. This is the normal Rails asset pipeline stuff - among other things, it will take app/assets/app.js, compress it, add a cache-busting hash to its filename, and place it in public/assets/app.js where the server can find it later.

This could be optimised away, or nearly away, since browserify has already done most of this work.

Once everything's built, the actual request is very simple:

Request

The browser makes a request to /teams/abcde.

Render

The rails app renders teams/show.html.erb, which includes:

<%= javascript_include_tag "app" %>

Load JS

The browser requests "/assets/app.js"

Serve JS

The server serves the previously-built file public/assets/javascripts/app.js.