feat: reuse webpack-dev-middleware browser runtime, deleting this package's client - #5750
alexander-akait wants to merge 12 commits into
Conversation
🦋 Changeset detectedLatest commit: 0104f53 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
1e729d2 to
14fa95c
Compare
|
One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via: Supported
Alternatively, if the co-author should not be included, remove the Please update your commit message(s) by doing |
c78c27d to
420ee5c
Compare
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
a4091d1 to
3c7c3ff
Compare
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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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'shotoption drives the runtime, the overlay, the indicator, the entry andHotModuleReplacementPlugin.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:hot: true/hot: "only"hot.client.apply: "hmr"/"hmr-only"hot: false, liveReload: truehot.client.apply: "reload"hot: false, liveReload: falsehot.client.apply: "nothing"client.reconnecthot.client.connect.retriesclient.webSocketURLhot.client.path— already exactly that shapeclient.overlayhot.client.overlay, with this project's element idclient.progress,client.loggingclient.webSocketTransport: "ws"/"sse"hot.transportandhot.client.transportclient.webSocketTransport: <module>__webpack_dev_server_client__, as alwayswebSocketServer: falsehot: falseclient: falsehot.inject: false, and the HMR plugin applied herehotandliveReloadwere never independent — live reload is what happens when hot module replacement is off — which is why they become oneapplymode 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, theOriginchecks and the same-origin rule are unchanged, and still applied before anything is published to a client. The middleware is handedcors: trueand 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 toallowedHosts, 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 withattach. A handshake is not subject to the browser's own origin rules — it sendsOriginand 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
Originon a WebSocket handshake whether or not it is cross-origin, so one without it is not a page and is refused;EventSourcesends noOriginon 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:
{ 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" }preventReloadingneeded 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
webSocketServerof your own still worksBaseServerkeeps 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 nowThe
lib/servers/EventSourceServer.jsadapter 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"andwebSocketServer: "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 "notws" — so it went looking for a class to construct and threw before a page could connect.The published
client/*pathsGenerated re-exports of the middleware's client, so
webpack-dev-server/client/index.js,client/overlay.js,client/progress.js,client/clients/WebSocketClient.jsandclient/clients/EventSourceClient.jsresolve 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:
hot.client.pathtakes the parts of a url. This is dev-server's owncreateSocketURLported with its 28 test cases;client.webSocketURLcannot be mapped without it, since which host a page is opened on and which scheme it was served over are the browser's to report.publishcarry a payload of your own webpack-dev-middleware#2460 —publishtyped to carry a payload of your own, sosetupProgressPlugincan report which plugin a tick came from. It was the lasttscerror here.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
loadordomcontentloadedwhen scripting a browser against the dev server.State
tscclean, eslint clean, and a live server verified over both transports: the WebSocket endpoint answers a plain GET with426 Upgrade Required, the stream endpoint withtext/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