cloudflare / cloudflare/workers-sdk

[vite-plugin] server.ws: false leaves Vite HMR WebSocket upgrades hanging

Open
#15,285 2 comments 0 reactions 0 assignees View on GitHub
package:vite-plugin
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.