feat(hot): a secret on the endpoint, a file per transport, and publish - #2451
Conversation
🦋 Changeset detectedLatest commit: b937c07 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 |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe pull request adds token support for hot endpoints and client connections. It resolves tokens from options, passes them to WebSocket and SSE transports, and appends them to injected or manual client URLs. It also adds Priority: ➖ Normal Merge Risk: 🔵 Low · up to Clarify that users should disable hot.progress before registering their own ProgressPlugin to avoid duplicate progress reports. This is a limited migration issue, not a broad workflow blocker. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change adds optional connection protection without opening a new remote publishing interface. Remaining risk is concentrated in custom integrations and refreshing clients when credentials change. Retained concerns Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟡 Minor · Add token to HotClientOptions. · hot.d.ts:208
types/hot.d.ts:208
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAdd
tokentoHotClientOptions.
src/options.jsonacceptshot.client.token, andsrc/utils.jsserializes that override. However,HotClientOptionsdoes not declare it. TypeScript therefore rejects an inline configuration such as{ hot: { client: { token: "remote-secret" } } }as an excess property. (typescriptlang.org)Add
token?: string | undefinedtoHotClientOptionsand update the source typedef if these declarations are generated.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: 26e5489a-137a-45dd-8aac-e7ef24c566c4
📒 Files selected for processing (18)
.changeset/hot-token.md.cspell.jsonREADME.mdclient-src/index.jssrc/hot.jssrc/index.jssrc/options.check.jssrc/options.jsonsrc/servers/WebSocketServer.jssrc/utils.jstest/__snapshots__/validation-options.test.js.snap.webpack5test/helpers/hot-app.jstest/hot.test.jstypes/client/index.d.tstypes/hot.d.tstypes/index.d.tstypes/servers/WebSocketServer.d.tstypes/utils.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.
`hot.cors` is answered by `Origin`, and that is its weakness: a browser omits `Origin` and the whole `Sec-Fetch-*` family when the destination is not potentially trustworthy — plain `http` to anything but `localhost`, which `host: "0.0.0.0"` gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way, CVE-2026-6402 and then CVE-2026-14620. A token asks the browser to volunteer nothing. `createHot` mints one per run, `injectHotClient` hands it to the client as another entry-query option, and both wires check it with `timingSafeEqual` before anything else — so a caller without one is told nothing about which origins the endpoint would have allowed. A transport of your own is given it too. Driven end to end: SSE, token: true no token 403 wrong 403 right 200 WS, default no token 403 wrong 403 right CONNECTED WS, token: false no token CONNECTED The two transports default as they did for `cors`: `true` for the WebSocket, which is unreleased so nothing is connecting to it that would not be handed one, and `false` for Server-Sent Events, where requiring one would refuse every client already connecting. Two things the implementation had to account for, both found by the browser tests rather than by reasoning: `inject: false` turns the requirement off. The token reaches the browser through the entry this middleware adds, so with nothing injected there is no way to hand one over and requiring it would refuse a correctly wired client. The client needed the option after all. The first attempt folded the token into the `path` query on the assumption that the client uses that verbatim — true for an injected client, useless for a hand-wired entry, which has no `path` parameter and would not know what a bare `token=` meant. It is a client option like the others now, and `hot.client.token` accepts it in node for a client pointed at another endpoint, which keeps the two name sets identical — a test asserts that and caught the asymmetry. What it does not protect, said in the README rather than left implied: the client reads the token from its entry query, so it is a string in the bundle. Anything that can already read the bundle cross-origin reads the token with it, and over plain `http` to a non-localhost address nothing stops that unless the server sends `Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not readable, and is defence in depth where it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
`createEventStream` moves out of `hot.js` into `src/servers/EventSourceServer.js`, beside the WebSocket one, and both are required where the transport is picked rather than at the top of the module. Neither is reached until a `createHot` call asks for it: transport EventSourceServer WebSocketServer ws "sse" (default) loaded — — "ws" — loaded loaded your own — — — Previously the event stream was parsed by every consumer, including one on a WebSocket or on a transport of its own, because it lived in `hot.js`. `src/servers/` now holds both, which is the shape webpack-dev-server's `lib/servers/` has, and the CORS and token rules sit with the transport that enforces them — `hot.js` keeps only the default each one starts from and the mint that hands a token to both. `createEventStream` is still exported from `hot.js`, as a wrapper that loads the module on the first call, so an importer sees no change. `requireServer` spells each path out rather than building one from its argument: a bundler has to be able to see both statically. Also fixes two fixtures that hand-wire a client and so have to carry a token of their own, which is what a developer wiring their own entry has to do: the worker app, and the cross-origin test that builds its own WebSocket url — that one reads `instance.token` rather than hardcoding one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
Follow-up to extracting the event stream: the module boundaries were right but three things were still on the wrong side of them. Each transport's CORS default moves to the transport that applies it. `HOT_DEFAULT_CORS_SSE` and `HOT_DEFAULT_CORS_WS` lived in `utils.js`, which `hot.js` then imported the SSE one from purely to re-export it — it never used it. `resolveCors` is called inside each server, so the default each one starts from belongs there too, with the comment explaining why they differ. `pathMatch` moves to `utils.js`. `middleware.js` was reaching through `hot.js` for a url helper, which is the wrong direction: the middleware does not otherwise depend on the hot module, and every other request helper it uses is already in `utils.js`. `hot.js` did not use `pathMatch` itself either — that import was another re-export. And a comment that the lazy-loading change had stranded: "what a transport has to do for itself" describes `CLIENT_STREAM_METHODS`, and `requireServer` had been inserted between the two. What is left in `hot.js` is one thing: the hot lifecycle and the payloads it publishes — its own defaults, the contract a custom transport has to meet, and `createHot`. Transport mechanics are in `./servers`, request helpers in `./utils`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
eab2f01 to
53cfd98
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟡 Minor · Add token to HotClientOptions. · hot.d.ts:208
types/hot.d.ts:208
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAdd
tokentoHotClientOptions.
hot.client.tokenis defined by the schema and serialized into the browser query. The source typedef and generated declaration omit it. An inline TypeScript configuration therefore fails excess-property checking.Add the property to
src/hot.js, then regeneratetypes/hot.d.ts.Suggested fix
// src/hot.js * @property {string=} name limit the runtime to one compilation's builds, the compilation's own name by default + * @property {string=} token the secret the runtime puts on its connection url// types/hot.d.ts name?: string | undefined; + token?: string | undefined;
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: 3e2ad919-b9e0-419b-b0dd-028b3ae09662
📒 Files selected for processing (11)
.changeset/hot-token.mdREADME.mdsrc/hot.jssrc/options.check.jssrc/options.jsonsrc/utils.jstest/helpers/hot-app.jstest/hot.test.jstest/inject-client.test.jstypes/hot.d.tstypes/utils.d.ts
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2451 +/- ##
==========================================
- Coverage 96.34% 96.25% -0.09%
==========================================
Files 20 22 +2
Lines 2378 2430 +52
==========================================
+ Hits 2291 2339 +48
- Misses 87 91 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Measuring a build is the server's call rather than the middleware's, and
`hot.progress` made it the middleware's: it applied `ProgressPlugin` to
your compiler, so a server that applies one itself — webpack-dev-server
does — ends up with two of them on one compiler.
What only the middleware can do is carry the result, and the bundled
client already renders `{ action: "progress" }`. So the option becomes one
public method: `instance.publish(payload)` puts a payload of your own on
the stream, and a server hands over what its own plugin reports. It is a
no-op when `hot` is off, so a caller does not have to ask first, and
nothing is sent when no client is connected — a `ProgressPlugin` tick
fires far more often than anyone is listening.
`hot.progress` keeps working until the next major release. Its browser
end, `hot.client.progress`, is unaffected and stays: a server publishing
its own progress payload gets it drawn with no further configuration.
Two things the option did for you become the caller's: rounding the
percent, and dropping a tick that rounds to the same whole number as the
last one.
Also fixes two README links that pointed at headings which do not exist,
for `etag` and for the `attach` method.
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: 486ce750-fde2-4501-94dd-259fe64a675b
📒 Files selected for processing (19)
.changeset/deprecate-hot-progress.md.changeset/instance-publish.md.changeset/readme-anchors.mdREADME.mdsrc/hot.jssrc/index.jssrc/middleware.jssrc/servers/EventSourceServer.jssrc/servers/WebSocketServer.jssrc/utils.jstest/e2e/cors.test.jstest/e2e/live-reload.test.jstest/e2e/worker.test.jstest/hot.test.jstest/instance-hot-api.test.jstypes/hot.d.tstypes/index.d.tstypes/servers/EventSourceServer.d.tstypes/utils.d.ts
💤 Files with no reviewable changes (1)
- 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.
621e013 to
0f49364
Compare
Three things, all consequences of the token work rather than of the
refactors on top of it.
**A `path` that already carried a `token` ended up with two.** The client
appended `&token=`, and the endpoint reads the first one — so the token
configured here was the one ignored. A fragment was mishandled the same
way: `path#frag` became `path#frag?token=…`, putting the query inside the
fragment.
Now in `client-src/utils/with-token.js`, where it can be tested: the url
is taken apart on `#` and then `?` rather than parsed with `URL`, which
would need a base and would turn a relative path into an absolute one.
Every shape it can arrive in survives — absolute url, protocol-relative,
rooted path, relative path, with or without a query or fragment.
**The "no client was injected" warning printed the token.** Infrastructure
warnings travel into CI output, and a minted token is different every run,
so printing it also invited the wrong fix — pasting a value that is
already stale. It names `token=<the token>` and points at the `token`
property instead.
**A README example read `instance.token` from a configuration that does
not ask for one**, so it served `{ token: false }`. It asks for one now.
The sentence beside it still described the per-transport defaults this
option no longer has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
0f49364 to
95294b9
Compare
hot.tokenpublish
`hot.client.token` was in the schema and serialized into the browser
query, but missing from the `HotClientOptions` typedef, so TypeScript
rejected it as an excess property:
error TS2353: Object literal may only specify known properties, and
'token' does not exist in type 'HotClientOptions'.
The test that keeps the schema and the client source to one set of names
now covers the typedef too. That was the third place holding this list and
the easiest to forget, which is how the omission shipped; removing the
line again fails the test by name.
The `publish` example published on every `ProgressPlugin` tick. The prose
beside it said the caller now owns both rounding the percent and dropping
a tick that repeats one, and then the example only rounded — so anyone
copying it sent a message per callback, most of them repeating what the
last one said. The example, the deprecation warning and the changeset all
show both now.
Also stopped overselling the no-clients short-circuit: it spares a caller
from asking whether anyone is listening, but with a page open every call
is still a message, so keeping a chatty source down to what changed stays
the caller's job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
On the outside-diff finding —
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Advanced
Run ID: be684230-882e-4a31-ac20-e4b7b174faf6
📒 Files selected for processing (5)
.changeset/deprecate-hot-progress.mdREADME.mdsrc/hot.jstest/inject-client.test.jstypes/hot.d.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- .changeset/deprecate-hot-progress.md
- README.md
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.
| // TODO in the next major release remove `progress` and this warning | ||
| if (options.progress) { | ||
| logger.warn( | ||
| "The 'hot.progress' option is deprecated and will be removed in the next major release. Measuring a build is the server's call, not the middleware's: a server that applies 'ProgressPlugin' itself — webpack-dev-server does — ends up with two of them on one compiler. Apply it yourself and hand what it reports to the middleware's 'publish' method, rounding the percent and dropping a tick that repeats one as this option did for you — the example is at https://github.com/webpack/webpack-dev-middleware#publishpayload. Until then this keeps working.", |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Tell users to remove hot.progress before adding a replacement plugin.
If hot.progress remains enabled, Line 565 also installs a ProgressPlugin. A user who follows this instruction and adds their own plugin can register two plugins and publish duplicate progress events. State that users must remove hot.progress before adding their own plugin.
#2452 and #2453 were merged into this branch, so this PR now carries all three pieces of work. They are still one commit each, in this order:
feat(hot): require a secret on the endpoint, with \hot.token``refactor(hot): load only the transport that was chosenEventSourceServerextracted, both transports lazily requiredrefactor(hot): put each piece where it belongspathMatchtoutilsfeat(hot): add \publish`, and deprecate `hot.progress``fix(hot): address the review on \hot.token``hot.tokenA secret on the hot endpoint that a client has to carry to connect. Without it anything that can reach the port can open the stream and read what a build reports — module paths, and the source frames webpack puts in a failed build's errors.
hot.corsalready scoped which origins a page may be on. This scopes who may connect at all, which is the part an allowlist cannot cover: a request from outside a browser carries noOriginto check.Off by default, on both transports
truein the next major release. The reason it cannot be the default today is worth stating, because an earlier revision of this PR had it on for the WebSocket transport and that was wrong:A token only reaches the browser on the entry this middleware adds, and
injectbeing on does not mean an entry was added.injectHotClientdeclines in three cases — every entry point already pulls the client in,hot.transportis a function, the target is not the web. Requiring a token by default turns each of those into a403on every client.The first is wiring the README documents: the client listed in your own
entry, withinjectleft alone. Measured on that setup before the default changed:"The WebSocket transport is unreleased, so nothing is connecting to it that would not carry one" is true — published 8.3.0's schema has only
path,heartbeat,progressandstatsOptionsunderhot— but it answers the wrong question. The break is in supported wiring within the same version, not in clients already on the wire.Asking for one where no client was injected warns rather than leaving an unexplained refusal.
What it does not protect
Stated in the README rather than left to be discovered: the token reaches the browser in the bundle, as a literal in the client's entry query. A page that can read your bundle cross-origin can read the token out of it — the technique in CVE-2026-6402, a
<script>tag plus intercepting webpack's module registration.hot.corsis what answers that, and the two are complementary rather than alternatives.So the token's job is narrower and worth naming precisely: it stops anything that can reach the port but cannot read a bundle — another process on the machine, something on the LAN, a request that is not a browser at all.
For a client you wired yourself
A hand-wired entry is built before the middleware exists, so it can never carry a per-run token. Two ways out, both documented:
hot.inject: falseturns the requirement off even when asked for: with nothing injected there is no way to hand a token over, and requiring one would refuse a client the developer wired correctly.A file per transport
hot.jshad one transport in a file of its own and the other inline, which made the structure read as if Server-Sent Events were the special case rather than the default. Both are files now, andhot.jsis the lifecycle and the payloads — down from 231 lines of stream plumbing.Each transport owns what is specific to it, next to the code that applies it:
What stays in
hot.jsis the mint that hands one token to whichever transport was built — genuinely shared.pathMatchmoves toutils.js, sincemiddleware.jswas reaching throughhot.jsfor it.#2450 made the WebSocket server lazy; both are now, so a project on
'ws'no longer parses the SSE server either, and a custom transport parses neither.publish(payload), andhot.progressdeprecatedhot.progressapplies webpack'sProgressPluginto your compiler. A server that applies one itself — webpack-dev-server does — then has two of them on one compiler. Measuring a build is the server's call; carrying the result is the middleware's.The bundled client already renders
{ action: "progress" }, so a server with its own plugin had everywhere to put it and needed only this. Any action the clients understand goes through it;{ action: "reload" }is the other useful one.hot.progresswarns and keeps working until the next major release.hot.client.progressis unaffected and stays — it is the browser's end of this, and defaults totrue, so a server publishing its own payload gets it drawn with no further configuration.Two things the option did for you become the caller's: rounding the percent, and dropping a tick that rounds to the same whole number as the last one.
The review findings
paththat already carried atokenended up with two, and the endpoint reads the first — so the token configured here was the one ignored. A fragment was mishandled the same way:path#fragbecamepath#frag?token=…. Now inclient-src/utils/with-token.js, taken apart on#then?rather than parsed withURL— which needs a base and would turn a relative path into an absolute one. 9 tests cover absolute, protocol-relative, rooted, relative, existing query, existing token, fragment and escaping.token=<the token>and points at thetokenproperty.instance.tokenfrom a configuration that does not ask for one, so it served{ token: false }.Notes for review
tokenis a real client option, not something the middleware slips intopath. It has to be: a hand-wired entry has nopathparameter to slip it into, which 7 e2e failures established before the option existed.crypto.timingSafeEqual, length-checked first since it throws on a mismatch.base64urlencodes 9 with no padding, so the query carries 12 characters and no=.403before the CORS grant and before the stream opens; the WebSocket side answers rather than dropping the socket, so a client learns why.hot.client.tokenexists as well ashot.token, which a symmetry test insisted on — every option the query takes is settable on the node side under the same name.etagrow and both references to theattachmethod pointed at headings that do not exist.Verified
npm run lint— clean (eslint, prettier, cspell,tsc, client types, schema-check)🤖 Generated with Claude Code
https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
Summary by CodeRabbit
publish(payload)for sending custom events to connected hot-reload clients, including progress updates.hot.progressas deprecated; it remains available until the next major release.