Skip to content

refactor(client): reuse webpack-dev-middleware's transports and overlay, and add "sse" - #5750

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

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

Conversation

@alexander-akait

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

Copy link
Copy Markdown
Member

Rebuilt on current main, against webpack-dev-middleware main.

What changed

Both transports come from webpack-dev-middleware now.
client-src/clients/WebSocketClient.js and client-src/clients/EventSourceClient.js
re-export them, and client.webSocketTransport resolves to those paths as it
always has. One copy means one place for the things this package's WebSocket
client was missing: a close() (which the interface declares and it never
had), the guard that stops a queued event reporting after the caller closed,
and resolving a relative or http(s): url for browsers whose WebSocket will
not.

The overlay comes from there too, behind an adapter that keeps this
package's shape on top of it — createOverlay().send and formatProblem —
its element id (webpack-dev-server-client-overlay), its Trusted Types policy
name, and its open-in-editor route. Its markup is the shared one now, so the
overlay snapshots are regenerated: the problem text and location survive, the
location is clickable (data-open-file), and the heading is the level rather
than "Compiled with problems".

"sse" is a second transport.

module.exports = {
  devServer: {
    client: { webSocketTransport: "sse" },
  },
};

The page connects with EventSource. The endpoint is the middleware's hot
endpoint — an ordinary response rather than an upgrade — so there is no second
server to start, and it sits in the chain where everything this package puts
in front of it can still see it. lib/servers/EventSourceServer.js is the
adapter between that endpoint and the transport shape the rest of the package
is written against, so nothing above the wire can tell which one it is talking
to: same protocol, same messages, same options.

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.

Protection stays here

The endpoint hands over the request each client connected with, so
allowedHosts and the origin check decide exactly as they do for a socket.

There is one difference in how that check reads a request, and it is the
reason a browser could not connect at first. A browser puts an Origin on a
WebSocket handshake whether or not it is cross-origin, so a handshake without
one is not a page and is refused. EventSource sends no Origin on a
same-origin request, so the existing check refused every page this server
serves, in a reconnect loop. For a stream an absent Origin is now taken as a
page of this server's own; a stream that does carry one — which is what a
cross-origin page sends — is checked as before, and the Host check, which is
the DNS-rebinding defence, runs either way. Nothing about the WebSocket path
changed.

Tests

The EventSource side had none, here or anywhere in this package. It does now:

  • test/client/clients/EventSourceClient.test.js — the contract both
    transports answer (connect, open, message as a string, close, and silence
    after close()), plus the one thing only a stream has: it reports a close
    when the connection falls silent without ever failing, and does not when
    messages keep arriving.
  • test/client/clients/WebsocketClient.test.js — rewritten. It poked
    client.client.onerror, an internal that is webpack-dev-middleware's now, so
    it failed; it tests the public contract against a real server instead, and
    gained the close-means-silence case.
  • test/e2e/event-source.test.js — a real browser over a real stream: it
    connects and takes a rebuild as a hot update, it is greeted with this
    package's handshake (hot, liveReload, progress, overlay), a
    disallowed origin is refused, and a request with no origin at all is taken
    as same-origin.

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.

Worth knowing if you script a browser against the dev server: a stream is a
request that never ends, so a page with an open one never reaches "network
idle". Wait for load or domcontentloaded.

State

Green locally: overlay 39, the two transport client suites, the new
event-source suite, server-and-client-transport, web-socket-server,
web-socket-communication, allowed-hosts, client, validate-options,
normalize-options — 63/63 across the suites this touches, and lint, both
typecheck passes, spelling, the precompiled schema check and the full build
clean. Two IPv6 cases in test/server and the test/cli/basic cases fail
identically on main in this environment, so they are not from this change.

CI will be red until webpack-dev-middleware ships. ./client/ws and
./client/sse are not in the published 8.3.0 exports — this was verified
against webpack-dev-middleware main, installed from a local pack.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA

@changeset-bot

changeset-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3328b32

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 and others added 3 commits September 29, 2026 20:12
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
@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
`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
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