diff --git a/docs/guide/api-hmr.md b/docs/guide/api-hmr.md index f4ddf59d8abcd1..46eabab04e8868 100644 --- a/docs/guide/api-hmr.md +++ b/docs/guide/api-hmr.md @@ -123,3 +123,11 @@ The following HMR events are dispatched by Vite automatically: - `'vite:error'` when an error occurs (e.g. syntax error) Custom HMR events can also be sent from plugins. See [handleHotUpdate](./api-plugin#handlehotupdate) for more details. + +## `hot.send(event, data)` + +Send custom events back to Vite's dev server. + +If called before connected, the data will be buffered and sent once the connection is established. + +See [Client-server Communication](/guide/api-plugin.html#client-server-communication) for more details. diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md index b3888b0fd7009b..13767c45dd3103 100644 --- a/docs/guide/api-plugin.md +++ b/docs/guide/api-plugin.md @@ -480,7 +480,7 @@ export default defineConfig({ Check out [Vite Rollup Plugins](https://vite-rollup-plugins.patak.dev) for a list of compatible official Rollup plugins with usage instructions. -## Path normalization +## Path Normalization Vite normalizes paths while resolving ids to use POSIX separators ( / ) while preserving the volume in Windows. On the other hand, Rollup keeps resolved paths untouched by default, so resolved ids have win32 separators ( \\ ) in Windows. However, Rollup plugins use a [`normalizePath` utility function](https://github.com/rollup/plugins/tree/master/packages/pluginutils#normalizepath) from `@rollup/pluginutils` internally, which converts separators to POSIX before performing comparisons. This means that when these plugins are used in Vite, the `include` and `exclude` config pattern and other similar paths against resolved ids comparisons work correctly. @@ -492,3 +492,71 @@ import { normalizePath } from 'vite' normalizePath('foo\\bar') // 'foo/bar' normalizePath('foo/bar') // 'foo/bar' ``` + +## Client-server Communication + +Since Vite 2.9, we provide some utilities for plugins to help handle the communication with clients. + +### Server to Client + +On the plugin side, we could use `server.ws.send` to boardcast events to all the clients: + +```js +// vite.config.js +export default defineConfig({ + plugins: [ + { + // ... + configureServer(server) { + server.ws.send('my:greetings', { msg: 'hello' }) + } + } + ] +}) +``` + +::: tip NOTE +We recommend **alway prefixing** your event names to avoid collisions with other plugins. +::: + +On the client side, use [`hot.on`](/guide/api-hmr.html#hot-on-event-cb) to listen to the events: + +```ts +// client side +if (import.meta.hot) { + import.meta.hot.on('my:greetings', (data) => { + console.log(data.msg) // hello + }) +} +``` + +### Client to Server + +To send events from the client to the server, we can use [`hot.send`](/guide/api-hmr.html#hot-send-event-payload): + +```ts +// client side +if (import.meta.hot) { + import.meta.hot.send('my:from-client', { msg: 'Hey!' }) +} +``` + +Then use `server.ws.on` and listen to the events on the server side: + +```js +// vite.config.js +export default defineConfig({ + plugins: [ + { + // ... + configureServer(server) { + server.ws.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/playground/hmr/__tests__/hmr.spec.ts b/packages/playground/hmr/__tests__/hmr.spec.ts index 1f28763a90df94..4d0491af91a69e 100644 --- a/packages/playground/hmr/__tests__/hmr.spec.ts +++ b/packages/playground/hmr/__tests__/hmr.spec.ts @@ -123,11 +123,16 @@ if (!isBuild) { await untilUpdated(() => el.textContent(), 'edited') }) + test('plugin client-server communication', async () => { + const el = await page.$('.custom-communication') + await untilUpdated(() => el.textContent(), '3') + }) + test('full-reload encodeURI path', async () => { await page.goto( viteTestUrl + '/unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html' ) - let el = await page.$('#app') + const el = await page.$('#app') expect(await el.textContent()).toBe('title') await editFile( 'unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', diff --git a/packages/playground/hmr/hmr.js b/packages/playground/hmr/hmr.js index 01dca20f5dd71c..e80b517e6449dc 100644 --- a/packages/playground/hmr/hmr.js +++ b/packages/playground/hmr/hmr.js @@ -57,6 +57,12 @@ if (import.meta.hot) { import.meta.hot.on('foo', ({ msg }) => { text('.custom', msg) }) + + // send custom event to server to calculate 1 + 2 + import.meta.hot.send('remote-add', { a: 1, b: 2 }) + import.meta.hot.on('remote-add-result', ({ result }) => { + text('.custom-communication', result) + }) } function text(el, text) { diff --git a/packages/playground/hmr/index.html b/packages/playground/hmr/index.html index 766338598e51ad..fc398c60c4cadf 100644 --- a/packages/playground/hmr/index.html +++ b/packages/playground/hmr/index.html @@ -5,5 +5,6 @@
+
diff --git a/packages/playground/hmr/vite.config.js b/packages/playground/hmr/vite.config.js index c34637844e2170..57252c91be410b 100644 --- a/packages/playground/hmr/vite.config.js +++ b/packages/playground/hmr/vite.config.js @@ -9,14 +9,13 @@ module.exports = { if (file.endsWith('customFile.js')) { const content = await read() const msg = content.match(/export const msg = '(\w+)'/)[1] - server.ws.send({ - type: 'custom', - event: 'foo', - data: { - msg - } - }) + server.ws.send('foo', { msg }) } + }, + configureServer(server) { + server.ws.on('remote-add', ({ a, b }, client) => { + client.send('remote-add-result', { result: a + b }) + }) } } ] diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index a9e8fb639de958..40f0bb0418f365 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -30,6 +30,7 @@ const socketHost = __HMR_PORT__ const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr') const base = __BASE__ || '/' +const messageBuffer: string[] = [] function warnFailedFetch(err: Error, path: string | string[]) { if (!err.message.match('fetch')) { @@ -59,9 +60,10 @@ async function handleMessage(payload: HMRPayload) { switch (payload.type) { case 'connected': console.log(`[vite] connected.`) + sendMessageBuffer() // proxy(nginx, docker) hmr ws maybe caused timeout, // so send ping package let ws keep alive. - setInterval(() => socket.send('ping'), __HMR_TIMEOUT__) + setInterval(() => socket.send('{"type":"ping"}'), __HMR_TIMEOUT__) break case 'update': notifyListeners('vite:beforeUpdate', payload) @@ -361,6 +363,13 @@ async function fetchUpdate({ path, acceptedPath, timestamp }: Update) { } } +function sendMessageBuffer() { + if (socket.readyState === 1) { + messageBuffer.forEach((msg) => socket.send(msg)) + messageBuffer.length = 0 + } +} + interface HotModule { id: string callbacks: HotCallback[] @@ -478,6 +487,11 @@ export const createHotContext = (ownerPath: string) => { } addToMap(customListenersMap) addToMap(newListeners) + }, + + send: (event: string, data?: any) => { + messageBuffer.push(JSON.stringify({ type: 'custom', event, data })) + sendMessageBuffer() } } diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 027a715c454a74..2b59da8737029b 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -73,7 +73,11 @@ export type { TransformOptions as EsbuildTransformOptions } from 'esbuild' export type { ESBuildOptions, ESBuildTransformResult } from './plugins/esbuild' export type { Manifest, ManifestChunk } from './plugins/manifest' export type { ResolveOptions, InternalResolveOptions } from './plugins/resolve' -export type { WebSocketServer } from './server/ws' +export type { + WebSocketServer, + WebSocketClient, + WebSocketCustomListener +} from './server/ws' export type { PluginContainer } from './server/pluginContainer' export type { ModuleGraph, ModuleNode, ResolvedUrl } from './server/moduleGraph' export type { SendOptions } from './server/send' diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts index ffbfd7a56eca97..3c6875e2475c64 100644 --- a/packages/vite/src/node/server/ws.ts +++ b/packages/vite/src/node/server/ws.ts @@ -3,28 +3,80 @@ import type { Server } from 'http' import { STATUS_CODES } from 'http' import type { ServerOptions as HttpsServerOptions } from 'https' import { createServer as createHttpsServer } from 'https' -import type { ServerOptions } from 'ws' -import { WebSocketServer as WebSocket } from 'ws' -import type { WebSocket as WebSocketTypes } from 'types/ws' -import type { ErrorPayload, HMRPayload } from 'types/hmrPayload' +import type { ServerOptions, WebSocket as WebSocketRaw } from 'ws' +import { WebSocketServer as WebSocketServerRaw } from 'ws' +import type { CustomPayload, ErrorPayload, HMRPayload } from 'types/hmrPayload' import type { ResolvedConfig } from '..' import { isObject } from '../utils' import type { Socket } from 'net' export const HMR_HEADER = 'vite-hmr' +export type WebSocketCustomListener = ( + data: T, + client: WebSocketClient +) => void + export interface WebSocketServer { - on: WebSocketTypes.Server['on'] - off: WebSocketTypes.Server['off'] + /** + * Get all connected clients. + */ + clients: Set + /** + * Boardcast events to all clients + */ send(payload: HMRPayload): void + /** + * Send custom event + */ + send(event: string, payload?: CustomPayload['data']): void + /** + * Disconnect all clients and terminate the server. + */ close(): Promise + /** + * Handle custom event emitted by `import.meta.hot.send` + */ + on: WebSocketServerRaw['on'] & { + (event: string, listener: WebSocketCustomListener): void + } + /** + * Unregister event listener. + */ + off: WebSocketServerRaw['off'] & { + (event: string, listener: WebSocketCustomListener): void + } +} + +export interface WebSocketClient { + /** + * Send event to the client + */ + send(payload: HMRPayload): void + /** + * Send custom event + */ + send(event: string, payload?: CustomPayload['data']): void + /** + * The raw WebSocket instance + * @advanced + */ + socket: WebSocketRaw } +const wsServerEvents = [ + 'connection', + 'error', + 'headers', + 'listening', + 'message' +] + export function createWebSocketServer( server: Server | null, config: ResolvedConfig, httpsOptions?: HttpsServerOptions ): WebSocketServer { - let wss: WebSocket + let wss: WebSocketServerRaw let httpsServer: Server | undefined = undefined const hmr = isObject(config.server.hmr) && config.server.hmr @@ -33,9 +85,11 @@ export function createWebSocketServer( // TODO: the main server port may not have been chosen yet as it may use the next available const portsAreCompatible = !hmrPort || hmrPort === config.server.port const wsServer = hmrServer || (portsAreCompatible && server) + const customListeners = new Map>>() + const clientsMap = new WeakMap() if (wsServer) { - wss = new WebSocket({ noServer: true }) + wss = new WebSocketServerRaw({ noServer: true }) wsServer.on('upgrade', (req, socket, head) => { if (req.headers['sec-websocket-protocol'] === HMR_HEADER) { wss.handleUpgrade(req, socket as Socket, head, (ws) => { @@ -76,10 +130,22 @@ export function createWebSocketServer( } // vite dev server in middleware mode - wss = new WebSocket(websocketServerOptions) + wss = new WebSocketServerRaw(websocketServerOptions) } wss.on('connection', (socket) => { + socket.on('message', (raw) => { + if (!customListeners.size) return + let parsed: any + try { + parsed = JSON.parse(String(raw)) + } catch {} + if (!parsed || parsed.type !== 'custom' || !parsed.event) return + const listeners = customListeners.get(parsed.event) + if (!listeners?.size) return + const client = getSocketClent(socket) + listeners.forEach((listener) => listener(parsed.data, client)) + }) socket.send(JSON.stringify({ type: 'connected' })) if (bufferedError) { socket.send(JSON.stringify(bufferedError)) @@ -96,6 +162,30 @@ export function createWebSocketServer( } }) + // Provide a wrapper to the ws client so we can send messages in JSON format + // To be consistent with server.ws.send + function getSocketClent(socket: WebSocketRaw) { + if (!clientsMap.has(socket)) { + clientsMap.set(socket, { + send: (...args) => { + let payload: HMRPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1] + } + } else { + payload = args[0] + } + socket.send(JSON.stringify(payload)) + }, + socket + }) + } + return clientsMap.get(socket)! + } + // On page reloads, if a file fails to compile and returns 500, the server // sends the error payload before the client connection is established. // If we have no open clients, buffer the error and send it to the next @@ -103,9 +193,39 @@ export function createWebSocketServer( let bufferedError: ErrorPayload | null = null return { - on: wss.on.bind(wss), - off: wss.off.bind(wss), - send(payload: HMRPayload) { + on: ((event: string, fn: () => void) => { + if (wsServerEvents.includes(event)) wss.on(event, fn) + else { + if (!customListeners.has(event)) { + customListeners.set(event, new Set()) + } + customListeners.get(event)!.add(fn) + } + }) as WebSocketServer['on'], + off: ((event: string, fn: () => void) => { + if (wsServerEvents.includes(event)) { + wss.off(event, fn) + } else { + customListeners.get(event)?.delete(fn) + } + }) as WebSocketServer['off'], + + get clients() { + return new Set(Array.from(wss.clients).map(getSocketClent)) + }, + + send(...args: any[]) { + let payload: HMRPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1] + } + } else { + payload = args[0] + } + if (payload.type === 'error' && !wss.clients.size) { bufferedError = payload return diff --git a/packages/vite/types/importMeta.d.ts b/packages/vite/types/importMeta.d.ts index 56b82125381e20..1fd39b993d5142 100644 --- a/packages/vite/types/importMeta.d.ts +++ b/packages/vite/types/importMeta.d.ts @@ -59,6 +59,8 @@ interface ImportMeta { cb: (data: any) => void ): void } + + send(event: string, data?: any): void } readonly env: ImportMetaEnv