From bf1e9c2fd7b05f84d05e59f72b3fc26ca22807bb Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 16 Jan 2024 20:49:24 +0100 Subject: [PATCH] feat: support multiple HMR clients on the server (#15340) --- docs/guide/api-plugin.md | 14 +- packages/vite/src/node/optimizer/optimizer.ts | 2 +- packages/vite/src/node/plugin.ts | 2 +- packages/vite/src/node/plugins/esbuild.ts | 2 +- packages/vite/src/node/server/hmr.ts | 133 ++++++++++++++++-- packages/vite/src/node/server/index.ts | 38 +++-- .../vite/src/node/server/middlewares/error.ts | 2 +- packages/vite/src/node/server/ws.ts | 12 +- playground/hmr/vite.config.ts | 8 +- playground/vitestGlobalSetup.ts | 9 +- playground/vitestSetup.ts | 15 +- 11 files changed, 179 insertions(+), 58 deletions(-) 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 477f42915e9a93..0ca16143d7b86e 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -473,7 +473,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 ff05559611a0ec..a0ee622c34f26e 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 isConfig = file === config.configFile @@ -98,7 +162,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: '*', }) @@ -131,7 +195,7 @@ export async function handleHMRUpdate( clear: true, timestamp: true, }) - ws.send({ + hot.send({ type: 'full-reload', path: config.server.middlewareMode ? '*' @@ -153,7 +217,7 @@ export function updateModules( file: string, modules: ModuleNode[], timestamp: number, - { config, ws, moduleGraph }: ViteDevServer, + { config, hot, moduleGraph }: ViteDevServer, afterInvalidation?: boolean, ): void { const updates: Update[] = [] @@ -202,7 +266,7 @@ export function updateModules( colors.green(`page reload `) + colors.dim(file) + reason, { clear: !afterInvalidation, timestamp: true }, ) - ws.send({ + hot.send({ type: 'full-reload', }) return @@ -218,7 +282,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, }) @@ -453,7 +517,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 @@ -463,7 +527,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), }) @@ -638,3 +702,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 2501e1fde26170..c7c5270067adbf 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, @@ -71,8 +70,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, @@ -229,8 +229,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 */ @@ -376,12 +384,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') @@ -400,7 +408,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) @@ -438,6 +451,7 @@ export async function _createServer( watcher, pluginContainer: container, ws, + hot, moduleGraph, resolvedUrls: null, // will be set on listen ssrTransform( @@ -556,7 +570,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(), @@ -651,7 +665,7 @@ export async function _createServer( try { await handleHMRUpdate(file, server, configOnly) } catch (err) { - ws.send({ + hot.send({ type: 'error', err: prepareError(err), }) @@ -691,7 +705,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( @@ -834,7 +848,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) @@ -843,8 +857,8 @@ export async function _createServer( return listen(port, ...args) }) as any } else { - if (options.ws) { - ws.listen() + if (options.hotListen) { + hot.listen() } await initServer() } @@ -995,7 +1009,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, @@ -1028,7 +1042,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 3643738733d445..b290ff60a3140d 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -14,12 +14,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 }) }) }, @@ -47,7 +47,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 + } +}