diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md
index d2f3b6ff8e25c9..4dd76122fc4f6f 100644
--- a/docs/guide/api-plugin.md
+++ b/docs/guide/api-plugin.md
@@ -423,11 +423,11 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo
- Filter and narrow down the affected module list so that the HMR is more accurate.
- - Return an empty array and perform complete custom HMR handling by sending custom events to the client:
+ - Return an empty array and perform complete custom HMR handling by sending custom events to the client (example uses `server.hot` which was introduced in Vite 5.1, it is recommended to also use `server.ws` if you support lower versions):
```js
handleHotUpdate({ server }) {
- server.ws.send({
+ server.hot.send({
type: 'custom',
event: 'special-update',
data: {}
@@ -534,7 +534,7 @@ Since Vite 2.9, we provide some utilities for plugins to help handle the communi
### Server to Client
-On the plugin side, we could use `server.ws.send` to broadcast events to all the clients:
+On the plugin side, we could use `server.hot.send` (since Vite 5.1) or `server.ws.send` to broadcast events to all the clients:
```js
// vite.config.js
@@ -544,8 +544,8 @@ export default defineConfig({
// ...
configureServer(server) {
// Example: wait for a client to connect before sending a message
- server.ws.on('connection', () => {
- server.ws.send('my:greetings', { msg: 'hello' })
+ server.hot.on('connection', () => {
+ server.hot.send('my:greetings', { msg: 'hello' })
})
},
},
@@ -579,7 +579,7 @@ if (import.meta.hot) {
}
```
-Then use `server.ws.on` and listen to the events on the server side:
+Then use `server.hot.on` (since Vite 5.1) or `server.ws.on` and listen to the events on the server side:
```js
// vite.config.js
@@ -588,7 +588,7 @@ export default defineConfig({
{
// ...
configureServer(server) {
- server.ws.on('my:from-client', (data, client) => {
+ server.hot.on('my:from-client', (data, client) => {
console.log('Message from client:', data.msg) // Hey!
// reply only to the client (if needed)
client.send('my:ack', { msg: 'Hi! I got your message!' })
diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts
index f4a278bd54bcfd..420b4951200a91 100644
--- a/packages/vite/src/node/optimizer/optimizer.ts
+++ b/packages/vite/src/node/optimizer/optimizer.ts
@@ -487,7 +487,7 @@ async function createDepsOptimizer(
// reloaded.
server.moduleGraph.invalidateAll()
- server.ws.send({
+ server.hot.send({
type: 'full-reload',
path: '*',
})
diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts
index a110de16033034..63b7598908c984 100644
--- a/packages/vite/src/node/plugin.ts
+++ b/packages/vite/src/node/plugin.ts
@@ -129,7 +129,7 @@ export interface Plugin extends RollupPlugin {
* the descriptors.
*
* - The hook can also return an empty array and then perform custom updates
- * by sending a custom hmr payload via server.ws.send().
+ * by sending a custom hmr payload via server.hot.send().
*
* - If the hook doesn't return a value, the hmr update will be performed as
* normal.
diff --git a/packages/vite/src/node/plugins/esbuild.ts b/packages/vite/src/node/plugins/esbuild.ts
index 6589756f37f5a6..ea0c1604beac4a 100644
--- a/packages/vite/src/node/plugins/esbuild.ts
+++ b/packages/vite/src/node/plugins/esbuild.ts
@@ -491,7 +491,7 @@ async function reloadOnTsconfigChange(changedFile: string) {
// server may not be available if vite config is updated at the same time
if (server) {
// force full reload
- server.ws.send({
+ server.hot.send({
type: 'full-reload',
path: '*',
})
diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts
index ec95dcdd5d6e0b..0d0d6beef02a4e 100644
--- a/packages/vite/src/node/server/hmr.ts
+++ b/packages/vite/src/node/server/hmr.ts
@@ -2,7 +2,7 @@ import fsp from 'node:fs/promises'
import path from 'node:path'
import type { Server } from 'node:http'
import colors from 'picocolors'
-import type { Update } from 'types/hmrPayload'
+import type { CustomPayload, HMRPayload, Update } from 'types/hmrPayload'
import type { RollupError } from 'rollup'
import { CLIENT_DIR } from '../constants'
import {
@@ -12,7 +12,7 @@ import {
withTrailingSlash,
wrapId,
} from '../utils'
-import type { ViteDevServer } from '..'
+import type { InferCustomEventPayload, ViteDevServer } from '..'
import { isCSSRequest } from '../plugins/css'
import { getAffectedGlobModules } from '../plugins/importMetaGlob'
import { isExplicitImportRequired } from '../plugins/importAnalysis'
@@ -35,6 +35,8 @@ export interface HmrOptions {
timeout?: number
overlay?: boolean
server?: Server
+ /** @internal */
+ channels?: HMRChannel[]
}
export interface HmrContext {
@@ -51,6 +53,68 @@ interface PropagationBoundary {
isWithinCircularImport: boolean
}
+export interface HMRBroadcasterClient {
+ /**
+ * Send event to the client
+ */
+ send(payload: HMRPayload): void
+ /**
+ * Send custom event
+ */
+ send(event: string, payload?: CustomPayload['data']): void
+}
+
+export interface HMRChannel {
+ /**
+ * Unique channel name
+ */
+ name: string
+ /**
+ * Broadcast events to all clients
+ */
+ send(payload: HMRPayload): void
+ /**
+ * Send custom event
+ */
+ send(event: T, payload?: InferCustomEventPayload): void
+ /**
+ * Handle custom event emitted by `import.meta.hot.send`
+ */
+ on(
+ event: T,
+ listener: (
+ data: InferCustomEventPayload,
+ client: HMRBroadcasterClient,
+ ...args: any[]
+ ) => void,
+ ): void
+ on(event: 'connection', listener: () => void): void
+ /**
+ * Unregister event listener
+ */
+ off(event: string, listener: Function): void
+ /**
+ * Start listening for messages
+ */
+ listen(): void
+ /**
+ * Disconnect all clients, called when server is closed or restarted.
+ */
+ close(): void
+}
+
+export interface HMRBroadcaster extends Omit {
+ /**
+ * All registered channels. Always has websocket channel.
+ */
+ readonly channels: HMRChannel[]
+ /**
+ * Add a new third-party channel.
+ */
+ addChannel(connection: HMRChannel): HMRBroadcaster
+ close(): Promise
+}
+
export function getShortName(file: string, root: string): string {
return file.startsWith(withTrailingSlash(root))
? path.posix.relative(root, file)
@@ -62,7 +126,7 @@ export async function handleHMRUpdate(
server: ViteDevServer,
configOnly: boolean,
): Promise {
- const { ws, config, moduleGraph } = server
+ const { hot, config, moduleGraph } = server
const shortFile = getShortName(file, config.root)
const fileName = path.basename(file)
@@ -100,7 +164,7 @@ export async function handleHMRUpdate(
// (dev only) the client itself cannot be hot updated.
if (file.startsWith(withTrailingSlash(normalizedClientDir))) {
- ws.send({
+ hot.send({
type: 'full-reload',
path: '*',
})
@@ -133,7 +197,7 @@ export async function handleHMRUpdate(
clear: true,
timestamp: true,
})
- ws.send({
+ hot.send({
type: 'full-reload',
path: config.server.middlewareMode
? '*'
@@ -155,7 +219,7 @@ export function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
- { config, ws, moduleGraph }: ViteDevServer,
+ { config, hot, moduleGraph }: ViteDevServer,
afterInvalidation?: boolean,
): void {
const updates: Update[] = []
@@ -204,7 +268,7 @@ export function updateModules(
colors.green(`page reload `) + colors.dim(file) + reason,
{ clear: !afterInvalidation, timestamp: true },
)
- ws.send({
+ hot.send({
type: 'full-reload',
})
return
@@ -220,7 +284,7 @@ export function updateModules(
colors.dim([...new Set(updates.map((u) => u.path))].join(', ')),
{ clear: !afterInvalidation, timestamp: true },
)
- ws.send({
+ hot.send({
type: 'update',
updates,
})
@@ -455,7 +519,7 @@ function isNodeWithinCircularImports(
export function handlePrunedModules(
mods: Set,
- { ws }: ViteDevServer,
+ { hot }: ViteDevServer,
): void {
// update the disposed modules' hmr timestamp
// since if it's re-imported, it should re-apply side effects
@@ -465,7 +529,7 @@ export function handlePrunedModules(
mod.lastHMRTimestamp = t
debugHmr?.(`[dispose] ${colors.dim(mod.file)}`)
})
- ws.send({
+ hot.send({
type: 'prune',
paths: [...mods].map((m) => m.url),
})
@@ -644,3 +708,52 @@ async function readModifiedFile(file: string): Promise {
return content
}
}
+
+export function createHMRBroadcaster(): HMRBroadcaster {
+ const channels: HMRChannel[] = []
+ const readyChannels = new WeakSet()
+ const broadcaster: HMRBroadcaster = {
+ get channels() {
+ return [...channels]
+ },
+ addChannel(channel) {
+ if (channels.some((c) => c.name === channel.name)) {
+ throw new Error(`HMR channel "${channel.name}" is already defined.`)
+ }
+ channels.push(channel)
+ return broadcaster
+ },
+ on(event: string, listener: (...args: any[]) => any) {
+ // emit connection event only when all channels are ready
+ if (event === 'connection') {
+ // make a copy so we don't wait for channels that might be added after this is triggered
+ const channels = this.channels
+ channels.forEach((channel) =>
+ channel.on('connection', () => {
+ readyChannels.add(channel)
+ if (channels.every((c) => readyChannels.has(c))) {
+ listener()
+ }
+ }),
+ )
+ return
+ }
+ channels.forEach((channel) => channel.on(event, listener))
+ return
+ },
+ off(event, listener) {
+ channels.forEach((channel) => channel.off(event, listener))
+ return
+ },
+ send(...args: any[]) {
+ channels.forEach((channel) => channel.send(...(args as [any])))
+ },
+ listen() {
+ channels.forEach((channel) => channel.listen())
+ },
+ close() {
+ return Promise.all(channels.map((channel) => channel.close()))
+ },
+ }
+ return broadcaster
+}
diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts
index b73ecfa07a2517..1ac5d25454d87d 100644
--- a/packages/vite/src/node/server/index.ts
+++ b/packages/vite/src/node/server/index.ts
@@ -15,7 +15,6 @@ import launchEditorMiddleware from 'launch-editor-middleware'
import type { SourceMap } from 'rollup'
import picomatch from 'picomatch'
import type { Matcher } from 'picomatch'
-import type { InvalidatePayload } from 'types/customEvent'
import type { CommonServerOptions } from '../http'
import {
httpServerStart,
@@ -74,8 +73,9 @@ import type { ModuleNode } from './moduleGraph'
import { ModuleGraph } from './moduleGraph'
import { notFoundMiddleware } from './middlewares/notFound'
import { errorMiddleware, prepareError } from './middlewares/error'
-import type { HmrOptions } from './hmr'
+import type { HMRBroadcaster, HmrOptions } from './hmr'
import {
+ createHMRBroadcaster,
getShortName,
handleFileAddUnlink,
handleHMRUpdate,
@@ -232,8 +232,16 @@ export interface ViteDevServer {
watcher: FSWatcher
/**
* web socket server with `send(payload)` method
+ * @deprecated use `hot` instead
*/
ws: WebSocketServer
+ /**
+ * HMR broadcaster that can be used to send custom HMR messages to the client
+ *
+ * Always sends a message to at least a WebSocket client. Any third party can
+ * add a channel to the broadcaster to process messages
+ */
+ hot: HMRBroadcaster
/**
* Rollup plugin container that can run plugin hooks on a given file
*/
@@ -379,12 +387,12 @@ export interface ResolvedServerUrls {
export function createServer(
inlineConfig: InlineConfig = {},
): Promise {
- return _createServer(inlineConfig, { ws: true })
+ return _createServer(inlineConfig, { hotListen: true })
}
export async function _createServer(
inlineConfig: InlineConfig = {},
- options: { ws: boolean },
+ options: { hotListen: boolean },
): Promise {
const config = await resolveConfig(inlineConfig, 'serve')
@@ -403,7 +411,12 @@ export async function _createServer(
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
+
const ws = createWebSocketServer(httpServer, config, httpsOptions)
+ const hot = createHMRBroadcaster().addChannel(ws)
+ if (typeof config.server.hmr === 'object' && config.server.hmr.channels) {
+ config.server.hmr.channels.forEach((channel) => hot.addChannel(channel))
+ }
if (httpServer) {
setClientErrorHandler(httpServer, config.logger)
@@ -437,6 +450,7 @@ export async function _createServer(
watcher,
pluginContainer: container,
ws,
+ hot,
moduleGraph,
resolvedUrls: null, // will be set on listen
ssrTransform(
@@ -558,7 +572,7 @@ export async function _createServer(
}
await Promise.allSettled([
watcher.close(),
- ws.close(),
+ hot.close(),
container.close(),
getDepsOptimizer(server.config)?.close(),
getDepsOptimizer(server.config, true)?.close(),
@@ -642,7 +656,7 @@ export async function _createServer(
try {
await handleHMRUpdate(file, server, configOnly)
} catch (err) {
- ws.send({
+ hot.send({
type: 'error',
err: prepareError(err),
})
@@ -682,7 +696,7 @@ export async function _createServer(
onFileAddUnlink(file, true)
})
- ws.on('vite:invalidate', async ({ path, message }: InvalidatePayload) => {
+ hot.on('vite:invalidate', async ({ path, message }) => {
const mod = moduleGraph.urlToModuleMap.get(path)
if (mod && mod.isSelfAccepting && mod.lastHMRTimestamp > 0) {
config.logger.info(
@@ -825,7 +839,7 @@ export async function _createServer(
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
// ensure ws server started
- ws.listen()
+ hot.listen()
await initServer()
} catch (e) {
httpServer.emit('error', e)
@@ -834,8 +848,8 @@ export async function _createServer(
return listen(port, ...args)
}) as any
} else {
- if (options.ws) {
- ws.listen()
+ if (options.hotListen) {
+ hot.listen()
}
await initServer()
}
@@ -986,7 +1000,7 @@ async function restartServer(server: ViteDevServer) {
let newServer = null
try {
// delay ws server listen
- newServer = await _createServer(inlineConfig, { ws: false })
+ newServer = await _createServer(inlineConfig, { hotListen: false })
} catch (err: any) {
server.config.logger.error(err.message, {
timestamp: true,
@@ -1019,7 +1033,7 @@ async function restartServer(server: ViteDevServer) {
if (!middlewareMode) {
await server.listen(port, true)
} else {
- server.ws.listen()
+ server.hot.listen()
}
logger.info('server restarted.', { timestamp: true })
diff --git a/packages/vite/src/node/server/middlewares/error.ts b/packages/vite/src/node/server/middlewares/error.ts
index 966d1663749ba2..1d67f1aa55e4ed 100644
--- a/packages/vite/src/node/server/middlewares/error.ts
+++ b/packages/vite/src/node/server/middlewares/error.ts
@@ -51,7 +51,7 @@ export function logError(server: ViteDevServer, err: RollupError): void {
error: err,
})
- server.ws.send({
+ server.hot.send({
type: 'error',
err: prepareError(err),
})
diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts
index d9a67e4934c42f..6b70d1fbea5e77 100644
--- a/packages/vite/src/node/server/ws.ts
+++ b/packages/vite/src/node/server/ws.ts
@@ -13,6 +13,7 @@ import type { CustomPayload, ErrorPayload, HMRPayload } from 'types/hmrPayload'
import type { InferCustomEventPayload } from 'types/customEvent'
import type { ResolvedConfig } from '..'
import { isObject } from '../utils'
+import type { HMRChannel } from './hmr'
import type { HttpServer } from '.'
/* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version
@@ -30,7 +31,7 @@ export type WebSocketCustomListener = (
client: WebSocketClient,
) => void
-export interface WebSocketServer {
+export interface WebSocketServer extends HMRChannel {
/**
* Listen on port and host
*/
@@ -39,14 +40,6 @@ export interface WebSocketServer {
* Get all connected clients.
*/
clients: Set
- /**
- * Broadcast events to all clients
- */
- send(payload: HMRPayload): void
- /**
- * Send custom event
- */
- send(event: T, payload?: InferCustomEventPayload): void
/**
* Disconnect all clients and terminate the server.
*/
@@ -230,6 +223,7 @@ export function createWebSocketServer(
let bufferedError: ErrorPayload | null = null
return {
+ name: 'ws',
listen: () => {
wsHttpServer?.listen(port, host)
},
diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts
index 9ae2186d1b8b5e..054fe0635a96a2 100644
--- a/playground/hmr/vite.config.ts
+++ b/playground/hmr/vite.config.ts
@@ -12,12 +12,12 @@ export default defineConfig({
if (file.endsWith('customFile.js')) {
const content = await read()
const msg = content.match(/export const msg = '(\w+)'/)[1]
- server.ws.send('custom:foo', { msg })
- server.ws.send('custom:remove', { msg })
+ server.hot.send('custom:foo', { msg })
+ server.hot.send('custom:remove', { msg })
}
},
configureServer(server) {
- server.ws.on('custom:remote-add', ({ a, b }, client) => {
+ server.hot.on('custom:remote-add', ({ a, b }, client) => {
client.send('custom:remote-add-result', { result: a + b })
})
},
@@ -44,7 +44,7 @@ export const virtual = _virtual + '${num}';`
}
},
configureServer(server) {
- server.ws.on('virtual:increment', async () => {
+ server.hot.on('virtual:increment', async () => {
const mod = await server.moduleGraph.getModuleByUrl('\0virtual:file')
if (mod) {
num++
diff --git a/playground/vitestGlobalSetup.ts b/playground/vitestGlobalSetup.ts
index d62edca8f23daf..7f85d9d12748bf 100644
--- a/playground/vitestGlobalSetup.ts
+++ b/playground/vitestGlobalSetup.ts
@@ -1,15 +1,13 @@
-import os from 'node:os'
import path from 'node:path'
import fs from 'fs-extra'
+import type { GlobalSetupContext } from 'vitest/node'
import type { BrowserServer } from 'playwright-chromium'
import { chromium } from 'playwright-chromium'
import { hasWindowsUnicodeFsBug } from './hasWindowsUnicodeFsBug'
-const DIR = path.join(os.tmpdir(), 'vitest_playwright_global_setup')
-
let browserServer: BrowserServer | undefined
-export async function setup(): Promise {
+export async function setup({ provide }: GlobalSetupContext): Promise {
process.env.NODE_ENV = process.env.VITE_TEST_BUILD
? 'production'
: 'development'
@@ -21,8 +19,7 @@ export async function setup(): Promise {
: undefined,
})
- await fs.mkdirp(DIR)
- await fs.writeFile(path.join(DIR, 'wsEndpoint'), browserServer.wsEndpoint())
+ provide('wsEndpoint', browserServer.wsEndpoint())
const tempDir = path.resolve(__dirname, '../playground-temp')
await fs.ensureDir(tempDir)
diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts
index c73b358f5731c7..cb4ab8f125a9df 100644
--- a/playground/vitestSetup.ts
+++ b/playground/vitestSetup.ts
@@ -1,6 +1,5 @@
import type * as http from 'node:http'
-import path, { dirname, join, resolve } from 'node:path'
-import os from 'node:os'
+import path, { dirname, resolve } from 'node:path'
import fs from 'fs-extra'
import { chromium } from 'playwright-chromium'
import type {
@@ -22,7 +21,7 @@ import {
import type { Browser, Page } from 'playwright-chromium'
import type { RollupError, RollupWatcher, RollupWatcherEvent } from 'rollup'
import type { File } from 'vitest'
-import { beforeAll } from 'vitest'
+import { beforeAll, inject } from 'vitest'
// #region env
@@ -80,8 +79,6 @@ export function setViteUrl(url: string): void {
// #endregion
-const DIR = join(os.tmpdir(), 'vitest_playwright_global_setup')
-
beforeAll(async (s) => {
const suite = s as File
// skip browser setup for non-playground tests
@@ -89,7 +86,7 @@ beforeAll(async (s) => {
return
}
- const wsEndpoint = fs.readFileSync(join(DIR, 'wsEndpoint'), 'utf-8')
+ const wsEndpoint = inject('wsEndpoint')
if (!wsEndpoint) {
throw new Error('wsEndpoint not found')
}
@@ -354,3 +351,9 @@ declare module 'vite' {
__test__?: () => void
}
}
+
+declare module 'vitest' {
+ export interface ProvidedContext {
+ wsEndpoint: string
+ }
+}