Skip to content

Commit

Permalink
feat: use a single transport for fetchModule and HMR support (vitejs#…
Browse files Browse the repository at this point in the history
…18362)

Co-authored-by: Vladimir <[email protected]>
  • Loading branch information
sapphi-red and sheremet-va authored Nov 7, 2024
1 parent 643928c commit 78dc490
Show file tree
Hide file tree
Showing 31 changed files with 1,155 additions and 746 deletions.
2 changes: 1 addition & 1 deletion docs/guide/api-environment-instances.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
167 changes: 89 additions & 78 deletions docs/guide/api-environment-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ function createWorkedEnvironment(
dev: {
createEnvironment(name, config) {
return createWorkerdDevEnvironment(name, config, {
hot: customHotChannel(),
hot: true,
transport: customHotChannel(),
})
},
},
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(),
)
Expand All @@ -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.
Expand All @@ -197,10 +194,6 @@ export interface ModuleRunnerOptions {
hmr?:
| false
| {
/**
* Configure how HMR communicates between client and server.
*/
connection: ModuleRunnerHMRConnection
/**
* Configure HMR logger.
*/
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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'
Expand All @@ -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()
},
},
Expand All @@ -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")`).

`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))
}
```
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")`).
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.
3 changes: 2 additions & 1 deletion packages/vite/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
}),
Expand Down Expand Up @@ -186,7 +187,7 @@ const moduleRunnerConfig = defineConfig({
],
plugins: [
...createSharedNodePlugins({ esbuildOptions: { minifySyntax: true } }),
bundleSizeLimit(50),
bundleSizeLimit(53),
],
})

Expand Down
Loading

0 comments on commit 78dc490

Please sign in to comment.