Skip to content

feat(hot): ask less of a custom transport - #2427

Merged
alexander-akait merged 4 commits into
mainfrom
feat/simpler-transport-contract
Sep 26, 2026
Merged

alexander-akait merged 4 commits into
mainfrom
feat/simpler-transport-contract

Conversation

@alexander-akait

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

Copy link
Copy Markdown
Member

Summary

A custom hot.transport had to implement six methods. Three of those were only required because of how createHot happened to call them, and two are not a transport's job at all:

  • handler is meaningless unless the transport is served over HTTP. The built-in WebSocket one carried it only to answer 426 to a plain request — which is the right answer for any transport that does not serve requests, so it is now the default and the WebSocket stream has no handler of its own. The built-in demonstrates the smaller contract rather than merely permitting it.
  • hasClients exists to skip building a payload nobody will read. That is an optimization a transport can make inside publish, and both built-ins already do. A transport that does not offer it is now asked to publish and decides for itself, rather than being treated as having no clients — which is what the old !eventStream.hasClients() would have done, by throwing.

So the required set is onConnect, publish, publishTo, close, and the validation names that shorter list when something is missing. handler and hasClients are still accepted, so a transport written against 8.3.0 keeps working untouched.

A TODO beside CLIENT_STREAM_METHODS marks both for removal in the next major, with the reasoning, so whoever does that release finds it at the code rather than in an issue.

On the client side — deliberately unchanged

The obvious next thought is to make the browser client symmetrical with this factory. I would not: the class-constructor shape is client.webSocketTransport, which webpack-dev-server has shipped for years, so every custom client written against it works here as-is. Symmetry is not worth breaking those, and the reasoning is in the commit message so it does not get "fixed" later.

What kind of change does this PR introduce?

feature — a relaxed requirement for transport authors

Did you add tests for your changes?

Yes, two:

  • a transport implementing only the four required methods publishes normally;
  • a transport with no hasClients still receives progress events, since that is the only place it was read.

The existing 426 case now also asserts the body, because it is what proves the fallback serves it rather than the WebSocket stream's own handler.

Both fallbacks teeth-checked — removing them gives eventStream.handler is not a function and eventStream.hasClients is not a function across three cases.

Verified locally: end-to-end 110/110, non-browser 6821 passed (the hot suite at 68), lint and both typechecks clean. test/logging.test.js fails 74/74 on a clean main too, unrelated to this.

types/hot.d.ts is regenerated with build:types rather than hand-edited, so handler and hasClients are ?: there.

Does this PR introduce a breaking change?

No. Nothing that worked before stops working: a transport implementing all six is unaffected, and the two methods only became optional. The observable behaviour of both built-in transports is unchanged — the WebSocket endpoint still answers 426 on a plain GET, just from the default rather than from its own method.

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

Documented here: the README's custom-transport section now shows four required methods and four optional ones, with what happens when each optional one is absent.

Use of AI

AI-assisted (Claude Code). Used to work out which of the six methods the middleware actually needs, implement the fallbacks, write the tests and the README, and verify: each fallback was teeth-checked by removing it and confirming the matching tests fail.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Custom hot transports can omit the endpoint handler and client-availability check. Without a handler, endpoint requests receive an “Upgrade Required” response. Without a client check, progress events are published without first checking for connected clients.
    • The required-method contract for custom transports is smaller, while transports that implement the optional methods remain compatible.
  • Documentation
    • Clarified required and optional transport methods, endpoint behavior when no handler is provided, and progress-event behavior when no client check is available.

Six required methods, of which three were only required because of how
`createHot` happened to call them. Two of those are not a transport's job
at all:

`handler` is meaningless unless the transport is served over HTTP. The
built-in WebSocket one carried it only to answer 426 to a plain request,
which is the right answer for any transport that does not serve requests —
so that is the default now, and the WebSocket stream no longer has a
`handler` of its own.

`hasClients` exists to skip building a payload nobody will read. That is an
optimization a transport can make inside `publish`, and both built-ins
already do. A transport that does not offer it is asked to publish rather
than assumed to have no clients.

Both stay accepted, so a transport written against 8.3.0 keeps working, and
a TODO marks them for removal in the next major. What is required is now
`onConnect`, `publish`, `publishTo` and `close` — and the validation names
that shorter set when something is missing.

Left alone deliberately: the client side keeps its class-constructor shape.
It is what `client.webSocketTransport` has always been, so every custom
client written for webpack-dev-server works here untouched; symmetry with
the factory is not worth breaking those.

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

changeset-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 703ab0a

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

@coderabbitai

coderabbitai Bot commented Sep 26, 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: ce9d5882-e3c4-4a28-9b29-a976c77379d0

📥 Commits

Reviewing files that changed from the base of the PR and between d2f38e8 and 703ab0a.

📒 Files selected for processing (2)
  • test/e2e/overlay.test.js
  • test/helpers/e2e.js

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.


Walkthrough

The custom transport contract now requires close, onConnect, publish, and publishTo; handler and hasClients are optional. When handler is absent, the middleware responds to endpoint requests with 426 Upgrade Required. When hasClients is absent, progress payloads proceed to publish. The README, type declarations, tests, and a minor-release changeset describe these updates. The overlay runtime-slot test now waits for pending builds to settle before waiting for runtime listeners. The overlay tests also use a helper to wait for specified overlay text.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 703ab

The transport changes and overlay-test synchronization have no established issue requiring a fix before merge.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 703ab

The smaller transport contract preserves existing custom handlers and retains a rejection response for plain HTTP requests without one. No introduced security issue was established, though custom deployment behavior is not fully covered.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed production request behavior is at the configured hot endpoint, while publication behavior depends on the configured transport. The exported overlay helper does not establish an attacker-reachable production entrypoint.

Trust Boundaries and Controls

  • observed — The core does not install a request handler on behalf of a custom transport that omits one: it rejects ordinary HTTP requests with 426. A supplied custom handler retains control of those requests, and WebSocket upgrades use a path check.

Resilience and Maintainability Implications

  • observed — After close, the hot-module core declines publications and ends intercepted requests rather than passing them to the closed stream; the WebSocket transport detaches its upgrade listener on close.
🚥 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 main change: reducing the required methods for custom hot transports.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 6 files.
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: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 43266814-0063-4aaa-b086-f2eb4c2c4067

📥 Commits

Reviewing files that changed from the base of the PR and between 4083960 and e30b22d.

📒 Files selected for processing (6)
  • .changeset/simpler-transport-contract.md
  • README.md
  • src/hot.js
  • src/servers/WebSocketServer.js
  • test/hot.test.js
  • types/hot.d.ts

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

Comment thread src/hot.js
Comment thread src/hot.js Outdated
alexander-akait and others added 3 commits September 26, 2026 16:22
Two review findings, both regressions from making the two methods optional.

Dropping them from the required list stopped them being type-checked at all,
so `handler: true` passed startup and threw from the endpoint instead —
exactly the late, far-from-the-option failure this validation exists to
prevent. Present and not callable is now rejected by name, while absent
stays fine.

`(eventStream.handler || upgradeRequired)(req, res)` also called the
handler unbound. A stream written as an object literal reaching for `this`
worked before this change, so the call goes back to being a method call and
the default is a separate branch. Every other call on the stream was
already a method call, so only this one had lost its receiver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
CI surfaced this on an unrelated PR: "Execution context was destroyed, most
likely because of a navigation".

The fixture accepts no updates, so every rebuild is a full reload, and the
watcher can see one write twice — file timestamp granularity is enough. The
second reload lands inside the `evaluate` that triggers the next error and
takes its execution context with it. Nothing in the assertion is wrong; it
just had no guarantee the page had stopped moving.

`settle()` exists for this and is now awaited before the page is touched
again. The test predates the transport work on this branch — it came in with
#2370 — and is not reachable from anything that work changes, but it fails
this branch's CI, so it is fixed here rather than re-run.

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

The red `Client` job, properly diagnosed this time. The failing test is
"turns an error overlay into a warning overlay on partial recovery", not the
one the previous commit hardened — I had inferred it from the line number in
the stack, and for a puppeteer error jest points at the blank line between
tests, so the name was the only reliable signal and I had grepped it away.

The race: recovering from a build that failed cannot be applied hot, so the
page reloads. `waitForFunction` survives that, because puppeteer re-installs
it in the new document, but the `page.evaluate` that read the overlay
afterwards does not — and dies with "Execution context was destroyed" when
the reload lands between the two. Reproduced locally at roughly one run in
ten.

Waiting and reading are now one step, through a `waitForOverlayText` helper
that returns the text the wait matched, with a retry for the narrower race
where the navigation arrives after it resolved. The assertion still reads
the text, so a missing "WARNING" fails as a comparison rather than a
timeout. Twenty consecutive runs of the case, and no occurrence of the
error.

The `settle()` added to "resets the runtime slot on a clean build" in the
previous commit stays: that test has the same reload hazard, so guarding it
is right even though it was not what CI was failing on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
@alexander-akait
alexander-akait merged commit 85f0633 into main Sep 26, 2026
19 checks passed
@alexander-akait
alexander-akait deleted the feat/simpler-transport-contract branch September 26, 2026 18:59
@codecov

codecov Bot commented Sep 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.93%. Comparing base (4083960) to head (703ab0a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2427      +/-   ##
==========================================
+ Coverage   95.91%   95.93%   +0.01%     
==========================================
  Files          18       18              
  Lines        2006     2015       +9     
==========================================
+ Hits         1924     1933       +9     
  Misses         82       82              

☔ 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.

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