From 78dc4902ffef7f316e84d21648b04dc62dd0ae0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0=20/=20green?= Date: Thu, 7 Nov 2024 11:24:48 +0900 Subject: [PATCH] feat: use a single transport for fetchModule and HMR support (#18362) Co-authored-by: Vladimir --- docs/guide/api-environment-instances.md | 2 +- docs/guide/api-environment-runtimes.md | 167 +++++----- packages/vite/rollup.config.ts | 3 +- packages/vite/src/client/client.ts | 190 +++++------ packages/vite/src/module-runner/hmrHandler.ts | 3 +- packages/vite/src/module-runner/index.ts | 14 +- packages/vite/src/module-runner/runner.ts | 47 ++- .../vite/src/module-runner/runnerTransport.ts | 73 ---- packages/vite/src/module-runner/types.ts | 85 +---- packages/vite/src/node/config.ts | 18 +- packages/vite/src/node/index.ts | 3 +- packages/vite/src/node/optimizer/optimizer.ts | 7 +- .../server/__tests__/pluginContainer.spec.ts | 2 +- packages/vite/src/node/server/environment.ts | 33 +- .../src/node/server/environmentTransport.ts | 38 --- .../environments/runnableEnvironment.ts | 8 +- packages/vite/src/node/server/hmr.ts | 287 +++++++++++++--- packages/vite/src/node/server/ws.ts | 196 +++++------ .../ssr/runtime/__tests__/fixtures/worker.mjs | 27 +- .../__tests__/server-worker-runner.spec.ts | 45 ++- .../node/ssr/runtime/serverHmrConnector.ts | 64 ---- .../node/ssr/runtime/serverModuleRunner.ts | 67 +++- packages/vite/src/node/ssr/ssrModuleLoader.ts | 9 +- packages/vite/src/node/utils.ts | 15 - packages/vite/src/shared/hmr.ts | 44 +-- packages/vite/src/shared/invokeMethods.ts | 85 +++++ .../vite/src/shared/moduleRunnerTransport.ts | 315 ++++++++++++++++++ packages/vite/src/shared/utils.ts | 15 + packages/vite/types/hmrPayload.d.ts | 5 + playground/hmr-ssr/__tests__/hmr-ssr.spec.ts | 7 +- playground/ssr/__tests__/ssr.spec.ts | 29 +- 31 files changed, 1156 insertions(+), 747 deletions(-) delete mode 100644 packages/vite/src/module-runner/runnerTransport.ts delete mode 100644 packages/vite/src/node/server/environmentTransport.ts delete mode 100644 packages/vite/src/node/ssr/runtime/serverHmrConnector.ts create mode 100644 packages/vite/src/shared/invokeMethods.ts create mode 100644 packages/vite/src/shared/moduleRunnerTransport.ts diff --git a/docs/guide/api-environment-instances.md b/docs/guide/api-environment-instances.md index 52768a9f1..81b326c1e 100644 --- a/docs/guide/api-environment-instances.md +++ b/docs/guide/api-environment-instances.md @@ -41,7 +41,7 @@ class DevEnvironment { * Communication channel to send and receive messages from the * associated module runner in the target runtime. */ - hot: HotChannel | null + hot: NormalizedHotChannel /** * Graph of module nodes, with the imported relationship between * processed modules and the cached result of the processed code. diff --git a/docs/guide/api-environment-runtimes.md b/docs/guide/api-environment-runtimes.md index 57f73c15d..1e8736a16 100644 --- a/docs/guide/api-environment-runtimes.md +++ b/docs/guide/api-environment-runtimes.md @@ -29,7 +29,8 @@ function createWorkedEnvironment( dev: { createEnvironment(name, config) { return createWorkerdDevEnvironment(name, config, { - hot: customHotChannel(), + hot: true, + transport: customHotChannel(), }) }, }, @@ -82,29 +83,26 @@ A Vite Module Runner allows running any code by processing it with Vite plugins One of the goals of this feature is to provide a customizable API to process and run code. Users can create new environment factories using the exposed primitives. ```ts -import { DevEnvironment, RemoteEnvironmentTransport } from 'vite' +import { DevEnvironment, HotChannel } from 'vite' function createWorkerdDevEnvironment( name: string, config: ResolvedConfig, context: DevEnvironmentContext ) { - const hot = /* ... */ const connection = /* ... */ - const transport = new RemoteEnvironmentTransport({ + const transport: HotChannel = { + on: (listener) => { connection.on('message', listener) }, send: (data) => connection.send(data), - onMessage: (listener) => connection.on('message', listener), - }) + } const workerdDevEnvironment = new DevEnvironment(name, config, { options: { resolve: { conditions: ['custom'] }, ...context.options, }, - hot, - remoteRunner: { - transport, - }, + hot: true, + transport, }) return workerdDevEnvironment } @@ -152,13 +150,12 @@ Module runner exposes `import` method. When Vite server triggers `full-reload` H ```js import { ModuleRunner, ESModulesEvaluator } from 'vite/module-runner' -import { root, fetchModule } from './rpc-implementation.js' +import { root, transport } from './rpc-implementation.js' const moduleRunner = new ModuleRunner( { root, - fetchModule, - // you can also provide hmr.connection to support HMR + transport, }, new ESModulesEvaluator(), ) @@ -177,7 +174,7 @@ export interface ModuleRunnerOptions { /** * A set of methods to communicate with the server. */ - transport: RunnerTransport + transport: ModuleRunnerTransport /** * Configure how source maps are resolved. * Prefers `node` if `process.setSourceMapsEnabled` is available. @@ -197,10 +194,6 @@ export interface ModuleRunnerOptions { hmr?: | false | { - /** - * Configure how HMR communicates between client and server. - */ - connection: ModuleRunnerHMRConnection /** * Configure HMR logger. */ @@ -245,59 +238,91 @@ export interface ModuleEvaluator { Vite exports `ESModulesEvaluator` that implements this interface by default. It uses `new AsyncFunction` to evaluate code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by the `ESModulesEvaluator`. Custom evaluators will not add additional lines. -## RunnerTransport +## `ModuleRunnerTransport` **Type Signature:** ```ts -interface RunnerTransport { - /** - * A method to get the information about the module. - */ - fetchModule: FetchFunction +interface ModuleRunnerTransport { + connect?(handlers: ModuleRunnerTransportHandlers): Promise | void + disconnect?(): Promise | void + send?(data: HotPayload): Promise | void + invoke?( + data: HotPayload, + ): Promise<{ /** result */ r: any } | { /** error */ e: any }> + timeout?: number } ``` -Transport object that communicates with the environment via an RPC or by directly calling the function. By default, you need to pass an object with `fetchModule` method - it can use any type of RPC inside of it, but Vite also exposes bidirectional transport interface via a `RemoteRunnerTransport` class to make the configuration easier. You need to couple it with the `RemoteEnvironmentTransport` instance on the server like in this example where module runner is created in the worker thread: +Transport object that communicates with the environment via an RPC or by directly calling the function. When `invoke` method is not implemented, the `send` method and `connect` method is required to be implemented. Vite will construct the `invoke` internally. + +You need to couple it with the `HotChannel` instance on the server like in this example where module runner is created in the worker thread: ::: code-group -```ts [worker.js] +```js [worker.js] import { parentPort } from 'node:worker_threads' import { fileURLToPath } from 'node:url' -import { - ESModulesEvaluator, - ModuleRunner, - RemoteRunnerTransport, -} from 'vite/module-runner' +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +/** @type {import('vite/module-runner').ModuleRunnerTransport} */ +const transport = { + connect({ onMessage, onDisconnection }) { + parentPort.on('message', onMessage) + parentPort.on('close', onDisconnection) + }, + send(data) { + parentPort.postMessage(data) + }, +} const runner = new ModuleRunner( { root: fileURLToPath(new URL('./', import.meta.url)), - transport: new RemoteRunnerTransport({ - send: (data) => parentPort.postMessage(data), - onMessage: (listener) => parentPort.on('message', listener), - timeout: 5000, - }), + transport, }, new ESModulesEvaluator(), ) ``` -```ts [server.js] +```js [server.js] import { BroadcastChannel } from 'node:worker_threads' import { createServer, RemoteEnvironmentTransport, DevEnvironment } from 'vite' function createWorkerEnvironment(name, config, context) { const worker = new Worker('./worker.js') - return new DevEnvironment(name, config, { - hot: /* custom hot channel */, - remoteRunner: { - transport: new RemoteEnvironmentTransport({ - send: (data) => worker.postMessage(data), - onMessage: (listener) => worker.on('message', listener), - }), + const handlerToWorkerListener = new WeakMap() + + const workerHotChannel = { + send: (data) => w.postMessage(data), + on: (event, handler) => { + if (event === 'connection') return + + const listener = (value) => { + if (value.type === 'custom' && value.event === event) { + const client = { + send(payload) { + w.postMessage(payload) + }, + } + handler(value.data, client) + } + } + handlerToWorkerListener.set(handler, listener) + w.on('message', listener) }, + off: (event, handler) => { + if (event === 'connection') return + const listener = handlerToWorkerListener.get(handler) + if (listener) { + w.off('message', listener) + handlerToWorkerListener.delete(handler) + } + }, + } + + return new DevEnvironment(name, config, { + transport: workerHotChannel, }) } @@ -314,7 +339,7 @@ await createServer({ ::: -`RemoteRunnerTransport` and `RemoteEnvironmentTransport` are meant to be used together, but you don't have to use them at all. You can define your own function to communicate between the runner and the server. For example, if you connect to the environment via an HTTP request, you can call `fetch().json()` in `fetchModule` function: +A different example using an HTTP request to communicate between the runner and the server: ```ts import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' @@ -323,10 +348,11 @@ export const runner = new ModuleRunner( { root: fileURLToPath(new URL('./', import.meta.url)), transport: { - async fetchModule(id, importer) { - const response = await fetch( - `http://my-vite-server/fetch?id=${id}&importer=${importer}`, - ) + async invoke(data) { + const response = await fetch(`http://my-vite-server/invoke`, { + method: 'POST', + body: JSON.stringify(data), + }) return response.json() }, }, @@ -337,37 +363,22 @@ export const runner = new ModuleRunner( await runner.import('/entry.js') ``` -## ModuleRunnerHMRConnection - -**Type Signature:** +In this case, the `handleInvoke` method in the `NormalizedHotChannel` can be used: ```ts -export interface ModuleRunnerHMRConnection { - /** - * Checked before sending messages to the server. - */ - isReady(): boolean - /** - * Send a message to the server. - */ - send(payload: HotPayload): void - /** - * Configure how HMR is handled when this connection triggers an update. - * This method expects that the connection will start listening for HMR - * updates and call this callback when it's received. - */ - onUpdate(callback: (payload: HotPayload) => void): void -} +const customEnvironment = new DevEnvironment(name, config, context) + +server.onRequest((request: Request) => { + const url = new URL(request.url) + if (url.pathname === '/invoke') { + const payload = (await request.json()) as HotPayload + const result = customEnvironment.hot.handleInvoke(payload) + return new Response(JSON.stringify(result)) + } + return Response.error() +}) ``` -This interface defines how HMR communication is established. Vite exports `ServerHMRConnector` from the main entry point to support HMR during Vite SSR. The `isReady` and `send` methods are usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). +But note that for HMR support, `send` and `connect` methods are required. The `send` method is usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). -`onUpdate` is called only once when the new module runner is initiated. It passed down a method that should be called when connection triggers the HMR event. The implementation depends on the type of connection (as an example, it can be `WebSocket`/`EventEmitter`/`MessageChannel`), but it usually looks something like this: - -```js -function onUpdate(callback) { - this.connection.on('hmr', (event) => callback(event.data)) -} -``` - -The callback is queued and it will wait for the current update to be resolved before processing the next update. Unlike the browser implementation, HMR updates in a module runner will wait until all listeners (like, `vite:beforeUpdate`/`vite:beforeFullReload`) are finished before updating the modules. +Vite exports `createServerHotChannel` from the main entry point to support HMR during Vite SSR. diff --git a/packages/vite/rollup.config.ts b/packages/vite/rollup.config.ts index c8137469a..b7cf437aa 100644 --- a/packages/vite/rollup.config.ts +++ b/packages/vite/rollup.config.ts @@ -32,6 +32,7 @@ const clientConfig = defineConfig({ input: path.resolve(__dirname, 'src/client/client.ts'), external: ['@vite/env'], plugins: [ + nodeResolve({ preferBuiltins: true }), esbuild({ tsconfig: path.resolve(__dirname, 'src/client/tsconfig.json'), }), @@ -186,7 +187,7 @@ const moduleRunnerConfig = defineConfig({ ], plugins: [ ...createSharedNodePlugins({ esbuildOptions: { minifySyntax: true } }), - bundleSizeLimit(50), + bundleSizeLimit(53), ], }) diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 046110caf..ecf57cb07 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -2,6 +2,10 @@ import type { ErrorPayload, HotPayload } from 'types/hmrPayload' import type { ViteHotContext } from 'types/hot' import type { InferCustomEventPayload } from 'types/customEvent' import { HMRClient, HMRContext } from '../shared/hmr' +import { + createWebSocketModuleRunnerTransport, + normalizeModuleRunnerTransport, +} from '../shared/moduleRunnerTransport' import { ErrorOverlay, overlayId } from './overlay' import '@vite/env' @@ -30,96 +34,75 @@ const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${ }${__HMR_BASE__}` const directSocketHost = __HMR_DIRECT_TARGET__ const base = __BASE__ || '/' +const hmrTimeout = __HMR_TIMEOUT__ -let socket: WebSocket -try { - let fallback: (() => void) | undefined - // only use fallback when port is inferred to prevent confusion - if (!hmrPort) { - fallback = () => { - // fallback to connecting directly to the hmr server - // for servers which does not support proxying websocket - socket = setupWebSocket(socketProtocol, directSocketHost, () => { - const currentScriptHostURL = new URL(import.meta.url) - const currentScriptHost = - currentScriptHostURL.host + - currentScriptHostURL.pathname.replace(/@vite\/client$/, '') - console.error( - '[vite] failed to connect to websocket.\n' + - 'your current setup:\n' + - ` (browser) ${currentScriptHost} <--[HTTP]--> ${serverHost} (server)\n` + - ` (browser) ${socketHost} <--[WebSocket (failing)]--> ${directSocketHost} (server)\n` + - 'Check out your Vite / network configuration and https://vite.dev/config/server-options.html#server-hmr .', - ) - }) - socket.addEventListener( - 'open', - () => { - console.info( - '[vite] Direct websocket connection fallback. Check out https://vite.dev/config/server-options.html#server-hmr to remove the previous connection error.', - ) - }, - { once: true }, - ) +const transport = normalizeModuleRunnerTransport( + (() => { + let wsTransport = createWebSocketModuleRunnerTransport({ + createConnection: () => + new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr'), + pingInterval: hmrTimeout, + }) + + return { + async connect(handlers) { + try { + await wsTransport.connect(handlers) + } catch (e) { + // only use fallback when port is inferred and was not connected before to prevent confusion + if (!hmrPort) { + wsTransport = createWebSocketModuleRunnerTransport({ + createConnection: () => + new WebSocket( + `${socketProtocol}://${directSocketHost}`, + 'vite-hmr', + ), + pingInterval: hmrTimeout, + }) + try { + await wsTransport.connect(handlers) + console.info( + '[vite] Direct websocket connection fallback. Check out https://vite.dev/config/server-options.html#server-hmr to remove the previous connection error.', + ) + } catch (e) { + if ( + e instanceof Error && + e.message.includes('WebSocket closed without opened.') + ) { + const currentScriptHostURL = new URL(import.meta.url) + const currentScriptHost = + currentScriptHostURL.host + + currentScriptHostURL.pathname.replace(/@vite\/client$/, '') + console.error( + '[vite] failed to connect to websocket.\n' + + 'your current setup:\n' + + ` (browser) ${currentScriptHost} <--[HTTP]--> ${serverHost} (server)\n` + + ` (browser) ${socketHost} <--[WebSocket (failing)]--> ${directSocketHost} (server)\n` + + 'Check out your Vite / network configuration and https://vite.dev/config/server-options.html#server-hmr .', + ) + } + } + return + } + console.error(`[vite] failed to connect to websocket (${e}). `) + throw e + } + }, + async disconnect() { + await wsTransport.disconnect() + }, + send(data) { + wsTransport.send(data) + }, } - } + })(), +) - socket = setupWebSocket(socketProtocol, socketHost, fallback) -} catch (error) { - console.error(`[vite] failed to connect to websocket (${error}). `) -} - -function setupWebSocket( - protocol: string, - hostAndPath: string, - onCloseWithoutOpen?: () => void, -) { - const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr') - let isOpened = false - - socket.addEventListener( - 'open', - () => { - isOpened = true - notifyListeners('vite:ws:connect', { webSocket: socket }) - }, - { once: true }, - ) - - // Listen for messages - socket.addEventListener('message', async ({ data }) => { - handleMessage(JSON.parse(data)) +let willUnload = false +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + willUnload = true }) - - let willUnload = false - window.addEventListener( - 'beforeunload', - () => { - willUnload = true - }, - { once: true }, - ) - - // ping server - socket.addEventListener('close', async () => { - // ignore close caused by top-level navigation - if (willUnload) return - - if (!isOpened && onCloseWithoutOpen) { - onCloseWithoutOpen() - return - } - - notifyListeners('vite:ws:disconnect', { webSocket: socket }) - - if (hasDocument) { - console.log(`[vite] server connection lost. Polling for restart...`) - await waitForSuccessfulPing(protocol, hostAndPath) - location.reload() - } - }) - - return socket } function cleanUrl(pathname: string): string { @@ -150,10 +133,7 @@ const hmrClient = new HMRClient( error: (err) => console.error('[vite]', err), debug: (...msg) => console.debug('[vite]', ...msg), }, - { - isReady: () => socket && socket.readyState === 1, - send: (payload) => socket.send(JSON.stringify(payload)), - }, + transport, async function importUpdatedModule({ acceptedPath, timestamp, @@ -181,19 +161,12 @@ const hmrClient = new HMRClient( return await importPromise }, ) +transport.connect!(handleMessage) async function handleMessage(payload: HotPayload) { switch (payload.type) { case 'connected': console.debug(`[vite] connected.`) - hmrClient.messenger.flush() - // proxy(nginx, docker) hmr ws maybe caused timeout, - // so send ping package let ws keep alive. - setInterval(() => { - if (socket.readyState === socket.OPEN) { - socket.send('{"type":"ping"}') - } - }, __HMR_TIMEOUT__) break case 'update': notifyListeners('vite:beforeUpdate', payload) @@ -264,6 +237,14 @@ async function handleMessage(payload: HotPayload) { break case 'custom': { notifyListeners(payload.event, payload.data) + if (payload.event === 'vite:ws:disconnect') { + if (hasDocument && !willUnload) { + console.log(`[vite] server connection lost. Polling for restart...`) + const socket = payload.data.webSocket as WebSocket + await waitForSuccessfulPing(socket.url) + location.reload() + } + } break } case 'full-reload': @@ -305,6 +286,8 @@ async function handleMessage(payload: HotPayload) { } break } + case 'ping': // noop + break default: { const check: never = payload return check @@ -336,16 +319,9 @@ function hasErrorOverlay() { return document.querySelectorAll(overlayId).length } -async function waitForSuccessfulPing( - socketProtocol: string, - hostAndPath: string, - ms = 1000, -) { +async function waitForSuccessfulPing(socketUrl: string, ms = 1000) { async function ping() { - const socket = new WebSocket( - `${socketProtocol}://${hostAndPath}`, - 'vite-ping', - ) + const socket = new WebSocket(socketUrl, 'vite-ping') return new Promise((resolve) => { function onOpen() { resolve(true) diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index 34946e337..bedfc7198 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -19,7 +19,6 @@ export async function handleHotPayload( switch (payload.type) { case 'connected': hmrClient.logger.debug(`connected.`) - hmrClient.messenger.flush() break case 'update': await hmrClient.notifyListeners('vite:beforeUpdate', payload) @@ -73,6 +72,8 @@ export async function handleHotPayload( ) break } + case 'ping': // noop + break default: { const check: never = payload return check diff --git a/packages/vite/src/module-runner/index.ts b/packages/vite/src/module-runner/index.ts index 7130795f8..fab452f46 100644 --- a/packages/vite/src/module-runner/index.ts +++ b/packages/vite/src/module-runner/index.ts @@ -3,19 +3,21 @@ export { EvaluatedModules, type EvaluatedModuleNode } from './evaluatedModules' export { ModuleRunner } from './runner' export { ESModulesEvaluator } from './esmEvaluator' -export { RemoteRunnerTransport } from './runnerTransport' -export type { RunnerTransport } from './runnerTransport' -export type { HMRLogger, HMRConnection } from '../shared/hmr' +export { createWebSocketModuleRunnerTransport } from '../shared/moduleRunnerTransport' + +export type { FetchFunctionOptions, FetchResult } from '../shared/invokeMethods' +export type { + ModuleRunnerTransportHandlers, + ModuleRunnerTransport, +} from '../shared/moduleRunnerTransport' +export type { HMRLogger } from '../shared/hmr' export type { ModuleEvaluator, ModuleRunnerContext, - FetchResult, FetchFunction, - FetchFunctionOptions, ResolvedResult, SSRImportMetadata, - ModuleRunnerHMRConnection, ModuleRunnerImportMeta, ModuleRunnerOptions, ModuleRunnerHmr, diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 2cf8098f2..66332593f 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -1,7 +1,11 @@ import type { ViteHotContext } from 'types/hot' -import { HMRClient, HMRContext } from '../shared/hmr' +import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' import { cleanUrl, isPrimitive, isWindows } from '../shared/utils' import { analyzeImportedModDifference } from '../shared/ssrTransform' +import { + type NormalizedModuleRunnerTransport, + normalizeModuleRunnerTransport, +} from '../shared/moduleRunnerTransport' import type { EvaluatedModuleNode } from './evaluatedModules' import { EvaluatedModules } from './evaluatedModules' import type { @@ -29,7 +33,6 @@ import { import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandler } from './hmrHandler' import { enableSourceMapSupport } from './sourcemap/index' -import type { RunnerTransport } from './runnerTransport' interface ModuleRunnerDebugger { (formatter: unknown, ...args: unknown[]): void @@ -46,7 +49,7 @@ export class ModuleRunner { ) }, }) - private readonly transport: RunnerTransport + private readonly transport: NormalizedModuleRunnerTransport private readonly resetSourceMapSupport?: () => void private readonly root: string private readonly concurrentModuleNodePromises = new Map< @@ -64,16 +67,27 @@ export class ModuleRunner { const root = this.options.root this.root = root[root.length - 1] === '/' ? root : `${root}/` this.evaluatedModules = options.evaluatedModules ?? new EvaluatedModules() - this.transport = options.transport - if (typeof options.hmr === 'object') { + this.transport = normalizeModuleRunnerTransport(options.transport) + if (options.hmr) { + const resolvedHmrLogger: HMRLogger = + options.hmr === true || options.hmr.logger === undefined + ? hmrLogger + : options.hmr.logger === false + ? silentConsole + : options.hmr.logger this.hmrClient = new HMRClient( - options.hmr.logger === false - ? silentConsole - : options.hmr.logger || hmrLogger, - options.hmr.connection, + resolvedHmrLogger, + this.transport, ({ acceptedPath }) => this.import(acceptedPath), ) - options.hmr.connection.onUpdate(createHMRHandler(this)) + if (!this.transport.connect) { + throw new Error( + 'HMR is not supported by this runner transport, but `hmr` option was set to true', + ) + } + this.transport.connect(createHMRHandler(this)) + } else { + this.transport.connect?.() } if (options.sourcemapInterceptor !== false) { this.resetSourceMapSupport = enableSourceMapSupport(this) @@ -105,6 +119,7 @@ export class ModuleRunner { this.clearCache() this.hmrClient = undefined this.closed = true + await this.transport.disconnect?.() } /** @@ -255,10 +270,14 @@ export class ModuleRunner { ( url.startsWith('data:') ? { externalize: url, type: 'builtin' } - : await this.transport.fetchModule(url, importer, { - cached: isCached, - startOffset: this.evaluator.startOffset, - }) + : await this.transport.invoke('fetchModule', [ + url, + importer, + { + cached: isCached, + startOffset: this.evaluator.startOffset, + }, + ]) ) as ResolvedResult if ('cache' in fetchedModule) { diff --git a/packages/vite/src/module-runner/runnerTransport.ts b/packages/vite/src/module-runner/runnerTransport.ts deleted file mode 100644 index 4e45c45de..000000000 --- a/packages/vite/src/module-runner/runnerTransport.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { nanoid } from 'nanoid/non-secure' -import type { FetchFunction, FetchResult } from './types' - -export interface RunnerTransport { - fetchModule: FetchFunction -} - -export class RemoteRunnerTransport implements RunnerTransport { - private rpcPromises = new Map< - string, - { - resolve: (data: any) => void - reject: (data: any) => void - timeoutId?: NodeJS.Timeout - } - >() - - constructor( - private readonly options: { - send: (data: any) => void - onMessage: (handler: (data: any) => void) => void - timeout?: number - }, - ) { - this.options.onMessage(async (data) => { - if (typeof data !== 'object' || !data || !data.__v) return - - const promise = this.rpcPromises.get(data.i) - if (!promise) return - - if (promise.timeoutId) clearTimeout(promise.timeoutId) - - this.rpcPromises.delete(data.i) - - if (data.e) { - promise.reject(data.e) - } else { - promise.resolve(data.r) - } - }) - } - - private resolve(method: string, ...args: any[]) { - const promiseId = nanoid() - this.options.send({ - __v: true, - m: method, - a: args, - i: promiseId, - }) - - return new Promise((resolve, reject) => { - const timeout = this.options.timeout ?? 60000 - let timeoutId - if (timeout > 0) { - timeoutId = setTimeout(() => { - this.rpcPromises.delete(promiseId) - reject( - new Error( - `${method}(${args.map((arg) => JSON.stringify(arg)).join(', ')}) timed out after ${timeout}ms`, - ), - ) - }, timeout) - timeoutId?.unref?.() - } - this.rpcPromises.set(promiseId, { resolve, reject, timeoutId }) - }) - } - - fetchModule(id: string, importer?: string): Promise { - return this.resolve('fetchModule', id, importer) - } -} diff --git a/packages/vite/src/module-runner/types.ts b/packages/vite/src/module-runner/types.ts index 70bda14ad..d880fa7a8 100644 --- a/packages/vite/src/module-runner/types.ts +++ b/packages/vite/src/module-runner/types.ts @@ -1,10 +1,16 @@ import type { ViteHotContext } from 'types/hot' -import type { HotPayload } from 'types/hmrPayload' -import type { HMRConnection, HMRLogger } from '../shared/hmr' +import type { HMRLogger } from '../shared/hmr' import type { DefineImportMetadata, SSRImportMetadata, } from '../shared/ssrTransform' +import type { + ExternalFetchResult, + FetchFunctionOptions, + FetchResult, + ViteFetchResult, +} from '../shared/invokeMethods' +import type { ModuleRunnerTransport } from '../shared/moduleRunnerTransport' import type { EvaluatedModuleNode, EvaluatedModules } from './evaluatedModules' import type { ssrDynamicImportKey, @@ -14,18 +20,9 @@ import type { ssrModuleExportsKey, } from './constants' import type { InterceptorOptions } from './sourcemap/interceptor' -import type { RunnerTransport } from './runnerTransport' export type { DefineImportMetadata, SSRImportMetadata } -export interface ModuleRunnerHMRConnection extends HMRConnection { - /** - * Configure how HMR is handled when this connection triggers an update. - * This method expects that connection will start listening for HMR updates and call this callback when it's received. - */ - onUpdate(callback: (payload: HotPayload) => void): void -} - export interface ModuleRunnerImportMeta extends ImportMeta { url: string env: ImportMetaEnv @@ -67,59 +64,6 @@ export interface ModuleEvaluator { runExternalModule(file: string): Promise } -export type FetchResult = - | CachedFetchResult - | ExternalFetchResult - | ViteFetchResult - -export interface CachedFetchResult { - /** - * If module cached in the runner, we can just confirm - * it wasn't invalidated on the server side. - */ - cache: true -} - -export interface ExternalFetchResult { - /** - * The path to the externalized module starting with file://, - * by default this will be imported via a dynamic "import" - * instead of being transformed by vite and loaded with vite runner - */ - externalize: string - /** - * Type of the module. Will be used to determine if import statement is correct. - * For example, if Vite needs to throw an error if variable is not actually exported - */ - type: 'module' | 'commonjs' | 'builtin' | 'network' -} - -export interface ViteFetchResult { - /** - * Code that will be evaluated by vite runner - * by default this will be wrapped in an async function - */ - code: string - /** - * File path of the module on disk. - * This will be resolved as import.meta.url/filename - * Will be equal to `null` for virtual modules - */ - file: string | null - /** - * Module ID in the server module graph. - */ - id: string - /** - * Module URL used in the import. - */ - url: string - /** - * Invalidate module on the client side. - */ - invalidate: boolean -} - export type ResolvedResult = (ExternalFetchResult | ViteFetchResult) & { url: string id: string @@ -131,16 +75,7 @@ export type FetchFunction = ( options?: FetchFunctionOptions, ) => Promise -export interface FetchFunctionOptions { - cached?: boolean - startOffset?: number -} - export interface ModuleRunnerHmr { - /** - * Configure how HMR communicates between the client and the server. - */ - connection: ModuleRunnerHMRConnection /** * Configure HMR logger. */ @@ -155,7 +90,7 @@ export interface ModuleRunnerOptions { /** * A set of methods to communicate with the server. */ - transport: RunnerTransport + transport: ModuleRunnerTransport /** * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. @@ -169,7 +104,7 @@ export interface ModuleRunnerOptions { /** * Disable HMR or configure HMR options. */ - hmr?: false | ModuleRunnerHmr + hmr?: boolean | ModuleRunnerHmr /** * Custom module cache. If not provided, creates a separate module cache for each ModuleRunner instance. */ diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index a536058e8..e0ff775db 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -203,21 +203,13 @@ function defaultCreateClientDevEnvironment( context: CreateDevEnvironmentContext, ) { return new DevEnvironment(name, config, { - hot: context.ws, + hot: true, + transport: context.ws, }) } -function defaultCreateSsrDevEnvironment( - name: string, - config: ResolvedConfig, -): DevEnvironment { - return createRunnableDevEnvironment(name, config) -} - function defaultCreateDevEnvironment(name: string, config: ResolvedConfig) { - return new DevEnvironment(name, config, { - hot: false, - }) + return createRunnableDevEnvironment(name, config) } export type ResolvedDevEnvironmentOptions = Required @@ -608,9 +600,7 @@ export function resolveDevEnvironmentOptions( dev?.createEnvironment ?? (environmentName === 'client' ? defaultCreateClientDevEnvironment - : environmentName === 'ssr' - ? defaultCreateSsrDevEnvironment - : defaultCreateDevEnvironment), + : defaultCreateDevEnvironment), recoverable: dev?.recoverable ?? consumer === 'client', moduleRunnerTransform: dev?.moduleRunnerTransform ?? diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 66808e039..f62d45de9 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -19,7 +19,6 @@ export { formatPostcssSourceMap, preprocessCSS } from './plugins/css' export { transformWithEsbuild } from './plugins/esbuild' export { buildErrorMessage } from './server/middlewares/error' -export { RemoteEnvironmentTransport } from './server/environmentTransport' export { createRunnableDevEnvironment, isRunnableDevEnvironment, @@ -35,7 +34,6 @@ export { BuildEnvironment } from './build' export { fetchModule, type FetchModuleOptions } from './ssr/fetchModule' export { createServerModuleRunner } from './ssr/runtime/serverModuleRunner' export { createServerHotChannel } from './server/hmr' -export { ServerHMRConnector } from './ssr/runtime/serverHmrConnector' export { ssrTransform as moduleRunnerTransform } from './ssr/ssrTransform' export type { ModuleRunnerTransformOptions } from './ssr/ssrTransform' @@ -165,6 +163,7 @@ export type { HMRBroadcasterClient, ServerHMRChannel, HMRChannel, + HotChannelListener, HotChannel, ServerHotChannel, HotChannelClient, diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts index 54fffc4ca..a0a526b4a 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -1,6 +1,9 @@ import colors from 'picocolors' -import { createDebugger, getHash, promiseWithResolvers } from '../utils' -import type { PromiseWithResolvers } from '../utils' +import { createDebugger, getHash } from '../utils' +import { + type PromiseWithResolvers, + promiseWithResolvers, +} from '../../shared/utils' import type { DevEnvironment } from '../server/environment' import { devToScanEnvironment } from './scan' import { diff --git a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts index 6cd19a7d6..a6491bc96 100644 --- a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts +++ b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts @@ -226,7 +226,7 @@ async function getDevEnvironment( // @ts-expect-error This plugin requires a ViteDevServer instance. config.plugins = config.plugins.filter((p) => !p.name.includes('pre-alias')) - const environment = new DevEnvironment('client', config, { hot: false }) + const environment = new DevEnvironment('client', config, { hot: true }) await environment.init() return environment diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index c8af218b7..86144be15 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -10,7 +10,7 @@ import type { ResolvedConfig, ResolvedEnvironmentOptions, } from '../config' -import { mergeConfig, promiseWithResolvers } from '../utils' +import { mergeConfig } from '../utils' import { fetchModule } from '../ssr/fetchModule' import type { DepsOptimizer } from '../optimizer' import { isDepOptimizationDisabled } from '../optimizer' @@ -20,11 +20,12 @@ import { } from '../optimizer/optimizer' import { resolveEnvironmentPlugins } from '../plugin' import { ERR_OUTDATED_OPTIMIZED_DEP } from '../constants' +import { promiseWithResolvers } from '../../shared/utils' import type { ViteDevServer } from '../server' import { EnvironmentModuleGraph } from './moduleGraph' import type { EnvironmentModuleNode } from './moduleGraph' -import type { HotChannel } from './hmr' -import { createNoopHotChannel, getShortName, updateModules } from './hmr' +import type { HotChannel, NormalizedHotChannel } from './hmr' +import { getShortName, normalizeHotChannel, updateModules } from './hmr' import type { TransformResult } from './transformRequest' import { transformRequest } from './transformRequest' import type { EnvironmentPluginContainer } from './pluginContainer' @@ -32,16 +33,15 @@ import { ERR_CLOSED_SERVER, createEnvironmentPluginContainer, } from './pluginContainer' -import type { RemoteEnvironmentTransport } from './environmentTransport' -import { isWebSocketServer } from './ws' +import { type WebSocketServer, isWebSocketServer } from './ws' import { warmupFiles } from './warmup' export interface DevEnvironmentContext { - hot: false | HotChannel + hot: boolean + transport?: HotChannel | WebSocketServer options?: EnvironmentOptions remoteRunner?: { inlineSourceMap?: boolean - transport?: RemoteEnvironmentTransport } depsOptimizer?: DepsOptimizer } @@ -95,7 +95,7 @@ export class DevEnvironment extends BaseEnvironment { * @example * environment.hot.send({ type: 'full-reload' }) */ - hot: HotChannel + hot: NormalizedHotChannel constructor( name: string, config: ResolvedConfig, @@ -117,12 +117,21 @@ export class DevEnvironment extends BaseEnvironment { this.pluginContainer!.resolveId(url, undefined), ) - this.hot = context.hot || createNoopHotChannel() - this._crawlEndFinder = setupOnCrawlEnd() this._remoteRunnerOptions = context.remoteRunner ?? {} - context.remoteRunner?.transport?.register(this) + + this.hot = context.transport + ? isWebSocketServer in context.transport + ? context.transport + : normalizeHotChannel(context.transport, context.hot) + : normalizeHotChannel({}, context.hot) + + this.hot.setInvokeHandler({ + fetchModule: (id, importer, options) => { + return this.fetchModule(id, importer, options) + }, + }) this.hot.on('vite:invalidate', async ({ path, message }) => { invalidateModule(this, { @@ -226,7 +235,7 @@ export class DevEnvironment extends BaseEnvironment { this.pluginContainer.close(), this.depsOptimizer?.close(), // WebSocketServer is independent of HotChannel and should not be closed on environment close - isWebSocketServer in this.hot ? Promise.resolve() : this.hot.close(), + isWebSocketServer in this.hot ? Promise.resolve() : this.hot.close?.(), (async () => { while (this._pendingRequests.size > 0) { await Promise.allSettled( diff --git a/packages/vite/src/node/server/environmentTransport.ts b/packages/vite/src/node/server/environmentTransport.ts deleted file mode 100644 index 4340c144a..000000000 --- a/packages/vite/src/node/server/environmentTransport.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { DevEnvironment } from './environment' - -export class RemoteEnvironmentTransport { - constructor( - private readonly options: { - send: (data: any) => void - onMessage: (handler: (data: any) => void) => void - }, - ) {} - - register(environment: DevEnvironment): void { - this.options.onMessage(async (data) => { - if (typeof data !== 'object' || !data || !data.__v) return - - const method = data.m as 'fetchModule' - const parameters = data.a as [string, string] - - try { - const result = await environment[method](...parameters) - this.options.send({ - __v: true, - r: result, - i: data.i, - }) - } catch (error) { - this.options.send({ - __v: true, - e: { - name: error.name, - message: error.message, - stack: error.stack, - }, - i: data.i, - }) - } - }) - } -} diff --git a/packages/vite/src/node/server/environments/runnableEnvironment.ts b/packages/vite/src/node/server/environments/runnableEnvironment.ts index ff84e1988..5aca0f193 100644 --- a/packages/vite/src/node/server/environments/runnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/runnableEnvironment.ts @@ -4,7 +4,6 @@ import type { DevEnvironmentContext } from '../environment' import { DevEnvironment } from '../environment' import type { ServerModuleRunnerOptions } from '../../ssr/runtime/serverModuleRunner' import { createServerModuleRunner } from '../../ssr/runtime/serverModuleRunner' -import type { HotChannel } from '../hmr' import { createServerHotChannel } from '../hmr' import type { Environment } from '../../environment' @@ -13,8 +12,11 @@ export function createRunnableDevEnvironment( config: ResolvedConfig, context: RunnableDevEnvironmentContext = {}, ): DevEnvironment { + if (context.transport == null) { + context.transport = createServerHotChannel() + } if (context.hot == null) { - context.hot = createServerHotChannel() + context.hot = true } return new RunnableDevEnvironment(name, config, context) @@ -27,7 +29,7 @@ export interface RunnableDevEnvironmentContext options?: ServerModuleRunnerOptions, ) => ModuleRunner runnerOptions?: ServerModuleRunnerOptions - hot?: false | HotChannel + hot?: boolean } export function isRunnableDevEnvironment( diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 86ceb1d64..18c9537d0 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -4,6 +4,11 @@ import { EventEmitter } from 'node:events' import colors from 'picocolors' import type { CustomPayload, HotPayload, Update } from 'types/hmrPayload' import type { RollupError } from 'rollup' +import type { + InvokeMethods, + InvokeResponseData, + InvokeSendData, +} from '../../shared/invokeMethods' import { CLIENT_DIR } from '../constants' import { createDebugger, normalizePath } from '../utils' import type { InferCustomEventPayload, ViteDevServer } from '..' @@ -71,6 +76,51 @@ interface PropagationBoundary { } export interface HotChannelClient { + send(payload: HotPayload): void +} +/** @deprecated use `HotChannelClient` instead */ +export type HMRBroadcasterClient = HotChannelClient + +export type HotChannelListener = ( + data: InferCustomEventPayload, + client: HotChannelClient, +) => void + +export interface HotChannel { + /** + * Broadcast events to all clients + */ + send?(payload: HotPayload): void + /** + * Handle custom event emitted by `import.meta.hot.send` + */ + on?(event: T, listener: HotChannelListener): 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?(): Promise | void + + api?: Api +} +/** @deprecated use `HotChannel` instead */ +export type HMRChannel = HotChannel + +export function getShortName(file: string, root: string): string { + return file.startsWith(withTrailingSlash(root)) + ? path.posix.relative(root, file) + : file +} + +export interface NormalizedHotChannelClient { /** * Send event to the client */ @@ -80,10 +130,8 @@ export interface HotChannelClient { */ send(event: string, payload?: CustomPayload['data']): void } -/** @deprecated use `HotChannelClient` instead */ -export type HMRBroadcasterClient = HotChannelClient -export interface HotChannel { +export interface NormalizedHotChannel { /** * Broadcast events to all clients */ @@ -99,8 +147,7 @@ export interface HotChannel { event: T, listener: ( data: InferCustomEventPayload, - client: HotChannelClient, - ...args: any[] + client: NormalizedHotChannelClient, ) => void, ): void on(event: 'connection', listener: () => void): void @@ -108,6 +155,9 @@ export interface HotChannel { * Unregister event listener */ off(event: string, listener: Function): void + /** @internal */ + setInvokeHandler(invokeHandlers: InvokeMethods | undefined): void + handleInvoke(payload: HotPayload): Promise<{ r: any } | { e: any }> /** * Start listening for messages */ @@ -116,14 +166,169 @@ export interface HotChannel { * Disconnect all clients, called when server is closed or restarted. */ close(): Promise | void -} -/** @deprecated use `HotChannel` instead */ -export type HMRChannel = HotChannel -export function getShortName(file: string, root: string): string { - return file.startsWith(withTrailingSlash(root)) - ? path.posix.relative(root, file) - : file + api?: Api +} + +export const normalizeHotChannel = ( + channel: HotChannel, + enableHmr: boolean, +): NormalizedHotChannel => { + const normalizedListenerMap = new WeakMap< + (data: any, client: NormalizedHotChannelClient) => void | Promise, + (data: any, client: HotChannelClient) => void | Promise + >() + const listenersForEvents = new Map< + string, + Set<(data: any, client: HotChannelClient) => void | Promise> + >() + + let invokeHandlers: InvokeMethods | undefined + let listenerForInvokeHandler: + | ((data: InvokeSendData, client: HotChannelClient) => void) + | undefined + const handleInvoke = async ( + payload: HotPayload, + ) => { + if (!invokeHandlers) { + return { + e: { + name: 'TransportError', + message: 'invokeHandlers is not set', + stack: new Error().stack, + }, + } + } + + const data: InvokeSendData = (payload as CustomPayload).data + const { name, data: args } = data + try { + const invokeHandler = invokeHandlers[name] + // @ts-expect-error `invokeHandler` is `InvokeMethods[T]`, so passing the args is fine + const result = await invokeHandler(...args) + return { r: result } + } catch (error) { + return { + e: { + name: error.name, + message: error.message, + stack: error.stack, + }, + } + } + } + + return { + ...channel, + on: ( + event: string, + fn: (data: any, client: NormalizedHotChannelClient) => void, + ) => { + if (event === 'connection') { + channel.on?.(event, fn as () => void) + return + } + + const listenerWithNormalizedClient = ( + data: any, + client: HotChannelClient, + ) => { + const normalizedClient: NormalizedHotChannelClient = { + send: (...args) => { + let payload: HotPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1], + } + } else { + payload = args[0] + } + client.send(payload) + }, + } + fn(data, normalizedClient) + } + normalizedListenerMap.set(fn, listenerWithNormalizedClient) + + channel.on?.(event, listenerWithNormalizedClient) + if (!listenersForEvents.has(event)) { + listenersForEvents.set(event, new Set()) + } + listenersForEvents.get(event)!.add(listenerWithNormalizedClient) + }, + off: (event: string, fn: () => void) => { + if (event === 'connection') { + channel.off?.(event, fn as () => void) + return + } + + const normalizedListener = normalizedListenerMap.get(fn) + if (normalizedListener) { + channel.off?.(event, normalizedListener) + listenersForEvents.get(event)?.delete(normalizedListener) + } + }, + setInvokeHandler(_invokeHandlers) { + invokeHandlers = _invokeHandlers + if (!_invokeHandlers) { + if (listenerForInvokeHandler) { + channel.off?.('vite:invoke', listenerForInvokeHandler) + } + return + } + + listenerForInvokeHandler = async (payload, client) => { + const responseInvoke = payload.id.replace('send', 'response') as + | 'response' + | `response:${string}` + client.send({ + type: 'custom', + event: 'vite:invoke', + data: { + name: payload.name, + id: responseInvoke, + data: (await handleInvoke({ + type: 'custom', + event: 'vite:invoke', + data: payload, + }))!, + } satisfies InvokeResponseData, + }) + } + channel.on?.('vite:invoke', listenerForInvokeHandler) + }, + handleInvoke, + send: (...args: any[]) => { + let payload: HotPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1], + } + } else { + payload = args[0] + } + + if ( + enableHmr || + payload.type === 'connected' || + payload.type === 'ping' || + payload.type === 'custom' || + payload.type === 'error' + ) { + channel.send?.(payload) + } + }, + listen() { + return channel.listen?.() + }, + close() { + return channel.close?.() + }, + } } export function getSortedPluginsByHotUpdateHook( @@ -892,12 +1097,14 @@ async function readModifiedFile(file: string): Promise { } } -export interface ServerHotChannel extends HotChannel { - api: { - innerEmitter: EventEmitter - outsideEmitter: EventEmitter - } +export type ServerHotChannelApi = { + innerEmitter: EventEmitter + outsideEmitter: EventEmitter } + +export type ServerHotChannel = HotChannel +export type NormalizedServerHotChannel = + NormalizedHotChannel /** @deprecated use `ServerHotChannel` instead */ export type ServerHMRChannel = ServerHotChannel @@ -906,17 +1113,7 @@ export function createServerHotChannel(): ServerHotChannel { const outsideEmitter = new EventEmitter() return { - send(...args: any[]) { - let payload: HotPayload - if (typeof args[0] === 'string') { - payload = { - type: 'custom', - event: args[0], - data: args[1], - } - } else { - payload = args[0] - } + send(payload: HotPayload) { outsideEmitter.emit('send', payload) }, off(event, listener: () => void) { @@ -939,23 +1136,9 @@ export function createServerHotChannel(): ServerHotChannel { } } -export function createNoopHotChannel(): HotChannel { - function noop() { - // noop - } - - return { - send: noop, - on: noop, - off: noop, - listen: noop, - close: noop, - } -} - /** @deprecated use `environment.hot` instead */ -export interface HotBroadcaster extends HotChannel { - readonly channels: HotChannel[] +export interface HotBroadcaster extends NormalizedHotChannel { + readonly channels: NormalizedHotChannel[] /** * A noop. * @deprecated @@ -966,12 +1149,22 @@ export interface HotBroadcaster extends HotChannel { /** @deprecated use `environment.hot` instead */ export type HMRBroadcaster = HotBroadcaster -export function createDeprecatedHotBroadcaster(ws: HotChannel): HotBroadcaster { +export function createDeprecatedHotBroadcaster( + ws: NormalizedHotChannel, +): HotBroadcaster { const broadcaster: HotBroadcaster = { on: ws.on, off: ws.off, listen: ws.listen, send: ws.send, + setInvokeHandler: ws.setInvokeHandler, + handleInvoke: async () => ({ + e: { + name: 'TransportError', + message: 'handleInvoke not implemented', + stack: new Error().stack, + }, + }), get channels() { return [ws] }, @@ -979,7 +1172,9 @@ export function createDeprecatedHotBroadcaster(ws: HotChannel): HotBroadcaster { return broadcaster }, close() { - return Promise.all(broadcaster.channels.map((channel) => channel.close())) + return Promise.all( + broadcaster.channels.map((channel) => channel.close?.()), + ) }, } return broadcaster diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts index c68ba6ce3..70e21f3e9 100644 --- a/packages/vite/src/node/server/ws.ts +++ b/packages/vite/src/node/server/ws.ts @@ -9,11 +9,11 @@ import colors from 'picocolors' import type { WebSocket as WebSocketRaw } from 'ws' import { WebSocketServer as WebSocketServerRaw_ } from 'ws' import type { WebSocket as WebSocketTypes } from 'dep-types/ws' -import type { ErrorPayload, HotPayload } from 'types/hmrPayload' +import type { ErrorPayload } from 'types/hmrPayload' import type { InferCustomEventPayload } from 'types/customEvent' import type { HotChannelClient, ResolvedConfig } from '..' import { isObject } from '../utils' -import type { HotChannel } from './hmr' +import { type NormalizedHotChannel, normalizeHotChannel } from './hmr' import type { HttpServer } from '.' /* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version @@ -29,24 +29,12 @@ export const HMR_HEADER = 'vite-hmr' export type WebSocketCustomListener = ( data: T, client: WebSocketClient, + invoke?: 'send' | `send:${string}`, ) => void export const isWebSocketServer = Symbol('isWebSocketServer') -export interface WebSocketServer extends HotChannel { - [isWebSocketServer]: true - /** - * Listen on port and host - */ - listen(): void - /** - * Get all connected clients. - */ - clients: Set - /** - * Disconnect all clients and terminate the server. - */ - close(): Promise +export interface WebSocketServer extends NormalizedHotChannel { /** * Handle custom event emitted by `import.meta.hot.send` */ @@ -62,6 +50,20 @@ export interface WebSocketServer extends HotChannel { off: WebSocketTypes.Server['off'] & { (event: string, listener: Function): void } + /** + * Listen on port and host + */ + listen(): void + /** + * Disconnect all clients and terminate the server. + */ + close(): Promise + + [isWebSocketServer]: true + /** + * Get all connected clients. + */ + clients: Set } export interface WebSocketClient extends HotChannelClient { @@ -100,6 +102,14 @@ export function createWebSocketServer( }, on: noop as any as WebSocketServer['on'], off: noop as any as WebSocketServer['off'], + setInvokeHandler: noop, + handleInvoke: async () => ({ + e: { + name: 'TransportError', + message: 'handleInvoke not implemented', + stack: new Error().stack, + }, + }), listen: noop, send: noop, } @@ -209,7 +219,9 @@ export function createWebSocketServer( const listeners = customListeners.get(parsed.event) if (!listeners?.size) return const client = getSocketClient(socket) - listeners.forEach((listener) => listener(parsed.data, client)) + listeners.forEach((listener) => + listener(parsed.data, client, parsed.invoke), + ) }) socket.on('error', (err) => { config.logger.error(`${colors.red(`ws error:`)}\n${err.stack}`, { @@ -243,17 +255,7 @@ export function createWebSocketServer( function getSocketClient(socket: WebSocketRaw) { if (!clientsMap.has(socket)) { clientsMap.set(socket, { - send: (...args) => { - let payload: HotPayload - if (typeof args[0] === 'string') { - payload = { - type: 'custom', - event: args[0], - data: args[1], - } - } else { - payload = args[0] - } + send: (payload) => { socket.send(JSON.stringify(payload)) }, socket, @@ -268,86 +270,90 @@ export function createWebSocketServer( // connected client. let bufferedError: ErrorPayload | null = null - return { - [isWebSocketServer]: true, - listen: () => { - wsHttpServer?.listen(port, host) - }, - on: ((event: string, fn: () => void) => { - if (wsServerEvents.includes(event)) wss.on(event, fn) - else { + const normalizedHotChannel = normalizeHotChannel( + { + send(payload) { + if (payload.type === 'error' && !wss.clients.size) { + bufferedError = payload + return + } + + const stringified = JSON.stringify(payload) + wss.clients.forEach((client) => { + // readyState 1 means the connection is open + if (client.readyState === 1) { + client.send(stringified) + } + }) + }, + on(event: string, fn: any) { if (!customListeners.has(event)) { customListeners.set(event, new Set()) } customListeners.get(event)!.add(fn) + }, + off(event: string, fn: any) { + customListeners.get(event)?.delete(fn) + }, + listen() { + wsHttpServer?.listen(port, host) + }, + close() { + // should remove listener if hmr.server is set + // otherwise the old listener swallows all WebSocket connections + if (hmrServerWsListener && wsServer) { + wsServer.off('upgrade', hmrServerWsListener) + } + return new Promise((resolve, reject) => { + wss.clients.forEach((client) => { + client.terminate() + }) + wss.close((err) => { + if (err) { + reject(err) + } else { + if (wsHttpServer) { + wsHttpServer.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + } else { + resolve() + } + } + }) + }) + }, + }, + config.server.hmr !== false, + ) + return { + ...normalizedHotChannel, + + on: ((event: string, fn: any) => { + if (wsServerEvents.includes(event)) { + wss.on(event, fn) + return } + normalizedHotChannel.on(event, fn) }) as WebSocketServer['on'], - off: ((event: string, fn: () => void) => { + off: ((event: string, fn: any) => { if (wsServerEvents.includes(event)) { wss.off(event, fn) - } else { - customListeners.get(event)?.delete(fn) + return } + normalizedHotChannel.off(event, fn) }) as WebSocketServer['off'], + async close() { + await normalizedHotChannel.close() + }, + [isWebSocketServer]: true, get clients() { return new Set(Array.from(wss.clients).map(getSocketClient)) }, - - send(...args: any[]) { - let payload: HotPayload - 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 - } - - const stringified = JSON.stringify(payload) - wss.clients.forEach((client) => { - // readyState 1 means the connection is open - if (client.readyState === 1) { - client.send(stringified) - } - }) - }, - - close() { - // should remove listener if hmr.server is set - // otherwise the old listener swallows all WebSocket connections - if (hmrServerWsListener && wsServer) { - wsServer.off('upgrade', hmrServerWsListener) - } - return new Promise((resolve, reject) => { - wss.clients.forEach((client) => { - client.terminate() - }) - wss.close((err) => { - if (err) { - reject(err) - } else { - if (wsHttpServer) { - wsHttpServer.close((err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - } else { - resolve() - } - } - }) - }) - }, } } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs index bc617d030..3e00f5ff6 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs @@ -2,23 +2,30 @@ import { BroadcastChannel, parentPort } from 'node:worker_threads' import { fileURLToPath } from 'node:url' -import { ESModulesEvaluator, ModuleRunner, RemoteRunnerTransport } from 'vite/module-runner' +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' if (!parentPort) { throw new Error('File "worker.js" must be run in a worker thread') } +/** @type {import('worker_threads').MessagePort} */ +const pPort = parentPort + +/** @type {import('vite/module-runner').ModuleRunnerTransport} */ +const messagePortTransport = { + connect({ onMessage, onDisconnection }) { + pPort.on('message', onMessage) + pPort.on('close', onDisconnection) + }, + send(data) { + pPort.postMessage(data) + }, +} + const runner = new ModuleRunner( { root: fileURLToPath(new URL('./', import.meta.url)), - transport: new RemoteRunnerTransport({ - onMessage: listener => { - parentPort?.on('message', listener) - }, - send: message => { - parentPort?.postMessage(message) - } - }) + transport: messagePortTransport, }, new ESModulesEvaluator(), ) @@ -32,4 +39,4 @@ channel.onmessage = async (message) => { channel.postMessage({ error: e.stack }) } } -parentPort.postMessage('ready') \ No newline at end of file +parentPort.postMessage('ready') diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts index 4cb52e87d..4af2fcb18 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts @@ -1,8 +1,44 @@ import { BroadcastChannel, Worker } from 'node:worker_threads' import { describe, expect, it, onTestFinished } from 'vitest' -import { DevEnvironment, RemoteEnvironmentTransport } from '../../..' +import type { HotChannel, HotChannelListener, HotPayload } from 'vite' +import { DevEnvironment } from '../../..' import { createServer } from '../../../server' +const createWorkerTransport = (w: Worker): HotChannel => { + const handlerToWorkerListener = new WeakMap< + HotChannelListener, + (value: HotPayload) => void + >() + + return { + send: (data) => w.postMessage(data), + on: (event: string, handler: HotChannelListener) => { + if (event === 'connection') return + + const listener = (value: HotPayload) => { + if (value.type === 'custom' && value.event === event) { + const client = { + send(payload: HotPayload) { + w.postMessage(payload) + }, + } + handler(value.data, client) + } + } + handlerToWorkerListener.set(handler, listener) + w.on('message', listener) + }, + off: (event, handler: HotChannelListener) => { + if (event === 'connection') return + const listener = handlerToWorkerListener.get(handler) + if (listener) { + w.off('message', listener) + handlerToWorkerListener.delete(handler) + } + }, + } +} + describe('running module runner inside a worker', () => { it('correctly runs ssr code', async () => { expect.assertions(1) @@ -31,13 +67,8 @@ describe('running module runner inside a worker', () => { dev: { createEnvironment: (name, config) => { return new DevEnvironment(name, config, { - remoteRunner: { - transport: new RemoteEnvironmentTransport({ - send: (data) => worker.postMessage(data), - onMessage: (handler) => worker.on('message', handler), - }), - }, hot: false, + transport: createWorkerTransport(worker), }) }, }, diff --git a/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts b/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts deleted file mode 100644 index 45cb57421..000000000 --- a/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { CustomPayload, HotPayload } from 'types/hmrPayload' -import type { ModuleRunnerHMRConnection } from 'vite/module-runner' -import type { HotChannelClient, ServerHotChannel } from '../../server/hmr' - -class ServerHMRBroadcasterClient implements HotChannelClient { - constructor(private readonly hotChannel: ServerHotChannel) {} - - send(...args: any[]) { - let payload: HotPayload - if (typeof args[0] === 'string') { - payload = { - type: 'custom', - event: args[0], - data: args[1], - } - } else { - payload = args[0] - } - if (payload.type !== 'custom') { - throw new Error( - 'Cannot send non-custom events from the client to the server.', - ) - } - this.hotChannel.send(payload) - } -} - -/** - * The connector class to establish HMR communication between the server and the Vite runtime. - * @experimental - */ -export class ServerHMRConnector implements ModuleRunnerHMRConnection { - private handlers: ((payload: HotPayload) => void)[] = [] - private hmrClient: ServerHMRBroadcasterClient - - private connected = false - - constructor(private hotChannel: ServerHotChannel) { - this.hmrClient = new ServerHMRBroadcasterClient(hotChannel) - hotChannel.api.outsideEmitter.on('send', (payload: HotPayload) => { - this.handlers.forEach((listener) => listener(payload)) - }) - this.hotChannel = hotChannel - } - - isReady(): boolean { - return this.connected - } - - send(payload_: HotPayload): void { - const payload = payload_ as CustomPayload - this.hotChannel.api.innerEmitter.emit( - payload.event, - payload.data, - this.hmrClient, - ) - } - - onUpdate(handler: (payload: HotPayload) => void): void { - this.handlers.push(handler) - handler({ type: 'connected' }) - this.connected = true - } -} diff --git a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts index 931bd07b8..47f012557 100644 --- a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts +++ b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts @@ -2,13 +2,16 @@ import { existsSync, readFileSync } from 'node:fs' import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' import type { ModuleEvaluator, - ModuleRunnerHMRConnection, ModuleRunnerHmr, ModuleRunnerOptions, } from 'vite/module-runner' +import type { HotPayload } from 'types/hmrPayload' import type { DevEnvironment } from '../../server/environment' -import type { ServerHotChannel } from '../../server/hmr' -import { ServerHMRConnector } from './serverHmrConnector' +import type { + HotChannelClient, + NormalizedServerHotChannel, +} from '../../server/hmr' +import type { ModuleRunnerTransport } from '../../../shared/moduleRunnerTransport' /** * @experimental @@ -24,7 +27,6 @@ export interface ServerModuleRunnerOptions hmr?: | false | { - connection?: ModuleRunnerHMRConnection logger?: ModuleRunnerHmr['logger'] } /** @@ -40,16 +42,8 @@ function createHMROptions( if (environment.config.server.hmr === false || options.hmr === false) { return false } - if (options.hmr?.connection) { - return { - connection: options.hmr.connection, - logger: options.hmr.logger, - } - } if (!('api' in environment.hot)) return false - const connection = new ServerHMRConnector(environment.hot as ServerHotChannel) return { - connection, logger: options.hmr?.logger, } } @@ -78,6 +72,48 @@ function resolveSourceMapOptions(options: ServerModuleRunnerOptions) { return prepareStackTrace } +export const createServerModuleRunnerTransport = (options: { + channel: NormalizedServerHotChannel +}): ModuleRunnerTransport => { + const hmrClient: HotChannelClient = { + send: (payload: HotPayload) => { + if (payload.type !== 'custom') { + throw new Error( + 'Cannot send non-custom events from the client to the server.', + ) + } + options.channel.send(payload) + }, + } + + let handler: ((data: HotPayload) => void) | undefined + + return { + connect({ onMessage }) { + options.channel.api!.outsideEmitter.on('send', onMessage) + onMessage({ type: 'connected' }) + handler = onMessage + }, + disconnect() { + if (handler) { + options.channel.api!.outsideEmitter.off('send', handler) + } + }, + send(payload) { + if (payload.type !== 'custom') { + throw new Error( + 'Cannot send non-custom events from the server to the client.', + ) + } + options.channel.api!.innerEmitter.emit( + payload.event, + payload.data, + hmrClient, + ) + }, + } +} + /** * Create an instance of the Vite SSR runtime that support HMR. * @experimental @@ -91,10 +127,9 @@ export function createServerModuleRunner( { ...options, root: environment.config.root, - transport: { - fetchModule: (id, importer, options) => - environment.fetchModule(id, importer, options), - }, + transport: createServerModuleRunnerTransport({ + channel: environment.hot as NormalizedServerHotChannel, + }), hmr, sourcemapInterceptor: resolveSourceMapOptions(options), }, diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index c60c03d4f..f707b93d6 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -4,7 +4,9 @@ import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' import type { ViteDevServer } from '../server' import { unwrapId } from '../../shared/utils' import type { DevEnvironment } from '../server/environment' +import type { NormalizedServerHotChannel } from '../server/hmr' import { ssrFixStacktrace } from './ssrStacktrace' +import { createServerModuleRunnerTransport } from './runtime/serverModuleRunner' type SSRModule = Record @@ -62,10 +64,9 @@ class SSRCompatModuleRunner extends ModuleRunner { super( { root: environment.config.root, - transport: { - fetchModule: (id, importer, options) => - environment.fetchModule(id, importer, options), - }, + transport: createServerModuleRunnerTransport({ + channel: environment.hot as NormalizedServerHotChannel, + }), sourcemapInterceptor: false, hmr: false, }, diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 4ae3f29b1..876544cd2 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -1358,21 +1358,6 @@ export function isDevServer( return 'pluginContainer' in server } -export interface PromiseWithResolvers { - promise: Promise - resolve: (value: T | PromiseLike) => void - reject: (reason?: any) => void -} -export function promiseWithResolvers(): PromiseWithResolvers { - let resolve: any - let reject: any - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject - }) - return { promise, resolve, reject } -} - export function createSerialPromiseQueue(): { run(f: () => Promise): Promise } { diff --git a/packages/vite/src/shared/hmr.ts b/packages/vite/src/shared/hmr.ts index 610ed2ef3..76c4ece4d 100644 --- a/packages/vite/src/shared/hmr.ts +++ b/packages/vite/src/shared/hmr.ts @@ -1,6 +1,7 @@ import type { HotPayload, Update } from 'types/hmrPayload' import type { ModuleNamespace, ViteHotContext } from 'types/hot' import type { InferCustomEventPayload } from 'types/customEvent' +import type { NormalizedModuleRunnerTransport } from './moduleRunnerTransport' type CustomListenersMap = Map void)[]> @@ -20,17 +21,6 @@ export interface HMRLogger { debug(...msg: unknown[]): void } -export interface HMRConnection { - /** - * Checked before sending messages to the client. - */ - isReady(): boolean - /** - * Send message to the client. - */ - send(messages: HotPayload): void -} - export class HMRContext implements ViteHotContext { private newListeners: CustomListenersMap @@ -154,7 +144,7 @@ export class HMRContext implements ViteHotContext { } send(event: T, data?: InferCustomEventPayload): void { - this.hmrClient.messenger.send({ type: 'custom', event, data }) + this.hmrClient.send({ type: 'custom', event, data }) } private acceptDeps( @@ -173,24 +163,6 @@ export class HMRContext implements ViteHotContext { } } -class HMRMessenger { - constructor(private connection: HMRConnection) {} - - private queue: HotPayload[] = [] - - public send(payload: HotPayload): void { - this.queue.push(payload) - this.flush() - } - - public flush(): void { - if (this.connection.isReady()) { - this.queue.forEach((msg) => this.connection.send(msg)) - this.queue = [] - } - } -} - export class HMRClient { public hotModulesMap = new Map() public disposeMap = new Map void | Promise>() @@ -199,16 +171,12 @@ export class HMRClient { public customListenersMap: CustomListenersMap = new Map() public ctxToListenersMap = new Map() - public messenger: HMRMessenger - constructor( public logger: HMRLogger, - connection: HMRConnection, + private transport: NormalizedModuleRunnerTransport, // This allows implementing reloading via different methods depending on the environment private importUpdatedModule: (update: Update) => Promise, - ) { - this.messenger = new HMRMessenger(connection) - } + ) {} public async notifyListeners( event: T, @@ -221,6 +189,10 @@ export class HMRClient { } } + public send(payload: HotPayload): void { + this.transport.send(payload) + } + public clear(): void { this.hotModulesMap.clear() this.disposeMap.clear() diff --git a/packages/vite/src/shared/invokeMethods.ts b/packages/vite/src/shared/invokeMethods.ts new file mode 100644 index 000000000..b37adacc0 --- /dev/null +++ b/packages/vite/src/shared/invokeMethods.ts @@ -0,0 +1,85 @@ +export interface FetchFunctionOptions { + cached?: boolean + startOffset?: number +} + +export type FetchResult = + | CachedFetchResult + | ExternalFetchResult + | ViteFetchResult + +export interface CachedFetchResult { + /** + * If module cached in the runner, we can just confirm + * it wasn't invalidated on the server side. + */ + cache: true +} + +export interface ExternalFetchResult { + /** + * The path to the externalized module starting with file://, + * by default this will be imported via a dynamic "import" + * instead of being transformed by vite and loaded with vite runner + */ + externalize: string + /** + * Type of the module. Will be used to determine if import statement is correct. + * For example, if Vite needs to throw an error if variable is not actually exported + */ + type: 'module' | 'commonjs' | 'builtin' | 'network' +} + +export interface ViteFetchResult { + /** + * Code that will be evaluated by vite runner + * by default this will be wrapped in an async function + */ + code: string + /** + * File path of the module on disk. + * This will be resolved as import.meta.url/filename + * Will be equal to `null` for virtual modules + */ + file: string | null + /** + * Module ID in the server module graph. + */ + id: string + /** + * Module URL used in the import. + */ + url: string + /** + * Invalidate module on the client side. + */ + invalidate: boolean +} + +export type InvokeSendData< + T extends keyof InvokeMethods = keyof InvokeMethods, +> = { + name: T + /** 'send' is for requests without an id */ + id: 'send' | `send:${string}` + data: Parameters +} + +export type InvokeResponseData< + T extends keyof InvokeMethods = keyof InvokeMethods, +> = { + name: T + /** 'response' is for responses without an id */ + id: 'response' | `response:${string}` + data: + | { r: Awaited>; e?: undefined } + | { r?: undefined; e: any } +} + +export type InvokeMethods = { + fetchModule: ( + id: string, + importer?: string, + options?: FetchFunctionOptions, + ) => Promise +} diff --git a/packages/vite/src/shared/moduleRunnerTransport.ts b/packages/vite/src/shared/moduleRunnerTransport.ts new file mode 100644 index 000000000..f643e3d6a --- /dev/null +++ b/packages/vite/src/shared/moduleRunnerTransport.ts @@ -0,0 +1,315 @@ +import { nanoid } from 'nanoid/non-secure' +import type { CustomPayload, HotPayload } from 'types/hmrPayload' +import { promiseWithResolvers } from './utils' +import type { + InvokeMethods, + InvokeResponseData, + InvokeSendData, +} from './invokeMethods' + +export type ModuleRunnerTransportHandlers = { + onMessage: (data: HotPayload) => void + onDisconnection: () => void +} + +/** + * "send and connect" or "invoke" must be implemented + */ +export interface ModuleRunnerTransport { + connect?(handlers: ModuleRunnerTransportHandlers): Promise | void + disconnect?(): Promise | void + send?(data: HotPayload): Promise | void + invoke?( + data: HotPayload, + ): Promise<{ /** result */ r: any } | { /** error */ e: any }> + timeout?: number +} + +type InvokeableModuleRunnerTransport = Omit & { + invoke( + name: T, + data: Parameters, + ): Promise>> +} + +const createInvokeableTransport = ( + transport: ModuleRunnerTransport, +): InvokeableModuleRunnerTransport => { + if (transport.invoke) { + return { + ...transport, + async invoke(name, data) { + const result = await transport.invoke!({ + type: 'custom', + event: 'vite:invoke', + data: { + id: 'send', + name, + data, + } satisfies InvokeSendData, + } satisfies CustomPayload) + if ('e' in result) { + throw result.e + } + return result.r + }, + } + } + + if (!transport.send || !transport.connect) { + throw new Error( + 'transport must implement send and connect when invoke is not implemented', + ) + } + + const rpcPromises = new Map< + string, + { + resolve: (data: any) => void + reject: (data: any) => void + name: string + timeoutId?: ReturnType + } + >() + + return { + ...transport, + connect({ onMessage, onDisconnection }) { + return transport.connect!({ + onMessage(payload) { + if (payload.type === 'custom' && payload.event === 'vite:invoke') { + const data = payload.data as InvokeResponseData + if (data.id.startsWith('response:')) { + const invokeId = data.id.slice('response:'.length) + const promise = rpcPromises.get(invokeId) + if (!promise) return + + if (promise.timeoutId) clearTimeout(promise.timeoutId) + + rpcPromises.delete(invokeId) + + const { e, r } = data.data + if (e) { + promise.reject(e) + } else { + promise.resolve(r) + } + return + } + } + onMessage(payload) + }, + onDisconnection, + }) + }, + disconnect() { + rpcPromises.forEach((promise) => { + promise.reject( + new Error( + `transport was disconnected, cannot call ${JSON.stringify(promise.name)}`, + ), + ) + }) + rpcPromises.clear() + return transport.disconnect?.() + }, + send(data) { + return transport.send!(data) + }, + async invoke( + name: T, + data: Parameters, + ) { + const promiseId = nanoid() + const wrappedData: CustomPayload = { + type: 'custom', + event: 'vite:invoke', + data: { + name, + id: `send:${promiseId}`, + data, + } satisfies InvokeSendData, + } + const sendPromise = transport.send!(wrappedData) + + const { promise, resolve, reject } = + promiseWithResolvers>>() + const timeout = transport.timeout ?? 60000 + let timeoutId: ReturnType | undefined + if (timeout > 0) { + timeoutId = setTimeout(() => { + rpcPromises.delete(promiseId) + reject( + new Error( + `transport invoke timed out after ${timeout}ms (data: ${JSON.stringify(wrappedData)})`, + ), + ) + }, timeout) + timeoutId?.unref?.() + } + rpcPromises.set(promiseId, { resolve, reject, name, timeoutId }) + + if (sendPromise) { + sendPromise.catch((err) => { + clearTimeout(timeoutId) + rpcPromises.delete(promiseId) + reject(err) + }) + } + + return await promise + }, + } +} + +export interface NormalizedModuleRunnerTransport { + connect?(onMessage?: (data: HotPayload) => void): Promise | void + disconnect?(): Promise | void + send(data: HotPayload): void + invoke( + name: T, + data: Parameters, + ): Promise>> +} + +export const normalizeModuleRunnerTransport = ( + transport: ModuleRunnerTransport, +): NormalizedModuleRunnerTransport => { + const invokeableTransport = createInvokeableTransport(transport) + + let isConnected = !invokeableTransport.connect + let connectingPromise: Promise | undefined + + return { + ...(transport as Omit), + ...(invokeableTransport.connect + ? { + async connect(onMessage) { + if (isConnected) return + if (connectingPromise) { + await connectingPromise + return + } + + const maybePromise = invokeableTransport.connect!({ + onMessage: onMessage ?? (() => {}), + onDisconnection() { + isConnected = false + }, + }) + if (maybePromise) { + connectingPromise = maybePromise + await connectingPromise + connectingPromise = undefined + } + isConnected = true + }, + } + : {}), + ...(invokeableTransport.disconnect + ? { + async disconnect() { + if (!isConnected) return + if (connectingPromise) { + await connectingPromise + } + isConnected = false + await invokeableTransport.disconnect!() + }, + } + : {}), + async send(data) { + if (!invokeableTransport.send) return + + if (!isConnected) { + if (connectingPromise) { + await connectingPromise + } else { + throw new Error('send was called before connect') + } + } + await invokeableTransport.send(data) + }, + async invoke(name, data) { + if (!isConnected) { + if (connectingPromise) { + await connectingPromise + } else { + throw new Error('invoke was called before connect') + } + } + return invokeableTransport.invoke(name, data) + }, + } +} + +export const createWebSocketModuleRunnerTransport = (options: { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + createConnection: () => WebSocket + pingInterval?: number +}): Required< + Pick +> => { + const pingInterval = options.pingInterval ?? 30000 + + // eslint-disable-next-line n/no-unsupported-features/node-builtins + let ws: WebSocket | undefined + let pingIntervalId: ReturnType | undefined + return { + async connect({ onMessage, onDisconnection }) { + const socket = options.createConnection() + socket.addEventListener('message', async ({ data }) => { + onMessage(JSON.parse(data)) + }) + + let isOpened = socket.readyState === socket.OPEN + if (!isOpened) { + await new Promise((resolve, reject) => { + socket.addEventListener( + 'open', + () => { + isOpened = true + resolve() + }, + { once: true }, + ) + socket.addEventListener('close', async () => { + if (!isOpened) { + reject(new Error('WebSocket closed without opened.')) + return + } + + onMessage({ + type: 'custom', + event: 'vite:ws:disconnect', + data: { webSocket: socket }, + }) + onDisconnection() + }) + }) + } + + onMessage({ + type: 'custom', + event: 'vite:ws:connect', + data: { webSocket: socket }, + }) + ws = socket + + // proxy(nginx, docker) hmr ws maybe caused timeout, + // so send ping package let ws keep alive. + pingIntervalId = setInterval(() => { + if (socket.readyState === socket.OPEN) { + socket.send(JSON.stringify({ type: 'ping' })) + } + }, pingInterval) + }, + disconnect() { + clearInterval(pingIntervalId) + ws?.close() + }, + send(data) { + ws!.send(JSON.stringify(data)) + }, + } +} diff --git a/packages/vite/src/shared/utils.ts b/packages/vite/src/shared/utils.ts index 1ac0d7716..007c6b611 100644 --- a/packages/vite/src/shared/utils.ts +++ b/packages/vite/src/shared/utils.ts @@ -67,3 +67,18 @@ export function getAsyncFunctionDeclarationPaddingLineCount(): number { } return asyncFunctionDeclarationPaddingLineCount } + +export interface PromiseWithResolvers { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} +export function promiseWithResolvers(): PromiseWithResolvers { + let resolve: any + let reject: any + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { promise, resolve, reject } +} diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 7c7f81f6c..c2a0e26ab 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -2,6 +2,7 @@ export type HMRPayload = HotPayload export type HotPayload = | ConnectedPayload + | PingPayload | UpdatePayload | FullReloadPayload | CustomPayload @@ -12,6 +13,10 @@ export interface ConnectedPayload { type: 'connected' } +export interface PingPayload { + type: 'ping' +} + export interface UpdatePayload { type: 'update' updates: Update[] diff --git a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts index c87ef9667..d41ca3c19 100644 --- a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts +++ b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts @@ -11,11 +11,7 @@ import { vi, } from 'vitest' import type { InlineConfig, RunnableDevEnvironment, ViteDevServer } from 'vite' -import { - createRunnableDevEnvironment, - createServer, - createServerHotChannel, -} from 'vite' +import { createRunnableDevEnvironment, createServer } from 'vite' import type { ModuleRunner } from 'vite/module-runner' import { addFile, @@ -1085,7 +1081,6 @@ async function setupModuleRunner( createEnvironment(name, config) { return createRunnableDevEnvironment(name, config, { runnerOptions: { hmr: { logger } }, - hot: createServerHotChannel(), }) }, }, diff --git a/playground/ssr/__tests__/ssr.spec.ts b/playground/ssr/__tests__/ssr.spec.ts index c56223644..a3a8fa126 100644 --- a/playground/ssr/__tests__/ssr.spec.ts +++ b/playground/ssr/__tests__/ssr.spec.ts @@ -36,6 +36,15 @@ test(`deadlock doesn't happen for dynamic imports`, async () => { ) }) +test.runIf(isServe)('html proxy is encoded', async () => { + await page.goto( + `${url}?%22%3E%3C/script%3E%3Cscript%3Econsole.log(%27html%20proxy%20is%20not%20encoded%27)%3C/script%3E`, + ) + + expect(browserLogs).not.toContain('html proxy is not encoded') +}) + +// run this at the end to reduce flakiness test('should restart ssr', async () => { editFile('./vite.config.ts', (content) => content) await withRetry(async () => { @@ -47,23 +56,3 @@ test('should restart ssr', async () => { ) }) }) - -test.runIf(isServe)('html proxy is encoded', async () => { - try { - await page.goto( - `${url}?%22%3E%3C/script%3E%3Cscript%3Econsole.log(%27html%20proxy%20is%20not%20encoded%27)%3C/script%3E`, - ) - - expect(browserLogs).not.toContain('html proxy is not encoded') - } catch (e) { - // Ignore net::ERR_ABORTED, which is causing flakiness in this test - if ( - !( - e.message.includes('net::ERR_ABORTED') || - e.message.includes('interrupted') - ) - ) { - throw e - } - } -})