Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support multiple HMR clients on the server #15340

Merged
merged 11 commits into from
Jan 16, 2024
14 changes: 7 additions & 7 deletions docs/guide/api-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down Expand Up @@ -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
Expand All @@ -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' })
})
},
},
Expand Down Expand Up @@ -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
Expand All @@ -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!' })
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/optimizer/optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ async function createDepsOptimizer(
// reloaded.
server.moduleGraph.invalidateAll()

server.ws.send({
server.hot.send({
type: 'full-reload',
path: '*',
})
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface Plugin<A = any> extends RollupPlugin<A> {
* 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.
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/plugins/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '*',
})
Expand Down
102 changes: 92 additions & 10 deletions packages/vite/src/node/server/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'
Expand Down Expand Up @@ -51,6 +51,60 @@ 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 {
/**
* Broadcast events to all clients
*/
send(payload: HMRPayload): void
/**
* Send custom event
*/
send<T extends string>(event: T, payload?: InferCustomEventPayload<T>): void
/**
* Handle custom event emitted by `import.meta.hot.send`
*/
on<T extends string>(
event: T,
listener: (
data: InferCustomEventPayload<T>,
client: HMRBroadcasterClient,
...args: any[]
) => void,
): void
on(event: 'connection', listener: () => void): void
/**
* Unregister event listener
*/
off(event: string, listener: Function): void
/**
* Disconnect all clients, called when server is closed or restarted.
*/
close(): void | Promise<void>
}

export interface HMRBroadcaster extends Omit<HMRChannel, 'close'> {
/**
* All registered channels. Always has websocket channel.
*/
readonly channels: HMRChannel[]
/**
* Add a new third-party channel.
*/
addChannel(connection: HMRChannel): HMRBroadcaster
close(): Promise<unknown[]>
}

export function getShortName(file: string, root: string): string {
return file.startsWith(withTrailingSlash(root))
? path.posix.relative(root, file)
Expand All @@ -62,7 +116,7 @@ export async function handleHMRUpdate(
server: ViteDevServer,
configOnly: boolean,
): Promise<void> {
const { ws, config, moduleGraph } = server
const { hot, config, moduleGraph } = server
const shortFile = getShortName(file, config.root)
const fileName = path.basename(file)

Expand Down Expand Up @@ -100,7 +154,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: '*',
})
Expand Down Expand Up @@ -133,7 +187,7 @@ export async function handleHMRUpdate(
clear: true,
timestamp: true,
})
ws.send({
hot.send({
type: 'full-reload',
path: config.server.middlewareMode
? '*'
Expand All @@ -155,7 +209,7 @@ export function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws, moduleGraph }: ViteDevServer,
{ config, hot, moduleGraph }: ViteDevServer,
afterInvalidation?: boolean,
): void {
const updates: Update[] = []
Expand Down Expand Up @@ -204,7 +258,7 @@ export function updateModules(
colors.green(`page reload `) + colors.dim(file) + reason,
{ clear: !afterInvalidation, timestamp: true },
)
ws.send({
hot.send({
type: 'full-reload',
})
return
Expand All @@ -220,7 +274,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,
})
Expand Down Expand Up @@ -455,7 +509,7 @@ function isNodeWithinCircularImports(

export function handlePrunedModules(
mods: Set<ModuleNode>,
{ ws }: ViteDevServer,
{ hot }: ViteDevServer,
): void {
// update the disposed modules' hmr timestamp
// since if it's re-imported, it should re-apply side effects
Expand All @@ -465,7 +519,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),
})
Expand Down Expand Up @@ -644,3 +698,31 @@ async function readModifiedFile(file: string): Promise<string> {
return content
}
}

export function createHMRBroadcaster(): HMRBroadcaster {
const channels: HMRChannel[] = []
const broadcaster: HMRBroadcaster = {
get channels() {
return [...channels]
},
addChannel(channel) {
channels.push(channel)
return broadcaster
},
on(event: string, listener: (...args: any[]) => any) {
channels.forEach((channel) => channel.on(event, listener))
return broadcaster
},
off(event, listener) {
channels.forEach((channel) => channel.off(event, listener))
return broadcaster
},
send(...args: any[]) {
channels.forEach((channel) => channel.send(...(args as [any])))
},
close() {
return Promise.all(channels.map((channel) => channel.close()))
},
}
return broadcaster
}
21 changes: 16 additions & 5 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 so be aware of that
*/
hot: HMRBroadcaster
/**
* Rollup plugin container that can run plugin hooks on a given file
*/
Expand Down Expand Up @@ -403,7 +411,9 @@ 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 (httpServer) {
setClientErrorHandler(httpServer, config.logger)
Expand Down Expand Up @@ -437,6 +447,7 @@ export async function _createServer(
watcher,
pluginContainer: container,
ws,
hot,
moduleGraph,
resolvedUrls: null, // will be set on listen
ssrTransform(
Expand Down Expand Up @@ -558,7 +569,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(),
Expand Down Expand Up @@ -642,7 +653,7 @@ export async function _createServer(
try {
await handleHMRUpdate(file, server, configOnly)
} catch (err) {
ws.send({
hot.send({
type: 'error',
err: prepareError(err),
})
Expand Down Expand Up @@ -682,7 +693,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(
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/server/middlewares/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
11 changes: 2 additions & 9 deletions packages/vite/src/node/server/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +31,7 @@ export type WebSocketCustomListener<T> = (
client: WebSocketClient,
) => void

export interface WebSocketServer {
export interface WebSocketServer extends HMRChannel {
/**
* Listen on port and host
*/
Expand All @@ -39,14 +40,6 @@ export interface WebSocketServer {
* Get all connected clients.
*/
clients: Set<WebSocketClient>
/**
* Broadcast events to all clients
*/
send(payload: HMRPayload): void
/**
* Send custom event
*/
send<T extends string>(event: T, payload?: InferCustomEventPayload<T>): void
/**
* Disconnect all clients and terminate the server.
*/
Expand Down
Loading