Skip to content

feat: reuse webpack-dev-middleware browser runtime, deleting this package's client - #5750

Draft
alexander-akait wants to merge 12 commits into
mainfrom
feat/reuse-dev-middleware-client
Draft

alexander-akait wants to merge 12 commits into
mainfrom
feat/reuse-dev-middleware-client

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Scope grew: this is now the whole migration, not the waypoint. This server has no browser code left at all.

Earlier revisions of this PR re-exported webpack-dev-middleware's transports and overlay from client-src/, behind adapters that kept this package's shapes on top of them. An adapter is still a client file, so the last four commits finish the job: client-src/ is deleted, and the middleware's hot option drives the runtime, the overlay, the indicator, the entry and HotModuleReplacementPlugin.

3112 insertions, 6282 deletions. Around two thousand lines of browser code existed in near-duplicate across the two projects; now it exists once.

Nothing about a configuration changes

Every option keeps its name and its meaning. They are mapped onto the middleware's vocabulary in one place, lib/hotOptions.js:

this server the middleware
hot: true / hot: "only" hot.client.apply: "hmr" / "hmr-only"
hot: false, liveReload: true hot.client.apply: "reload"
hot: false, liveReload: false hot.client.apply: "nothing"
client.reconnect hot.client.connect.retries
client.webSocketURL hot.client.path — already exactly that shape
client.overlay hot.client.overlay, with this project's element id
client.progress, client.logging the same names
client.webSocketTransport: "ws" / "sse" hot.transport and hot.client.transport
client.webSocketTransport: <module> __webpack_dev_server_client__, as always
webSocketServer: false hot: false
client: false hot.inject: false, and the HMR plugin applied here

hot and liveReload were never independent — live reload is what happens when hot module replacement is off — which is why they become one apply mode rather than two flags.

The overlay keeps its id, webpack-dev-server-client-overlay, so anything querying for it still finds it, and it gains the middleware's extra overlay options this project did not have: styles, ansiColors, openEditorEndpoint, paginate.

Protection stays here

allowedHosts, the Origin checks and the same-origin rule are unchanged, and still applied before anything is published to a client. The middleware is handed cors: true and no token — that is it applying no policy of its own — and every connection is judged here. Two policies over one socket is one of them silently losing, including to allowedHosts, which is this server's documented answer to the same question.

A WebSocket handshake is refused before it completes rather than after, because the middleware exposes handleUpgrade(req, socket, head) rather than taking the server over with attach. A handshake is not subject to the browser's own origin rules — it sends Origin and pays no attention to what comes back — so that is the only place it can be refused.

The difference between the two transports is kept: a browser puts an Origin on a WebSocket handshake whether or not it is cross-origin, so one without it is not a page and is refused; EventSource sends no Origin on a same-origin request, so refusing that would refuse every page this server serves. A cross-origin stream still carries one and is still checked.

The wire protocol is the middleware's

This is what changes for anyone reading the socket directly rather than through the client:

before now
{ type: "ok" } { action: "built" } — a build that changed something
{ type: "still-ok" } / { type: "hash" } { action: "sync" } — one the page is already running
{ type: "invalid" } { action: "building" }
{ type: "static-changed" } { action: "reload", file }
{ type: "progress-update" } { action: "progress" }
{ type: "warnings" }, { type: "errors" } carried by the build that produced them

preventReloading needed no equivalent, which was the one open question in the plan. Errors and warnings now arrive in the same payload, and the client declines to apply a build that has errors at all — strictly stronger than a flag that only suppressed the warning-triggered reload.

The configuration this server used to push to each client after the handshake is in the client's entry query instead, so a client wired by hand needs its options there: ?path=/ws&transport=ws&apply=hmr, rather than waiting to be told. The e2e fixtures that wire one by hand show this.

A webSocketServer of your own still works

BaseServer keeps its published export and its contract. A class, a module exporting one, or options giving the socket a port or a server of its own — none of which the middleware offers, all of which this package has documented since v4 — are wrapped into the shape the middleware asks a custom transport for (lib/servers/bridge.js). So there is one path from a build to a page either way, rather than a second way to publish living alongside the middleware's.

An implementation that forwarded whatever it was handed does not notice. One that inspected the old message shape sees the new one.

"sse" is the middleware's transport now

The lib/servers/EventSourceServer.js adapter an earlier revision of this PR added is gone: it existed to dress the middleware's hot endpoint up as the transport shape this server was written against, and that shape no longer exists. client.webSocketTransport: "sse" and webSocketServer: "sse" keep working and keep picking each other.

That caught a real bug: "sse" was read as an implementation of this server's own, because the test for that was "not ws" — so it went looking for a class to construct and threw before a page could connect.

The published client/* paths

Generated re-exports of the middleware's client, so webpack-dev-server/client/index.js, client/overlay.js, client/progress.js, client/clients/WebSocketClient.js and client/clients/EventSourceClient.js resolve to what they always did. Generated rather than committed, because a re-export checked in is still a client file to keep in step.

The paths that were only this bundle's internals are no longer published: client/socket.js, client/utils/log.js, client/utils/sendMessage.js, client/modules/logger/index.js. The middleware's client has its own.

Depends on

Two webpack-dev-middleware PRs, both prerequisites rather than nice-to-haves:

CI is red until those ship, as it has been for this PR throughout.

Worth knowing

A stream is a request that never ends, so a page with an open one never reaches "network idle". Wait for load or domcontentloaded when scripting a browser against the dev server.

State

tsc clean, eslint clean, and a live server verified over both transports: the WebSocket endpoint answers a plain GET with 426 Upgrade Required, the stream endpoint with text/event-stream, both serving the middleware's client with this project's overlay id and the mapped entry query. Suites are running; the protocol change above is what moves in the snapshots.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA


Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0104f53

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack-dev-server Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@alexander-akait alexander-akait changed the title refactor(client): reuse webpack-dev-middleware's hot client refactor(client): reuse webpack-dev-middleware's hot client and overlay Sep 28, 2026
@alexander-akait
alexander-akait force-pushed the feat/reuse-dev-middleware-client branch from 1e729d2 to 14fa95c Compare September 29, 2026 21:48
@alexander-akait alexander-akait changed the title refactor(client): reuse webpack-dev-middleware's hot client and overlay refactor(client): reuse webpack-dev-middleware's transports and overlay, and add "sse" Sep 29, 2026
@linux-foundation-easycla

linux-foundation-easycla Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

@alexander-akait alexander-akait changed the title refactor(client): reuse webpack-dev-middleware's transports and overlay, and add "sse" feat: reuse webpack-dev-middleware browser runtime, deleting this package's client Oct 5, 2026
@alexander-akait
alexander-akait force-pushed the feat/reuse-dev-middleware-client branch from c78c27d to 420ee5c Compare October 5, 2026 18:05
alexander-akait and others added 9 commits October 5, 2026 20:08
First step of moving the hot clients into webpack-dev-middleware: this
package's `WebSocketClient` is a strict subset of the one that package now
ships, and worse in three ways. It has no `close()` at all, despite
declaring `@implements {CommunicationClient}`, which the interface requires.
It has no guard against an event the socket had already queued reporting
after the caller closed, so a close could schedule a reconnection nobody
asked for. And it hands the url to `new WebSocket` unresolved, which throws
on browsers before Chrome 125 / Firefox 124 / Safari 17.3 for a relative or
`http(s):` url — this package always builds an absolute `ws:` url, so that
one never bit here, but it is a trap for anyone reusing the class.

Re-exported rather than deleted: `client.webSocketTransport` resolves to
this path, so anything pointing at it keeps working.

`import/no-unresolved` is switched off for the file, following the exemption
already in place for `@changesets/get-github-info`: the import resolver
cannot follow an `exports` subpath. TypeScript does resolve it, so
`lint:types-client` still covers the import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
692 lines of overlay deleted in favour of the one dev-middleware ships —
the state machine, the iframe, the rendering, the runtime-error listeners.
What is left is an adapter: this package's `createOverlay`/`send` shape on
top of that overlay's `showProblems`/`clear`, plus `formatProblem`, which
stays because the console uses it and because this package still receives
webpack's error objects rather than formatted strings.

Three things had to be carried across so nothing outward changes:

  * the element id. It is what a test, a screenshot tool or an integration
    finds the overlay by, so `webpack-dev-server-client-overlay` is passed
    through dev-middleware's new `overlay.id` rather than renamed;
  * the Trusted Types policy name. Under an enforced
    `require-trusted-types-for 'script'` the page's CSP allowlists a policy
    by name, and dev-middleware's default is a different one, so
    `webpack-dev-server#overlay` is passed explicitly — including where
    this package's option is `false`, which means "no name of my own";
  * `/webpack-dev-server/open-editor`. dev-middleware leaves the endpoint
    empty by default, having no route to point at, so the file references
    in a problem would have stopped being clickable.

The first two came from the e2e suite failing, not from reading the code,
which is the argument for swapping under the tests rather than after.

What does change is the overlay's internal DOM, which is not an interface
this package documents: a clickable file reference is `[data-open-file]`
rather than `[data-can-open]`, and a problem is headed by its level and
origin rather than "Compiled with problems". Two tests assert on those and
move with the implementation.

Checked against a baseline taken first: the overlay suite fails exactly
the set it fails on a clean `main` in this container — no case newly
failing, none newly passing — and the client and web-socket-url suites are
unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
`client.webSocketTransport: "sse"` connects the page with `EventSource`
instead of a WebSocket. The endpoint is webpack-dev-middleware's hot endpoint,
which is an ordinary response rather than an upgrade, so there is no second
server to start and it sits in the middleware chain where everything this
package puts in front of it can still see it.

Naming it as the client's transport picks the endpoint that serves it, so
`webSocketServer` does not have to be set as well; `webSocketServer: "sse"` is
accepted too. `lib/servers/EventSourceServer.js` is the adapter between that
endpoint and the transport shape the rest of this package is written against,
so nothing above the wire can tell which one it is talking to — same protocol,
same messages, same options.

All of the protection stays here: the endpoint hands over the request each
client connected with, and `allowedHosts` and the origin check decide as they
do for a socket. One difference in how that check reads a request. A browser
puts an `Origin` on a WebSocket handshake whether or not it is cross-origin,
so one without it is refused; `EventSource` sends no `Origin` on a same-origin
request, so for a stream an absent one is taken as a page of this server's own.
A stream that does carry an `Origin` — which is what a cross-origin page sends
— is checked as before, and the `Host` check runs either way.

Both transports are the middleware's now, re-exported from
`client-src/clients/`, and each has a test of its own: the contract both
answer, plus the silence watchdog only a stream needs. `test/e2e/event-source.test.js`
drives a real browser through a hot update over the stream, the handshake it
is greeted with, and both sides of the host check.

`socket.js` no longer throws on a frame it cannot parse. A stream's keep-alive
has to be a `data:` frame rather than a comment, or the client's own watchdog
would count a quiet connection as a dead one, so something that is not JSON
now arrives on the wire by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
`client-src/overlay.js` was the last of it: an adapter whose substance was
turning webpack's error objects into the shape the shared overlay renders, and
a `send({ type })` state machine over `showProblems`/`clear`. Both belong
elsewhere. The formatting is webpack-dev-middleware's `client/problem` now,
and the state machine was standing in for per-source slots that overlay
already has, so the events map onto two calls.

What stays is this package's identity on top of a shared overlay, as options:
the `webpack-dev-server-client-overlay` element id, so anything querying it is
unaffected; the `webpack-dev-server#overlay` Trusted Types policy name, which
a page's CSP allowlists by name; and the `/webpack-dev-server/open-editor`
route that makes a file reference clickable.

`test/client/ReactErrorBoundary.test.js` goes with it — the heuristic it
covers lives in webpack-dev-middleware, which tests it.

Requires webpack/webpack-dev-middleware#2438.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
This server has no client of its own any more. `client-src/` is gone — the
runtime, the error overlay, the progress indicator, the logger, the socket
wrapper and the WebSocket client, around two thousand lines that existed in
near-duplicate in both projects — and the middleware's hot option drives all
of it, including putting the entry and `HotModuleReplacementPlugin` into the
compilation.

Nothing about a configuration changes. Every option keeps its name and its
meaning and is mapped onto the middleware's vocabulary in `lib/hotOptions.js`:
`hot`/`liveReload` become one `apply` mode, `client.reconnect` becomes
`connect.retries`, `client.webSocketURL` is already the shape the middleware's
`hot.client.path` takes, and the overlay is passed through with this project's
own element id so anything querying for it still finds it.

The `webpack-dev-server/client/*` paths are generated re-exports of the
middleware's client, so importing them resolves to what it always did. The
paths that were only this bundle's internals are no longer published.

Protection stays here. `allowedHosts`, the `Origin` checks and the same-origin
rule are unchanged: the middleware is handed `cors: true` and no token, which
is it applying no policy of its own, and every connection is judged here
before anything is published to it. A handshake is refused before it completes
rather than after, since the middleware exposes the upgrade rather than taking
the server over.

A `webSocketServer` of your own still works. A class, a module exporting one,
or options giving the socket a port or a server of its own are wrapped into
the shape the middleware asks a custom transport for, so there is one path
from a build to a page either way, and `BaseServer` keeps its export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
The adapter this branch added, `lib/servers/EventSourceServer.js`, existed to
dress the middleware's hot endpoint up as the transport shape this server was
written against. There is no such shape any more — the middleware owns both
ends of both transports — so naming `sse` is now just choosing one of its
transports, and the adapter and its special case in `normalizeOptions` are
gone with it.

`client.webSocketTransport: "sse"` and `webSocketServer: "sse"` keep working
and keep picking each other, which is what the option values are for. The
published `client/clients/EventSourceClient.js` path is generated alongside
its WebSocket neighbour, so a transport named by module still resolves.

One thing this caught: `"sse"` was being read as an implementation of this
server's own, since the test for that was "not `ws`" — which sent it looking
for a class to construct and failed before a page could connect. Both of the
middleware's transports are named now, and a smoke run over each confirms the
endpoint answers: `426` to a plain GET on the WebSocket one, an event stream
on the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
Two things the browser used to say that the middleware's runtime had no way
to say for it.

The console prefix: every message read `[webpack-dev-server]`, and four
snapshots record exactly that, including `Invalid Host/Origin header` — a line
people search for. Taking the runtime as-is would relabel all of it with a
dependency's name and send anyone with a problem to the wrong repository, so
the name travels with the logging option.

The refusal itself: a client turned away by `allowedHosts` or the origin check
is told why, and the reason is logged in the page. That needs the handshake to
complete, so the upgrade is answered even for a client that will be refused
and `guardHotEndpoint` closes it immediately after saying so — which is what
this server has always done. Owning the `upgrade` listener is still what keeps
a proxied upgrade with whoever it belongs to; `isHotEndpoint` went with the
earlier attempt to refuse before the handshake, which would have made the
reason unreachable.

Verified in a browser: the console carries `[webpack-dev-server]`, and a
client from a disallowed origin receives
`{"action":"error","message":"Invalid Host/Origin header"}` and is closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
Two proxy cases waited for `{ type: "hot" }`, which was this server telling a
client what to enable right after the handshake. That configuration is in the
client's entry query now, so the first thing the socket says is the build the
page is to run.

What the cases are about is untouched and still asserted: the HMR upgrade must
not reach the proxy target (`backendUpgradeCount` stays `0`), and must not
make the proxy log an error. Both hold.

`test/server/open-option.js` fails 33/33 here and on `main` alike — it drives
a real browser through `open`, which this environment has none of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
`allowedHosts` and the origin check were not being enforced on the `sse`
transport. The guard ran and decided to refuse, and then neither half of the
refusal landed: the message went through `sendMessage`, which tests
`readyState === 1` and calls `client.send` — a `ServerResponse` has neither,
so the loop skipped it — and `client.close()`, which a `ServerResponse` also
does not have, threw and was swallowed. The stream stayed open and kept
receiving builds.

The message goes through the middleware's `publishTo` now, which puts it on
the wire the client is actually on, and the connection is dropped the way that
client is dropped: a WebSocket closes, a response ends.

Verified on both transports: a disallowed origin is told
`Invalid Host/Origin header` and dropped, over a WebSocket and over a stream
alike — and a same-origin page, which `EventSource` gives no `Origin` at all,
is still let through.

Found via a CodeRabbit review of the middleware's README example, which had
the same two mistakes in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
@alexander-akait
alexander-akait force-pushed the feat/reuse-dev-middleware-client branch from a4091d1 to 3c7c3ff Compare October 5, 2026 20:09
alexander-akait and others added 3 commits October 5, 2026 20:21
A `client.webSocketURL` written as a string is normalized by parsing it, which
fills in every part — as `""` for the ones the url did not carry. Those were
passed straight through to the middleware, which refuses an empty port: so
`webSocketURL: "ws://example.test/ws"` stopped the server from starting at
all.

Empty has always meant "not said" here, and both the old client and the new
one resolve such a part from the page, so it is left out rather than sent.

The `allowed-hosts` snapshots are regenerated with it. Three changes, all
consequences of the runtime moving rather than of anything going wrong:
`connected` is logged where this server used to log which features were
enabled, `[HMR] Waiting for update signal from WDS...` is gone with the
`webpack/hot/dev-server` entry that printed it, and the prefix is still
`[webpack-dev-server]`. The suite is 31 of 32 here; the one failure is an
IPv6 case, and this container cannot listen on `::1` at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
…types

Two things CI's Lint job reports that are not about the dependency.

`test/client/clients/EventSourceClient.test.js` was added earlier on this
branch and imports `client-src/clients/EventSourceClient.js`, which is gone.
It never survived the rebase as a deletion because it did not exist on the
side that deleted the rest of `test/client/`. Every case in it tests the
middleware's own stream client through a re-export, and the middleware's
`test/client-transports.test.js` already covers all six — plus the ones only a
stream has — so nothing is lost.

`types/` is tracked and the Lint job fails with "Missing types" when a build
changes it. `Server.d.ts` still described the removed methods, and the two
new modules had no declarations at all.

The changeset now says the two public methods that went with them,
`getClientEntry()` and `getClientHotEntry()`, are a breaking removal. It
recorded the rest of the removal and not that.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
DO NOT MERGE THIS COMMIT. Revert it, and set the dependency back to a
released `^8.4.0`, once webpack-dev-middleware ships the changes below.

This branch needs APIs the published 8.3.0 does not have (`onConnect` with the
request, `handleUpgrade`, `publish`, `publishTo`, `hot.client.path` as parts,
`hot.client.logging` with a name, an `error` action). CI installs from the
registry, so `tsc` failed in the Build step and every job behind it was
cancelled — which says nothing about whether the code works.

This vendors a pack of the middleware as it will be once these land:

  - webpack/webpack-dev-middleware#2459  (client path in parts)
  - webpack/webpack-dev-middleware#2462  (logger name)

on top of `main`, which already carries #2458, #2460 and #2461. The lockfile
change is confined to that one dependency and its own metadata.

To undo, with the release out:

    git revert <this commit>
    npm install webpack-dev-middleware@^8.4.0

Squash-merging also drops it, but only if the branch's last state is clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedwebpack-dev-middleware@​8.3.0 ⏵ 8.3.0N/AN/AN/AN/AN/A

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant