feat: use a single transport for fetchModule and HMR support (#18362)

Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
This commit is contained in:
翠 / green 2024-11-07 11:24:48 +09:00 committed by GitHub
parent 643928ceeb
commit 78dc4902ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1156 additions and 747 deletions

View File

@ -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.

View File

@ -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: {
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> | void
disconnect?(): Promise<void> | void
send?(data: HotPayload): Promise<void> | 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.

View File

@ -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),
],
})

View File

@ -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,16 +34,41 @@ 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
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) {
fallback = () => {
// fallback to connecting directly to the hmr server
// for servers which does not support proxying websocket
socket = setupWebSocket(socketProtocol, directSocketHost, () => {
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 +
@ -51,75 +80,29 @@ try {
` (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 },
)
}
}
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
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()
console.error(`[vite] failed to connect to websocket (${e}). `)
throw e
}
})
},
async disconnect() {
await wsTransport.disconnect()
},
send(data) {
wsTransport.send(data)
},
}
})(),
)
return socket
let willUnload = false
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
willUnload = true
})
}
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<boolean>((resolve) => {
function onOpen() {
resolve(true)

View File

@ -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

View File

@ -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,

View File

@ -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.hmrClient = new HMRClient(
options.hmr.logger === false
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 || hmrLogger,
options.hmr.connection,
: options.hmr.logger
this.hmrClient = new HMRClient(
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, {
: await this.transport.invoke('fetchModule', [
url,
importer,
{
cached: isCached,
startOffset: this.evaluator.startOffset,
})
},
])
) as ResolvedResult
if ('cache' in fetchedModule) {

View File

@ -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<T>(method: string, ...args: any[]) {
const promiseId = nanoid()
this.options.send({
__v: true,
m: method,
a: args,
i: promiseId,
})
return new Promise<T>((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<FetchResult> {
return this.resolve<FetchResult>('fetchModule', id, importer)
}
}

View File

@ -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<any>
}
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<FetchResult>
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.
*/

View File

@ -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<DevEnvironmentOptions>
@ -608,8 +600,6 @@ export function resolveDevEnvironmentOptions(
dev?.createEnvironment ??
(environmentName === 'client'
? defaultCreateClientDevEnvironment
: environmentName === 'ssr'
? defaultCreateSsrDevEnvironment
: defaultCreateDevEnvironment),
recoverable: dev?.recoverable ?? consumer === 'client',
moduleRunnerTransform:

View File

@ -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,

View File

@ -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 {

View File

@ -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

View File

@ -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(

View File

@ -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,
})
}
})
}
}

View File

@ -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(

View File

@ -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<T extends string = string> = (
data: InferCustomEventPayload<T>,
client: HotChannelClient,
) => void
export interface HotChannel<Api = any> {
/**
* Broadcast events to all clients
*/
send?(payload: HotPayload): void
/**
* Handle custom event emitted by `import.meta.hot.send`
*/
on?<T extends string>(event: T, listener: HotChannelListener<T>): 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<unknown> | 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<Api = any> {
/**
* Broadcast events to all clients
*/
@ -99,8 +147,7 @@ export interface HotChannel {
event: T,
listener: (
data: InferCustomEventPayload<T>,
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<unknown> | 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<void>,
(data: any, client: HotChannelClient) => void | Promise<void>
>()
const listenersForEvents = new Map<
string,
Set<(data: any, client: HotChannelClient) => void | Promise<void>>
>()
let invokeHandlers: InvokeMethods | undefined
let listenerForInvokeHandler:
| ((data: InvokeSendData, client: HotChannelClient) => void)
| undefined
const handleInvoke = async <T extends keyof InvokeMethods>(
payload: HotPayload,
) => {
if (!invokeHandlers) {
return {
e: {
name: 'TransportError',
message: 'invokeHandlers is not set',
stack: new Error().stack,
},
}
}
const data: InvokeSendData<T> = (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<string> {
}
}
export interface ServerHotChannel extends HotChannel {
api: {
export type ServerHotChannelApi = {
innerEmitter: EventEmitter
outsideEmitter: EventEmitter
}
}
export type ServerHotChannel = HotChannel<ServerHotChannelApi>
export type NormalizedServerHotChannel =
NormalizedHotChannel<ServerHotChannelApi>
/** @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

View File

@ -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<T> = (
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<WebSocketClient>
/**
* Disconnect all clients and terminate the server.
*/
close(): Promise<void>
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<void>
[isWebSocketServer]: true
/**
* Get all connected clients.
*/
clients: Set<WebSocketClient>
}
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,44 +270,9 @@ 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 {
if (!customListeners.has(event)) {
customListeners.set(event, new Set())
}
customListeners.get(event)!.add(fn)
}
}) as WebSocketServer['on'],
off: ((event: string, fn: () => void) => {
if (wsServerEvents.includes(event)) {
wss.off(event, fn)
} else {
customListeners.get(event)?.delete(fn)
}
}) as WebSocketServer['off'],
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]
}
const normalizedHotChannel = normalizeHotChannel(
{
send(payload) {
if (payload.type === 'error' && !wss.clients.size) {
bufferedError = payload
return
@ -319,14 +286,25 @@ export function createWebSocketServer(
}
})
},
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) => {
return new Promise<void>((resolve, reject) => {
wss.clients.forEach((client) => {
client.terminate()
})
@ -349,5 +327,33 @@ export function createWebSocketServer(
})
})
},
},
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: any) => {
if (wsServerEvents.includes(event)) {
wss.off(event, 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))
},
}
}

View File

@ -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(),
)

View File

@ -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),
})
},
},

View File

@ -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
}
}

View File

@ -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),
},

View File

@ -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<string, any>
@ -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,
},

View File

@ -1358,21 +1358,6 @@ export function isDevServer(
return 'pluginContainer' in server
}
export interface PromiseWithResolvers<T> {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (reason?: any) => void
}
export function promiseWithResolvers<T>(): PromiseWithResolvers<T> {
let resolve: any
let reject: any
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
export function createSerialPromiseQueue<T>(): {
run(f: () => Promise<T>): Promise<T>
} {

View File

@ -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<string, ((data: any) => 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<T extends string>(event: T, data?: InferCustomEventPayload<T>): 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<string, HotModule>()
public disposeMap = new Map<string, (data: any) => void | Promise<void>>()
@ -199,16 +171,12 @@ export class HMRClient {
public customListenersMap: CustomListenersMap = new Map()
public ctxToListenersMap = new Map<string, CustomListenersMap>()
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<ModuleNamespace>,
) {
this.messenger = new HMRMessenger(connection)
}
) {}
public async notifyListeners<T extends string>(
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()

View File

@ -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<InvokeMethods[T]>
}
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<ReturnType<InvokeMethods[T]>>; e?: undefined }
| { r?: undefined; e: any }
}
export type InvokeMethods = {
fetchModule: (
id: string,
importer?: string,
options?: FetchFunctionOptions,
) => Promise<FetchResult>
}

View File

@ -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> | void
disconnect?(): Promise<void> | void
send?(data: HotPayload): Promise<void> | void
invoke?(
data: HotPayload,
): Promise<{ /** result */ r: any } | { /** error */ e: any }>
timeout?: number
}
type InvokeableModuleRunnerTransport = Omit<ModuleRunnerTransport, 'invoke'> & {
invoke<T extends keyof InvokeMethods>(
name: T,
data: Parameters<InvokeMethods[T]>,
): Promise<ReturnType<Awaited<InvokeMethods[T]>>>
}
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<typeof setTimeout>
}
>()
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<T extends keyof InvokeMethods>(
name: T,
data: Parameters<InvokeMethods[T]>,
) {
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<ReturnType<Awaited<InvokeMethods[T]>>>()
const timeout = transport.timeout ?? 60000
let timeoutId: ReturnType<typeof setTimeout> | 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> | void
disconnect?(): Promise<void> | void
send(data: HotPayload): void
invoke<T extends keyof InvokeMethods>(
name: T,
data: Parameters<InvokeMethods[T]>,
): Promise<ReturnType<Awaited<InvokeMethods[T]>>>
}
export const normalizeModuleRunnerTransport = (
transport: ModuleRunnerTransport,
): NormalizedModuleRunnerTransport => {
const invokeableTransport = createInvokeableTransport(transport)
let isConnected = !invokeableTransport.connect
let connectingPromise: Promise<void> | undefined
return {
...(transport as Omit<ModuleRunnerTransport, 'connect'>),
...(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<ModuleRunnerTransport, 'connect' | 'disconnect' | 'send'>
> => {
const pingInterval = options.pingInterval ?? 30000
// eslint-disable-next-line n/no-unsupported-features/node-builtins
let ws: WebSocket | undefined
let pingIntervalId: ReturnType<typeof setInterval> | 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<void>((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))
},
}
}

View File

@ -67,3 +67,18 @@ export function getAsyncFunctionDeclarationPaddingLineCount(): number {
}
return asyncFunctionDeclarationPaddingLineCount
}
export interface PromiseWithResolvers<T> {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (reason?: any) => void
}
export function promiseWithResolvers<T>(): PromiseWithResolvers<T> {
let resolve: any
let reject: any
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}

View File

@ -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[]

View File

@ -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(),
})
},
},

View File

@ -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
}
}
})