Marius Gundersen

Webpack CSS LiveReload

In this article I will describe how to make a very simple but useful live-reload plugin for Webpack, something I recently needed for a project. There are more complex plugins available, but they rely on the dev server which I couldn't use. Therefore I hacked together a simple standalone live-reload plugin, and now I'm sharing my journey with you.

The goal is for the CSS in a website to automatically update whenever the source stylesheet is edited. To achive this we need to break the problem into smaller parts:

This should all be implemented as a webpack plugin. The simplest way to create a plugin in webpack, perfect for hacking together a quick test, is as a function.

//webpack.config.js
// webpack.config.js
module.exports = {
entry: [ './src/index.js', './src/index.css'],
output: {
path: __dirname+'/output/assets',
publicPath: '/assets/'
},
// Lots more configuration complicating things here...
plugins: [
]
}
//webpack.config.js
// Lots more configuration complicating things here...
compiler.hooks.assetEmitted.tap(plugin_name, (name, info) => {
//webpack.config.js
const publicPath = compiler\.options\.output\.publicPath ?? '/';
compiler.hooks.assetEmitted.tap(plugin_name, (name, info) => {
//webpack.config.js
const publicPath = compiler\.options\.output\.publicPath ?? '/';
//webpack.config.js
const publicPath = compiler\.options\.output\.publicPath ?? '/';
const es = new EventSource('http://localhost:8789', { withCredentials: false });
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
const es = new EventSource('http://localhost:8789', { withCredentials: false });
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
//webpack.config.js
events.emit('message', `event: message\ndata: ${JSON.stringify(data)}\n\n`);
compiler.options.entry[this.options.entry].import?.push(require.resolve('./client.js'));
//webpack.config.js
compiler.options.entry[this.options.entry].import?.push(require.resolve('./client.js'));
//webpack.config.js
compiler.options.entry[this.options.entry].import?.push(require.resolve('./client.js'));
//webpack.config.js
compiler.options.entry[this.options.entry].import?.push(require.resolve('./client.js'));

Given a simple webpack configuration file like this, where I have left out most of the interesting things, it's easy to add a new plugin

The plugin is a function that gets called with one parameter, the compiler. I've created a variable called plugin_name that we need several times later on.

Detecting changes to assets

Webpack plugins can be notified whenever an asset (any file generated by webpack) is emited (written to the filesystem) by subscribing to the assetEmitted hook, so let's set it up in the plugin.

The name is relative to the output.path property, so a file like /output/assets/main.css will only have the name main.css. To get the path that the client is interested in, the one that is in <link rel="stylesheet" href="/assets/main.css">, we need to concat the output.publicPath property with the name. If publicPath isn't set then we use / to get the root path.

Note that publicPath can be a function, but I'm not adding support for that here.

Notifying the client

Now that we know if a file has changed we need to notify the client. I'm going to use Server Sent Events to do this, because it's very simple to set up yet robust. This requires a small client side script that can connect to a small webserver and listen for events. The events are sent to the client as newline separated text, something like this:

event: message
data: Something here

event: message
data: Data is always a string

event: message
data: {"json": true, "text": "To send something more complex just serialize it as JSON"}

In the above example you can see that each event is separated by two newline characters, and each event has a name (message in the above example) and some data. There are some more options and technical details, but this is enough for us to implement a live-reload server.

Webpack runs in node.js so we can make a simple node http server that only sends messages. This little server will send two messages and then do nothing. Creating a client that listens to these events is even simpler.

EventSource is part of the web platform, so this is a no dependency client. It connects to the server and then uses an event listener to get notified every time the server sends a message. This client will even reconnect to the server if the connection is lost, without me having to code anything.

I've expanded the server with an EventEmitter and made the function return an object with a sendMessage function. The data parameter is serialized as json and sent to the client. We can now use this in our plugin to send messages to the client.

Instead of logging to the console the plugin now sends a message to all connceted clients.

Updating the CSS in the client

The client gets a message every time any asset changes, but so far it only console.logs it. We can filter it so it only reacts to css files (maybe this should have been done in the server?).

Next we want to update the stylesheet that has changed. We can look at all the stylesheets in the document using document.stylesheets and compare their href with the asset value. We can then use ownerNode to find the <link rel="stylesheet" href="..."> that imported it. This is slightly limiting in that it cannot replace @import url('./another.css') stylesheets.

We can find the html <link rel="stylesheet" href="..."> element for the given stylesheet by using the ownerNode property. We need to force this element to reload the stylesheet, so we append the current timestamp to the end of the url, which will bypass the cache. But instead of updating the existing link I create a clone and insert it, and I only remove the old element once the new one has loaded. This prevents flash of unstyled content (https://en.wikipedia.org/wiki/Flash_of_unstyled_content).

And that's about it, we have a simple but working live-reload implementation.

Getting the client code into the bundle

There are a few details I have skipped over so far to keep things simple. For example, we only want this plugin during watch. So let's check if the watch option is enabled, and if it isn't this plugin shouldn't do anything.

But we also want to add the client code to our entry bundle. When developing and testing this plugin I just added the code to my entry, but that means the code is included in the production build as well. A much better solution is for the plugin to add the client code when it is needed. This is quite easy to do, although the webpack documentation doesn't want to admit that.

I'm adding the client.js file to the main entry, since in this example I only have one entry. But maybe it should be added to another entry? It would be nice if the user of the plugin could specify themselves which entry it should be added to.

I've pulled out the plugin as a class so that an instance of it can be made in the plugin array with some option parameters. This way we can pass in different options depending on what you want to do.

This way we can supply other options, for example the port we want the SSE server to run on, which is passed into the createSseServer function.

We also need to tell the client js code what port to run on. A neat trick with webpack plugins is that one plugin can call another plugi, so we use the DefinePlugin to define a magic variable for the client code to use.

And that's about it! There are improvements that can be done, obviously, but this is the code I'm using in my project, and it is working perfectly well.

I have published this plugin to npm and on github.

Did you find a mistake or have a suggestion for an improvement? Let me know or fork it and send me a pull-request.