Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
154 changes: 136 additions & 18 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,26 +329,31 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
* @property {typeof useFn} use
*/

const pluginName = "webpack-dev-server";

/**
* @template {BasicApplication} [A=ExpressApplication]
* @template {BasicServer} [S=HTTPServer]
*/
class Server {
/**
* @param {Configuration<A, S>} options options
* @param {Compiler | MultiCompiler} compiler compiler
* @param {(Compiler | MultiCompiler)=} compiler compiler, omitted when the server is used as a plugin via `apply()`
*/
constructor(options, compiler) {
validate(/** @type {Schema} */ (schema), options, {
name: "Dev 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 @@ -375,6 +380,11 @@ class Server {
*/

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

static get schema() {
Expand Down Expand Up @@ -561,14 +571,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 All @@ -583,7 +593,7 @@ class Server {
// client. `target: false` is `null` everywhere, so it is excluded.
return Boolean(
platform.web ||
platform?.universal ||
/** @type {{ universal?: boolean | null }} */ (platform)?.universal ||
(compiler.options.target !== false &&
platform.web === null &&
platform.node === null),
Expand Down Expand Up @@ -1263,7 +1273,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 @@ -1362,7 +1372,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 @@ -1658,7 +1673,7 @@ class Server {
this.server.emit("progress-update", { percent, msg, pluginName });
}
},
).apply(this.compiler);
).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler));
}

/**
Expand Down Expand Up @@ -1777,7 +1792,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 @@ -1878,13 +1893,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 @@ -1937,6 +1955,7 @@ class Server {
* @returns {Promise<void>}
*/
async setupMiddlewares() {
if (this.compiler === undefined) return;
/**
* @type {Middleware[]}
*/
Expand Down Expand Up @@ -2440,8 +2459,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 @@ -3410,6 +3431,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 @@ -3461,7 +3491,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 @@ -3613,6 +3649,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;
Loading
Loading