Skip to content

Commit 543d87c

Browse files
authored
feat: add vite client connect events (#20978)
1 parent 185641e commit 543d87c

File tree

10 files changed

+220
-38
lines changed

10 files changed

+220
-38
lines changed

docs/guide/api-environment-plugins.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,43 @@ export default defineConfig({
227227
228228
The `applyToEnvironment` hook is called at config time, currently after `configResolved` due to projects in the ecosystem modifying the plugins in it. Environment plugins resolution may be moved before `configResolved` in the future.
229229
230+
## Application-Plugin Communication
231+
232+
`environment.hot` allows plugins to communicate with the code on the application side for a given environment. This is the equivalent of [the Client-server Communication feature](/guide/api-plugin#client-server-communication), but supports environments other than the client environment.
233+
234+
:::warning Note
235+
236+
Note that this feature is only available for environments that supports HMR.
237+
238+
:::
239+
240+
### Managing the Application Instances
241+
242+
Be aware that there might be multiple application instances running in the same environment. For example, if you multiple tabs open in the browser, each tab is a separate application instance and have a separate connection to the server.
243+
244+
When a new connection is established, a `vite:client:connect` event is emitted on the environment's `hot` instance. When the connection is closed, a `vite:client:disconnect` event is emitted.
245+
246+
Each event handler receives the `NormalizedHotChannelClient` as the second argument. The client is an object with a `send` method that can be used to send messages to that specific application instance. The client reference is always the same for the same connection, so you can keep it to track the connection.
247+
248+
### Example Usage
249+
250+
The plugin side:
251+
252+
```js
253+
configureServer(server) {
254+
server.environments.ssr.hot.on('my:greetings', (data, client) => {
255+
// do something with the data,
256+
// and optionally send a response to that application instance
257+
client.send('my:foo:reply', `Hello from server! You said: ${data}`)
258+
})
259+
260+
// broadcast a message to all application instances
261+
server.environments.ssr.hot.send('my:foo', 'Hello from server!')
262+
}
263+
```
264+
265+
The application side is same with the Client-server Communication feature. You can use the `import.meta.hot` object to send messages to the plugin.
266+
230267
## Environment in Build Hooks
231268
232269
In the same way as during dev, plugin hooks also receive the environment instance during build, replacing the `ssr` boolean.

docs/guide/api-environment-runtimes.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,27 +316,45 @@ import { createServer, RemoteEnvironmentTransport, DevEnvironment } from 'vite'
316316
function createWorkerEnvironment(name, config, context) {
317317
const worker = new Worker('./worker.js')
318318
const handlerToWorkerListener = new WeakMap()
319+
const client = {
320+
send(payload: HotPayload) {
321+
worker.postMessage(payload)
322+
},
323+
}
319324

320325
const workerHotChannel = {
321326
send: (data) => worker.postMessage(data),
322327
on: (event, handler) => {
323-
if (event === 'connection') return
328+
// client is already connected
329+
if (event === 'vite:client:connect') return
330+
if (event === 'vite:client:disconnect') {
331+
const listener = () => {
332+
handler(undefined, client)
333+
}
334+
handlerToWorkerListener.set(handler, listener)
335+
worker.on('exit', listener)
336+
return
337+
}
324338

325339
const listener = (value) => {
326340
if (value.type === 'custom' && value.event === event) {
327-
const client = {
328-
send(payload) {
329-
worker.postMessage(payload)
330-
},
331-
}
332341
handler(value.data, client)
333342
}
334343
}
335344
handlerToWorkerListener.set(handler, listener)
336345
worker.on('message', listener)
337346
},
338347
off: (event, handler) => {
339-
if (event === 'connection') return
348+
if (event === 'vite:client:connect') return
349+
if (event === 'vite:client:disconnect') {
350+
const listener = handlerToWorkerListener.get(handler)
351+
if (listener) {
352+
worker.off('exit', listener)
353+
handlerToWorkerListener.delete(handler)
354+
}
355+
return
356+
}
357+
340358
const listener = handlerToWorkerListener.get(handler)
341359
if (listener) {
342360
worker.off('message', listener)
@@ -363,6 +381,8 @@ await createServer({
363381

364382
:::
365383

384+
Make sure to implement the `vite:client:connect` / `vite:client:disconnect` events in the `on` / `off` methods when those methods exist. `vite:client:connect` event should be emitted when the connection is established, and `vite:client:disconnect` event should be emitted when the connection is closed. The `HotChannelClient` object passed to the event handler must have the same reference for the same connection.
385+
366386
A different example using an HTTP request to communicate between the runner and the server:
367387

368388
```ts

packages/vite/src/node/server/hmr.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export interface NormalizedHotChannel<Api = any> {
145145
client: NormalizedHotChannelClient,
146146
) => void,
147147
): void
148+
/**
149+
* @deprecated use `vite:client:connect` event instead
150+
*/
148151
on(event: 'connection', listener: () => void): void
149152
/**
150153
* Unregister event listener
@@ -174,6 +177,10 @@ export const normalizeHotChannel = (
174177
(data: any, client: NormalizedHotChannelClient) => void | Promise<void>,
175178
(data: any, client: HotChannelClient) => void | Promise<void>
176179
>()
180+
const normalizedClients = new WeakMap<
181+
HotChannelClient,
182+
NormalizedHotChannelClient
183+
>()
177184

178185
let invokeHandlers: InvokeMethods | undefined
179186
let listenerForInvokeHandler:
@@ -226,22 +233,24 @@ export const normalizeHotChannel = (
226233
data: any,
227234
client: HotChannelClient,
228235
) => {
229-
const normalizedClient: NormalizedHotChannelClient = {
230-
send: (...args) => {
231-
let payload: HotPayload
232-
if (typeof args[0] === 'string') {
233-
payload = {
234-
type: 'custom',
235-
event: args[0],
236-
data: args[1],
236+
if (!normalizedClients.has(client)) {
237+
normalizedClients.set(client, {
238+
send: (...args) => {
239+
let payload: HotPayload
240+
if (typeof args[0] === 'string') {
241+
payload = {
242+
type: 'custom',
243+
event: args[0],
244+
data: args[1],
245+
}
246+
} else {
247+
payload = args[0]
237248
}
238-
} else {
239-
payload = args[0]
240-
}
241-
client.send(payload)
242-
},
249+
client.send(payload)
250+
},
251+
})
243252
}
244-
fn(data, normalizedClient)
253+
fn(data, normalizedClients.get(client)!)
245254
}
246255
normalizedListenerMap.set(fn, listenerWithNormalizedClient)
247256

packages/vite/src/node/server/ws.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,20 @@ export function createWebSocketServer(
280280
})
281281
}
282282

283+
const emitCustomEvent = <T extends string>(
284+
event: T,
285+
data: InferCustomEventPayload<T>,
286+
socket: WebSocketRaw,
287+
) => {
288+
const listeners = customListeners.get(event)
289+
if (!listeners?.size) return
290+
291+
const client = getSocketClient(socket)
292+
for (const listener of listeners) {
293+
listener(data, client)
294+
}
295+
}
296+
283297
wss.on('connection', (socket) => {
284298
socket.on('message', (raw) => {
285299
if (!customListeners.size) return
@@ -288,17 +302,20 @@ export function createWebSocketServer(
288302
parsed = JSON.parse(String(raw))
289303
} catch {}
290304
if (!parsed || parsed.type !== 'custom' || !parsed.event) return
291-
const listeners = customListeners.get(parsed.event)
292-
if (!listeners?.size) return
293-
const client = getSocketClient(socket)
294-
listeners.forEach((listener) => listener(parsed.data, client))
305+
emitCustomEvent(parsed.event, parsed.data, socket)
295306
})
296307
socket.on('error', (err) => {
297308
config.logger.error(`${colors.red(`ws error:`)}\n${err.stack}`, {
298309
timestamp: true,
299310
error: err,
300311
})
301312
})
313+
socket.on('close', () => {
314+
emitCustomEvent('vite:client:disconnect', undefined, socket)
315+
})
316+
317+
emitCustomEvent('vite:client:connect', undefined, socket)
318+
302319
socket.send(JSON.stringify({ type: 'connected' }))
303320
if (bufferedError) {
304321
socket.send(JSON.stringify(bufferedError))

packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,53 @@ import type { HotChannel, HotChannelListener, HotPayload } from 'vite'
44
import { DevEnvironment } from '../../..'
55
import { createServer } from '../../../server'
66

7-
const createWorkerTransport = (w: Worker): HotChannel => {
7+
const createWorkerTransport = (worker: Worker): HotChannel => {
88
const handlerToWorkerListener = new WeakMap<
99
HotChannelListener,
1010
(value: HotPayload) => void
1111
>()
12+
const client = {
13+
send(payload: HotPayload) {
14+
worker.postMessage(payload)
15+
},
16+
}
1217

1318
return {
14-
send: (data) => w.postMessage(data),
19+
send: (data) => worker.postMessage(data),
1520
on: (event: string, handler: HotChannelListener) => {
16-
if (event === 'connection') return
21+
// client is already connected
22+
if (event === 'vite:client:connect') return
23+
if (event === 'vite:client:disconnect') {
24+
const listener = () => {
25+
handler(undefined, client)
26+
}
27+
handlerToWorkerListener.set(handler, listener)
28+
worker.on('exit', listener)
29+
return
30+
}
1731

1832
const listener = (value: HotPayload) => {
1933
if (value.type === 'custom' && value.event === event) {
20-
const client = {
21-
send(payload: HotPayload) {
22-
w.postMessage(payload)
23-
},
24-
}
2534
handler(value.data, client)
2635
}
2736
}
2837
handlerToWorkerListener.set(handler, listener)
29-
w.on('message', listener)
38+
worker.on('message', listener)
3039
},
3140
off: (event, handler: HotChannelListener) => {
32-
if (event === 'connection') return
41+
if (event === 'vite:client:connect') return
42+
if (event === 'vite:client:disconnect') {
43+
const listener = handlerToWorkerListener.get(handler)
44+
if (listener) {
45+
worker.off('exit', listener)
46+
handlerToWorkerListener.delete(handler)
47+
}
48+
return
49+
}
50+
3351
const listener = handlerToWorkerListener.get(handler)
3452
if (listener) {
35-
w.off('message', listener)
53+
worker.off('message', listener)
3654
handlerToWorkerListener.delete(handler)
3755
}
3856
},

packages/vite/src/node/ssr/runtime/serverModuleRunner.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,23 @@ export const createServerModuleRunnerTransport = (options: {
9191
return {
9292
connect({ onMessage }) {
9393
options.channel.api!.outsideEmitter.on('send', onMessage)
94+
options.channel.api!.innerEmitter.emit(
95+
'vite:client:connect',
96+
undefined,
97+
hmrClient,
98+
)
9499
onMessage({ type: 'connected' })
95100
handler = onMessage
96101
},
97102
disconnect() {
98103
if (handler) {
99104
options.channel.api!.outsideEmitter.off('send', handler)
100105
}
106+
options.channel.api!.innerEmitter.emit(
107+
'vite:client:disconnect',
108+
undefined,
109+
hmrClient,
110+
)
101111
},
102112
send(payload) {
103113
if (payload.type !== 'custom') {

packages/vite/types/customEvent.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from './hmrPayload'
77

88
export interface CustomEventMap {
9+
// client events
910
'vite:beforeUpdate': UpdatePayload
1011
'vite:afterUpdate': UpdatePayload
1112
'vite:beforePrune': PrunePayload
@@ -14,6 +15,10 @@ export interface CustomEventMap {
1415
'vite:invalidate': InvalidatePayload
1516
'vite:ws:connect': WebSocketConnectionPayload
1617
'vite:ws:disconnect': WebSocketConnectionPayload
18+
19+
// server events
20+
'vite:client:connect': undefined
21+
'vite:client:disconnect': undefined
1722
}
1823

1924
export interface WebSocketConnectionPayload {

playground/hmr-ssr/__tests__/hmr-ssr.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
test,
1111
vi,
1212
} from 'vitest'
13-
import type { InlineConfig, RunnableDevEnvironment, ViteDevServer } from 'vite'
13+
import type {
14+
InlineConfig,
15+
Plugin,
16+
RunnableDevEnvironment,
17+
ViteDevServer,
18+
} from 'vite'
1419
import { createRunnableDevEnvironment, createServer } from 'vite'
1520
import type { ModuleRunner } from 'vite/module-runner'
1621
import {
@@ -47,12 +52,31 @@ const updated = (file: string, via?: string) => {
4752

4853
if (!isBuild) {
4954
describe('hmr works correctly', () => {
55+
const hotEventCounts = { connect: 0, disconnect: 0 }
56+
5057
beforeAll(async () => {
51-
await setupModuleRunner('/hmr.ts')
58+
function hotEventsPlugin(): Plugin {
59+
return {
60+
name: 'hot-events',
61+
configureServer(server) {
62+
server.environments.ssr.hot.on(
63+
'vite:client:connect',
64+
() => hotEventCounts.connect++,
65+
)
66+
server.environments.ssr.hot.on(
67+
'vite:client:disconnect',
68+
() => hotEventCounts.disconnect++,
69+
)
70+
},
71+
}
72+
}
73+
74+
await setupModuleRunner('/hmr.ts', { plugins: [hotEventsPlugin()] })
5275
})
5376

5477
test('should connect', async () => {
5578
expect(clientLogs).toContain('[vite] connected.')
79+
expect(hotEventCounts).toStrictEqual({ connect: 1, disconnect: 0 })
5680
})
5781

5882
test('self accept', async () => {

playground/hmr/__tests__/hmr.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ if (!isBuild) {
2929
browserLogs.length = 0
3030
})
3131

32+
const fetchHotEvents = async (): Promise<{
33+
connectCount: number
34+
disconnectCount: number
35+
}> => {
36+
const res = await fetch(viteTestUrl + '/hot-events-counts')
37+
return res.json()
38+
}
39+
test('hot events', async () => {
40+
expect(await fetchHotEvents()).toStrictEqual({
41+
connectCount: 1,
42+
disconnectCount: 0,
43+
})
44+
await untilBrowserLogAfter(() => page.reload(), [/connected/])
45+
expect(await fetchHotEvents()).toStrictEqual({
46+
connectCount: 2,
47+
disconnectCount: 1,
48+
})
49+
})
50+
3251
test('self accept', async () => {
3352
const el = await page.$('.app')
3453
await untilBrowserLogAfter(

0 commit comments

Comments
 (0)