Skip to content

Commit 4061ee0

Browse files
authored
fix: infer hmr ws target by client location (#8650)
1 parent 8ef7333 commit 4061ee0

File tree

3 files changed

+104
-32
lines changed

3 files changed

+104
-32
lines changed

docs/config/server-options.md

+17-2
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,24 @@ Disable or configure HMR connection (in cases where the HMR websocket must use a
139139

140140
Set `server.hmr.overlay` to `false` to disable the server error overlay.
141141

142-
`clientPort` is an advanced option that overrides the port only on the client side, allowing you to serve the websocket on a different port than the client code looks for it on. Useful if you're using an SSL proxy in front of your dev server.
142+
`clientPort` is an advanced option that overrides the port only on the client side, allowing you to serve the websocket on a different port than the client code looks for it on.
143143

144-
If specifying `server.hmr.server`, Vite will process HMR connection requests through the provided server. If not in middleware mode, Vite will attempt to process HMR connection requests through the existing server. This can be helpful when using self-signed certificates or when you want to expose Vite over a network on a single port.
144+
When `server.hmr.server` is defined, Vite will process the HMR connection requests through the provided server. If not in middleware mode, Vite will attempt to process HMR connection requests through the existing server. This can be helpful when using self-signed certificates or when you want to expose Vite over a network on a single port.
145+
146+
::: tip NOTE
147+
148+
With the default configuration, reverse proxies in front of Vite are expected to support proxying WebSocket. If the Vite HMR client fails to connect WebSocket, the client will fallback to connecting the WebSocket directly to the Vite HMR server bypassing the reverse proxies:
149+
150+
```
151+
Direct websocket connection fallback. Check out https://vitejs.dev/config/server-options.html#server-hmr to remove the previous connection error.
152+
```
153+
154+
The error that appears in the Browser when the fallback happens can be ignored. To avoid the error by directly bypassing reverse proxies, you could either:
155+
156+
- set `server.strictPort = true` and set `server.hmr.clientPort` to the same value with `server.port`
157+
- set `server.hmr.port` to a different value from `server.port`
158+
159+
:::
145160

146161
## server.watch
147162

packages/vite/src/client/client.ts

+62-10
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,70 @@ import '@vite/env'
77

88
// injected by the hmr plugin when served
99
declare const __BASE__: string
10-
declare const __HMR_PROTOCOL__: string
11-
declare const __HMR_HOSTNAME__: string
12-
declare const __HMR_PORT__: string
10+
declare const __HMR_PROTOCOL__: string | null
11+
declare const __HMR_HOSTNAME__: string | null
12+
declare const __HMR_PORT__: string | null
13+
declare const __HMR_DIRECT_TARGET__: string
14+
declare const __HMR_BASE__: string
1315
declare const __HMR_TIMEOUT__: number
1416
declare const __HMR_ENABLE_OVERLAY__: boolean
1517

1618
console.debug('[vite] connecting...')
1719

20+
const importMetaUrl = new URL(import.meta.url)
21+
1822
// use server configuration, then fallback to inference
1923
const socketProtocol =
2024
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
21-
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
25+
const hmrPort = __HMR_PORT__
26+
const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
27+
hmrPort || importMetaUrl.port
28+
}${__HMR_BASE__}`
29+
const directSocketHost = __HMR_DIRECT_TARGET__
2230
const base = __BASE__ || '/'
2331
const messageBuffer: string[] = []
2432

2533
let socket: WebSocket
2634
try {
27-
socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
35+
let fallback: (() => void) | undefined
36+
// only use fallback when port is inferred to prevent confusion
37+
if (!hmrPort) {
38+
fallback = () => {
39+
// fallback to connecting directly to the hmr server
40+
// for servers which does not support proxying websocket
41+
socket = setupWebSocket(socketProtocol, directSocketHost)
42+
socket.addEventListener(
43+
'open',
44+
() => {
45+
console.info(
46+
'[vite] Direct websocket connection fallback. Check out https://vitejs.dev/config/server-options.html#server-hmr to remove the previous connection error.'
47+
)
48+
},
49+
{ once: true }
50+
)
51+
}
52+
}
53+
54+
socket = setupWebSocket(socketProtocol, socketHost, fallback)
55+
} catch (error) {
56+
console.error(`[vite] failed to connect to websocket (${error}). `)
57+
}
58+
59+
function setupWebSocket(
60+
protocol: string,
61+
hostAndPath: string,
62+
onCloseWithoutOpen?: () => void
63+
) {
64+
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
65+
let isOpened = false
66+
67+
socket.addEventListener(
68+
'open',
69+
() => {
70+
isOpened = true
71+
},
72+
{ once: true }
73+
)
2874

2975
// Listen for messages
3076
socket.addEventListener('message', async ({ data }) => {
@@ -34,12 +80,18 @@ try {
3480
// ping server
3581
socket.addEventListener('close', async ({ wasClean }) => {
3682
if (wasClean) return
83+
84+
if (!isOpened && onCloseWithoutOpen) {
85+
onCloseWithoutOpen()
86+
return
87+
}
88+
3789
console.log(`[vite] server connection lost. polling for restart...`)
38-
await waitForSuccessfulPing()
90+
await waitForSuccessfulPing(hostAndPath)
3991
location.reload()
4092
})
41-
} catch (error) {
42-
console.error(`[vite] failed to connect to websocket (${error}). `)
93+
94+
return socket
4395
}
4496

4597
function warnFailedFetch(err: Error, path: string | string[]) {
@@ -222,13 +274,13 @@ async function queueUpdate(p: Promise<(() => void) | undefined>) {
222274
}
223275
}
224276

225-
async function waitForSuccessfulPing(ms = 1000) {
277+
async function waitForSuccessfulPing(hostAndPath: string, ms = 1000) {
226278
// eslint-disable-next-line no-constant-condition
227279
while (true) {
228280
try {
229281
// A fetch on a websocket URL will return a successful promise with status 400,
230282
// but will reject a networking error.
231-
await fetch(`${location.protocol}//${socketHost}`)
283+
await fetch(`${location.protocol}//${hostAndPath}`)
232284
break
233285
} catch (e) {
234286
// wait ms before attempting to ping again

packages/vite/src/node/plugins/clientInjections.ts

+25-20
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from 'node:path'
22
import type { Plugin } from '../plugin'
33
import type { ResolvedConfig } from '../config'
44
import { CLIENT_ENTRY, ENV_ENTRY } from '../constants'
5-
import { isObject, normalizePath } from '../utils'
5+
import { isObject, normalizePath, resolveHostname } from '../utils'
66

77
// ids in transform are normalized to unix style
88
const normalizedClientEntry = normalizePath(CLIENT_ENTRY)
@@ -15,30 +15,33 @@ const normalizedEnvEntry = normalizePath(ENV_ENTRY)
1515
export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
1616
return {
1717
name: 'vite:client-inject',
18-
transform(code, id, options) {
18+
async transform(code, id, options) {
1919
if (id === normalizedClientEntry || id === normalizedEnvEntry) {
20-
let options = config.server.hmr
21-
options = options && typeof options !== 'boolean' ? options : {}
22-
const host = options.host || null
23-
const protocol = options.protocol || null
24-
const timeout = options.timeout || 30000
25-
const overlay = options.overlay !== false
26-
let port: number | string | undefined
27-
if (isObject(config.server.hmr)) {
28-
port = config.server.hmr.clientPort || config.server.hmr.port
29-
}
20+
let hmrConfig = config.server.hmr
21+
hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined
22+
const host = hmrConfig?.host || null
23+
const protocol = hmrConfig?.protocol || null
24+
const timeout = hmrConfig?.timeout || 30000
25+
const overlay = hmrConfig?.overlay !== false
26+
27+
// hmr.clientPort -> hmr.port
28+
// -> (24678 if middleware mode) -> new URL(import.meta.url).port
29+
let port = hmrConfig
30+
? String(hmrConfig.clientPort || hmrConfig.port)
31+
: null
3032
if (config.server.middlewareMode) {
31-
port = String(port || 24678)
32-
} else {
33-
port = String(port || options.port || config.server.port!)
33+
port ||= '24678'
3434
}
35+
3536
const devBase = config.base
37+
let directTarget =
38+
hmrConfig?.host || (await resolveHostname(config.server.host)).name
39+
directTarget += `:${hmrConfig?.port || config.server.port!}`
40+
directTarget += devBase
41+
3642
let hmrBase = devBase
37-
if (options.path) {
38-
hmrBase = path.posix.join(hmrBase, options.path)
39-
}
40-
if (hmrBase !== '/') {
41-
port = path.posix.normalize(`${port}${hmrBase}`)
43+
if (hmrConfig?.path) {
44+
hmrBase = path.posix.join(hmrBase, hmrConfig.path)
4245
}
4346

4447
return code
@@ -48,6 +51,8 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
4851
.replace(`__HMR_PROTOCOL__`, JSON.stringify(protocol))
4952
.replace(`__HMR_HOSTNAME__`, JSON.stringify(host))
5053
.replace(`__HMR_PORT__`, JSON.stringify(port))
54+
.replace(`__HMR_DIRECT_TARGET__`, JSON.stringify(directTarget))
55+
.replace(`__HMR_BASE__`, JSON.stringify(hmrBase))
5156
.replace(`__HMR_TIMEOUT__`, JSON.stringify(timeout))
5257
.replace(`__HMR_ENABLE_OVERLAY__`, JSON.stringify(overlay))
5358
} else if (!options?.ssr && code.includes('process.env.NODE_ENV')) {

0 commit comments

Comments
 (0)