Skip to main content

Stop rolling your own Webpack configuration!

So you have your custom frontend project with a huge webpack configuration file, while enviously looking at some newer code bases based on Create React App from Facebook? Or, you've started out with a vanilla Create React App, but felt that you needed to eject (even though many advised you not to)?

Either way, wouldn't it be nice to be able to re-use some of that magic in a dependency-kind-of-way? After all, Facebook's configuration is probably more tested and thought-through than yours! :)

Well, you can! All that Webpack magic, incl. Jest and Webpack Dev Server configuration, is packaged by Facebook in react-scripts. Internally, Create React App is using it, but that doesn't help us, right? In this post, I'll show you how.

In order to be able to tap into react-scripts we first need to install them as a dependency using npm install react-scripts --save-dev.

This is where the fun begins!

In essence, you create a webpack.shared.config.js and import the base config from react-scripts and merge that with any additional config you might need. For things in the base config that you want to remove, you'll have to do some additional clean-up after the merge, see the example config with comments below:

const webpack = require("webpack");
// note that react-scripts uses it's own webpack dependency (and hence version) from react-scripts/node_modules/webpack
const reactScriptsWebpack = require("react-scripts/node_modules/webpack");
const webpackVersion = require("react-scripts/node_modules/webpack/package.json");
const chalk = require("chalk");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const merge = require("webpack-merge");
const createBaseConfig = require("react-scripts/config/webpack.config");
const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
const path = require("path");
const paths = require("./paths");

module.exports = (env, argv) => {
  // eslint-disable-next-line no-console
  console.log(
    chalk.cyan.bold(
      "Creating webpack config based on webpack v" + webpackVersion.version
    )
  );

  const baseConfig = createBaseConfig(process.env.NODE_ENV);

  const sharedConfig = merge(baseConfig, {
    entry: {
      // replace entire entry-array, as the webpack-dev-server CLI adds client- and hot entries by default, see https://github.com/webpack/webpack-dev-server/blob/143762596682d8da4fdc73555880be05255734d7/lib/utils/addEntries.js#L22
      // react-scripts uses the Node API and adds them manually (i.e. we get them twice) which we don't want, see https://github.com/facebook/create-react-app/blob/2c6dd45cf4cb81f3cbd2e4073713647ebd033bbf/packages/react-scripts/config/webpack.config.js#L145
      home: [path.resolve(__dirname, "../src/index")]
    },
    output: {
      path: path.resolve(__dirname, paths.bundleOutputDir)
    },
    plugins: [
      new CleanWebpackPlugin({
        cleanOnceBeforeBuildPatterns: [
          path.resolve(__dirname, paths.bundleOutputDir),
          path.resolve(__dirname, paths.reportsOutputDir)
        ]
      })
    ]
  });

  // remove WorkboxWebpackPlugin
  sharedConfig.plugins = sharedConfig.plugins.filter(
    plugin => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW)
  );

  // make it possible to extend eslint rules
  const eslintLoader = sharedConfig.module.rules.find(
    rule =>
      rule.enforce === "pre" &&
      rule.use[0].loader === require.resolve("eslint-loader")
  );
  eslintLoader.use[0].options.baseConfig = {
    extends: require.resolve("../.eslintrc")
  };

  // replace react-scripts/node_modules/webpack version of HotModuleReplacementPlugin with our version (or we'll get a mismatch of the actual webpack reference)
  const index = sharedConfig.plugins.findIndex(
    plugin => plugin instanceof reactScriptsWebpack.HotModuleReplacementPlugin
  );
  if (index >= 0) {
    sharedConfig.plugins.splice(
      index,
      1,
      new webpack.HotModuleReplacementPlugin()
    );
  }

  // create less-rule by using sass rule as template
  const oneOf = sharedConfig.module.rules.find(rule => "oneOf" in rule).oneOf;
  const sassRule = oneOf.find(rule => RegExp(rule.test).test(".sass"));
  const sassLoader = sassRule.use.find(loader =>
    /sass-loader/.test(loader.loader)
  );
  const otherLoaders = sassRule.use.filter(
    loader => loader.loader !== sassLoader.loader
  );

  const lessRule = {
    test: /\.less$/,
    // use all loaders but sassLoader and end off with less-loader
    use: otherLoaders.concat({
      loader: require.resolve("less-loader"),
      // reuse options from sassLoader
      options: sassLoader.options
    }),
    sideEffects: true
  };

  // insert less rule before css-rules
  oneOf.splice(
    oneOf.findIndex(rule => RegExp(rule.test).test(".css")),
    0,
    lessRule
  );

  return sharedConfig;
};
Notice e.g. the addition of handling of .less-files.

Next thing is to use this shared config for your production build by adding a webpack.prod.config.js where you put production specific config, e.g. process.env.NODE_ENV = "production" to minimize the build.

process.env.NODE_ENV = "production";
process.env.BABEL_ENV = "production";

const chalk = require("chalk");
const merge = require("webpack-merge");
const getSharedConfig = require("./webpack.shared.config");

module.exports = (env, argv) => {
  // eslint-disable-next-line no-console
  console.log(chalk.cyan.bold("Starting prod build..."));

  const prodConfig = merge(getSharedConfig(env, argv), {
    // add extra configs here
  });

  // eslint-disable-next-line no-console
  console.log(prodConfig);

  return prodConfig;
};
Now, to use it, add "build": "webpack --config ./config/webpack.prod.config.js" into your package.json and fire away!

Next up is to handle the Webpack Dev Server configuration, which can be achieved pretty much the same way - create a webpackDevServer.js and import the base configuration from react-scripts. The base config is really a function requiring proxy information and allowed hosts, which can be added through the prepareUrls utility function of WebpackDevServerUtils, see example below:

const merge = require("webpack-merge");
const createBaseConfig = require("react-scripts/config/webpackDevServer.config");
const { prepareUrls } = require("react-dev-utils/WebpackDevServerUtils");

const DEFAULT_PORT = 8080;
const HOST = process.env.HOST || "0.0.0.0";

module.exports = () => {
  const baseConfig = createBaseConfig(
    undefined,
    prepareUrls("http", HOST, DEFAULT_PORT).lanUrlForConfig
  );

  const webpackDevServerConfig = merge(baseConfig, {
    // add extra config here
  });

  return webpackDevServerConfig;
};
Add the configuration above to a webpack.dev.config.js and use it by adding "start": "webpack-dev-server --config ./config/webpack.dev.config.js" into your package.json.

process.env.NODE_ENV = "development";
process.env.BABEL_ENV = "development";

const chalk = require("chalk");
const merge = require("webpack-merge");
const getSharedConfig = require("./webpack.shared.config");
const webpackDevServerConfig = require("./webpackDevServer");

module.exports = (env, argv) => {
  // eslint-disable-next-line no-console
  console.log(chalk.cyan.bold("Starting local build..."));

  const devConfig = merge(getSharedConfig(env, argv), {
    devServer: webpackDevServerConfig()
  });

  // eslint-disable-next-line no-console
  console.log(devConfig);

  return devConfig;
};
Finally, if you're running unit tests using Jest, you can also get that configuration for free - just import the base configuration like previously and extend it with whatever you like. The configuration functions requires some handling to resolve relative paths, see example below:

process.env.BABEL_ENV = "test";
process.env.NODE_ENV = "test";

const merge = require("webpack-merge");
const chalk = require("chalk");
const path = require("path");
const createJestConfig = require("react-scripts/scripts/utils/createJestConfig");
const sharedJestConfig = createJestConfig(
  relativePath =>
    path.resolve(
      require.resolve("react-scripts/scripts/utils/createJestConfig"),
      "..",
      "..",
      "..",
      relativePath
    ),
  path.resolve(path.resolve(__dirname, "..")),
  false
);

module.exports = (argv => {
  // eslint-disable-next-line no-console
  console.log(chalk.cyan.bold("Starting test run..."));

  const config = merge(sharedJestConfig, {
    setupFilesAfterEnv: ["/config/jest.setup.js"] // https://facebook.github.io/create-react-app/docs/running-tests#initializing-test-environment,
  });

  if (!argv.includes("--watch")) {
    config.collectCoverage = true;
    config.coverageDirectory = "/reports/coverage";
    config.collectCoverageFrom = [
      "src/**/*.{ts,tsx}",
      "!src/**/index.{ts,tsx}",
      "!src/**/*.d.ts"
    ];
    config.coverageReporters = ["lcov", "html", "cobertura"];
  }

  // eslint-disable-next-line no-console
  console.log(config);

  return config;
})(process.argv);


// don't mind these, something the formatter added...
Use it by adding "test": "jest --config ./config/jest.config.js" to your package.json.

Even though the setup is a little bit tricky, you'll reap all the benefits of relying on Facebook to do the configuration for you. And even better, when they update it, you only have to bump the version number of react-scripts!

I understand that this isn't for everyone and there are absolutely many ways to skin a cat - if you like it, use it. If not - don't!

A complete example project can be found here.

Comments

Popular posts from this blog

GWT and Spring Security

Update! - Based on the post below, and my other post regarding Spring Security and OpenID, I have added Open-ID support to the sample application below. For those interested, here's the write-up of changes. I've spent quite some time digging into ways of integrating GWT and Spring Security. It all started by reading the following post in the GWT Forum - Best practices/ideas for GWT with Spring Security (or equivalent) , and then checking out this blog - GWT and Spring Security . To make matters worse, I started reading Security for GWT Applications and specifically about the "Cross-Site Request Forging"-attacks. Now, what could I do about it? Well, starting by setting up my own project (Maven-based) with all updated dependencies (GWT 2.0.3 etc) and started reading the Spring Security Reference Documentation (puh!). Instead of See Wah Cheng's approach of implementing a custom authentication service, I decided to rely on standard namespace configuration

GWT and Open-ID using Spring Security

In this post I'll combine the GWT and Spring Security integration from http://technowobble.blogspot.com/2010/05/gwt-and-spring-security.html and the Open-ID using Spring Security from http://technowobble.blogspot.com/2010/06/using-spring-securitys-openid.html . I'm assuming you've read them before reading further... :) I was also inspired by http://www.sociallipstick.com/?p=86 and http://code.google.com/p/dyuproject/wiki/OpenidLoginWithoutLeavingPage to get this working with a pop-up as my sample application is based on GWT - hence, I don't want to direct the user to another page and loose the application state etc. I'm also showing how to exchange Open-ID attributes with e.g. Google. As with the previous blogposts, the sample application is runnable on Google App Engine. With no further ado, this is basically what is needed to add Open-ID support to my previous sample application: From my second post, add Openid4javaFetcher, MyHttpCacheProvider and OpenI

Google Apps Script and ES Modules

Currently, Google Apps Script does not support ES modules - and any usage of export/import will fail. One way of handling this is to use rollup.js to bundle your project into one single JavaScript file. The trick here is to make sure not to export any functions in your entry point code, e.g. index.ts , and to prevent any generation of export statement in the final bundle (see the custom rollup plugin in the rollup.config.js below). import { babel } from "@rollup/plugin-babel"; import { nodeResolve } from "@rollup/plugin-node-resolve"; const extensions = [".ts", ".js"]; const preventThreeShakingPlugin = () => { return { name: 'no-threeshaking', resolveId(id, importer) { if (!importer) { // let's not theeshake entry points, as we're not exporting anything in Apps Script files return {id, moduleSideEffects: "no-treeshake" } } return null; }