Adventures in Webpack DevServer

Introduction

Trying something new is usually an adventure. When talking about webpack, it's more like a journey. A journey you spend a lot of time wishing you never started. However, like any challenging journey or any time your really challenge yourself, great rewards come with success (and often failure). Thankfully, I found success (eventually) and it has made for a much improved dev setup. Getting Webpack DevServer (WDS) running with Hot Module Replacement (HMR) was pretty exciting. Anyway, hyperbole aside, let's get down to explaining how I did this so you can hopefully do the same.

Here was my challenge, and no this is not keyword spamming, I was trying to get Webpack DevServer with HMR to work with: my local Grav and WordPress theme setups, HTTPS / localhost SSL, and named-based virtual hosts (i.e. https://mytheme.localhost). That's a mouthful I know. Doing parts of the above on their own with WDS is not hard, and there are other blog posts and/or Stack Overflow answers regarding it. However, I most definitely did not find anyone who had attempted to put all the above pieces together -- so let's do that now. I will explain much of the code below, especially where it pertains to the specifics I was trying to solve, but some standard WDS and HMR details I will leave out and I highly recommend a read through the docs for those (linked above).

Webpack Dev Server

First thing you have to get going, if you aren't already using it, is your webpack dev server (WDS). I was previously using BrowserSync, and it still seems lots of people still do, so you may need to start with this step first. If so you need to do something like this:

/**
 * Configure Webpack Dev Server
 *
 * @return {Object}
 */
const configureDevServer = () => {
    return {
    before(app, server) {
      chokidar.watch([
        './**/*.twig',
        // './**/*.js'
      ]).on( 'all', function() {
        server.sockWrite( server.sockets, 'content-changed' );
      })
    },
    host: serverAddress
    hot: true,
    // open: true,
    overlay: true,
    port: config.port,
    publicPath: config.publicPath,
    proxy: {
      '/': {
        target: config.siteURL,
        secure: false,
        changeOrigin: true,
      }
    },
    https: {
      key: 'C:/Users/onetr/.localhost-ssl/gravdev.localhost.key',
      cert: 'C:/Users/onetr/.localhost-ssl/gravdev.localhost.crt',
      ca: 'C:/Users/onetr/.localhost-ssl/myCA.pem',
    },
    // Allow access to WDS data from anywhere, including the standard non-proxied site URL
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
    },
    };
};

Okay, there's a lot going on above, so let's start unpacking it all. First of all we have chokidar which is an excellent file watcher utility. You'll see I'm watching for changes to .twig files since this is for a Grav setup. If you're working in WP, then of course just change that to .php. I also have a line in there to watch for JS file changes, but it's commented out. Depending on whether you are / want to do HMR with your JS then you can uncomment that line as needed.

There are two other important items above, hot and host. For former is required to enable HMR, the latter is your name-based virtual host. For a while I did have to use hotOnly instead of hot to force WDS to not reload on every save, but somewhere along the line that seems to have resolved itself. Classic webpack!

Now the host value and some upcoming values are thing which I set in a config variable to keep things clean. So you may want to do something like this below, or just directly inline the respective settings each time you need them.

// custom config settings...
const { config } = {
  "config": {
    "port" : "3016",
    "publicPath": "/user/themes/mygravtheme/dist/",
    "serverAddress" : "gravdev.localhost",
    "siteURL": "https://gravdev.localhost"
  }
}

The most important item above, at least for me because it caused the most trouble, is the publicPath value. This is important because the output files when using WDS are stored in memory not written to disk somewhere. That's a key thing to note because it has a number of implications. This functionality of writing to memory can cause confusion and it necessitates a few tweaks to your setup, but it's worth it. One key reason why it's worth the trouble is because it means WDS is fast, very fast. Anyway, because WDS works this way, it means that without any extra settings our ouput file will be served from the original port based WDS address, not the proxy address. So while my site is proxyed to https://gravdev.localhost, the output files are found at https://gravdev.localhost:3016.

So where does the publicPath part come in you may be wondering? Well, by default the output files are served from the root URL, so your JS bundle would be at something like https://gravdev.localhost:3016/site.bundle.js. This is all fine and good if you adjust your theme to look for those (we'll discuss how to do that later) but one major problem arises when you have relative paths in your source files -- for example background images in your CSS. What publicPath allows is for you to set a virtual location for your output files. Accordingly, I've made the virtual location the same path as my production output folder, so that way the results are the same in either environment and relative paths match up in both circumstances.

The last major thing we haven't covered from the main code block above is the headers stuff. This is something I found in a Stack Overflow answer and the same person has a WP theme where they've used this. These settings are absolutely awesome. Without this setting, things were fine in Grav, but WordPress was causing nothing but trouble. The navigation links in WP would point to the original site address / virtual host URL, but to access your WDS you had to go the address with the port number. In other words no links on your site worked (well kept you on your WDS). This is not good. However, the above setting allows for you to access your WDS without the port number. Relief! I think this is excellent for development since now you can do all work, whether running WDS or not, at the exact same address. This also means you shouldn't have issues with things like plugins that do AJAX stuff and require you to hardcode the site address.

Webpack Exports (general config)

Okay, we made it through the webpack DevServer stuff. Phew. Take a break. Once you're back, we're on to the usual "exports" config stuff.

module.exports = {
  context: path.resolve(__dirname, 'src'),
  devServer: configureDevServer(),
  devtool: "cheap-module-source-map",
  entry: {
    site : "./site.js",
    site_header : "./site_header.js"
  },
  mode: 'development',
  output: {
    filename: "[name].bundle.js",
    publicPath: config.siteURL + ':' + config.port + config.publicPath,
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              hmr: true
            }
          },
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2,
              sourceMap: true,
              url: false
            },
          },
          { 
            loader: 'postcss-loader',
            options: {
              sourceMap: true,
              ident: 'postcss',
              plugins: (loader) => [
                require('postcss-import')({ root: loader.resourcePath }),
                require('postcss-preset-env')(),
              ]
            }
          },
          { 
            loader: 'sass-loader', 
            options: {
              sourceMap: true, 
              implementation: require('sass'),
              sassOptions: {
                importer: globImporter(),
              },
            } 
          },
        ]
      } // ++ END scss test +++++++++++++++++
    ] // ++ END rules ++++++++++++++++++++++
  }, // ++ END modules ++++++++++++++++++++
  optimization: {
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new MiniCssExtractPlugin({
      filename: "[name].bundle.css"
    })
  ] // ++ END plugins ++++++++++++++++++++++
}

First up you can see we are pulling in our WDS settings we previously set. Next up you may also notice I'm making use of those custom config settings I created. Otherwise, there are only two other important things to note. One is that you do need to load the HMR plugin (note this is part of webpack, there is no package to install for it). The other key thing above is I am using MiniCssExtract for my CSS hot module replacement. A lot of advice online has people using style-loader for development. This was necessary until 2019 when MiniCssExtract added support for HMR. So, despite much of the advice online, you can use MiniCssExtract now. I like doing that because it keeps my dev and production setups much more similar.

Good news! You should now have Webpack DevServer with HMR working with your named-based virtual host local site that has SSL. But at the risk of being a party crasher, we still have a bit more work to do. Also, one caveat, while CSS HMR should work perfectly, if you are trying to do HMR with your JS, that's a whole other subject and honestly not something I have really done much with.

Okay, the last thing we have to do now is get this all working with your Grav and/or WordPress theme. If you remember I mentioned above when talking about publicPath that we need to setup your theme to look for the files served by WDS. This applies regardless of whether you did set the publicPath option or just kept serving files from the root URL, because either way you need your theme to be looking for these files at the port number based address. Let me show you what I mean.

Webpack DevServer with Grav

So in your base.html.twig file you probably want to set this var to save you some duplication. Right at the top just put in this line and adjust for your dev siteURL:

{% set WDSpath = 'https://gravdev.localhost:3016' ~ theme_url ~ '/dist/' %}

Then in your <head> section where you are loading all your assets, you'll want to make a few adjustments. Basically what we are doing is loading the WDS file when we are in dev mode. To enable that I use the Grav debugger option, which is handy to have on anyway any time you are developing anyway.

{% block stylesheets %}
    {% if config.system.debugger.enabled == true %}
        <link href="{{"#{ WDSpath }site.bundle.css"}}" type="text/css" rel="stylesheet" />
    {% else %}
        {% do assets.addCss('theme://dist/site.bundle.css') %}
    {% endif %}
{% endblock %}

{% block javascripts %}
    {% do assets.addJs( 'jquery', 101 ) %}
    {% if config.system.debugger.enabled == true %}
            <script src="{{"#{ WDSpath }site_header.bundle.js"}}"></script>
    {% else %}
        {% do assets.addJs( 'theme://dist/site_header.bundle.js' ) %}
        {% do assets.addJs( 'theme://dist/site.bundle.js', { group: 'bottom' } ) %}
    {% endif %}
{% endblock %}

And then in your "bottom" block, you'll need to add something like this below. You could just put your bundle in your header I guess, but I like to do this to keep things consistent and as close to production output as possible.

{% block bottom %}
    {% if config.system.debugger.enabled == true %}
        <script src="{{"#{ WDSpath }site.bundle.js"}}"></script>
    {% endif %}

    {{ assets.js('bottom')|raw }}
{% endblock %}

Webpack DevServer with WordPress

So similar to a Grav setup, we need a way for WP to know whether we need to access the WDS output or your production files. So we use the WP global debug which is handy to have enabled during dev anyway. You'll also want to set a variable to hold your localhost path to keep things cleaner. Overall, I setup my WP enqueues like this:

<?php
if ( !is_admin() ) {

  /**
   * Enqueue JavaScript files and (S)CSS styles in... dev mode, production, always
   */
  function gdt_enqueue_stuff() {
    // include these script and style files only when in dev mode.
    if ( WP_DEBUG ) {
      $WDSpath = 'https://wpdev.localhost:3015/wp-content/themes/mytheme/dist/';

      wp_register_script( 'mytheme-header-bundle', $WDSpath . 'site_header.bundle.js', array('jquery'), null, false );
      wp_enqueue_script( 'mytheme-header-bundle' );

      wp_register_script( 'mytheme-bundle', $WDSpath . 'site.bundle.js', array('jquery'), null, true );
      wp_enqueue_script( 'mytheme-bundle' );

      wp_register_style( 'mytheme-styles', $WDSpath . 'site.bundle.css', array(), null, 'all' );
      wp_enqueue_style( 'mytheme-styles' );
    }

    // include these script files only when in production mode.
    if ( ! WP_DEBUG ) {
      $js_file_time = filemtime( get_stylesheet_directory() . '/dist/site_header.bundle.js' );
      wp_register_script( 'mytheme-header-bundle', get_stylesheet_directory_uri() . '/dist/site_header.bundle.js', array('jquery'), $js_file_time, false );
      wp_enqueue_script( 'mytheme-header-bundle' );

      $js_file_time = filemtime( get_stylesheet_directory() . '/dist/site.bundle.js' );
      wp_register_script('mytheme-bundle', get_stylesheet_directory_uri() . '/dist/site.bundle.js', array('jquery'), $js_file_time, true);
      wp_enqueue_script( 'mytheme-bundle' );

      $css_file_time = filemtime(get_stylesheet_directory() . '/dist/site.bundle.css');
      wp_register_style( 'mytheme-styles', get_stylesheet_directory_uri() . '/dist/site.bundle.css', array(), $css_file_time, 'all' );
      wp_enqueue_style( 'mytheme-styles' );
    }

    // always include these scripts and styles:
    // ...

    // comment reply script for threaded comments
    if ( is_singular() AND comments_open() AND (get_option('thread_comments') == 1)) {
      wp_enqueue_script( 'comment-reply' );
    }
  }

  /* add custom enqueue function to the applicable action */
  add_action( 'wp_enqueue_scripts', 'gdt_enqueue_stuff', 999 );
}

?>

Okay, now we can party -- were all done. Finally. It's been a long journey, thanks for making it this far with me. Yes, this is a lot to figure out and sort out, however, I believe it's well worth the time. Hopefully this has helped you get this figured out too. Cheers!

Addtional Reading & References

I'm far from a webpack expert. These blog posts and Stack Exchange answers were instrumental in my piecing this all together. Please consult these for more information.

Image Credit

Banner Photo by Kelly Sikkema on Unsplash.

© 2020 Creative Logic Tech Solutions.