diff --git a/deno.json b/deno.json index 3c9d05c..f1dd589 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,8 @@ "exports": { ".": "./src/plugin.ts", "./server": "./src/server.ts", - "./internal/server": "./src/runtime/server.ts" + "./internal/server": "./src/runtime/server.ts", + "./internal/worker": "./src/runtime/worker.ts" }, "imports": { "@mary/array-fns": "jsr:@mary/array-fns@^0.1.6", diff --git a/src/dev/environment.ts b/src/dev/environment.ts index 2043bb4..50ea69c 100644 --- a/src/dev/environment.ts +++ b/src/dev/environment.ts @@ -406,6 +406,13 @@ export class DenoDevEnvironment extends DevEnvironment { this.#ipc.receive(message.payload); break; } + case 'invoke': { + // handleInvoke returns handler failures as { error } + this.hot.handleInvoke(message.payload).then((result) => { + this.#ipc.post({ type: 'result', id: message.id, result }); + }); + break; + } case 'log': { this.logger[message.level](message.message, { timestamp: true }); break; diff --git a/src/import-meta.ts b/src/import-meta.ts index 9efbd19..248bc53 100644 --- a/src/import-meta.ts +++ b/src/import-meta.ts @@ -31,6 +31,9 @@ const URL_MODULE_RE = new RegExp(`^${URL_MODULE_PREFIX}`); const URL_ID = 'dromi'; +const WORKER_RUNNER_URL = import.meta.resolve('./runtime/worker.ts'); +const MODULE_RUNNER_URL = import.meta.resolve('vite/module-runner'); + const cleanUrl = (url: string): string => { return url.replace(POSTFIX_RE, ''); }; @@ -104,12 +107,20 @@ const toSourceUrl = (file: string, postfix: string): string => { return `new URL(${JSON.stringify(pathToFileURL(file).href + postfix)})`; }; +const toWorkerRunnerUrl = (entry: string): string => { + const options = { entry, moduleRunner: MODULE_RUNNER_URL }; + const code = `import { runWorker } from ${JSON.stringify(WORKER_RUNNER_URL)}; ` + + `await runWorker(${JSON.stringify(options)});`; + + return `new URL(${JSON.stringify('data:text/javascript,' + encodeURIComponent(code))})`; +}; + const toReferenceUrl = (reference: string): string => { return `import.meta.ROLLDOWN_FILE_URL_${reference}_${URL_ID}`; }; /** - * resolves `new URL(path, import.meta.url)` to file URLs; copies files and bundles workers for production. + * resolves `new URL(path, import.meta.url)` for Deno; copies files and bundles workers for production. * * @param environment name of the environment running in Deno * @returns Vite plugin @@ -201,8 +212,8 @@ export const importMetaPlugin = (environment: string): Plugin => { const { file, postfix } = splitFileAndPostfix(id); if (ctx.environment.mode !== 'build') { - // workers can't use the main thread's module runner - return toSourceUrl(file, postfix[0] === '?' ? postfix : ''); + // workers need their own module runner + return toWorkerRunnerUrl(file + (postfix[0] === '?' ? postfix : '')); } let reference = workers.get(file); diff --git a/src/runtime/module-runner.ts b/src/runtime/module-runner.ts new file mode 100644 index 0000000..3e368af --- /dev/null +++ b/src/runtime/module-runner.ts @@ -0,0 +1,44 @@ +import type { ModuleRunner, ModuleRunnerImportMeta, ModuleRunnerOptions } from 'vite/module-runner'; + +type ModuleRunnerModule = typeof import('vite/module-runner'); + +/** creates a module runner with Deno import and `import.meta` support */ +export type CreateModuleRunner = (options: Pick) => ModuleRunner; + +/** + * loads the dev server's Vite module runner. + * + * @param url URL of `vite/module-runner` + * @returns Deno module runner factory + */ +export const loadModuleRunner = async (url: string): Promise => { + const { createDefaultImportMeta, ESModulesEvaluator, ModuleRunner }: ModuleRunnerModule = await import(url); + + class DenoEvaluator extends ESModulesEvaluator { + // import here because Deno doesn't resolve jsr: specifiers from inside npm packages such as Vite + override runExternalModule(file: string): Promise { + return import(file); + } + } + + // Deno lacks the node:module hooks used by Vite's createNodeImportMeta + const createImportMeta = (modulePath: string): ModuleRunnerImportMeta => { + const meta = createDefaultImportMeta(modulePath); + + return { + ...meta, + // evaluated modules are imports, not the process entrypoint + main: false, + resolve(specifier: string) { + if (/^\.\.?\//.test(specifier)) { + return new URL(specifier, meta.url).href; + } + + // match externalized imports: resolve from the runner, not the evaluated module + return import.meta.resolve(specifier); + }, + }; + }; + + return (options) => new ModuleRunner({ ...options, createImportMeta }, new DenoEvaluator()); +}; diff --git a/src/runtime/protocol.ts b/src/runtime/protocol.ts index 434e7c5..dec4805 100644 --- a/src/runtime/protocol.ts +++ b/src/runtime/protocol.ts @@ -32,8 +32,29 @@ export type ReadyMessage = { port: number; }; +/** worker module runner request to Vite's hot channel */ +export type InvokeMessage = { + type: 'invoke'; + /** request id, unique across workers */ + id: string; + payload: HotPayload; +}; + +/** response to an {@link InvokeMessage}, with the same `id` */ +export type ResultMessage = { + type: 'result'; + id: string; + result: { result: unknown } | { error: unknown }; +}; + /** message sent from the Vite host to the Deno process */ -export type HostMessage = HotMessage | InitMessage; +export type HostMessage = HotMessage | InitMessage | ResultMessage; /** message sent from the Deno process to the Vite host */ -export type RunnerMessage = HotMessage | LogMessage | ReadyMessage; +export type RunnerMessage = HotMessage | InvokeMessage | LogMessage | ReadyMessage; + +/** `BroadcastChannel` shared by the Deno main thread and its workers */ +export const WORKER_CHANNEL = 'dromi:worker'; + +/** worker requests and host responses relayed by the main thread over {@link WORKER_CHANNEL} */ +export type WorkerChannelMessage = InvokeMessage | ResultMessage; diff --git a/src/runtime/runner.ts b/src/runtime/runner.ts index 73da6e7..b0334d6 100644 --- a/src/runtime/runner.ts +++ b/src/runtime/runner.ts @@ -2,8 +2,7 @@ import process from 'node:process'; -import type { ModuleRunnerImportMeta } from 'vite/module-runner'; - +import { loadModuleRunner } from './module-runner.ts'; import { ERROR_HEADER, type HostMessage, @@ -11,9 +10,10 @@ import { type InitMessage, type RunnerMessage, type SerializedError, + WORKER_CHANNEL, + type WorkerChannelMessage, } from './protocol.ts'; -type ModuleRunnerModule = typeof import('vite/module-runner'); type HotPayload = HotMessage['payload']; const post = (message: RunnerMessage) => { @@ -26,6 +26,14 @@ const init = Promise.withResolvers(); const pendingHotPayloads: HotPayload[] = []; let onHotPayload: ((payload: HotPayload) => void) | undefined; +const workerChannel = new BroadcastChannel(WORKER_CHANNEL); + +workerChannel.onmessage = ({ data }: MessageEvent) => { + if (data.type === 'invoke') { + post(data); + } +}; + process.on('message', (message: HostMessage) => { switch (message.type) { case 'init': { @@ -40,40 +48,16 @@ process.on('message', (message: HostMessage) => { } break; } + case 'result': { + workerChannel.postMessage(message); + break; + } } }); const { entry, moduleRunner: moduleRunnerUrl } = await init.promise; -const { createDefaultImportMeta, ESModulesEvaluator, ModuleRunner }: ModuleRunnerModule = await import( - moduleRunnerUrl -); - -class DenoEvaluator extends ESModulesEvaluator { - // import here because Deno doesn't resolve jsr: specifiers from inside npm packages such as Vite - override runExternalModule(file: string): Promise { - return import(file); - } -} - -// Deno lacks the node:module hooks used by Vite's createNodeImportMeta -const createImportMeta = (modulePath: string): ModuleRunnerImportMeta => { - const meta = createDefaultImportMeta(modulePath); - - return { - ...meta, - // evaluated modules are imports, not the process entrypoint - main: false, - resolve(specifier: string) { - if (/^\.\.?\//.test(specifier)) { - return new URL(specifier, meta.url).href; - } - - // match externalized imports: resolve from the runner, not the evaluated module - return import.meta.resolve(specifier); - }, - }; -}; +const createRunner = await loadModuleRunner(moduleRunnerUrl); const serializeError = (error: unknown): SerializedError => { if (!(error instanceof Error)) { @@ -105,7 +89,7 @@ const redirectUnacceptedUpdate = (payload: HotPayload): HotPayload => { return payload; }; -const runner = new ModuleRunner({ +const runner = createRunner({ transport: { connect({ onMessage }) { const receive = (payload: HotPayload) => { @@ -135,8 +119,7 @@ const runner = new ModuleRunner({ }, }, }, - createImportMeta, -}, new DenoEvaluator()); +}); type EntryModule = { default: Deno.ServeDefaultExport }; diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts new file mode 100644 index 0000000..cd38c94 --- /dev/null +++ b/src/runtime/worker.ts @@ -0,0 +1,73 @@ +import { loadModuleRunner } from './module-runner.ts'; +import { + type InvokeMessage, + type ResultMessage, + WORKER_CHANNEL, + type WorkerChannelMessage, +} from './protocol.ts'; + +/** options for {@link runWorker} */ +export interface RunWorkerOptions { + /** absolute path to the worker module, with any query */ + entry: string; + /** URL of `vite/module-runner`, matching the `vite` that runs the dev server */ + moduleRunner: string; +} + +/** + * evaluates a module through Vite in a worker spawned by the dev server's Deno process. + * call before registering message listeners. + * + * @param options worker options + * @returns resolves after module evaluation + * @throws when the module fails to load or evaluate + */ +export const runWorker = async ({ entry, moduleRunner }: RunWorkerOptions): Promise => { + // buffer messages during async module loading to preserve native worker startup ordering + const early: MessageEvent[] = []; + const capture = (event: Event) => { + if (event instanceof MessageEvent) { + event.stopImmediatePropagation(); + early.push(event); + } + }; + globalThis.addEventListener('message', capture); + + const channel = new BroadcastChannel(WORKER_CHANNEL); + const invokes = new Map void>(); + + channel.onmessage = ({ data }: MessageEvent) => { + if (data.type === 'result') { + const resolve = invokes.get(data.id); + if (resolve) { + invokes.delete(data.id); + resolve(data.result); + } + } + }; + + const createRunner = await loadModuleRunner(moduleRunner); + const runner = createRunner({ + transport: { + invoke(payload) { + const id = crypto.randomUUID(); + const { promise, resolve } = Promise.withResolvers(); + invokes.set(id, resolve); + + const message: InvokeMessage = { type: 'invoke', id, payload }; + channel.postMessage(message); + + return promise; + }, + }, + // re-evaluation would leave existing message listeners registered + hmr: false, + }); + + await runner.import(entry); + + globalThis.removeEventListener('message', capture); + for (const { data, ports } of early) { + globalThis.dispatchEvent(new MessageEvent('message', { data, ports: [...ports] })); + } +};