mirror of
https://github.com/vitejs/vite.git
synced 2024-11-21 14:48:41 +00:00
feat: use a single transport for fetchModule and HMR support (#18362)
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
This commit is contained in:
parent
643928ceeb
commit
78dc4902ff
@ -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.
|
||||
|
@ -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> | 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.
|
||||
|
@ -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),
|
||||
],
|
||||
})
|
||||
|
||||
|
@ -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<boolean>((resolve) => {
|
||||
function onOpen() {
|
||||
resolve(true)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
@ -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,9 +600,7 @@ export function resolveDevEnvironmentOptions(
|
||||
dev?.createEnvironment ??
|
||||
(environmentName === 'client'
|
||||
? defaultCreateClientDevEnvironment
|
||||
: environmentName === 'ssr'
|
||||
? defaultCreateSsrDevEnvironment
|
||||
: defaultCreateDevEnvironment),
|
||||
: defaultCreateDevEnvironment),
|
||||
recoverable: dev?.recoverable ?? consumer === 'client',
|
||||
moduleRunnerTransform:
|
||||
dev?.moduleRunnerTransform ??
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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: {
|
||||
innerEmitter: EventEmitter
|
||||
outsideEmitter: EventEmitter
|
||||
}
|
||||
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
|
||||
|
@ -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,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<void>((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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
parentPort.postMessage('ready')
|
||||
|
@ -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),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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),
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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>
|
||||
} {
|
||||
|
@ -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()
|
||||
|
85
packages/vite/src/shared/invokeMethods.ts
Normal file
85
packages/vite/src/shared/invokeMethods.ts
Normal 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>
|
||||
}
|
315
packages/vite/src/shared/moduleRunnerTransport.ts
Normal file
315
packages/vite/src/shared/moduleRunnerTransport.ts
Normal 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))
|
||||
},
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
||||
|
5
packages/vite/types/hmrPayload.d.ts
vendored
5
packages/vite/types/hmrPayload.d.ts
vendored
@ -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[]
|
||||
|
@ -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(),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user