Running Webpack and Rails with Guard

A while ago I decided to graft React and Flux onto an existing Rails app using Webpack. I opted to avoid hacking on Sprockets and instead used Guard to smooth out the development process.

This is me finally writing about that process.

Node

I installed all the necessary node modules from the root of the Rails app.

Dependencies and scripts from package.json:

{
  "scripts": {
    "test": "PHANTOMJS_BIN=./node_modules/.bin/phantomjs ./node_modules/karma/bin/karma start karma.config.js",
    "test-debug": "./node_modules/karma/bin/karma start karma.debug.config.js",
    "build": "./node_modules/webpack/bin/webpack.js --config webpack.prod.config.js -c"
  },
  "dependencies": {
    "classnames": "^1.2.0",
    "eventemitter3": "^0.1.6",
    "flux": "^2.0.1",
    "keymirror": "^0.1.1",
    "lodash": "^3.5.0",
    "moment": "^2.9.0",
    "react": "^0.13.0",
    "react-bootstrap": "^0.19.1",
    "react-router": "^0.13.2",
    "react-router-bootstrap": "^0.12.1",
    "react-tools": "^0.13.1",
    "webpack": "^1.7.3",
    "whatwg-fetch": "^0.7.0"
  },
  "devDependencies": {
    "jasmine-core": "^2.2.0",
    "jsx-loader": "^0.12.2",
    "karma": "^0.12.31",
    "karma-jasmine": "^0.3.5",
    "karma-jasmine-matchers": "^2.0.0-beta1",
    "karma-mocha": "^0.1.10",
    "karma-mocha-reporter": "^1.0.2",
    "karma-phantomjs-launcher": "^0.1.4",
    "karma-webpack": "^1.5.0",
    "mocha": "^2.2.1",
    "node-inspector": "^0.9.2",
    "phantomjs": "^1.9.16",
    "react-hot-loader": "^1.2.3",
    "webpack-dev-server": "^1.7.0"
  }
}

Development Server

I wanted a single command to run my development server as per normal Rails development.

Firstly, I set up the Webpack config to read from, and build to app/assets/javascripts.

From webpack.config.js:

var webpack = require("webpack");

module.exports = {
  // Set the directory where webpack looks when you use 'require'
  context: __dirname + "/app/assets/javascripts",

  // Just one entry for this app
  entry: {
    main: [
      "webpack-dev-server/client?http://localhost:8080/assets",
      "webpack/hot/only-dev-server",
      "./main.js",
    ],
  },

  plugins: [new webpack.HotModuleReplacementPlugin()],

  output: {
    filename: "[name].bundle.js",
    // Save the bundle in the same directory as our other JS
    path: __dirname + "/app/assets/javascripts",
    // Required for webpack-dev-server
    publicPath: "http://localhost:8080/assets",
  },

  // The only version of source maps that seemed to consistently work
  devtool: "inline-source-map",

  // Make sure we can resolve requires to jsx files
  resolve: {
    extensions: ["", ".web.js", ".js", ".jsx"],
  },

  // Would make more sense to use Babel now
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: [/node_modules/, /__tests__/],
        loaders: ["react-hot", "jsx-loader?harmony"],
      },
    ],
  },
};

Then the Rails app includes the built Webpack bundle.

From app/assets/javascripts/application.js:

//= require main.bundle

To get access to the Webpack Dev Server and React hot loader during development I added some asset URL rewrite hackery in development mode.

From config/environments/development.rb:

  # In development send *.bundle.js to the webpack-dev-server running on 8080
  config.action_controller.asset_host = Proc.new { |source|
    if source =~ /\.bundle\.js$/i
      "http://localhost:8080"
    end
  }

Then I kick it all off via Guard using guard-rails and guard-process.

Selections from Guardfile:

# Run the Rails server
guard :rails do
  watch('Gemfile.lock')
  watch(%r{^(config|lib)/.*})
end

# Run the Webpack dev server
guard :process, :name => "Webpack Dev Server", :command => "webpack-dev-server --config webpack.config.js --inline"

All Javascript and JSX files live in app/assets/javascripts and app/assets/javascripts/main.js is the application’s entry point.

To develop locally I run guard, hit http://localhost:3000 like normal, and have React hot swapping goodness when editing Javascript files.

Tests

I originally tried integrating Jest for Javascript tests but found it difficult to debug failing tests whilst using it. So, I switched to Karma and Jasmine and had Guard run the tests continually.

From Guardfile:

# Run Karma
guard :process, :name => "Javascript tests", :command => "npm test", dont_stop: true do
  watch(%r{Spec.js$})
  watch(%r{.jsx?$})
end

Like Jest, I keep tests next to application code in __tests__ directories. Karma will pick them all up based upon file suffixes.

A test-debug npm script1 runs the tests in a browser for easy debugging.

karma.config.js:

module.exports = function (config) {
  config.set({
    /**
     * These are the files required to run the tests.
     *
     * The `Function.prototype.bind` polyfill is required by PhantomJS
     * because it uses an older version of JavaScript.
     */
    files: [
      "./app/assets/javascripts/test/polyfill.js",
      "./app/assets/javascripts/**/__tests__/*Spec.js",
    ],

    /**
     * The actual tests are preprocessed by the karma-webpack plugin, so that
     * their source can be properly transpiled.
     */
    preprocessors: {
      "./app/assets/javascripts/**/__tests__/*Spec.js": ["webpack"],
    },

    /* We want to run the tests using the PhantomJS headless browser. */
    browsers: ["PhantomJS"],

    frameworks: ["jasmine", "jasmine-matchers"],

    reporters: ["mocha"],

    /**
     * The configuration for the karma-webpack plugin.
     *
     * This is very similar to the main webpack.local.config.js.
     */
    webpack: {
      context: __dirname + "/app/assets/javascripts",
      module: {
        loaders: [
          {
            test: /\.jsx?$/,
            exclude: /node_modules/,
            loader: "jsx-loader?harmony",
          },
        ],
      },
      resolve: {
        extensions: ["", ".js", ".jsx"],
      },
    },

    /**
     * Configuration option to turn off verbose logging of webpack compilation.
     */
    webpackMiddleware: {
      noInfo: true,
    },

    /**
     * Once the mocha test suite returns, we want to exit from the test runner as well.
     */
    singleRun: true,

    plugins: [
      "karma-webpack",
      "karma-jasmine",
      "karma-jasmine-matchers",
      "karma-phantomjs-launcher",
      "karma-mocha-reporter",
    ],
  });
};

Deployment

When deploying I use Capistrano to build the Javascript with Webpack before allowing Rails to precompile the assets as per normal.

From package.json:

{
  "scripts": {
    "build": "./node_modules/webpack/bin/webpack.js --config webpack.prod.config.js -c"
  }
}

The Webpack config for prod just has the development server and hot loader config stripped out.

webpack.prod.config.js:

var webpack = require("webpack");

module.exports = {
  // 'context' sets the directory where webpack looks for module files you list in
  // your 'require' statements
  context: __dirname + "/app/assets/javascripts",

  // 'entry' specifies the entry point, where webpack starts reading all
  // dependencies listed and bundling them into the output file.
  // The entrypoint can be anywhere and named anything - here we are storing it in
  // the 'javascripts' directory to follow Rails conventions.
  entry: {
    main: ["./main.js"],
  },

  // 'output' specifies the filepath for saving the bundled output generated by
  // wepback.
  // It is an object with options, and you can interpolate the name of the entry
  // file using '[name]' in the filename.
  // You will want to add the bundled filename to your '.gitignore'.
  output: {
    filename: "[name].bundle.js",
    // We want to save the bundle in the same directory as the other JS.
    path: __dirname + "/app/assets/javascripts",
  },

  // Make sure we can resolve requires to jsx files
  resolve: {
    extensions: ["", ".web.js", ".js", ".jsx"],
  },

  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: [/node_modules/, /__tests__/],
        loaders: ["jsx-loader?harmony"],
      },
    ],
  },
};

The Capistrano tasks in config/deploy.rb:

namespace :deploy do
  namespace :assets do
    desc "Build Javascript via webpack"
    task :webpack do
      on roles(:app) do
        execute("cd #{release_path} && npm install && npm run build")
      end
    end
  end
end

before "deploy:assets:precompile", "deploy:assets:webpack"

Finally

I’m not sure if there is a simpler way to incorporate Webpack into Rails nowadays but this approach worked pretty well for me.


  1. As shown in the package.json listing above. ↩︎