Webpack 4 - Resolving globally installed dependencies

Craig Buchanan

Craig Buchanan / September 25, 2018

When using NPM (or yarn) to manage JavaScript dependencies for a project, it is best practice to install the dependencies locally in the project so that multiple NPM projects on the system do not have clashing dependencies or dependency versions. However, it is best to break away from this pattern in favor of using a globally installed version of the dependency if the following cases hold true:

  1. The dependency is massive or takes an extraordinarily long time to install.
  2. The project is the only NPM project (or one of very few closely related NPM projects) on the system (e.g. running inside a docker container).

One instance where both of these criteria hold true is building a docker container for a BuckleScript (or ReasonML) project.

BuckleScript is primarily a compiler that compiles either OCaml or ReasonML code into JavaScript. Therefore, it would seem that bs-platform (the NPM BuckleScript dependency) should only be required as a devDependency and used during the build process. However, bs-platform also contains some code that must be included in the project during runtime. Therefore, bs-platform must be included in the project as either a dependency or a peerDependency.

The simplest option to include bs-platform in the project is to add it to the dependencies field of package.json and allow NPM (or yarn) to install it locally into the project's node_modules directory. This method works great on a development machine where npm dependencies are cached and only need to be installed again if the package is deleted from the local node_modules directory. However, when installing npm dependencies in a docker container, any change to the package.json file would trigger a full install of all of the npm packages. This typically isn’t a problem for smaller npm dependencies that take less than a few seconds to install, but bs-platform installs and compiles an OCaml compiler from scratch. On my fast machine, this process takes over 6 minutes. On my slower machines, this process takes nearly half an hour. Waiting for over 6 minutes to build the docker container whenever anything changes in the package.json file is unacceptable. Especially during development when package.json changes nearly all the time.

"One Eternity Later" meme

Waiting for docker to build the image with bs-platform installed every time as a local dependency....

So now the situation is this: we have a dependency that takes an extraordinarily long time to install AND this project is the only NPM project running on the system (i.e. docker container). Looks like we have a perfect candidate for breaking away from best practice of installing the dependency locally, and, instead, install the dependency globally ahead of time. This will allow us to install bs-platform a single time and cache it as a docker layer. Then, any changes to package.json will happen on a subsequent docker layer without requiring the reinstall of bs-platform.

Next, since we need to resolve the globally installed dependency at run time, we include it as a peerDependency in package.json. This will inform our build tool (Webpack 4) that the project requires bs-platform, BUT it should already be installed on the system.

Finally, we configure webpack to resolve bs-platform as a globally installed dependency instead of a locally installed dependency by adding the following lines to webpack.config.js:

resolve: {
  alias: {
    'bs-platform': path.resolve(execSync('npm root -g').toString().trim(),
                                'bs-platform')
  }
},

When we are ready to build the ReasonML project, we will run bsb -make-world and webpack --mode production which will output our final JavaScript file to send to the client’s browser.

Dockerfile

# Multistage docker file with builder container and running container.
# This isn't a complete working example. It is just to illustrate how to
# globally install bs-platform on the builder container as its own docker layer.

FROM node/carbon-alpine as builder

# bs-platform post-install requires python
RUN apk update && apk add python

# requires --unsafe-perm since installing as root user in docker
RUN npm i -g bs-platform --unsafe-perm

# build node deps (except for bs-platform since it's a peerDep)
COPY package* ./
RUN npm i

# app files
COPY . .

# build project
RUN npm run build
RUN npm run deploy

# copy built JS to new alpine image without build dependencies (to keep final image as slim as possible)
FROM node/carbon-alpine
COPY --from=builder path/to/app.js app.js

# Run the program or do whatever.....

rawpackage.json

{
  "repository": {},
  "license": "Unlicensed",
  "scripts": {
    "build": "bsb -make-world",
    "start": "bsb -make-world -w",
    "clean": "bsb -clean-world",
    "test": "npm run build && jest --coverage",
    "deploy": "webpack --mode production",
    "watch": "webpack --mode development --watch"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...,
    "@glennsl/bs-jest": "^0.4.2",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.3",
    "babel-preset-env": "^1.6.1",
    "copy-webpack-plugin": "^4.5.0",
    "css-loader": "^0.28.10",
    "jest": "^23.4.1",
    "mini-css-extract-plugin": "^0.4.0",
    "optimize-css-assets-webpack-plugin": "^4.0.0",
    "sass-loader": "^7.1.0",
    "uglifyjs-webpack-plugin": "^1.2.4",
    "webpack": "4.4.0",
    "webpack-cli": "^2.0.10"
  },
  "peerDependencies": {
    "bs-platform": "^4.0.0"
  }
}

webpack.config.js

/* line 23 is the important part */

const path = require('path');
const glob = require('glob');
const { execSync } = require('child_process');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = (env, options) => ({
  optimization: {
    minimizer: [
      new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }),
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  entry: {
    './js/app.js': ['./js/app.js', './js/App.bs.js'].concat(glob.sync('./vendor/**/*.js'))
  },
  resolve: {
    alias: {
      'bs-platform': path.resolve(execSync('npm root -g').toString().trim(), 'bs-platform')
    }
  },
  output: {
    filename: 'app.js',
    path: path.resolve(__dirname, '../priv/static/js')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      },
      {
        test: /\.s?[ac]ss$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: '../css/app.css' }),
    new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
  ]
});