Skip to content

Commit bf1e9c2

Browse files
authored
feat: support multiple HMR clients on the server (#15340)
1 parent dfcb83d commit bf1e9c2

File tree

11 files changed

+179
-58
lines changed

11 files changed

+179
-58
lines changed

docs/guide/api-plugin.md

+7-7
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,11 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo
423423

424424
- Filter and narrow down the affected module list so that the HMR is more accurate.
425425

426-
- Return an empty array and perform complete custom HMR handling by sending custom events to the client:
426+
- 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):
427427

428428
```js
429429
handleHotUpdate({ server }) {
430-
server.ws.send({
430+
server.hot.send({
431431
type: 'custom',
432432
event: 'special-update',
433433
data: {}
@@ -534,7 +534,7 @@ Since Vite 2.9, we provide some utilities for plugins to help handle the communi
534534

535535
### Server to Client
536536

537-
On the plugin side, we could use `server.ws.send` to broadcast events to all the clients:
537+
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:
538538

539539
```js
540540
// vite.config.js
@@ -544,8 +544,8 @@ export default defineConfig({
544544
// ...
545545
configureServer(server) {
546546
// Example: wait for a client to connect before sending a message
547-
server.ws.on('connection', () => {
548-
server.ws.send('my:greetings', { msg: 'hello' })
547+
server.hot.on('connection', () => {
548+
server.hot.send('my:greetings', { msg: 'hello' })
549549
})
550550
},
551551
},
@@ -579,7 +579,7 @@ if (import.meta.hot) {
579579
}
580580
```
581581

582-
Then use `server.ws.on` and listen to the events on the server side:
582+
Then use `server.hot.on` (since Vite 5.1) or `server.ws.on` and listen to the events on the server side:
583583

584584
```js
585585
// vite.config.js
@@ -588,7 +588,7 @@ export default defineConfig({
588588
{
589589
// ...
590590
configureServer(server) {
591-
server.ws.on('my:from-client', (data, client) => {
591+
server.hot.on('my:from-client', (data, client) => {
592592
console.log('Message from client:', data.msg) // Hey!
593593
// reply only to the client (if needed)
594594
client.send('my:ack', { msg: 'Hi! I got your message!' })

packages/vite/src/node/optimizer/optimizer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ async function createDepsOptimizer(
473473
// reloaded.
474474
server.moduleGraph.invalidateAll()
475475

476-
server.ws.send({
476+
server.hot.send({
477477
type: 'full-reload',
478478
path: '*',
479479
})

packages/vite/src/node/plugin.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export interface Plugin<A = any> extends RollupPlugin<A> {
129129
* the descriptors.
130130
*
131131
* - The hook can also return an empty array and then perform custom updates
132-
* by sending a custom hmr payload via server.ws.send().
132+
* by sending a custom hmr payload via server.hot.send().
133133
*
134134
* - If the hook doesn't return a value, the hmr update will be performed as
135135
* normal.

packages/vite/src/node/plugins/esbuild.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ async function reloadOnTsconfigChange(changedFile: string) {
491491
// server may not be available if vite config is updated at the same time
492492
if (server) {
493493
// force full reload
494-
server.ws.send({
494+
server.hot.send({
495495
type: 'full-reload',
496496
path: '*',
497497
})

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

+123-10
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fsp from 'node:fs/promises'
22
import path from 'node:path'
33
import type { Server } from 'node:http'
44
import colors from 'picocolors'
5-
import type { Update } from 'types/hmrPayload'
5+
import type { CustomPayload, HMRPayload, Update } from 'types/hmrPayload'
66
import type { RollupError } from 'rollup'
77
import { CLIENT_DIR } from '../constants'
88
import {
@@ -12,7 +12,7 @@ import {
1212
withTrailingSlash,
1313
wrapId,
1414
} from '../utils'
15-
import type { ViteDevServer } from '..'
15+
import type { InferCustomEventPayload, ViteDevServer } from '..'
1616
import { isCSSRequest } from '../plugins/css'
1717
import { getAffectedGlobModules } from '../plugins/importMetaGlob'
1818
import { isExplicitImportRequired } from '../plugins/importAnalysis'
@@ -35,6 +35,8 @@ export interface HmrOptions {
3535
timeout?: number
3636
overlay?: boolean
3737
server?: Server
38+
/** @internal */
39+
channels?: HMRChannel[]
3840
}
3941

4042
export interface HmrContext {
@@ -51,6 +53,68 @@ interface PropagationBoundary {
5153
isWithinCircularImport: boolean
5254
}
5355

56+
export interface HMRBroadcasterClient {
57+
/**
58+
* Send event to the client
59+
*/
60+
send(payload: HMRPayload): void
61+
/**
62+
* Send custom event
63+
*/
64+
send(event: string, payload?: CustomPayload['data']): void
65+
}
66+
67+
export interface HMRChannel {
68+
/**
69+
* Unique channel name
70+
*/
71+
name: string
72+
/**
73+
* Broadcast events to all clients
74+
*/
75+
send(payload: HMRPayload): void
76+
/**
77+
* Send custom event
78+
*/
79+
send<T extends string>(event: T, payload?: InferCustomEventPayload<T>): void
80+
/**
81+
* Handle custom event emitted by `import.meta.hot.send`
82+
*/
83+
on<T extends string>(
84+
event: T,
85+
listener: (
86+
data: InferCustomEventPayload<T>,
87+
client: HMRBroadcasterClient,
88+
...args: any[]
89+
) => void,
90+
): void
91+
on(event: 'connection', listener: () => void): void
92+
/**
93+
* Unregister event listener
94+
*/
95+
off(event: string, listener: Function): void
96+
/**
97+
* Start listening for messages
98+
*/
99+
listen(): void
100+
/**
101+
* Disconnect all clients, called when server is closed or restarted.
102+
*/
103+
close(): void
104+
}
105+
106+
export interface HMRBroadcaster extends Omit<HMRChannel, 'close' | 'name'> {
107+
/**
108+
* All registered channels. Always has websocket channel.
109+
*/
110+
readonly channels: HMRChannel[]
111+
/**
112+
* Add a new third-party channel.
113+
*/
114+
addChannel(connection: HMRChannel): HMRBroadcaster
115+
close(): Promise<unknown[]>
116+
}
117+
54118
export function getShortName(file: string, root: string): string {
55119
return file.startsWith(withTrailingSlash(root))
56120
? path.posix.relative(root, file)
@@ -62,7 +126,7 @@ export async function handleHMRUpdate(
62126
server: ViteDevServer,
63127
configOnly: boolean,
64128
): Promise<void> {
65-
const { ws, config, moduleGraph } = server
129+
const { hot, config, moduleGraph } = server
66130
const shortFile = getShortName(file, config.root)
67131

68132
const isConfig = file === config.configFile
@@ -98,7 +162,7 @@ export async function handleHMRUpdate(
98162

99163
// (dev only) the client itself cannot be hot updated.
100164
if (file.startsWith(withTrailingSlash(normalizedClientDir))) {
101-
ws.send({
165+
hot.send({
102166
type: 'full-reload',
103167
path: '*',
104168
})
@@ -131,7 +195,7 @@ export async function handleHMRUpdate(
131195
clear: true,
132196
timestamp: true,
133197
})
134-
ws.send({
198+
hot.send({
135199
type: 'full-reload',
136200
path: config.server.middlewareMode
137201
? '*'
@@ -153,7 +217,7 @@ export function updateModules(
153217
file: string,
154218
modules: ModuleNode[],
155219
timestamp: number,
156-
{ config, ws, moduleGraph }: ViteDevServer,
220+
{ config, hot, moduleGraph }: ViteDevServer,
157221
afterInvalidation?: boolean,
158222
): void {
159223
const updates: Update[] = []
@@ -202,7 +266,7 @@ export function updateModules(
202266
colors.green(`page reload `) + colors.dim(file) + reason,
203267
{ clear: !afterInvalidation, timestamp: true },
204268
)
205-
ws.send({
269+
hot.send({
206270
type: 'full-reload',
207271
})
208272
return
@@ -218,7 +282,7 @@ export function updateModules(
218282
colors.dim([...new Set(updates.map((u) => u.path))].join(', ')),
219283
{ clear: !afterInvalidation, timestamp: true },
220284
)
221-
ws.send({
285+
hot.send({
222286
type: 'update',
223287
updates,
224288
})
@@ -453,7 +517,7 @@ function isNodeWithinCircularImports(
453517

454518
export function handlePrunedModules(
455519
mods: Set<ModuleNode>,
456-
{ ws }: ViteDevServer,
520+
{ hot }: ViteDevServer,
457521
): void {
458522
// update the disposed modules' hmr timestamp
459523
// since if it's re-imported, it should re-apply side effects
@@ -463,7 +527,7 @@ export function handlePrunedModules(
463527
mod.lastHMRTimestamp = t
464528
debugHmr?.(`[dispose] ${colors.dim(mod.file)}`)
465529
})
466-
ws.send({
530+
hot.send({
467531
type: 'prune',
468532
paths: [...mods].map((m) => m.url),
469533
})
@@ -638,3 +702,52 @@ async function readModifiedFile(file: string): Promise<string> {
638702
return content
639703
}
640704
}
705+
706+
export function createHMRBroadcaster(): HMRBroadcaster {
707+
const channels: HMRChannel[] = []
708+
const readyChannels = new WeakSet<HMRChannel>()
709+
const broadcaster: HMRBroadcaster = {
710+
get channels() {
711+
return [...channels]
712+
},
713+
addChannel(channel) {
714+
if (channels.some((c) => c.name === channel.name)) {
715+
throw new Error(`HMR channel "${channel.name}" is already defined.`)
716+
}
717+
channels.push(channel)
718+
return broadcaster
719+
},
720+
on(event: string, listener: (...args: any[]) => any) {
721+
// emit connection event only when all channels are ready
722+
if (event === 'connection') {
723+
// make a copy so we don't wait for channels that might be added after this is triggered
724+
const channels = this.channels
725+
channels.forEach((channel) =>
726+
channel.on('connection', () => {
727+
readyChannels.add(channel)
728+
if (channels.every((c) => readyChannels.has(c))) {
729+
listener()
730+
}
731+
}),
732+
)
733+
return
734+
}
735+
channels.forEach((channel) => channel.on(event, listener))
736+
return
737+
},
738+
off(event, listener) {
739+
channels.forEach((channel) => channel.off(event, listener))
740+
return
741+
},
742+
send(...args: any[]) {
743+
channels.forEach((channel) => channel.send(...(args as [any])))
744+
},
745+
listen() {
746+
channels.forEach((channel) => channel.listen())
747+
},
748+
close() {
749+
return Promise.all(channels.map((channel) => channel.close()))
750+
},
751+
}
752+
return broadcaster
753+
}

0 commit comments

Comments
 (0)