Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: infer hmr ws target by client location #8650

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/config/server-options.md
Expand Up @@ -139,9 +139,24 @@ Disable or configure HMR connection (in cases where the HMR websocket must use a

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

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

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

::: tip NOTE

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:

```
Direct websocket connection fallback. Check out https://vitejs.dev/config/server-options.html#server-hmr to remove the previous connection error.
```

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:

- set `server.strictPort = true` and set `server.hmr.clientPort` to the same value with `server.port`
- set `server.hmr.port` to a different value from `server.port`

:::

## server.watch

Expand Down
72 changes: 62 additions & 10 deletions packages/vite/src/client/client.ts
Expand Up @@ -7,24 +7,70 @@ import '@vite/env'

// injected by the hmr plugin when served
declare const __BASE__: string
declare const __HMR_PROTOCOL__: string
declare const __HMR_HOSTNAME__: string
declare const __HMR_PORT__: string
declare const __HMR_PROTOCOL__: string | null
declare const __HMR_HOSTNAME__: string | null
declare const __HMR_PORT__: string | null
declare const __HMR_DIRECT_TARGET__: string
declare const __HMR_BASE__: string
declare const __HMR_TIMEOUT__: number
declare const __HMR_ENABLE_OVERLAY__: boolean

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

const importMetaUrl = new URL(import.meta.url)

// use server configuration, then fallback to inference
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
const hmrPort = __HMR_PORT__
const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
hmrPort || importMetaUrl.port
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: I forgot to handle a case when importMetaUrl.port is ''. But surprisingly this actually works.

new URL("ws://localhost:").port // ''
new WebSocket("ws://localhost:") // connects to `ws://localhost`

}${__HMR_BASE__}`
const directSocketHost = __HMR_DIRECT_TARGET__
const base = __BASE__ || '/'
const messageBuffer: string[] = []

let socket: WebSocket
try {
socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
let fallback: (() => void) | undefined
// only use fallback when port is inferred to prevent confusion
if (!hmrPort) {
fallback = () => {
// fallback to connecting directly to the hmr server
// for servers which does not support proxying websocket
socket = setupWebSocket(socketProtocol, directSocketHost)
socket.addEventListener(
'open',
() => {
console.info(
'[vite] Direct websocket connection fallback. Check out https://vitejs.dev/config/server-options.html#server-hmr to remove the previous connection error.'
)
},
{ once: true }
)
}
}

socket = setupWebSocket(socketProtocol, socketHost, fallback)
} catch (error) {
console.error(`[vite] failed to connect to websocket (${error}). `)
}
sapphi-red marked this conversation as resolved.
Show resolved Hide resolved

function setupWebSocket(
protocol: string,
hostAndPath: string,
onCloseWithoutOpen?: () => void
) {
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
let isOpened = false

socket.addEventListener(
'open',
() => {
isOpened = true
},
{ once: true }
)

// Listen for messages
socket.addEventListener('message', async ({ data }) => {
Expand All @@ -34,12 +80,18 @@ try {
// ping server
socket.addEventListener('close', async ({ wasClean }) => {
if (wasClean) return

if (!isOpened && onCloseWithoutOpen) {
onCloseWithoutOpen()
return
}

console.log(`[vite] server connection lost. polling for restart...`)
await waitForSuccessfulPing()
await waitForSuccessfulPing(hostAndPath)
location.reload()
})
} catch (error) {
console.error(`[vite] failed to connect to websocket (${error}). `)

return socket
}

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

async function waitForSuccessfulPing(ms = 1000) {
async function waitForSuccessfulPing(hostAndPath: string, ms = 1000) {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
// A fetch on a websocket URL will return a successful promise with status 400,
// but will reject a networking error.
await fetch(`${location.protocol}//${socketHost}`)
await fetch(`${location.protocol}//${hostAndPath}`)
break
} catch (e) {
// wait ms before attempting to ping again
Expand Down
45 changes: 25 additions & 20 deletions packages/vite/src/node/plugins/clientInjections.ts
Expand Up @@ -2,7 +2,7 @@ import path from 'node:path'
import type { Plugin } from '../plugin'
import type { ResolvedConfig } from '../config'
import { CLIENT_ENTRY, ENV_ENTRY } from '../constants'
import { isObject, normalizePath } from '../utils'
import { isObject, normalizePath, resolveHostname } from '../utils'

// ids in transform are normalized to unix style
const normalizedClientEntry = normalizePath(CLIENT_ENTRY)
Expand All @@ -15,30 +15,33 @@ const normalizedEnvEntry = normalizePath(ENV_ENTRY)
export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
return {
name: 'vite:client-inject',
transform(code, id, options) {
async transform(code, id, options) {
if (id === normalizedClientEntry || id === normalizedEnvEntry) {
let options = config.server.hmr
options = options && typeof options !== 'boolean' ? options : {}
const host = options.host || null
const protocol = options.protocol || null
const timeout = options.timeout || 30000
const overlay = options.overlay !== false
let port: number | string | undefined
if (isObject(config.server.hmr)) {
port = config.server.hmr.clientPort || config.server.hmr.port
}
let hmrConfig = config.server.hmr
hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined
const host = hmrConfig?.host || null
const protocol = hmrConfig?.protocol || null
const timeout = hmrConfig?.timeout || 30000
const overlay = hmrConfig?.overlay !== false

// hmr.clientPort -> hmr.port
// -> (24678 if middleware mode) -> new URL(import.meta.url).port
let port = hmrConfig
? String(hmrConfig.clientPort || hmrConfig.port)
: null
if (config.server.middlewareMode) {
port = String(port || 24678)
} else {
port = String(port || options.port || config.server.port!)
port ||= '24678'
}

const devBase = config.base
let directTarget =
hmrConfig?.host || (await resolveHostname(config.server.host)).name
directTarget += `:${hmrConfig?.port || config.server.port!}`
directTarget += devBase

let hmrBase = devBase
if (options.path) {
hmrBase = path.posix.join(hmrBase, options.path)
}
if (hmrBase !== '/') {
port = path.posix.normalize(`${port}${hmrBase}`)
if (hmrConfig?.path) {
hmrBase = path.posix.join(hmrBase, hmrConfig.path)
}

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