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 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/config/server-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,23 @@ 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.

::: tip NOTE

By default configuration, reverse proxies in front of Vite is expected to support proxying WebSocket. Especially in this case, when Vite HMR client fails to connect WebSocket, the client fallbacks to connect WebSocket directly to the Vite HMR server bypassing reverse proxies.
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

Browser will show an error when the fallback happened but it could be ignored. If you want to completely remove this error, you could either:
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

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

then Vite will bypass reverse proxies from the beginning.

:::

## server.watch

- **Type:** `object`
Expand Down
72 changes: 62 additions & 10 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
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] falled back to connect websocket directly. ignore the connection error above. more details: https://vitejs.dev/config/server-options.html#server-hmr'
)
},
{ 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
43 changes: 24 additions & 19 deletions packages/vite/src/node/plugins/clientInjections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from '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 @@ -17,27 +17,30 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
name: 'vite:client-inject',
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'
}

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

let hmrBase = config.base
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 @@ -47,6 +50,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