Skip to content

Commit

Permalink
fix: infer hmr ws target by client location (#8650)
Browse files Browse the repository at this point in the history
  • Loading branch information
sapphi-red committed Jun 20, 2022
1 parent 8ef7333 commit 4061ee0
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 32 deletions.
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
}${__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}). `)
}

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

0 comments on commit 4061ee0

Please sign in to comment.