Skip to content

feat(hot): serve the events over a WebSocket too - #2420

Merged
alexander-akait merged 3 commits into
mainfrom
feat/hot-transport
Sep 24, 2026
Merged

alexander-akait merged 3 commits into
mainfrom
feat/hot-transport

Conversation

@alexander-akait

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

Copy link
Copy Markdown
Member

Summary

hot.transport chooses how hot module replacement events reach the clients: "sse" (the default, unchanged) or "ws".

devMiddleware(compiler, {
  hot: { transport: "ws", server },
});

This is the first step of moving the HMR clients into webpack-dev-middleware so webpack-dev-server can reuse them instead of carrying its own. It is the server half of the WebSocket transport only — the browser client still speaks Server-Sent Events, so transport: "ws" is not useful on its own yet. Splitting it this way keeps each piece reviewable; the client steps follow in their own PRs.

How it fits together

Both transports satisfy one ClientStream interface, so createHot publishes without knowing which one it is talking to and the SSE path is untouched:

/**
 * @typedef {object} ClientStream
 * @property {(req, res) => void} handler
 * @property {() => boolean} hasClients
 * @property {(fn: (client: StreamClient) => void) => void} onConnect
 * @property {(payload) => void} publish
 * @property {(client, payload) => void} publishTo
 * @property {() => void} close
 * @property {((server) => void)=} attach
 * @property {(() => void)=} detach
 */

Two things worth calling out, because they are the parts that are not mechanical:

  1. attach is new API, and it has to be. A WebSocket handshake is an upgrade the HTTP server answers; the middleware only ever sees (req, res, next) and never gets the chance. So the server is handed over, either as hot.server when it already exists, or through the middleware's new attach(server) when it is built later (which is webpack-dev-server's case). A plain GET on the path under transport: "ws" answers 426 Upgrade Required rather than being left hanging on a stream it cannot read.

  2. onConnect is what let the two share a code path. Catching a newly connected client up — the sync events carrying the last hashes, without which it can never apply the next update — used to be written inline in handle() and so only worked for Server-Sent Events. It is now a stream callback that both transports invoke once a client has joined, so a WebSocket client is caught up the same way. There is a test for it.

src/servers/WebSocketServer.js is ported from webpack-dev-server's lib/servers/WebsocketServer.js (noServer: true plus an upgrade listener), with ping/pong reaping half-open sockets — one that never emits close, so nothing else would drop it — and a heartbeat that runs only while clients are connected, matching what the SSE stream already does.

ws is an optional dependency, required lazily, so Server-Sent Events users do not pull it. Asking for transport: "ws" without it throws an error naming the install.

What kind of change does this PR introduce?

feat

Did you add tests for your changes?

Yes — test/hot.test.js gains five cases over a real HTTP server and a real ws client: publishing a build to a connected client, catching a late joiner up with sync, answering a plain request with 426, refusing upgrades once closed, and the log line naming the transport.

Each was checked against a deliberately broken attach: the two connection cases fail and the rest still pass, so they are not passing by accident. Doing that also turned up two defects in the tests themselves, both fixed here — the connect helper waited on open forever instead of timing out, and a failing assertion leaked the HTTP server, which is now torn down in afterEach regardless of outcome.

Unrelated to this change but worth flagging: test/logging.test.js fails 74/74 on a clean main with none of these changes applied, so it is red before this PR and red after it.

Does this PR introduce a breaking change?

No. transport defaults to "sse" and the Server-Sent Events path is unchanged. The one user-visible difference is that the startup log line now names the transport — Hot module replacement enabled, serving events at "/__webpack_hmr" over Server-Sent Events — since the two are configured alike but fail in different places.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

hot.transport, hot.server and the middleware's attach method, including that ws must be installed for the WebSocket transport.

Use of AI

AI-assisted (Claude Code). It was used to write the transport, the tests and the schema changes, and to verify them: every test here was run against a broken implementation to confirm it fails, and the pre-existing logging.test.js failure was confirmed by running that suite on a clean checkout. All output was reviewed before committing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Hot module replacement events can use WebSockets or a custom transport; Server-Sent Events remain the default.
    • Added an attach method to connect WebSocket upgrades to an HTTP server when no server is configured directly.
    • Added options to select a transport, configure its endpoint, and set its heartbeat interval.
    • Late-joining clients receive the current build state, and regular HTTP requests to a WebSocket endpoint receive an upgrade-required response.
    • Custom transports are checked for required methods, with missing methods identified in an error.

`hot.transport` chooses between Server-Sent Events and a WebSocket. Both
answer the same calls, so nothing downstream of `createHot` knows which one
it publishes to, and the SSE path is unchanged.

A WebSocket handshake is an upgrade the HTTP server answers, which the
middleware never sees, so the server is handed over through `hot.server` or
the middleware's new `attach` method. `ws` is an optional dependency, needed
only by this transport.
@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fc45c77

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-middleware 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

@socket-security

socket-security Bot commented Sep 24, 2026 •

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
Added@​types/​ws@​8.18.11001007380100

View full report

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: b719e5b2-005e-4d36-82a3-db75de38443a

📥 Commits

Reviewing files that changed from the base of the PR and between 187f186 and fc45c77.

📒 Files selected for processing (4)
  • README.md
  • src/hot.js
  • test/hot.test.js
  • types/hot.d.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/hot.js

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


Walkthrough

Hot module replacement now supports WebSocket and custom transports. Server-Sent Events remains the default. The WebSocket stream handles upgrades, clients, heartbeats, publishing, and closure. Hot instances and the middleware API support server attachment. New clients receive cached build statistics when available. Schemas, public types, documentation, dependencies, and tests cover the transports.

Merge Risk: ⚪ Minimal · up to fc45c

No actionable merge-blocking risk is established by the supplied evidence.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding WebSocket support for hot module replacement events.
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 9 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 5fdd5dd2-fa3e-4670-a750-7f8a903abeb7

📥 Commits

Reviewing files that changed from the base of the PR and between 4b1f3de and 7b7e4cf.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • .changeset/hot-transport-ws.md
  • package.json
  • src/hot.js
  • src/index.js
  • src/options.check.js
  • src/options.json
  • src/servers/WebSocketServer.js
  • test/__snapshots__/validation-options.test.js.snap.webpack5
  • test/hot.test.js
  • types/hot.d.ts
  • types/index.d.ts
  • types/servers/WebSocketServer.d.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread src/hot.js Outdated
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.12698% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.24%. Comparing base (4b1f3de) to head (fc45c77).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/servers/WebSocketServer.js 81.39% 16 Missing ⚠️
src/hot.js 94.44% 2 Missing ⚠️
src/index.js 33.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2420      +/-   ##
==========================================
- Coverage   97.11%   96.24%   -0.88%     
==========================================
  Files          13       14       +1     
  Lines        1664     1783     +119     
==========================================
+ Hits         1616     1716     +100     
- Misses         48       67      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`hot.transport` took one of two names, so the only way to carry the events
over anything else was to fork the middleware. It now also takes a function,
which is handed the resolved path and heartbeat and returns the same client
stream the built-in two are.

A returned object missing one of those calls throws, naming what is absent,
rather than failing later from wherever it is first published to.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: f13f8813-d93f-41d4-be04-00c166e7f254

📥 Commits

Reviewing files that changed from the base of the PR and between 7b7e4cf and 187f186.

📒 Files selected for processing (9)
  • .changeset/hot-transport-ws.md
  • README.md
  • src/hot.js
  • src/options.check.js
  • src/options.json
  • test/__snapshots__/validation-options.test.js.snap.webpack5
  • test/hot.test.js
  • test/validation-options.test.js
  • types/hot.d.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/hot-transport-ws.md

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread src/hot.js
Comment on lines +499 to +500
} else if (transport === "ws") {
eventStream = createWebSocketStream({ heartbeat, path }, logger);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '475,535p' src/hot.js
sed -n '180,205p' client-src/index.js
sed -n '550,630p' client-src/index.js

Repository: webpack/webpack-dev-middleware

Length of output: 5491


Add WebSocket support to the bundled client.

When hot.transport is "ws", src/hot.js creates a WebSocket stream. The bundled client always creates an EventSource, and its bootstrap only calls connect() when window.EventSource exists. It has no WebSocket transport selection or fallback path. Therefore, the ordinary bundled-client hot-update workflow does not work with "ws".

Expose the selected transport to the client and add a WebSocket connection path while keeping SSE as the default.

Comment thread types/hot.d.ts Outdated
…e its client

`StreamClient` referred to `import("ws")`, and `types/index.d.ts` reaches it,
so every consumer loaded the optional dependency's declarations — including
one on Server-Sent Events, whose build failed outright with TS2307 under the
default `skipLibCheck: false`. It is a structural type now, and a test fails
if the import comes back.

`publishTo` also took only that union, so under `strictFunctionTypes` a
transport of your own could not be written in TypeScript at all: narrowing
the parameter to its own client type was rejected. `ClientStream` and
`ClientStreamFactory` are parameterized by the client type instead.
@alexander-akait
alexander-akait merged commit dfdce46 into main Sep 24, 2026
19 checks passed
@alexander-akait
alexander-akait deleted the feat/hot-transport branch September 24, 2026 15:10
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