npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

webpack-dev-middleware

v8.4.0

Published

A development middleware for webpack

Readme

[![npm][npm]][npm-url] [![node][node]][node-url] [![tests][tests]][tests-url] [![coverage][cover]][cover-url] [![discussion][discussion]][discussion-url] [![size][size]][size-url]

webpack-dev-middleware

An express-style development middleware for use with webpack bundles and allows for serving of the files emitted from webpack. This should be used for development only.

Some of the benefits of using this middleware include:

  • No files are written to disk, rather it handles files in memory
  • If files changed in watch mode, the middleware delays requests until compiling has completed.
  • Supports hot module reload (HMR).

Getting Started

First thing's first, install the module:

npm install webpack-dev-middleware --save-dev

[!WARNING]

We do not recommend installing this module globally.

Usage

const express = require("express");
const webpack = require("webpack");
const middleware = require("webpack-dev-middleware");

const compiler = webpack({
  // webpack options
});

const app = express();

app.use(
  middleware(compiler, {
    // webpack-dev-middleware options
  }),
);

app.listen(3000, () => console.log("Example app listening on port 3000!"));

See below for an example of use with fastify.

Options

| Name | Type | Default | Description | | :---------------------------------------------: | :-----------------------: | :-------------------------------------------: | :------------------------------------------------------------------------------------------------------------------- | | methods | Array | [ 'GET', 'HEAD' ] | Allows to pass the list of HTTP request methods accepted by the middleware | | headers | Array\|Object\|Function | undefined | Allows to pass custom HTTP headers on each request. | | index | boolean\|string | index.html | If false (but not undefined), the server will not respond to requests to the root URL. | | cache | Object | undefined | How responses are cached: etag, lastModified, control, immutable. | | mime | Object | undefined | How a file's media type is decided: types, default. | | publicPath | string | undefined | The public path that the middleware is bound to. | | stats | boolean\|string\|Object | stats (from a configuration) | Stats options object or preset name. | | serverSideRender | boolean | undefined | Instructs the module to enable or disable the server-side rendering mode. | | writeToDisk | boolean\|Function | false | Instructs the module to write files to the configured location on disk as specified in your webpack configuration. | | outputFileSystem | Object | memfs | Set the default file system which will be used by webpack as primary destination of generated files. | | modifyResponseData | Function | undefined | Allows to set up a callback to change the response data. | | hot | boolean\|Object | false | Enables a Server-Sent Events endpoint that drives the browser HMR client. | | forwardError | boolean | false | Enable or disable forwarding errors to the next middleware. |

The middleware accepts an options Object. The following is a property reference for the Object.

methods

Type: Array
Default: [ 'GET', 'HEAD' ]

This property allows a user to pass the list of HTTP request methods accepted by the middleware**.

headers

Type: Array|Object|Function Default: undefined

This property allows a user to pass custom HTTP headers on each request. eg. { "X-Custom-Header": "yes" }

or

webpackDevMiddleware(compiler, {
  headers: () => ({
    "Last-Modified": new Date(),
  }),
});

or

webpackDevMiddleware(compiler, {
  headers: (req, res, context) => {
    res.setHeader("Last-Modified", new Date());
  },
});

or

webpackDevMiddleware(compiler, {
  headers: [
    {
      key: "X-custom-header",
      value: "foo",
    },
    {
      key: "Y-custom-header",
      value: "bar",
    },
  ],
});

or

webpackDevMiddleware(compiler, {
  headers: () => [
    {
      key: "X-custom-header",
      value: "foo",
    },
    {
      key: "Y-custom-header",
      value: "bar",
    },
  ],
});

index

Type: Boolean|String Default: index.html

If false (but not undefined), the server will not respond to requests to the root URL.

mime

Type: { types?: Object, default?: String } Default: undefined

How a file's media type is decided.

app.use(
  middleware(compiler, {
    mime: {
      types: { phtml: "text/html" },
      default: "application/octet-stream",
    },
  }),
);

mime.types

Register custom media types or extension mappings, as { extension: 'media/type' }. See mime-db for what is known already.

Registered per instance rather than written into the shared table, so two middlewares in one process cannot reach each other through it.

mime.default

The media type to fall back on when the content type cannot be determined. Without it such a response carries no Content-Type at all.

cache

Type: { etag?: "weak" | "strong", lastModified?: Boolean, control?: Boolean | Number | String | { maxAge?: number, immutable?: boolean }, immutable?: Boolean } Default: undefined

How responses are cached.

app.use(
  middleware(compiler, {
    cache: { etag: "strong", lastModified: true, immutable: true },
  }),
);

cache.etag

Generate an ETag header, "weak" or "strong".

cache.lastModified

Generate a Last-Modified header from the file system's last modified value.

cache.control

Set a Cache-Control response header.

An age is given in milliseconds and sent in seconds, since max-age is defined in seconds — so control: 60000 sends max-age=60. An age above a year is clamped to a year, and a negative one is floored at zero.

| what you pass | the header | | :-------------------- | :------------------------------------------------------------------------------------- | | true | Cache-Control: public, max-age=31536000 — a year | | 60000 | Cache-Control: public, max-age=60 | | "max-age=123456" | Cache-Control: max-age=123456 — used verbatim, no conversion | | { maxAge: 60000 } | Cache-Control: public, max-age=60 | | { immutable: true } | Cache-Control: public, max-age=31536000, immutable — a year when maxAge is omitted |

cache.immutable

Send Cache-Control: public, max-age=31536000, immutable for immutable assets — ones with a hash in the file name, such as image.a4c12bde.jpg, which can be cached forever because changing their contents changes their name.

Takes preference over cache.control for an asset the build marked immutable.

etag, lastModified, cacheControl, cacheImmutable, mimeTypes, mimeTypeDefault

[!WARNING]

Deprecated, and removed in the next major release.

Six options that were the same two topics, now grouped:

| before | now | | :---------------- | :------------------- | | etag | cache.etag | | lastModified | cache.lastModified | | cacheControl | cache.control | | cacheImmutable | cache.immutable | | mimeTypes | mime.types | | mimeTypeDefault | mime.default |

Each still applies and warns, naming its replacement. Setting a name both ways applies the grouped one, so a migration that sets the new name and leaves the old in place is not silently ignored.

publicPath

Type: String Default: output.publicPath (from a configuration)

The public path that the middleware is bound to.

Best Practice: use the same publicPath defined in your webpack config. For more information about publicPath, please see the webpack documentation.

stats

Type: Boolean|String|Object Default: stats (from a configuration)

Stats options object or preset name.

serverSideRender

Type: Boolean
Default: undefined

Instructs the module to enable or disable the server-side rendering mode. Please see Server-Side Rendering for more information.

writeToDisk

Type: Boolean|Function
Default: false

If true, the option will instruct the module to write files to the configured location on disk as specified in your webpack config file. Setting writeToDisk: true won't change the behavior of the webpack-dev-middleware, and bundle files accessed through the browser will still be served from memory. This option provides the same capabilities as the WriteFilePlugin.

This option also accepts a Function value, which can be used to filter which files are written to disk. The function follows the same premise as Array#filter in which a return value of false will not write the file, and a return value of true will write the file to disk. eg.

const webpack = require("webpack");

const configuration = {/* Webpack configuration */};
const compiler = webpack(configuration);

middleware(compiler, {
  writeToDisk: (filePath) => /superman\.css$/.test(filePath),
});

outputFileSystem

Type: Object
Default: memfs

Set the default file system which will be used by webpack as primary destination of generated files. This option isn't affected by the writeToDisk option.

You have to provide .join() and mkdirp method to the outputFileSystem instance manually for compatibility with webpack@4.

This can be done simply by using path.join:

const path = require("node:path");
const mkdirp = require("mkdirp");
const myOutputFileSystem = require("my-fs");
const webpack = require("webpack");

myOutputFileSystem.join = path.join.bind(path); // no need to bind
myOutputFileSystem.mkdirp = mkdirp.bind(mkdirp); // no need to bind

const compiler = webpack({/* Webpack configuration */});

middleware(compiler, { outputFileSystem: myOutputFileSystem });

modifyResponseData

Allows to set up a callback to change the response data.

const webpack = require("webpack");

const configuration = {/* Webpack configuration */};
const compiler = webpack(configuration);

middleware(compiler, {
  // Note - if you send the `Range` header you will have `ReadStream`
  // Also `data` can be `string` or `Buffer`
  modifyResponseData: (req, res, data, byteLength) =>
    // Your logic
    // Don't use `res.end()` or `res.send()` here
    ({ data, byteLength }),
});

hot

Type: Boolean | Object Default: false

Enables hot module replacement. The middleware serves an endpoint that publishes the webpack compiler's building, built and sync events, and puts the browser runtime that listens to them into your bundle, along with HotModuleReplacementPlugin. Turning the option on is the whole of it — there is no entry to add, no plugin to apply and no change to your webpack configuration:

const webpack = require("webpack");
const middleware = require("webpack-dev-middleware");

const compiler = webpack({/* your webpack configuration, unchanged */});

app.use(middleware(compiler, { hot: true }));

Whether the events carry errors and warnings follows the stats option, so one setting governs what a build reports in the terminal and in the browser — stats: "errors-only" keeps warnings out of both, and stats: false keeps both out. When true, defaults are used; pass an object to customise.

See Hot Module Replacement client for which compilations get the runtime, how to configure it, and how to wire it yourself instead.

The object form accepts these options:

| Name | Type | Default | Description | | :------------------------------------: | :-------------------------------------------------------------: | :----------------: | :-------------------------------------------------------------------------------------------- | | transport | string \| function | 'sse' | How events reach the clients. | | path | string | '/__webpack_hmr' | Path the endpoint is served at. | | heartbeat | number | 10000 | Interval (in milliseconds) between keep-alive frames. | | progress | boolean | false | Publish compilation progress events to the clients. | | cors | boolean \| string \| string[] \| RegExp \| function \| object | see below | Which origins may reach the endpoint from a page on another one, over either transport. | | token | boolean \| string | see below | A secret the injected client carries and the endpoint requires, over either transport. | | inject | "all" \| "plugin" \| "client" \| "none" | "all" | What is added to the compilation: the runtime, HotModuleReplacementPlugin, both or neither. | | statsOptions | object | undefined | Deprecated — do not use; see stats. |

hot.transport

Type: 'sse' | 'ws' | Function Default: 'sse'

How events reach the clients.

'sse' serves them as Server-Sent Events from the middleware itself, which needs nothing else.

'ws' serves them over a WebSocket. It needs the optional ws package (npm install ws), and an HTTP server to answer upgrades on — a handshake is an upgrade the server answers, which the middleware never sees. Hand the server over with the middleware's attach method, or answer upgrades yourself through handleUpgrade:

const server = http.createServer(instance);

instance.attach(server);

A plain GET on the path under 'ws' answers 426 Upgrade Required. A handshake from an origin hot.cors does not allow is refused with 403, since a handshake is not subject to CORS and refusing is the only way that option can hold on this wire.

A function builds a transport of your own. It is called with the resolved path and heartbeat, the cors option as it was given, and a logger, and must return a client stream. Four methods are required:

/**
 * @param {{ path: string, heartbeat: number, cors: CorsOption | undefined }} options
 * @param {Logger} logger
 * @returns {ClientStream}
 */
middleware(compiler, {
  hot: {
    transport: ({ path, heartbeat, cors }, logger) => ({
      // Call `fn` with each client once it has joined. It is what catches a
      // client up with the last hashes, so it can apply the next update.
      onConnect(fn) {},
      // Publish a payload to every client.
      publish(payload) {},
      // Publish a payload to one client, as handed to `onConnect`.
      publishTo(client, payload) {},
      // End every client and stop any timers.
      close() {},
    }),
  },
});

A function that returns something missing one of those throws, naming what is absent, rather than failing later from wherever it is first published to.

Four more are optional:

const transport = ({ path, heartbeat }, logger) => ({
  // ...the four above, and any of these:

  // Answer a request on the endpoint's path. Only a transport served over
  // HTTP needs one; without it a request there is answered
  // `426 Upgrade Required`, which is what the built-in WebSocket relies on.
  handler(req, res) {},
  // True while at least one client is connected, so a compilation with none
  // can skip building its payload. Without it, `publish` is called and the
  // transport decides for itself.
  hasClients: () => clients.size > 0,
  // For a transport built on an upgrade, which the middleware never sees.
  attach(server) {},
  detach() {},
});

The clients are yours — whatever onConnect hands out is what publishTo takes back — so in TypeScript name their type through ClientStreamFactory<T>:

import { type ClientStreamFactory } from "webpack-dev-middleware/types/hot";

interface MyClient {
  id: number;
  send: (frame: string) => void;
}

const transport: ClientStreamFactory<MyClient> = ({ path }, logger) => ({
  // ...
  publishTo(client, payload) {
    client.send(JSON.stringify(payload));
  },
});

hot.path

Type: String Default: '/__webpack_hmr'

Path the endpoint is served at. Must start with a slash and match the path option used by the client.

hot.heartbeat

Type: Number Default: 10000

Heartbeat interval (in milliseconds) used to keep the connection alive when no compilation events are produced: keep-alive frames for 'sse', pings for 'ws'. Must be 1 or greater.

hot.ws

Type: Object Default: undefined

Options for the ws server behind the 'ws' transport — compression (perMessageDeflate), maxPayload, handleProtocols, verifyClient, and so on. Ignored by 'sse'.

A port (with an optional host) or a server gives the endpoint a server of its own to listen on, rather than the upgrades it is handed through attach or handleUpgrade — which then answer nothing:

app.use(
  middleware(compiler, {
    hot: { transport: "ws", ws: { port: 8081 } },
  }),
);

hot.cors and hot.token are checked either way, before the handshake completes; a verifyClient of your own is asked only about a client they allow. path, noServer and clientTracking are the middleware's, and are ignored here — the endpoint's path is hot.path.

hot.progress

Type: Boolean Default: false

[!WARNING]

Deprecated, and removed in the next major release. Use publish instead — the example is there.

Applies webpack's ProgressPlugin and publishes what it reports ({ action: "progress", percent, message }). The bundled client shows the percentage in its building badge, which the client progress option configures.

Deciding to measure a build is the server's call rather than the middleware's, and a server that applies ProgressPlugin already — webpack-dev-server does — ends up with two of them on one compiler. The client still renders the payload; what changes is who sends it.

hot.cors

Type: Boolean | String | RegExp | (String | RegExp)[] | Function | { origin } Default: true for 'sse', the local origins for 'ws'

Which origins may use the endpoint from a page on another origin, over either transport: for 'sse' that is reading the stream — the request is still answered, and a page not granted by cors is the one whose browser refuses to hand it the response — and for 'ws' it is whether the upgrade is accepted.

A payload carries a build's module paths and, when a build fails, the source frames webpack puts in the error — so a page that can read the stream can read parts of your source.

The two transports start from different places, because one of them has shipped before and the other has not:

| Transport | Default | Why | | :-------- | :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 'sse' | true — every origin | What the endpoint has always done. Narrowing it would stop a page served from another origin reading its own build, which is a breaking change, so it waits for a major release. Set cors yourself to narrow it today. | | 'ws' | the local origins | New in this release, so there is no behavior to keep: localhost and anything under it, 127.0.0.1 and [::1], any port, either scheme. |

A page on another port of the same machine is the one cross-origin case that is normal in development, and nothing a remote site can be served from looks like one — which is why the local origins are where both are headed. It is the same set, for the same reason, as Vite's server.cors default.

[!TIP]

To get there now, name them yourself. In the next major release this is what 'sse' will do on its own:

app.use(
  middleware(compiler, {
    hot: {
      cors: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/,
    },
  }),
);

Nothing needs to be set for the normal setup, where the page and the middleware are the same server.

Name your own origins when the page is served from somewhere else — a dev domain in your hosts file, a remote dev box, a container:

app.use(
  middleware(compiler, {
    hot: { cors: ["https://app.test", /^https:\/\/\w+\.dev\.internal$/] },
  }),
);

Every accepted form:

| Value | Meaning | | :--------------------- | :------------------------------------------------------------------------------- | | false | Nothing but the endpoint's own origin. | | true | Every origin, including remote ones. | | 'https://app.test' | That one origin. | | ['https://app.test'] | Any in the list; entries may be strings or regular expressions. | | /\.test$/ | Any origin the pattern matches. Anchor it, or it will match more than you think. | | (origin) => boolean | Asked about each origin. | | { origin: … } | Any of the above, so a cors written for Vite or expressjs/cors fits here. |

true is what the middleware did before this option existed, and what webpack-hot-middleware still does. It lets any site loaded in the same browser read your builds, so prefer naming your origins:

app.use(middleware(compiler, { hot: { cors: true } }));

What "may reach it" means differs by transport, because the two wires enforce it in different places:

| | 'sse' | 'ws' | | :----------------------------- | :------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------- | | How it is enforced | The response carries a grant, or does not. Nothing is ever refused; the browser decides what to do with it. | The upgrade is refused with 403 before the handshake completes. | | An allowed origin | Echoed back in Access-Control-Allow-Origin, with Vary: Origin — or * for true. | The handshake proceeds. | | A request with no origin | Served. Every same-origin EventSource is one: a browser sends no Origin for one, and needs no grant to read it. | Allowed. Browsers always send Origin on a handshake, so this is a Node client, a health check or a test. | | The origin it was addressed as | Served, and the browser never consults these headers for a same-origin read anyway. | Allowed whatever cors says — it is the page the middleware is serving, not another origin. |

A handshake is not subject to CORS: a browser sends Origin and pays no attention to what comes back, so on that wire the option can only be honoured by refusing. That refusal applies to attach(server) and handleUpgrade(req, socket, head) alike.

[!TIP]

If your server already decides for itself who may connect — the way webpack-dev-server does with allowedHosts — set cors: true so yours is the only rule and the middleware's default does not refuse first. A refusal is logged with the origin and this remedy, so you will see which it was.

A function transport of your own is handed the option as it was given, as cors, and decides for itself what to do with it.

[!IMPORTANT]

The local-origins set trusts every other server on the developer's own machine, because it cannot tell them apart from your dev server. If one of them serves content someone else controls, set cors to the origins you actually use.

This is about who may reach the endpoint, and nothing else. It does not decide who may reach the assets the middleware serves, which is your server's to answer — with a Cross-Origin-Resource-Policy response header, or with whatever your framework's own CORS middleware does.

hot.token

Type: Boolean | String Default: false

A secret the injected client carries and the endpoint requires. Reaching the stream then takes something a page has to have been given, rather than a header a browser may or may not send.

cors is answered by Origin, and that is the weakness: a browser omits Origin and the whole Sec-Fetch-* family when the destination is not potentially trustworthy — plain http to anything but localhost, which is what host: '0.0.0.0' gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way (CVE-2026-6402, then CVE-2026-14620). A token asks the browser to volunteer nothing.

Off by default, on both transports, and true in the next major release. A token only reaches the browser on the entry this middleware adds, and inject being on does not mean an entry was added — it is skipped when every entry point already pulls the client in, when hot.transport is a function, and for a non-web target. Requiring one by default would turn each of those into a 403 on every client.

Turn it on, which is all the normal setup needs — the client is injected, so it is handed the token and uses it:

app.use(middleware(compiler, { hot: { token: true } }));

If you turn it on where no client was injected, the middleware says so rather than leaving you with an unexplained 403:

[webpack-dev-middleware] 'hot.token' requires a token on the endpoint, but no
client entry was added to hand one over, so every client will be refused.

[!IMPORTANT]

What a token does not protect. The client reads it from its entry query, so it is a string in the bundle. Anything that can already read your bundle cross-origin can read the token out of it — and over plain http to a non-localhost address, nothing stops that unless your server sends Cross-Origin-Resource-Policy. The token hardens every case where the bundle is not readable; where it is, your source has already gone and the stream is the smaller loss. Closing that needs the response header and a Host allowlist, which are your server's to set.

Wiring the client yourself. The token travels in the entry this middleware adds, so hot.inject: "plugin" or "none" turns off the default requirement — there would be no way to hand one over, and requiring it would refuse a client you wired correctly. A hot.token you set to true or a string is still required: hand it to your client yourself, as below, or set hot.token: false.

A configuration that already lists the client as an entry is the other half of that: it is built before the middleware exists, so it cannot carry a token minted per run. Give it one of your own instead, which both sides can know in advance:

const token = "a-secret-of-my-own";

// webpack.config.js
entry: [`webpack-dev-middleware/client?token=${token}`, "./src/index.js"];

// and the middleware
app.use(middleware(compiler, { hot: { transport: "ws", token } }));

Or read the minted one off the instance, for a client you serve yourself:

const instance = middleware(compiler, {
  hot: { transport: "ws", token: true },
});

app.get("/my-client-config.json", (_req, res) => {
  res.json({ token: instance.token });
});

false requires none, which is the default.

hot.inject

Type: "all" | "plugin" | "client" | "none" Default: "all"

What the middleware adds to the compilation. There are two pieces: the runtime in the page — this package's client, with its overlay and building indicator — and HotModuleReplacementPlugin, which is webpack's own hot logic.

| Value | Runtime | Plugin | For | | :--------- | :-----: | :----: | :--------------------------------------------------------------------------------------------------------------- | | "all" | ✓ | ✓ | the default: nothing to add to the webpack configuration | | "plugin" | | ✓ | a page that wires a client of its own — one the middleware does not recognize as its own | | "client" | ✓ | | a configuration that applies HotModuleReplacementPlugin itself, or wants the page reloaded without the runtime | | "none" | | | wiring both yourself, or using the endpoint only through subscribe |

hot.statsOptions

[!WARNING]

Deprecated, and removed in the next major release. Do not use it.

Use these instead:

| To | Use | | :-------------------------------------------------------------- | :----------------------------------------------------------------------------------------------- | | decide what a build reports, in the terminal and in the payload | stats | | quiet the browser console alone | the client's logging | | hide problems from the overlay alone | the client's overlay | | drop a warning everywhere at once | webpack's ignoreWarnings |

Values still passed here apply until the option is removed, except hash, timings and children, which are ignored: the client compares hash against its own bundle's to decide whether an update applies, timings carries the build time it reports, and children would replace the bundle's hash with a child compilation's, so a page would reload instead of updating.

Hot Module Replacement client

The bundled application needs a small runtime that subscribes to the endpoint and applies the updates. hot puts it there for you, along with HotModuleReplacementPlugin — enabling the option is the whole of what a webpack configuration needs:

const middleware = require("webpack-dev-middleware");

app.use(middleware(compiler, { hot: true }));
// no entry to add, no plugin to apply, no configuration change

The runtime is told the endpoint and the transport the middleware resolved, so the two agree without the same value being written in two places.

Set hot.inject to "plugin" to wire it yourself instead (or "none" to apply the plugin yourself as well) — the runtime is published under the ./client subpath:

const webpack = require("webpack");

module.exports = {
  entry: ["webpack-dev-middleware/client", "./src/app.js"],
  plugins: [new webpack.HotModuleReplacementPlugin()],
};

An entry point that already has the client is left alone, so this keeps working with the default hot.inject. It is decided per entry point rather than per compilation: in a build with landing and dashboard where only landing has the client, dashboard still gets one, because they are separate pages and it would otherwise connect to nothing.

The client is recognized as webpack-dev-middleware/client (with or without a query) or as the path that resolves to, so your own ./src/client/index.js is your own file. It is a best effort over the entry shapes it can read: a function is computed per build and cannot be read, and a request can reach the client through an alias or a loader. Missing one costs a duplicate entry, not a broken build, and hot.inject: "plugin" is the way out.

Which compilations get the runtime

Only the ones a browser runs, decided by the compilation's target. HotModuleReplacementPlugin goes to every compilation the middleware serves, whatever its target: a server bundle hot-reloads itself through module.hot too, with webpack/hot/poll or webpack/hot/signal. In a multi-compiler build, a compilation whose configuration says devServer: false gets neither.

| target | Gets the runtime | | :------------------------------------------------------------- | :--------------- | | unset (webpack's default), web, browserslist: … | yes | | webworker | yes | | electron-renderer, electron-preload, nwjs, node-webkit | yes | | universal — "universal", or web and node together | yes | | node, node14, async-node, electron-main | no | | deno | no | | false, or a version with no platform such as es2020 | no |

So in a multi-compiler build the browser half gets a client and the server-rendering half does not, with nothing to configure — both get the plugin.

Web workers are included. A worker has no window and no document, but it has EventSource, WebSocket and webpack's runtime, which is all an update needs — so a worker compilation gets a client and applies updates in place, with the overlay and the building indicator left to the page. The one thing a worker cannot do is reload itself, since it has no location.reload; when an update cannot be applied the client says so and leaves the page that started the worker to reload it.

A universal build runs it in Node too. One bundle serves both, so the runtime is in what Node runs as well, and there it does nothing: it opens no connection, prints nothing and leaves nothing running.

deno is a context webpack also counts as web, and it stays out until it can be tested there — it has no window either, and whether the transports are available is not something this project's test suite can answer.

The last row names no platform for the middleware to go on; if it is a browser bundle, add the entry yourself as above.

Upgrading a project that wired it up itself

Nothing has to change, and both pieces are recognized rather than duplicated:

  • An entry that is already webpack-dev-middleware/client (with or without a query) is left as it is.
  • HotModuleReplacementPlugin already in plugins is not applied twice. It is now redundant, and the middleware says so once per build so you can drop it.

Two things do change:

  • A client the middleware does not recognize — anything other than webpack-dev-middleware/client, such as another package's hot client — is not detected, so a second client is added and both connect. hot.inject: "plugin" turns that off.
  • A project with no HotModuleReplacementPlugin on purpose now gets one, which puts the HMR runtime in the bundle and changes its output. hot.inject: "client" turns that off; if you only wanted the endpoint to listen to through subscribe, set hot.inject: "none". A project that sets hot.client.apply to reload or nothing does not: those modes never use the HMR runtime, so it is left out, and a plugin you wrote into the configuration yourself stays. A page whose url asks for hmr or hmr-only against such a bundle has no runtime to apply an update with, so hmr loads the page instead, and hmr-only says so and stops. (A runtime that is missing from a project whose own mode wanted it is a mistake, and is reported once instead of reloading.)

A package of your own can stand in for the client by re-exporting it. A query written on that module belongs to it rather than to the client, so leave it where the client looks, before requiring the client:

// my-client.js
globalThis.__webpack_dev_middleware_client_query__ = "?path=/custom-hmr";

module.exports = require("webpack-dev-middleware/client");

One caveat: if the compiler was already watching before the middleware was created, the runtime appears from the next build onwards rather than the first one. Create the middleware before starting the watch to avoid it.

No client is added when hot.transport is a function either — the built-in one speaks Server-Sent Events and WebSocket, and a transport of your own carries whatever protocol you wrote it to carry, so the client that speaks it is yours to add unless hot.client.transport says which of the two yours speaks. HotModuleReplacementPlugin is still applied for you. hot.inject: "plugin" silences the reminder.

The client options below are set on the middleware, next to the rest of the hot configuration, and the injected entry carries them to the browser:

app.use(
  middleware(compiler, {
    hot: { client: { overlay: false, logging: "warn" } },
  }),
);

Every option below can be set either way, and they are the same options: what hot.client takes is what the query carries. transport, url and name differ only in having a value the middleware already knows — the resolved hot.transport, the resolved hot.path and the compilation's own name — so setting one replaces that default rather than adding to it. That is what a page reaching the endpoint through a proxy or on another origin needs:

app.use(
  middleware(compiler, {
    hot: { client: { url: "wss://dev.example.com/__webpack_hmr" } },
  }),
);

A hot.transport of your own gets no client by default, since the built-in one speaks Server-Sent Events and WebSocket and cannot know what yours carries. If yours carries one of those two, say which and the client is added as usual:

app.use(
  middleware(compiler, {
    hot: { transport: myOwnStream, client: { transport: "sse" } },
  }),
);

The overlay filters can be functions here as well. They travel to the browser as their own source, so write one as an arrow function or a function — a method shorthand (overlay: { errors(message) {} }) works too, but anything that cannot be serialized is refused when the middleware is created rather than left to fail in the page:

app.use(
  middleware(compiler, {
    hot: {
      client: {
        overlay: {
          warnings: false,
          errors: (error) => !error.message.includes("deprecated"),
        },
      },
    },
  }),
);

hot.client is read only when the client is injected. With hot.inject set to "plugin" or "none", or for a client the configuration already has as an entry, the query string on the entry path is the only source — and it works either way:

entry: [
  "webpack-dev-middleware/client?apply=hmr-only&overlay=false",
  "./src/app.js",
];

The runtime ships as ES5 and uses no built-in newer than ES5, apart from Promise, the transport in use (EventSource or WebSocket) and what HMR itself needs, so it runs in old browsers too — set target to ["web", "es5"] in your configuration so webpack emits its own runtime as ES5 as well.

Client options

Each of these is set either on the middleware as hot.client.<name> or on the entry's query as <name>=<value>, with the same effect and the same spelling in both — except url, which the query also reads as path, the name it had there first. The query also still reads the six names apply and connect replaced (hot, liveReload, reload, autoConnect, reconnect, timeout), folded into those two, until the next major release; hot.client does not take them. transport, url and name default to what the middleware resolved rather than to the value in the table, which is what they are when nothing else is serving them.

One of them, apply, can also be set per page through a url parameter, which the rest cannot — see opting one page out. The two booleans it replaced, hot and live-reload, still work there for now and narrow the mode in force.

| Name | Type | Default | Description | | :-----------------: | :--------------------------------------: | :------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | transport | string | "sse" | How the events are carried: "sse" or "ws". Defaults to the server hot.transport; override it only together with url, since a client asking this endpoint for a protocol it does not serve never connects — the middleware warns when it sees that. Any other string is a module exporting a client of your own. | | url | string\|Object | /__webpack_hmr | Where the runtime connects. Defaults to the server hot.path; set it to an absolute url (wss://dev.example.com/__webpack_hmr) for an endpoint on another origin or behind a proxy, or to an object saying only the parts that differ — see a url in parts. | | apply | "hmr"\|"hmr-only"\|"reload"\|"nothing" | "hmr" | What a build does to the page. "hmr" applies the update and loads the page again if it cannot be applied; "hmr-only" applies it and stops with a message if it cannot; "reload" skips HMR and loads the page again on any build that changed something; "nothing" leaves the page alone until you reload it. One option rather than three booleans, since only four of their eight combinations differed. | | connect | boolean\|{ retries, timeout } | true | Whether to connect when the entry runs, and how the connection is held open. false does not connect — call setOptionsAndConnect() yourself. retries is how many times to reconnect before giving up, and Infinity never does; unset, "sse" keeps trying for as long as the page is open while "ws" gives up after 10. timeout is how long silence is tolerated before reconnecting, in milliseconds, and the interval between reconnections — "sse" only, since a "ws" heartbeat is a protocol ping the browser answers without telling JavaScript. | | overlay | boolean\|Object | true | In-page overlay for problems: a boolean, or a JSON object — see overlay options. Same value shape as webpack-dev-server's client.overlay, plus a few webpack-dev-middleware extensions. | | pageParamPrefix | string | "webpack-dev-middleware" | Names the page-url parameter (<prefix>-apply) that overrides apply for a single page — see opting one page out. Change it if you are building a server of your own and want parameters named after it. | | logging | string\|Object | "info" | Logger level — one of "none", "error", "warn", "info", "log", "verbose". Uses webpack's runtime logger. An object takes the same value as level plus a name, which is what every message is labelled with in the console: a package embedding this runtime is the one a developer installed and would report a problem to, so it can say its own name — { level: "warn", name: "my-dev-server" } logs [my-dev-server] …. | | name | string | "" | Restrict updates to a specific compilation name (useful with multi-compiler). In apply: "reload" a successful build of any other compilation still loads the page, since a page cannot tell which of the bundles it depends on. | | progress | boolean | true | Show a small badge in the page while a rebuild is in progress (with the compilation percentage when the server enables hot.progress). Set to false to disable. | | dynamicPublicPath | boolean | false | Prefix url with __webpack_public_path__ at runtime. The leading slash of url is stripped and no other normalization is applied, so the public path should end with /. |

A url in parts

An absolute url says everything, which is more than is usually known: the host a page will be opened on, and the scheme it will be served over, are the browser's to report. So url also takes the parts that differ and leaves the rest to be resolved in the page:

app.use(
  middleware(compiler, {
    hot: { client: { url: { port: 8080 } } },
  }),
);

| Part | Resolved from the page when unset | | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | protocol | Unset, or "auto", follows the page — and a page served over TLS then gets a secure connection, because a browser refuses a plaintext one from it. Set explicitly it is honoured as given, the one exception being a hostname that names every interface: that resolves to the page's host and takes the page's scheme with it. Only whether the scheme is the secure one carries over, since "sse" and "ws" do not share one. | | hostname | The page's host. 0.0.0.0 and :: resolve to it as well: they are what a server listening on every interface reports, and neither is an address a page can connect to — while the page itself demonstrably reached this server. | | port | The page's port. 0 resolves to it too, being a port the server picked and only the server knows. | | pathname | The resolved hot.path. | | username, password | Nothing — sent only when given. A password with no username is dropped, since basic authentication has no empty username. |

A raw IPv6 address is bracketed where the url needs it and nowhere else.

A client of your own

The transport the page speaks can be replaced. A client is a class constructed with the url, the same shape webpack-dev-server's client.webSocketTransport has always taken, so one written for that works here unchanged:

// my-client.js
module.exports = class MyClient {
  constructor(url) {
    this.socket = new WebSocket(url);
  }

  onOpen(fn) {
    this.socket.onopen = fn;
  }

  onClose(fn) {
    this.socket.onclose = fn;
  }

  onMessage(fn) {
    // Called with the message as a string.
    this.socket.onmessage = (event) => fn(event.data);
  }

  close() {
    // Close without reporting it, so the runtime does not reconnect.
    this.socket.onclose = null;
    this.socket.close();
  }
};

Reconnecting and the backoff between attempts are the runtime's job, not the client's — it only has to report onOpen and onClose honestly. Extend one of the built-in two rather than starting over if you only want to change part of it:

// The runtime ships as ES modules, so import it — a `require()` through a
// bundler hands back the namespace, whose class is on `.default`.
import EventSourceClient from "webpack-dev-middleware/client/sse";
import WebSocketClient from "webpack-dev-middleware/client/ws";

Name it in hot.client.transport — a path, or a package name resolved from the compilation's context — and the injected runtime uses it in place of the built-in one:

app.use(
  middleware(compiler, {
    hot: {
      transport: "ws",
      client: { transport: require.resolve("./my-client.js") },
    },
  }),
);

It reaches the runtime as __webpack_dev_server_client__, which is how webpack-dev-server's client.webSocketTransport option has always worked; a module exporting the class as default is unwrapped. The query carries the endpoint's own transport, which the runtime still builds the url's scheme from.

Listening alongside the runtime

Tooling that wants the raw messages without replacing the runtime can read the connection it holds:

import { client } from "webpack-dev-middleware/client/socket";

// `client` is live: `null` before the runtime starts to connect and while it
// waits to reconnect. `client.client` is the `WebSocket` or `EventSource`
// underneath, a new one for each connection, so a listener added to it hears
// that connection only.
if (client && client.client) {
  client.client.addEventListener("message", (event) => {
    console.log(JSON.parse(event.data));
  });
}

This is the shape webpack-dev-server's client/socket has always exported, which is what @pmmmwh/react-refresh-webpack-plugin reads.

Client overlay options

Passed as a JSON object, e.g. ?overlay={"warnings":false}. The three problem kinds default to true when the object leaves them out; a filter function (URI-encoded) shows only the messages it accepts.

| Name | Type | Default | Description | | :----------------------: | :-----------------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | errors | boolean\|Function | true | Show build errors. | | warnings | boolean\|Function | true | Show build warnings. | | runtimeErrors | boolean\|Function | true | Show uncaught runtime errors and unhandled rejections. | | trustedTypesPolicyName | string | undefined | Trusted Types policy name used for the overlay's HTML. | | styles | Object | undefined | webpack-dev-middleware extension: CSS overrides for the overlay card (element.style keys). | | ansiColors | Object | undefined | webpack-dev-middleware extension: ANSI → HTML color map, as in ansi-html-community. | | openEditorEndpoint | string | "" | webpack-dev-middleware extension: when set, file references become clickable and issue GET <endpoint>?fileName=<file:line:column>; your server provides it, e.g. a route calling launch-editor. | | paginate | boolean | true | webpack-dev-middleware extension: show one problem at a time with prev/next navigation. |

Programmatic API

webpack-dev-middleware/client also exports a few functions for advanced cases:

const hotClient = require("webpack-dev-middleware/client");

// Receive every HMR payload (building / built / sync / custom).
hotClient.subscribeAll((payload) => {
  console.log("hot event", payload);
});

// Receive payloads whose `action` is not recognised by the client (i.e. custom
// payloads published via the server's `instance.context.hot.publish(...)`).
hotClient.subscribe((payload) => {
  // do something
});

// Replace the default error overlay with your own implementation.
hotClient.useCustomOverlay({
  showProblems(type, lines) {
    /* ... */
  },
  clear() {
    /* ... */
  },
});

// Connect manually when `connect=false`. Accepts the same option keys as
// the query-string API above.
hotClient.setOptionsAndConnect({ path: "/__hmr" });

// Close the SSE connection and stop reconnecting (e.g. before tearing the
// page down). A later `setOptionsAndConnect` call opens a fresh connection.
hotClient.disconnect();

The error overlay is also exposed as a standalone module so other tooling (e.g. webpack-dev-server) can reuse it without the SSE client:

import configureOverlay, {
  clear,
  showProblems,
} from "webpack-dev-middleware/client/overlay";

const overlay = configureOverlay({
  // ansiColors, overlayStyles, trustedTypesPolicyName, catchRuntimeError,
  // openEditorEndpoint, paginate
});

overlay.showProblems("errors", ["Something broke"]);
overlay.clear();

The overlay state is a per-page singleton: every bundled copy of the module renders into the same overlay. Multiple clients can report side by side by passing a source — each source keeps its own slot and the overlay shows the union, with errors from any source taking precedence over warnings:

overlay.showProblems("errors", ["Something broke"], "my-client");
// Drop only this client's problems; other sources stay on screen.
overlay.clear("my-client");
// Without a source, everything is dismissed (same as Esc / backdrop / ×).
overlay.clear();

The building indicator is exposed the same way:

import { hide, show } from "webpack-dev-middleware/client/indicator";

show("Rebuilding…"); // pulsing dot
show("Rebuilding… 42%", 42); // progress ring
hide();

The badge is a per-page singleton shared by every bundled copy of the mod