diff --git a/packages/vite/rollup.config.ts b/packages/vite/rollup.config.ts index a83fcc5e62f525..8ae7eeb8a5268c 100644 --- a/packages/vite/rollup.config.ts +++ b/packages/vite/rollup.config.ts @@ -202,7 +202,7 @@ function createModuleRunnerConfig(isProduction: boolean) { isProduction ? false : './dist/node', ), esbuildMinifyPlugin({ minify: false, minifySyntax: true }), - bundleSizeLimit(47), + bundleSizeLimit(48), ], }) } @@ -335,7 +335,9 @@ const __require = require; // don't patch runner utils chunk because it should stay lightweight and we know it doesn't use require if ( chunk.name === 'utils' && - chunk.moduleIds.some((id) => id.endsWith('/ssr/module-runner/utils.ts')) + chunk.moduleIds.some((id) => + id.endsWith('/ssr/module-runner/utils.ts'.replace(/\//g, path.sep)), + ) ) return const match = code.match(/^(?:import[\s\S]*?;\s*)+/) diff --git a/packages/vite/rollup.dts.config.ts b/packages/vite/rollup.dts.config.ts index 1848c2c2b5ba04..30ee179ba0615e 100644 --- a/packages/vite/rollup.dts.config.ts +++ b/packages/vite/rollup.dts.config.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import path from 'node:path' import { findStaticImports } from 'mlly' import { defineConfig } from 'rollup' import type { Plugin, PluginContext, RenderedChunk } from 'rollup' @@ -92,11 +93,17 @@ function patchTypes(): Plugin { } }, renderChunk(code, chunk) { - if ( - chunk.fileName.startsWith('module-runner') || - chunk.fileName.startsWith('types.d-') - ) { - validateRunnerChunk.call(this, chunk) + const directory = (dir: string) => `${path.sep}${dir}${path.sep}` + const isNonMainChunk = + chunk.moduleIds.length && + chunk.moduleIds.every( + (id) => + id.includes(directory('module-runner')) || + id.includes(directory('shared')), + ) + + if (isNonMainChunk) { + validateNonMainChunk.call(this, chunk) } else { validateChunkImports.call(this, chunk) code = replaceConfusingTypeNames.call(this, code, chunk) @@ -111,11 +118,12 @@ function patchTypes(): Plugin { /** * Runner chunk should only import local dependencies to stay lightweight */ -function validateRunnerChunk(this: PluginContext, chunk: RenderedChunk) { +function validateNonMainChunk(this: PluginContext, chunk: RenderedChunk) { for (const id of chunk.imports) { if ( !id.startsWith('./') && !id.startsWith('../') && + !id.startsWith('remoteTransport.d') && !id.startsWith('types.d') ) { this.warn(`${chunk.fileName} imports "${id}" which is not allowed`) @@ -134,6 +142,7 @@ function validateChunkImports(this: PluginContext, chunk: RenderedChunk) { !id.startsWith('./') && !id.startsWith('../') && !id.startsWith('node:') && + !id.startsWith('remoteTransport.d') && !id.startsWith('types.d') && !id.startsWith('vite/') && !deps.includes(id) && diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 9037e2535b8bc1..3efa370d71b250 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -39,7 +39,7 @@ import { import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandler } from './hmrHandler' import { enableSourceMapSupport } from './sourcemap/index' -import type { RunnerTransport } from './runnerTransport' +import { RemoteRunnerTransport, type RunnerTransport } from './runnerTransport' interface ModuleRunnerDebugger { (formatter: unknown, ...args: unknown[]): void @@ -74,6 +74,9 @@ export class ModuleRunner { ) { this.moduleCache = options.moduleCache ?? new ModuleCacheMap(options.root) this.transport = options.transport + if (options.transport instanceof RemoteRunnerTransport) { + options.transport.register(this) + } if (typeof options.hmr === 'object') { this.hmrClient = new HMRClient( options.hmr.logger === false diff --git a/packages/vite/src/module-runner/runnerTransport.ts b/packages/vite/src/module-runner/runnerTransport.ts index f946d956342c25..ded8fcb650f948 100644 --- a/packages/vite/src/module-runner/runnerTransport.ts +++ b/packages/vite/src/module-runner/runnerTransport.ts @@ -1,83 +1,50 @@ +import type { + TransportMethods, + TransportOptions, +} from '../shared/remoteTransport' +import { RemoteTransport } from '../shared/remoteTransport' import type { FetchFunction, FetchResult } from './types' +import type { ModuleRunner } from './runner' 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) +interface RemoteRunnerTransportOptions + extends Omit, 'methods'> { + methods?: M +} - if (data.e) { - promise.reject(data.e) - } else { - promise.resolve(data.r) - } +export class RemoteRunnerTransport< + M extends TransportMethods = {}, + E extends TransportMethods = {}, + > + extends RemoteTransport + implements RunnerTransport +{ + private _runner: ModuleRunner | undefined + + constructor(options: RemoteRunnerTransportOptions) { + super({ + ...options, + methods: { + ...(options.methods as M), + evaluate: async (url: string) => { + if (!this._runner) { + throw new Error('[vite-transport] Runner is not registered') + } + await this._runner.import(url) + }, + }, }) } - private resolve(method: string, ...args: any[]) { - const promiseId = nanoid() - this.options.send({ - __v: true, - m: method, - a: args, - i: promiseId, - }) - - return new Promise((resolve, reject) => { - const timeout = this.options.timeout ?? 60000 - let timeoutId - if (timeout > 0) { - timeoutId = setTimeout(() => { - this.rpcPromises.delete(promiseId) - reject( - new Error( - `${method}(${args.map((arg) => JSON.stringify(arg)).join(', ')}) timed out after ${timeout}ms`, - ), - ) - }, timeout) - timeoutId?.unref?.() - } - this.rpcPromises.set(promiseId, { resolve, reject, timeoutId }) - }) + async fetchModule(id: string, importer?: string): Promise { + // fetchModule is a special method that we don't expoe in types + return await (this.invoke as any)('fetchModule', id, importer) } - fetchModule(id: string, importer?: string): Promise { - return this.resolve('fetchModule', id, importer) + register(runner: ModuleRunner): void { + this._runner = runner } } - -// port from nanoid -// https://github.com/ai/nanoid -const urlAlphabet = - 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' -function nanoid(size = 21) { - let id = '' - let i = size - while (i--) id += urlAlphabet[(Math.random() * 64) | 0] - return id -} diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 74706acdff24a8..bb39a7c2aa02b3 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -17,9 +17,9 @@ export { transformWithEsbuild } from './plugins/esbuild' export { buildErrorMessage } from './server/middlewares/error' export { RemoteEnvironmentTransport } from './server/environmentTransport' -export { createNodeDevEnvironment } from './server/environments/nodeEnvironment' export { DevEnvironment, type DevEnvironmentSetup } from './server/environment' export { BuildEnvironment } from './build' +export { createNodeDevEnvironment } from './server/environments/nodeEnvironment' export { fetchModule, type FetchModuleOptions } from './ssr/fetchModule' export { createServerModuleRunner } from './ssr/runtime/serverModuleRunner' diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index fd7d0860e3124e..452e8f7d70e872 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -21,12 +21,15 @@ import type { TransformResult } from './transformRequest' import { ERR_CLOSED_SERVER } from './pluginContainer' import type { RemoteEnvironmentTransport } from './environmentTransport' +export interface DevEnvironmentRunnerOptions extends FetchModuleOptions { + transport?: RemoteEnvironmentTransport +} + export interface DevEnvironmentSetup { hot?: false | HMRChannel options?: EnvironmentOptions - runner?: FetchModuleOptions & { - transport?: RemoteEnvironmentTransport - } + runner?: DevEnvironmentRunnerOptions + depsOptimizer?: DepsOptimizer } // Maybe we will rename this to DevEnvironment @@ -38,7 +41,7 @@ export class DevEnvironment extends Environment { /** * @internal */ - _ssrRunnerOptions: FetchModuleOptions | undefined + _ssrRunnerOptions: DevEnvironmentRunnerOptions | undefined /** * HMR channel for this environment. If not provided or disabled, * it will be a noop channel that does nothing. @@ -50,14 +53,7 @@ export class DevEnvironment extends Environment { constructor( server: ViteDevServer, name: string, - setup?: { - hot?: false | HMRChannel - options?: EnvironmentOptions - runner?: FetchModuleOptions & { - transport?: RemoteEnvironmentTransport - } - depsOptimizer?: DepsOptimizer - }, + setup?: DevEnvironmentSetup, ) { let options = server.config.environments[name] ?? @@ -80,7 +76,7 @@ export class DevEnvironment extends Environment { const ssrRunnerOptions = setup?.runner || {} this._ssrRunnerOptions = ssrRunnerOptions - setup?.runner?.transport?.register(this) + ssrRunnerOptions.transport?.register(this) this.hot.on('vite:invalidate', async ({ path, message }) => { invalidateModule(this, { @@ -137,6 +133,15 @@ export class DevEnvironment extends Environment { }) } + async evaluate(url: string): Promise { + if (!this._ssrRunnerOptions?.transport) { + throw new Error( + `Cannot evaluate module ${url} in ${this.name} environment without a configured remote transport.`, + ) + } + await this._ssrRunnerOptions.transport.evaluate(url) + } + async close(): Promise { await this.depsOptimizer?.close() } diff --git a/packages/vite/src/node/server/environmentTransport.ts b/packages/vite/src/node/server/environmentTransport.ts index 4340c144adc615..a518e5e85c1324 100644 --- a/packages/vite/src/node/server/environmentTransport.ts +++ b/packages/vite/src/node/server/environmentTransport.ts @@ -1,38 +1,42 @@ +import type { + TransportMethods, + TransportOptions, +} from '../../shared/remoteTransport' +import { RemoteTransport } from '../../shared/remoteTransport' 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 +interface RemoteEnvironmentTransportOptions + extends Omit, 'methods'> { + methods?: M +} - const method = data.m as 'fetchModule' - const parameters = data.a as [string, string] +export class RemoteEnvironmentTransport< + M extends TransportMethods = {}, + E extends TransportMethods = {}, +> extends RemoteTransport { + private _environment: DevEnvironment | undefined - 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, - }) - } + constructor(options: RemoteEnvironmentTransportOptions) { + super({ + ...options, + methods: { + ...(options.methods as M), + fetchModule: (url: string) => { + if (!this._environment) { + throw new Error('[vite-transport] Environment is not registered') + } + return this._environment.fetchModule(url) + }, + }, }) } + + async evaluate(url: string): Promise { + // evaluate is a special method that we don't expoe in types + await (this.invoke as any)('evaluate', url) + } + + register(environment: DevEnvironment): void { + this._environment = environment + } } diff --git a/packages/vite/src/node/server/environments/nodeEnvironment.ts b/packages/vite/src/node/server/environments/nodeEnvironment.ts index 152970f313c44c..1f21ff9dbaa159 100644 --- a/packages/vite/src/node/server/environments/nodeEnvironment.ts +++ b/packages/vite/src/node/server/environments/nodeEnvironment.ts @@ -1,14 +1,16 @@ +import type { ModuleRunner } from 'vite/module-runner' import type { DevEnvironmentSetup } from '../environment' import { DevEnvironment } from '../environment' import type { ViteDevServer } from '../index' import { asyncFunctionDeclarationPaddingLineCount } from '../../../shared/utils' +import { createServerModuleRunner } from '../../ssr/runtime/serverModuleRunner' export function createNodeDevEnvironment( server: ViteDevServer, name: string, options?: DevEnvironmentSetup, ): DevEnvironment { - return new DevEnvironment(server, name, { + return new NodeDevEnvironment(server, name, { ...options, runner: { processSourceMap(map) { @@ -22,3 +24,13 @@ export function createNodeDevEnvironment( }, }) } + +class NodeDevEnvironment extends DevEnvironment { + private runner: ModuleRunner | undefined + + override async evaluate(url: string): Promise { + if (this._ssrRunnerOptions?.transport) return super.evaluate(url) + const runner = this.runner || (this.runner = createServerModuleRunner(this)) + await runner.import(url) + } +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs index bc617d0300e69d..e3db7fa7163057 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs @@ -1,6 +1,6 @@ // @ts-check -import { BroadcastChannel, parentPort } from 'node:worker_threads' +import { parentPort } from 'node:worker_threads' import { fileURLToPath } from 'node:url' import { ESModulesEvaluator, ModuleRunner, RemoteRunnerTransport } from 'vite/module-runner' @@ -17,19 +17,18 @@ const runner = new ModuleRunner( }, send: message => { parentPort?.postMessage(message) + }, + methods: { + import(id) { + return runner.import(id) + }, + ping() { + return runner.transport.invoke('pong', 'ping') + } } }) }, new ESModulesEvaluator(), ) -const channel = new BroadcastChannel('vite-worker') -channel.onmessage = async (message) => { - try { - const mod = await runner.import(message.data.id) - channel.postMessage({ result: mod.default }) - } catch (e) { - channel.postMessage({ error: e.stack }) - } -} parentPort.postMessage('ready') \ No newline at end of file diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts index d829620583f6d3..22dfb20f034d2d 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts @@ -1,12 +1,12 @@ -import { BroadcastChannel, Worker } from 'node:worker_threads' -import { describe, expect, it, onTestFinished } from 'vitest' -import { DevEnvironment } from '../../../server/environment' +import { Worker } from 'node:worker_threads' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { createServer } from '../../../server' +import { DevEnvironment } from '../../../server/environment' import { RemoteEnvironmentTransport } from '../../..' describe('running module runner inside a worker', () => { it('correctly runs ssr code', async () => { - expect.assertions(1) + expect.assertions(6) const worker = new Worker( new URL('./fixtures/worker.mjs', import.meta.url), { @@ -17,51 +17,96 @@ describe('running module runner inside a worker', () => { worker.on('message', () => resolve()) worker.on('error', reject) }) - const server = await createServer({ - root: __dirname, - logLevel: 'error', - server: { - middlewareMode: true, - watch: null, - hmr: { - port: 9609, - }, + + const pong = vi.fn(() => 'pong') + const transform = vi.fn(() => `export const test = true`) + const transport = new RemoteEnvironmentTransport< + { + pong: (data: string) => void }, - environments: { - worker: { - dev: { - createEnvironment: (server) => { - return new DevEnvironment(server, 'worker', { - runner: { - transport: new RemoteEnvironmentTransport({ - send: (data) => worker.postMessage(data), - onMessage: (handler) => worker.on('message', handler), - }), - }, - }) - }, - }, - }, + { + import: (url: string) => { default: string } + ping: () => string + } + >({ + send: (data) => worker.postMessage(data), + onMessage: (handler) => worker.on('message', handler), + methods: { + pong, }, }) + + const server = await createWorkerServer(transport, transform) onTestFinished(() => { - server.close() worker.terminate() }) - const channel = new BroadcastChannel('vite-worker') - return new Promise((resolve, reject) => { - channel.onmessage = (event) => { - try { - expect((event as MessageEvent).data).toEqual({ - result: 'hello world', - }) - } catch (e) { - reject(e) - } finally { - resolve() - } - } - channel.postMessage({ id: './fixtures/default-string.ts' }) - }) + + // cross communication works + const testModule = await transport.invoke( + 'import', + './fixtures/default-string.ts', + ) + expect(testModule.default).toBe('hello world') + + expect(pong).not.toHaveBeenCalled() + + const pinPong = await transport.invoke('ping') + expect(pinPong).toBe('pong') + expect(pong).toHaveBeenCalled() + + expect(transform).not.toHaveBeenCalled() + + await server.environments.worker.evaluate('virtual:worker-test') + + expect(transform).toHaveBeenCalled() }) }) + +async function createWorkerServer( + transport: RemoteEnvironmentTransport, + transform: () => string, +) { + const server = await createServer({ + root: __dirname, + logLevel: 'error', + server: { + middlewareMode: true, + watch: null, + hmr: { + port: 9609, + }, + }, + plugins: [ + { + name: 'test:worker', + resolveId(id) { + if (id === 'virtual:worker-test') { + return `\0virtual:worker-test` + } + }, + load(id) { + if (id === '\0virtual:worker-test') { + return transform() + } + }, + }, + ], + environments: { + worker: { + dev: { + createEnvironment: (server) => { + return new DevEnvironment(server, 'worker', { + runner: { + transport, + }, + }) + }, + }, + }, + }, + }) + onTestFinished(() => { + server.close() + }) + return server +} diff --git a/packages/vite/src/shared/remoteTransport.ts b/packages/vite/src/shared/remoteTransport.ts new file mode 100644 index 00000000000000..b5f84ae138bd8d --- /dev/null +++ b/packages/vite/src/shared/remoteTransport.ts @@ -0,0 +1,141 @@ +export interface TransportOptions { + send: (data: RequestEvent | ResponseEvent) => void + onMessage: (handler: (data: any) => void) => void + methods: M + timeout?: number +} + +export type TransportMethods = Record any> + +export interface ResponseEvent { + __v: 's' + /** result */ + r?: any + /** error */ + e?: any + /** id */ + i: string +} + +export interface RequestEvent { + __v: 'q' + /** method name */ + m: string + /** parameters */ + a: any[] + /** id */ + i: string +} + +export class RemoteTransport< + // events that will be called when this transport is envoked from the other side of the RPC + M extends TransportMethods = {}, + // events that will be called on the other side of the RPC, only used in types + E extends TransportMethods = {}, +> { + private readonly _rpcPromises = new Map< + string, + { + resolve: (data: any) => void + reject: (data: any) => void + timeoutId?: any + } + >() + + constructor(private readonly _options: TransportOptions) { + this._options.onMessage(async (_data) => { + if (typeof _data !== 'object' || !_data || !_data.__v) return + + const data = _data as RequestEvent | ResponseEvent + + if (data.__v === 'q') { + await this._resolveRequest(data) + } else { + await this._resolveResponse(data) + } + }) + } + + invoke( + method: K, + ...args: Parameters + ): Promise> { + const promiseId = nanoid() + this._sendRequest({ + m: method, + a: args, + i: promiseId, + }) + return new Promise((resolve, reject) => { + const timeout = this._options.timeout ?? 60000 + let timeoutId + if (timeout > 0) { + timeoutId = setTimeout(() => { + this._rpcPromises.delete(promiseId) + reject(new Error(`${method} timed out after ${timeout}ms`)) + }, timeout) + timeoutId?.unref?.() + } + this._rpcPromises.set(promiseId, { resolve, reject, timeoutId }) + }) + } + + private async _resolveResponse(data: ResponseEvent) { + 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 async _resolveRequest(data: RequestEvent) { + const method = data.m + const parameters = data.a + try { + if (!(method in this._options.methods)) { + throw new Error(`Method not found: ${method}`) + } + + const result = await this._options.methods[method](...parameters) + this._sendResponse({ + r: result, + i: data.i, + }) + } catch (err: any) { + this._sendResponse({ + e: { + name: err.name, + message: err.message, + stack: err.stack, + }, + i: data.i, + }) + } + } + + private _sendResponse(data: Omit) { + this._options.send({ __v: 's', ...data }) + } + + private _sendRequest(data: Omit) { + this._options.send({ __v: 'q', ...data }) + } +} + +// port from nanoid +// https://github.com/ai/nanoid +const urlAlphabet = + 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' +function nanoid(size = 21) { + let id = '' + let i = size + while (i--) id += urlAlphabet[(Math.random() * 64) | 0] + return id +}