Webpack from Nothing

Auto-reload Code When You Change It

We've got all the right tools in places to build, manage, test, deploy, and debug our application. But it's all really really slow. We can make this faster by using Webpack's dev server. But first, a word on workflow.

A Reliable Development Workflow

We've been talking about JavaScript and CSS, but at the end of the day, you are building a web application, and that means there is some logic and code that must exist. This logic and code is likely the biggest source of complexity in your application. While CSS and UI is certainly complex, it pales in comparison to what is typically required to build an actual web application.

Because of this, your default workflow should be a test-driven one. You should not run your application to verify that its underlying logic is working correctly—you should run a test of that logic.

Granted, our markdown previewer doesn't have a lot of such logic, but it is important to understand that a “tweak and reload” style of development is the slowest style of development.

Unfortunately, for UI work, there isn't a great substitute, so let's see how Webpack helps.

Auto-reloading On Changes

Webpack is super slow:

> time yarn webpack
yarn run v1.21.1
$ webpack $npm_package_config_webpack_args
Hash: 4f3ab66cec3145c1dcd4
Version: webpack 4.41.5
Time: 1058ms
Built at: 01/21/2020 5:48:03 PM
     Asset       Size  Chunks             Chunk Names
 bundle.js    210 KiB       0  [emitted]  main
index.html  833 bytes          [emitted]  
styles.css    327 KiB       0  [emitted]  main
Entrypoint main = styles.css bundle.js
[0] ./js/index.js 421 bytes {0} [built]
[2] ./css/styles.css 39 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 9 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!css/styles.css:
    Entrypoint mini-css-extract-plugin = *
    [0] ./node_modules/css-loader/dist/cjs.js!./css/styles.css 303 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!node_modules/tachyons/css/tachyons.css:
    Entrypoint mini-css-extract-plugin = *
       2 modules
Done in 1.93s.
2.75 real         2.46 user         0.53 sys

When designing a UI, you need to change markup, CSS, and JavaScript. It's often not possible to assure that JavaScript is providing the UI interactions you want without trying it, and that's lots of small changes that you reload in the browser. Having to wait a few seconds or more each time sucks.

We can make this a bit better by using webpack-dev-server, which we can install with yarn:

> yarn add webpack-dev-server -D
yarn add v1.21.1
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
info This module is OPTIONAL, you can safely ignore this error
success Saved lockfile.
success Saved 101 new dependencies.
info Direct dependencies
└─ webpack-dev-server@3.10.1
info All dependencies
├─ @types/events@3.0.0
├─ @types/glob@7.1.1
├─ @types/minimatch@3.0.3
├─ @types/node@13.1.8
├─ accepts@1.3.7
├─ ansi-colors@3.2.4
├─ ansi-html@0.0.7
├─ array-flatten@1.1.1
├─ array-union@1.0.2
├─ array-uniq@1.0.3
├─ async@2.6.3
├─ batch@0.6.1
├─ body-parser@1.19.0
├─ bonjour@3.5.0
├─ buffer-indexof@1.1.1
├─ code-point-at@1.1.0
├─ compressible@2.0.18
├─ compression@1.7.4
├─ connect-history-api-fallback@1.6.0
├─ content-disposition@0.5.3
├─ cookie-signature@1.0.6
├─ cookie@0.4.0
├─ deep-equal@1.1.1
├─ default-gateway@4.2.0
├─ del@4.1.1
├─ destroy@1.0.4
├─ detect-node@2.0.4
├─ dns-equal@1.0.0
├─ dns-packet@1.3.1
├─ dns-txt@2.0.2
├─ ee-first@1.1.1
├─ eventemitter3@4.0.0
├─ eventsource@1.0.7
├─ express@4.17.1
├─ faye-websocket@0.10.0
├─ finalhandler@1.1.2
├─ follow-redirects@1.9.0
├─ forwarded@0.1.2
├─ globby@6.1.0
├─ handle-thing@2.0.0
├─ hpack.js@2.1.6
├─ html-entities@1.2.1
├─ http-deceiver@1.2.7
├─ http-parser-js@0.4.10
├─ http-proxy-middleware@0.19.1
├─ http-proxy@1.18.0
├─ internal-ip@4.3.0
├─ ip-regex@2.1.0
├─ ip@1.1.5
├─ ipaddr.js@1.9.1
├─ is-absolute-url@3.0.3
├─ is-arguments@1.0.4
├─ is-path-cwd@2.2.0
├─ is-path-in-cwd@2.1.0
├─ is-path-inside@2.1.0
├─ json3@3.3.3
├─ killable@1.0.1
├─ loglevel@1.6.6
├─ media-typer@0.3.0
├─ merge-descriptors@1.0.1
├─ methods@1.1.2
├─ mime@2.4.4
├─ multicast-dns-service-types@1.1.0
├─ multicast-dns@6.2.3
├─ negotiator@0.6.2
├─ node-forge@0.9.0
├─ number-is-nan@1.0.1
├─ object-is@1.0.2
├─ obuf@1.1.2
├─ on-headers@1.0.2
├─ opn@5.5.0
├─ original@1.0.2
├─ p-map@2.1.0
├─ p-retry@3.0.1
├─ path-is-inside@1.0.2
├─ path-to-regexp@0.1.7
├─ pinkie-promise@2.0.1
├─ pinkie@2.0.4
├─ portfinder@1.0.25
├─ proxy-addr@2.0.5
├─ querystringify@2.1.1
├─ raw-body@2.4.0
├─ regexp.prototype.flags@1.3.0
├─ retry@0.12.0
├─ select-hose@2.0.0
├─ selfsigned@1.10.7
├─ serve-index@1.9.1
├─ serve-static@1.14.1
├─ sockjs-client@1.4.0
├─ sockjs@0.3.19
├─ spdy-transport@3.0.0
├─ spdy@4.0.1
├─ thunky@1.1.0
├─ type-is@1.6.18
├─ unpipe@1.0.0
├─ utils-merge@1.0.1
├─ wbuf@1.7.3
├─ webpack-dev-middleware@3.7.2
├─ webpack-dev-server@3.10.1
├─ websocket-extensions@0.1.3
└─ ws@6.2.1
Done in 5.81s.
warning Error running install script for optional dependency: "/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/fsevents: Command failed.
Exit code: 1
Command: node-gyp rebuild
Arguments: 
Directory: /Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/fsevents
Output:
gyp info it worked if it ends with ok
gyp info using node-gyp@3.8.0
gyp info using node@12.3.0 | darwin | x64
gyp info spawn /usr/bin/python
gyp info spawn args [
gyp info spawn args   '/Users/davec/.asdf/installs/nodejs/12.3.0/lib/node_modules/npm/node_modules/node-gyp/gyp/gyp_main.py',
gyp info spawn args   'binding.gyp',
gyp info spawn args   '-f',
gyp info spawn args   'make',
gyp info spawn args   '-I',
gyp info spawn args   '/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/fsevents/build/config.gypi',
gyp info spawn args   '-I',
gyp info spawn args   '/Users/davec/.asdf/installs/nodejs/12.3.0/lib/node_modules/npm/node_modules/node-gyp/addon.gypi',
gyp info spawn args   '-I',
gyp info spawn args   '/Users/davec/.node-gyp/12.3.0/include/node/common.gypi',
gyp info spawn args   '-Dlibrary=shared_library',
gyp info spawn args   '-Dvisibility=default',
gyp info spawn args   '-Dnode_root_dir=/Users/davec/.node-gyp/12.3.0',
gyp info spawn args   '-Dnode_gyp_dir=/Users/davec/.asdf/installs/nodejs/12.3.0/lib/node_modules/npm/node_modules/node-gyp',
gyp info spawn args   '-Dnode_lib_file=/Users/davec/.node-gyp/12.3.0/<(target_arch)/node.lib',
gyp info spawn args   '-Dmodule_root_dir=/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/fsevents',
gyp info spawn args   '-Dnode_engine=v8',
gyp info spawn args   '--depth=.',
gyp info spawn args   '--no-parallel',
gyp info spawn args   '--generator-output',
gyp info spawn args   'build',
gyp info spawn args   '-Goutput_dir=.'
gyp info spawn args ]
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
  SOLINK_MODULE(target) Release/.node
  CXX(target) Release/obj.target/fse/fsevents.o
In file included from ../fsevents.cc:6:
In file included from ../../nan/nan.h:54:
/Users/davec/.node-gyp/12.3.0/include/node/node.h:107:12: fatal error: 'util-inl.h' file not found
#  include <util-inl.h>
           ^~~~~~~~~~~~
1 error generated.
make: *** [Release/obj.target/fse/fsevents.o] Error 1
gyp ERR! build error 
gyp ERR! stack Error: `make` failed with exit code: 2
gyp ERR! stack     at ChildProcess.onExit (/Users/davec/.asdf/installs/nodejs/12.3.0/lib/node_modules/npm/node_modules/node-gyp/lib/build.js:262:23)
gyp ERR! stack     at ChildProcess.emit (events.js:200:13)
gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_process.js:272:12)
gyp ERR! System Darwin 18.7.0
gyp ERR! command \"/Users/davec/.asdf/installs/nodejs/12.3.0/bin/node\" \"/Users/davec/.asdf/installs/nodejs/12.3.0/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js\" \"rebuild\"
gyp ERR! cwd /Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/fsevents
gyp ERR! node -v v12.3.0
gyp ERR! node-gyp -v v3.8.0
gyp ERR! not ok"

Once this is done, run it with the --open flag, and your application will pop up in your favorite browser:

> $(yarn bin)/webpack-dev-server --open

This will compile your app and load it, so it will take the same time as before.

Next, open up css/styles.css in your editor, and arrange your windows so that you can see both your editor and your browser. Make a change to the CSS, and viola, your browser refreshes with that change. Repeat with html/index.html and js/index.js. Not too bad.

The refresh isn't as fast as we'd like, but it's still not too bad. You could easily open up your code editor on one side of the screen, and your browser on the other, and get to work.

Part of the reason is that we split our code into dev and production. To re-run Webpack in dev, we aren't minifying, hashing, or generating a slow source map. That helps.

What about tests?

Auto-reloading in Tests

Since our testing workflow is to build the bundle with our tests, then point Jest at that bundle, we can't really use the webpack dev server for this. The dev server expects to send code to the browser directly, and there's no browser for tests.

What we can do is use the --watch flag of Webpack to have it auto-rebuild the bundle when files it depends on change. We can also test Jest to re-run tests when the bundle changes. Let's add two new npm scripts, one called webpack:test:server and the other called jest:server, like so:

{
  "scripts": {
    "webpack": "webpack $npm_package_config_webpack_args",
    "webpack:production": "webpack $npm_package_config_webpack_args --env=production",
    "webpack:test": "webpack $npm_package_config_webpack_args --env=test",
    "jest": "jest test/bundle.test.js",
    "test": "yarn webpack:test && yarn jest",
    "webpack:test:server": "webpack $npm_package_config_webpack_args --env=test --watch",
    "jest:server": "jest test/bundle.test.js --watch"
  }
}

Now, open three terminals. In one, start webpack:

> yarn webpack:test:server

« standard massive webpack output»

In the other, start Jest:

> yarn jest:server

«test runs»

In the third, edit one of your test to add a new it. You should see your new test get auto-run by Jest.

Both Webpack and Jest are re-running themselves when files change. This isn't bad. It's not great because we have to create our bundle each time, but it's not bad. If our project gets very large, this situation might not work, and then we're into a strange and painful world of getting Jest to work more directly with Webpack, which seems to not be well supported.

There's one last thing we need to look into, and that's the ability to use a better language than JavaScript Since Webpack is essentially compiling our JavaScript and CSS, it stands to reason that if we wanted to use something like TypeScript or ES2015, it should be able to handle that.