Skip to content

Commit 8a554b5

Browse files
authored
feat: plugin support (#5650)
* feat: add plugin support Allow webpack-dev-server to run as a webpack plugin. The server can now be created without explicitly passing a compiler, integrates with the compiler lifecycle to prevent multiple starts on recompilation, ensures clean shutdown, and supports MultiCompiler setups with multiple independent plugin servers. * test: add tests for plugin support Add e2e coverage for the plugin API (api-plugin.test.js), MultiCompiler and multiple independent plugin servers, build-mode behavior, and related logging snapshots. Add the compile helper and port mappings used by these tests. * docs: add example for using webpack-dev-server as a plugin Add a runnable example (app, webpack config and README) showing how to configure and use webpack-dev-server as a webpack plugin. * chore: add changeset for plugin support * test: add test for in-memory file system behavior in plugin * refactor: update hooks to use watchRun instead of beforeCompile for better handling of compiler events * test: add hot module replacement tests for plugin API * test: enhance output.clean behavior test for in-memory asset serving * test: log listen() failure instead of throwing in server setup * refactor: update constructor to make compiler parameter optional for plugin usage
1 parent 168f08a commit 8a554b5

12 files changed

Lines changed: 1146 additions & 26 deletions

File tree

.changeset/add-plugin-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack-dev-server": minor
3+
---
4+
5+
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.

examples/api/plugin/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# API: Plugin
2+
3+
Use `webpack-dev-server` as a webpack plugin by adding an instance to
4+
`plugins[]`. The dev server starts when the first compilation finishes and
5+
stops when the compiler closes — no separate `server.start()` call is needed.
6+
7+
```js
8+
// webpack.config.js
9+
const WebpackDevServer = require("webpack-dev-server");
10+
11+
module.exports = {
12+
// ...
13+
plugins: [new WebpackDevServer({ port: 8080, open: true })],
14+
};
15+
```
16+
17+
If you have existing `devServer` options in your config, spread them into the
18+
plugin instance — the plugin reads its options from its constructor argument,
19+
not from `config.devServer`:
20+
21+
```js
22+
const devServerOptions = { ...config.devServer, open: true };
23+
config.plugins.push(new WebpackDevServer(devServerOptions));
24+
```
25+
26+
## Run
27+
28+
```console
29+
npx webpack --watch
30+
```
31+
32+
## What should happen
33+
34+
1. Open `http://localhost:8080/` in your preferred browser.
35+
2. You should see the text on the page itself change to read `Success!`.
36+
3. Press `Ctrl+C` in the terminal — `webpack-cli` closes the compiler, which
37+
fires the plugin's `shutdown` hook, stopping the dev server cleanly.
38+
39+
## Notes
40+
41+
- The plugin works with both `webpack --watch` and `webpack serve`. With
42+
`webpack serve`, `webpack-cli` already creates its own standalone dev server
43+
for the same compiler, so you would end up with two servers running. If
44+
that's intentional (e.g. different ports/hosts), make sure the plugin's
45+
`port` does not clash with the one `webpack-cli` resolves from
46+
`config.devServer` and CLI args. Otherwise prefer one or the other.

examples/api/plugin/app.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"use strict";
2+
3+
const target = document.querySelector("#target");
4+
5+
target.classList.add("pass");
6+
target.innerHTML = "Success!";
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"use strict";
2+
3+
const WebpackDevServer = require("../../../lib/Server");
4+
// our setup function adds behind-the-scenes bits to the config that all of our
5+
// examples need
6+
const { setup } = require("../../util");
7+
8+
const config = setup({
9+
context: __dirname,
10+
entry: "./app.js",
11+
output: {
12+
filename: "bundle.js",
13+
},
14+
stats: {
15+
colors: true,
16+
},
17+
});
18+
19+
// `setup()` populates `config.devServer.setupMiddlewares` so that the example
20+
// layout assets (CSS, favicon, icons under `.assets/`) are served by the dev
21+
// server. Forward those options to the plugin instance — without them the
22+
// `<link rel="stylesheet">` from the shared layout would 404.
23+
config.plugins.push(
24+
new WebpackDevServer({ ...config.devServer, port: 8090, open: true }),
25+
);
26+
27+
module.exports = config;

lib/Server.js

Lines changed: 136 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -329,26 +329,31 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
329329
* @property {typeof useFn} use
330330
*/
331331

332+
const pluginName = "webpack-dev-server";
333+
332334
/**
333335
* @template {BasicApplication} [A=ExpressApplication]
334336
* @template {BasicServer} [S=HTTPServer]
335337
*/
336338
class Server {
337339
/**
338340
* @param {Configuration<A, S>} options options
339-
* @param {Compiler | MultiCompiler} compiler compiler
341+
* @param {(Compiler | MultiCompiler)=} compiler compiler, omitted when the server is used as a plugin via `apply()`
340342
*/
341343
constructor(options, compiler) {
342344
validate(/** @type {Schema} */ (schema), options, {
343345
name: "Dev Server",
344346
baseDataPath: "options",
345347
});
346348

347-
this.compiler = compiler;
348-
/**
349-
* @type {ReturnType<Compiler["getInfrastructureLogger"]>}
350-
*/
351-
this.logger = this.compiler.getInfrastructureLogger("webpack-dev-server");
349+
if (compiler) {
350+
this.compiler = compiler;
351+
352+
/**
353+
* @type {ReturnType<Compiler["getInfrastructureLogger"]>}
354+
*/
355+
this.logger = this.compiler.getInfrastructureLogger(pluginName);
356+
}
352357
this.options = options;
353358
/**
354359
* @type {FSWatcher[]}
@@ -375,6 +380,11 @@ class Server {
375380
*/
376381

377382
this.currentHash = undefined;
383+
/**
384+
* @private
385+
* @type {boolean}
386+
*/
387+
this.isPlugin = false;
378388
}
379389

380390
static get schema() {
@@ -561,14 +571,14 @@ class Server {
561571
}
562572

563573
if (!dir) {
564-
return path.resolve(cwd, ".cache/webpack-dev-server");
574+
return path.resolve(cwd, `.cache/${pluginName}`);
565575
} else if (process.versions.pnp === "1") {
566-
return path.resolve(dir, ".pnp/.cache/webpack-dev-server");
576+
return path.resolve(dir, `.pnp/.cache/${pluginName}`);
567577
} else if (process.versions.pnp === "3") {
568-
return path.resolve(dir, ".yarn/.cache/webpack-dev-server");
578+
return path.resolve(dir, `.yarn/.cache/${pluginName}`);
569579
}
570580

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

574584
/**
@@ -583,7 +593,7 @@ class Server {
583593
// client. `target: false` is `null` everywhere, so it is excluded.
584594
return Boolean(
585595
platform.web ||
586-
platform?.universal ||
596+
/** @type {{ universal?: boolean | null }} */ (platform)?.universal ||
587597
(compiler.options.target !== false &&
588598
platform.web === null &&
589599
platform.node === null),
@@ -1263,7 +1273,7 @@ class Server {
12631273
if (typeof options.ipc === "boolean") {
12641274
const isWindows = process.platform === "win32";
12651275
const pipePrefix = isWindows ? "\\\\.\\pipe\\" : os.tmpdir();
1266-
const pipeName = "webpack-dev-server.sock";
1276+
const pipeName = `${pluginName}.sock`;
12671277

12681278
options.ipc = path.join(pipePrefix, pipeName);
12691279
}
@@ -1362,7 +1372,12 @@ class Server {
13621372
}
13631373

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

13681383
if (typeof options.static === "undefined") {
@@ -1658,7 +1673,7 @@ class Server {
16581673
this.server.emit("progress-update", { percent, msg, pluginName });
16591674
}
16601675
},
1661-
).apply(this.compiler);
1676+
).apply(/** @type {Compiler | MultiCompiler} */ (this.compiler));
16621677
}
16631678

16641679
/**
@@ -1777,7 +1792,7 @@ class Server {
17771792
needForceShutdown = true;
17781793

17791794
this.stopCallback(() => {
1780-
if (typeof this.compiler.close === "function") {
1795+
if (typeof this.compiler?.close === "function") {
17811796
this.compiler.close(() => {
17821797
// eslint-disable-next-line n/no-process-exit
17831798
process.exit();
@@ -1878,13 +1893,16 @@ class Server {
18781893
* @returns {void}
18791894
*/
18801895
setupHooks() {
1881-
this.compiler.hooks.invalid.tap("webpack-dev-server", () => {
1896+
const compiler = /** @type {Compiler | MultiCompiler} */ (this.compiler);
1897+
1898+
compiler.hooks.invalid.tap(pluginName, () => {
18821899
if (this.webSocketServer) {
18831900
this.sendMessage(this.webSocketServer.clients, "invalid");
18841901
}
18851902
});
1886-
this.compiler.hooks.done.tap(
1887-
"webpack-dev-server",
1903+
1904+
compiler.hooks.done.tap(
1905+
pluginName,
18881906
/**
18891907
* @param {Stats | MultiStats} stats stats
18901908
*/
@@ -1937,6 +1955,7 @@ class Server {
19371955
* @returns {Promise<void>}
19381956
*/
19391957
async setupMiddlewares() {
1958+
if (this.compiler === undefined) return;
19401959
/**
19411960
* @type {Middleware[]}
19421961
*/
@@ -2440,8 +2459,10 @@ class Server {
24402459
// middleware for serving webpack bundle
24412460
/** @type {import("webpack-dev-middleware").API<Request, Response>} */
24422461
this.middleware = webpackDevMiddleware(
2462+
// @ts-expect-error
24432463
this.compiler,
24442464
this.options.devMiddleware,
2465+
this.isPlugin,
24452466
);
24462467
}
24472468

@@ -3410,6 +3431,15 @@ class Server {
34103431
* @returns {Promise<void>}
34113432
*/
34123433
async start() {
3434+
await this.setup();
3435+
await this.listen();
3436+
}
3437+
3438+
/**
3439+
* @private
3440+
* @returns {Promise<void>}
3441+
*/
3442+
async setup() {
34133443
await this.normalizeOptions();
34143444

34153445
if (this.options.ipc) {
@@ -3461,7 +3491,13 @@ class Server {
34613491
}
34623492

34633493
await this.initialize();
3494+
}
34643495

3496+
/**
3497+
* @private
3498+
* @returns {Promise<void>}
3499+
*/
3500+
async listen() {
34653501
const listenOptions = this.options.ipc
34663502
? { path: this.options.ipc }
34673503
: { host: this.options.host, port: this.options.port };
@@ -3613,6 +3649,88 @@ class Server {
36133649
.then(() => callback(), callback)
36143650
.catch(callback);
36153651
}
3652+
3653+
/**
3654+
* @param {Compiler | MultiCompiler} compiler compiler
3655+
* @returns {void}
3656+
*/
3657+
apply(compiler) {
3658+
this.compiler = compiler;
3659+
this.isPlugin = true;
3660+
this.logger = this.compiler.getInfrastructureLogger(pluginName);
3661+
3662+
/** @type {Promise<void> | undefined} */
3663+
let setupPromise;
3664+
let inWatchMode = false;
3665+
let listening = false;
3666+
let stopped = false;
3667+
3668+
// `setup()` boots webpack-dev-middleware, which replaces the compiler's
3669+
// `outputFileSystem` with an in-memory one. That swap has to happen before
3670+
// the first compilation writes its assets, otherwise the first build lands
3671+
// on the real disk — so it runs on `watchRun`, at the start of a watch run,
3672+
// before anything is emitted. Guarded so the async `setup()` runs at most
3673+
// once across rebuilds.
3674+
/**
3675+
* @returns {Promise<void>} promise
3676+
*/
3677+
const ensureSetup = () => {
3678+
if (!setupPromise) {
3679+
setupPromise = this.setup();
3680+
}
3681+
return setupPromise;
3682+
};
3683+
3684+
// `watchRun` and `done` are tapped on the compiler directly — no iteration
3685+
// over `MultiCompiler.compilers`:
3686+
// - `watchRun` is a `MultiHook` on a `MultiCompiler`, so the tap is forwarded
3687+
// to every child and awaited. It stays `tapPromise` so a failing `setup()`
3688+
// rejects the user's `watch()` callback. It only fires in watch mode, which
3689+
// is how we know a one-shot `compiler.run()` build is not in play.
3690+
// - `done` on a `MultiCompiler` is the aggregate `SyncHook` that fires once
3691+
// after every child finishes, so the server starts exactly once. Being a
3692+
// `SyncHook`, it can only be `tap`ped; `listen()` runs detached.
3693+
const { hooks } = /** @type {Compiler} */ (compiler);
3694+
3695+
hooks.watchRun.tapPromise(pluginName, () => {
3696+
inWatchMode = true;
3697+
return ensureSetup();
3698+
});
3699+
3700+
hooks.done.tap(pluginName, () => {
3701+
// `done` also fires for a one-shot `compiler.run()` build, where no
3702+
// `watchRun` ran; staying passive lets that build finish and exit.
3703+
if (listening || !inWatchMode) return;
3704+
listening = true;
3705+
ensureSetup()
3706+
.then(() => this.listen())
3707+
.catch((error) => {
3708+
this.logger.error(error);
3709+
});
3710+
});
3711+
3712+
/**
3713+
* @returns {Promise<void>} promise
3714+
*/
3715+
const onShutdown = async () => {
3716+
if (stopped) return;
3717+
stopped = true;
3718+
await this.stop();
3719+
};
3720+
3721+
// Teardown is the one place a loop is unavoidable. A `MultiCompiler` has no
3722+
// `shutdown` hook, and its aggregate `watchClose` does NOT fire on
3723+
// `compiler.close()` (only on `watching.close()`), so the only signal that
3724+
// survives `compiler.close()` is each child's own `shutdown`. Tapping it
3725+
// with `tapPromise` also lets `compiler.close()` await the server actually
3726+
// stopping, so the port is released before the next start.
3727+
const childCompilers = /** @type {MultiCompiler} */ (compiler)
3728+
.compilers || [compiler];
3729+
3730+
for (const childCompiler of childCompilers) {
3731+
childCompiler.hooks.shutdown.tapPromise(pluginName, onShutdown);
3732+
}
3733+
}
36163734
}
36173735

36183736
export default Server;

0 commit comments

Comments
 (0)