diff --git a/docs/config/server-options.md b/docs/config/server-options.md index 33f8bb03121b27..3065fe34ba0caf 100644 --- a/docs/config/server-options.md +++ b/docs/config/server-options.md @@ -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 diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 928ba2106fa59e..ceccdf345fbce4 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -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 }) => { @@ -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[]) { @@ -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 diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index a363288e26282c..d63c72d6143216 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -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) @@ -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 @@ -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')) {