Skip to content
5 changes: 5 additions & 0 deletions .changeset/add-plugin-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-server": minor
---

Add plugin support. `webpack-dev-server` can now be used as a webpack plugin, integrating with the compiler lifecycle without explicitly passing a compiler, preventing multiple server starts on recompilation, ensuring clean shutdown, and supporting `MultiCompiler` setups with multiple independent plugin servers.
46 changes: 46 additions & 0 deletions examples/api/plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# API: Plugin

Use `webpack-dev-server` as a webpack plugin by adding an instance to
`plugins[]`. The dev server starts when the first compilation finishes and
stops when the compiler closes — no separate `server.start()` call is needed.

```js
// webpack.config.js
const WebpackDevServer = require("webpack-dev-server");

module.exports = {
// ...
plugins: [new WebpackDevServer({ port: 8080, open: true })],
};
```

If you have existing `devServer` options in your config, spread them into the
plugin instance — the plugin reads its options from its constructor argument,
not from `config.devServer`:

```js
const devServerOptions = { ...config.devServer, open: true };
config.plugins.push(new WebpackDevServer(devServerOptions));
```

## Run

```console
npx webpack --watch
```

## What should happen

1. Open `http://localhost:8080/` in your preferred browser.
2. You should see the text on the page itself change to read `Success!`.
3. Press `Ctrl+C` in the terminal — `webpack-cli` closes the compiler, which
fires the plugin's `shutdown` hook, stopping the dev server cleanly.

## Notes

- The plugin works with both `webpack --watch` and `webpack serve`. With
`webpack serve`, `webpack-cli` already creates its own standalone dev server
for the same compiler, so you would end up with two servers running. If
that's intentional (e.g. different ports/hosts), make sure the plugin's
`port` does not clash with the one `webpack-cli` resolves from
`config.devServer` and CLI args. Otherwise prefer one or the other.
6 changes: 6 additions & 0 deletions examples/api/plugin/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use strict";

const target = document.querySelector("#target");

target.classList.add("pass");
target.innerHTML = "Success!";
27 changes: 27 additions & 0 deletions examples/api/plugin/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use strict";

const WebpackDevServer = require("../../../lib/Server");
// our setup function adds behind-the-scenes bits to the config that all of our
// examples need
const { setup } = require("../../util");

const config = setup({
context: __dirname,
entry: "./app.js",
output: {
filename: "bundle.js",
},
stats: {
colors: true,
},
});

// `setup()` populates `config.devServer.setupMiddlewares` so that the example
// layout assets (CSS, favicon, icons under `.assets/`) are served by the dev
// server. Forward those options to the plugin instance — without them the
// `<link rel="stylesheet">` from the shared layout would 404.
config.plugins.push(
new WebpackDevServer({ ...config.devServer, port: 8090, open: true }),
);

module.exports = config;
150 changes: 134 additions & 16 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
* @property {typeof useFn} use
*/

const pluginName = "webpack-dev-server";

/**
* @template {BasicApplication} [A=ExpressApplication]
* @template {BasicServer} [S=HTTPServer]
Expand All @@ -341,11 +343,14 @@ class Server {
baseDataPath: "options",
});

this.compiler = compiler;
/**
* @type {ReturnType<Compiler["getInfrastructureLogger"]>}
*/
this.logger = this.compiler.getInfrastructureLogger("webpack-dev-server");
if (compiler) {
this.compiler = compiler;

/**
* @type {ReturnType<Compiler["getInfrastructureLogger"]>}
*/
this.logger = this.compiler.getInfrastructureLogger(pluginName);
}
this.options = options;
/**
* @type {FSWatcher[]}
Expand All @@ -372,6 +377,11 @@ class Server {
*/

this.currentHash = undefined;
/**
* @private
* @type {boolean}
*/
this.isPlugin = false;
}

static get schema() {
Expand Down Expand Up @@ -558,14 +568,14 @@ class Server {
}

if (!dir) {
return path.resolve(cwd, ".cache/webpack-dev-server");
return path.resolve(cwd, `.cache/${pluginName}`);
} else if (process.versions.pnp === "1") {
return path.resolve(dir, ".pnp/.cache/webpack-dev-server");
return path.resolve(dir, `.pnp/.cache/${pluginName}`);
} else if (process.versions.pnp === "3") {
return path.resolve(dir, ".yarn/.cache/webpack-dev-server");
return path.resolve(dir, `.yarn/.cache/${pluginName}`);
}

return path.resolve(dir, "node_modules/.cache/webpack-dev-server");
return path.resolve(dir, `node_modules/.cache/${pluginName}`);
}

/**
Expand Down Expand Up @@ -1250,7 +1260,7 @@ class Server {
if (typeof options.ipc === "boolean") {
const isWindows = process.platform === "win32";
const pipePrefix = isWindows ? "\\\\.\\pipe\\" : os.tmpdir();
const pipeName = "webpack-dev-server.sock";
const pipeName = `${pluginName}.sock`;

options.ipc = path.join(pipePrefix, pipeName);
}
Expand Down Expand Up @@ -1349,7 +1359,12 @@ class Server {
}

if (typeof options.setupExitSignals === "undefined") {
options.setupExitSignals = true;
// In plugin mode, the host (e.g. `webpack-cli`) usually owns process
// signal handling and calls `compiler.close()` on shutdown, which fires
// our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of
// that would race with the host's handler and call `compiler.close()`
// twice.
options.setupExitSignals = !this.isPlugin;
}

if (typeof options.static === "undefined") {
Expand Down Expand Up @@ -1645,7 +1660,7 @@ class Server {
this.server.emit("progress-update", { percent, msg, pluginName });
}
},
).apply(this.compiler);
).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler));
}

/**
Expand Down Expand Up @@ -1732,7 +1747,7 @@ class Server {
needForceShutdown = true;

this.stopCallback(() => {
if (typeof this.compiler.close === "function") {
if (typeof this.compiler?.close === "function") {
this.compiler.close(() => {
// eslint-disable-next-line n/no-process-exit
process.exit();
Expand Down Expand Up @@ -1833,13 +1848,16 @@ class Server {
* @returns {void}
*/
setupHooks() {
this.compiler.hooks.invalid.tap("webpack-dev-server", () => {
const compiler = /** @type {Compiler | MultiCompiler} */ (this.compiler);

compiler.hooks.invalid.tap(pluginName, () => {
if (this.webSocketServer) {
this.sendMessage(this.webSocketServer.clients, "invalid");
}
});
this.compiler.hooks.done.tap(
"webpack-dev-server",

compiler.hooks.done.tap(
pluginName,
/**
* @param {Stats | MultiStats} stats stats
*/
Expand Down Expand Up @@ -1892,6 +1910,7 @@ class Server {
* @returns {Promise<void>}
*/
async setupMiddlewares() {
if (this.compiler === undefined) return;
/**
* @type {Middleware[]}
*/
Expand Down Expand Up @@ -2395,8 +2414,10 @@ class Server {
// middleware for serving webpack bundle
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
this.middleware = webpackDevMiddleware(
// @ts-expect-error
this.compiler,
this.options.devMiddleware,
this.isPlugin,
);
}

Expand Down Expand Up @@ -3365,6 +3386,15 @@ class Server {
* @returns {Promise<void>}
*/
async start() {
await this.setup();
await this.listen();
}

/**
* @private
* @returns {Promise<void>}
*/
async setup() {
await this.normalizeOptions();

if (this.options.ipc) {
Expand Down Expand Up @@ -3416,7 +3446,13 @@ class Server {
}

await this.initialize();
}

/**
* @private
* @returns {Promise<void>}
*/
async listen() {
const listenOptions = this.options.ipc
? { path: this.options.ipc }
: { host: this.options.host, port: this.options.port };
Expand Down Expand Up @@ -3568,6 +3604,88 @@ class Server {
.then(() => callback(), callback)
.catch(callback);
}

/**
* @param {Compiler | MultiCompiler} compiler compiler
* @returns {void}
*/
apply(compiler) {
this.compiler = compiler;
this.isPlugin = true;
this.logger = this.compiler.getInfrastructureLogger(pluginName);

/** @type {Promise<void> | undefined} */
let setupPromise;
let inWatchMode = false;
let listening = false;
let stopped = false;

// `setup()` boots webpack-dev-middleware, which replaces the compiler's
// `outputFileSystem` with an in-memory one. That swap has to happen before
// the first compilation writes its assets, otherwise the first build lands
// on the real disk — so it runs on `watchRun`, at the start of a watch run,
// before anything is emitted. Guarded so the async `setup()` runs at most
// once across rebuilds.
/**
* @returns {Promise<void>} promise
*/
const ensureSetup = () => {
if (!setupPromise) {
setupPromise = this.setup();
}
return setupPromise;
};

// `watchRun` and `done` are tapped on the compiler directly — no iteration
// over `MultiCompiler.compilers`:
// - `watchRun` is a `MultiHook` on a `MultiCompiler`, so the tap is forwarded
// to every child and awaited. It stays `tapPromise` so a failing `setup()`
// rejects the user's `watch()` callback. It only fires in watch mode, which
// is how we know a one-shot `compiler.run()` build is not in play.
// - `done` on a `MultiCompiler` is the aggregate `SyncHook` that fires once
// after every child finishes, so the server starts exactly once. Being a
// `SyncHook`, it can only be `tap`ped; `listen()` runs detached.
const { hooks } = /** @type {Compiler} */ (compiler);

hooks.watchRun.tapPromise(pluginName, () => {
inWatchMode = true;
return ensureSetup();
});

hooks.done.tap(pluginName, () => {
// `done` also fires for a one-shot `compiler.run()` build, where no
// `watchRun` ran; staying passive lets that build finish and exit.
if (listening || !inWatchMode) return;
listening = true;
ensureSetup()
.then(() => this.listen())
.catch((error) => {
this.logger.error(error);
});
});

/**
* @returns {Promise<void>} promise
*/
const onShutdown = async () => {
if (stopped) return;
stopped = true;
await this.stop();
};

// Teardown is the one place a loop is unavoidable. A `MultiCompiler` has no
// `shutdown` hook, and its aggregate `watchClose` does NOT fire on
// `compiler.close()` (only on `watching.close()`), so the only signal that
// survives `compiler.close()` is each child's own `shutdown`. Tapping it
// with `tapPromise` also lets `compiler.close()` await the server actually
// stopping, so the port is released before the next start.
const childCompilers = /** @type {MultiCompiler} */ (compiler)
.compilers || [compiler];

for (const childCompiler of childCompilers) {
childCompiler.hooks.shutdown.tapPromise(pluginName, onShutdown);
}
}
}

export default Server;
47 changes: 47 additions & 0 deletions test/e2e/__snapshots__/api-plugin.test.js.snap.webpack5
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
exports[`API (plugin) > MultiCompiler > should work with plugin API 1`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"one",
]
`;

exports[`API (plugin) > MultiCompiler > should work with plugin API 2`] = `
[]
`;

exports[`API (plugin) > plugin in webpack config > should work when added to webpack config plugins array 1`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"Hey.",
]
`;

exports[`API (plugin) > plugin in webpack config > should work when added to webpack config plugins array 2`] = `
[]
`;

exports[`API (plugin) > plugin in webpack config > should work with output.clean: true 1`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"Hey.",
]
`;

exports[`API (plugin) > plugin in webpack config > should work with output.clean: true 2`] = `
[]
`;

exports[`API (plugin) > should work with plugin API 1`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"Hey.",
]
`;

exports[`API (plugin) > should work with plugin API 2`] = `
[]
`;
Loading
Loading