cloudflare / cloudflare/workers-sdk
[vite-plugin] server.ws: false leaves Vite HMR WebSocket upgrades hanging
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 186
Description
### What versions & operating system are you using?
```text
System:
OS: macOS 26.4
CPU: arm64 Apple M1 Pro
Shell: fish 4.8.1
Binaries:
Node: 26.5.0
Packages:
@cloudflare/vite-plugin: 1.52.1
vite: 8.2.1
wrangler: 4.123.0
miniflare: 5.20260811.1-alpha (via @cloudflare/vite-plugin)
```
The behavior is also present in current `main` (`f2437e606fc69891009285831d94b49bf44f6aff`).
### Minimal reproduction
Self-contained reproduction below; no application-specific code is required.
`package.json`:
```json
{
"type": "module",
"devDependencies": {
"@cloudflare/vite-plugin": "1.52.1",
"vite": "8.2.1",
"wrangler": "4.123.0"
},
"scripts": {
"dev": "vite --mode test"
}
}
```
`vite.config.ts`:
```ts
import { cloudflare } from "@cloudflare/vite-plugin"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [cloudflare()],
server: {
forwardConsole: false,
hmr: false,
watch: null,
ws: false,
},
})
```
Use a minimal `wrangler.jsonc`, Worker entry, and `index.html`, then:
1. Start `vite --mode test`.
2. Open the page in a browser.
3. Observe WebSocket requests, or capture them with Playwright:
```ts
const sockets: WebSocket[] = []
page.on("websocket", socket => sockets.push(socket))
await page.goto("http://localhost:5173")
console.log(
sockets.filter(socket => !socket.isClosed()).map(socket => socket.url()),
)
```
### Describe the bug
Expected: `server.hmr: false` plus `server.ws: false` disables the Vite HMR WebSocket. No live or indefinitely pending browser WebSocket remains. `server.watch: null` is used at the same time to make a test server fully static/lightweight.
Observed with the Cloudflare plugin:
```text
[ "ws://localhost:56327/?token=zpLrJNkXU5th" ]
```
The WebSocket remains live/pending after navigation. The identical Vite server options without `@cloudflare/vite-plugin` leave no live WebSocket.
A post-enforced config plugin does not help. Vite's resolved config is correct:
```json
{
"hmr": false,
"ws": false,
"watch": null,
"forwardConsole": {
"enabled": false,
"unhandledErrors": false,
"logLevels": []
}
}
```
### Cause
Vite correctly makes its WebSocket server a no-op when `config.server.ws === false`.
The Cloudflare dev plugin nevertheless calls `handleWebSocket()` unconditionally whenever the HTTP server exists:
https://github.com/cloudflare/workers-sdk/blob/f2437e606fc69891009285831d94b49bf44f6aff/packages/vite-plugin-cloudflare/src/plugins/dev.ts#L188-L195
`handleWebSocket()` installs an `upgrade` listener. For a normal `vite-hmr` request it returns without claiming **or closing** the socket:
https://github.com/cloudflare/workers-sdk/blob/f2437e606fc69891009285831d94b49bf44f6aff/packages/vite-plugin-cloudflare/src/websockets.ts#L54-L67
When Vite's own WS handler is enabled, that return lets Vite claim the upgrade. When `server.ws` is false, Vite intentionally has no handler to claim it. The Cloudflare listener therefore keeps the raw upgrade socket hanging.
The Cloudflare Worker dev environment also hardcodes `hot: true` rather than deriving it from the resolved HMR setting:
https://github.com/cloudflare/workers-sdk/blob/f2437e606fc69891009285831d94b49bf44f6aff/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts#L95-L105
That may be required for the internal module-runner transport, but it means `server.hmr: false` is not consistently represented across environments and deserves an explicit compatibility decision.
Related but distinct: #13891 discusses the same silent-return branch while Vite's WS server is enabled. This bug is the disabled-WS case, where no later listener owns the upgrade.
### Suggested fix
Preserve Worker WebSocket forwarding, but explicitly destroy/close normal Vite HMR upgrade sockets when Vite WS is disabled, instead of silently returning and leaving them pending. Pass the resolved `server.ws` state into `handleWebSocket()` (or otherwise detect whether another Vite upgrade handler exists).
Also add integration coverage for:
- `server: { hmr: false, ws: false, watch: null }`
- HTML navigation creates no live/pending Vite WebSocket
- Worker application WebSockets still work when intentionally supported
- default HMR behavior remains unchanged
### Relevant error log
Playwright assertion:
```diff
{
- "liveWebsocketURLs": [],
+ "liveWebsocketURLs": [
+ "ws://localhost:56327/?token=zpLrJNkXU5th",
+ ],
"name": "dashboard",
"status": 200,
}
```
Contributor guide
Research direction
Read packages/vite-plugin-cloudflare/src/plugins/dev.ts around the unconditional handleWebSocket() call, then packages/vite-plugin-cloudflare/src/websockets.ts around its silent-return branch; check cloudflare-environment.ts for the hardcoded hot setting and the related issue #13891. Add integration coverage for disabled Vite WS alongside normal HMR and supported Worker WebSockets; done means navigation leaves no pending Vite socket and those other behaviors still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- playwright, typescript, vite
- Domain
- networking, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100