diff --git a/.changeset/ninety-queens-count.md b/.changeset/ninety-queens-count.md new file mode 100644 index 0000000000..b1232535e4 --- /dev/null +++ b/.changeset/ninety-queens-count.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Fix `Cannot perform I/O on behalf of a different request` errors for deferred dynamic imports + +Concurrent requests that loaded the same dynamic import were previously sharing the same promise to resolve it in a Worker context. We now ensure that all imports execute within a Durable Object's IoContext before the result is returned to the Worker. diff --git a/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts b/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts index 7f934a4980..c7f1bac97e 100644 --- a/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts +++ b/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts @@ -1,5 +1,9 @@ import { DurableObject } from "cloudflare:workers"; -import { ModuleRunner, ssrModuleExportsKey } from "vite/module-runner"; +import { + ModuleRunner, + ssrDynamicImportKey, + ssrModuleExportsKey, +} from "vite/module-runner"; import { ENVIRONMENT_NAME_HEADER, INIT_PATH, @@ -10,11 +14,6 @@ import { } from "../../shared"; import { stripInternalEnv } from "./env"; import type { WrapperEnv } from "./env"; -import type { - EvaluatedModuleNode, - ModuleEvaluator, - ModuleRunnerOptions, -} from "vite/module-runner"; declare global { // This global variable is accessed by `@vitejs/plugin-rsc` @@ -24,69 +23,52 @@ declare global { ) => Promise; } -/** - * Custom `ModuleRunner`. - * The `cachedModule` method is overridden to ensure compatibility with the Workers runtime. - */ -// @ts-expect-error: `cachedModule` is private -class CustomModuleRunner extends ModuleRunner { - #env: WrapperEnv; - #environmentName: string; - - constructor( - options: ModuleRunnerOptions, - evaluator: ModuleEvaluator, - env: WrapperEnv, - environmentName: string - ) { - super(options, evaluator); - this.#env = env; - this.#environmentName = environmentName; - } - override async cachedModule( - url: string, - importer?: string - ): Promise { - const stub = this.#env.__VITE_RUNNER_OBJECT__.get("singleton"); - const moduleId = await stub.getFetchedModuleId( - this.#environmentName, - url, - importer - ); - const module = this.evaluatedModules.getModuleById(moduleId); - - if (!module) { - throw new Error(`Module "${moduleId}" is undefined`); - } - - return module; - } -} - /** Module runner instances keyed by environment name */ -const moduleRunners = new Map(); +const moduleRunners = new Map(); /** The parent environment name (set explicitly via IS_PARENT_ENVIRONMENT_HEADER) */ let parentEnvironmentName: string | undefined; -interface EnvironmentState { - webSocket: WebSocket; - concurrentModuleNodePromises: Map>; +let nextCallbackId = 0; +const pendingCallbacks = new Map Promise>(); +const callbackResults = new Map(); + +/** + * Executes a callback in the runner Durable Object's IoContext via RPC + shared memory. + * The callback function is stored in a module-scope map (shared with the DO + * since both run in the same V8 isolate). Only a numeric ID crosses the RPC + * boundary. + */ +async function runInRunnerObject( + env: WrapperEnv, + callback: () => Promise +): Promise { + const id = nextCallbackId++; + pendingCallbacks.set(id, callback); + + const stub = env.__VITE_RUNNER_OBJECT__.get("singleton"); + await stub.executeCallback(id); + + const result = callbackResults.get(id); + callbackResults.delete(id); + + return result; } /** - * Durable Object that creates the module runner and handles WebSocket communication with the Vite dev server. + * Durable Object that provides an IoContext for module evaluation and handles + * WebSocket communication with the Vite dev server. + * + * In workerd, a Durable Object has a single shared IoContext across all + * incoming events, so promises are freely shareable within the DO without + * cross-context issues. */ export class __VITE_RUNNER_OBJECT__ extends DurableObject { - /** Per-environment state containing WebSocket and concurrent module node promises */ - #environments = new Map(); + /** Per-environment WebSockets */ + #webSockets = new Map(); /** - * Handles fetch requests to initialize a module runner for an environment. * Creates a WebSocket pair for communication with the Vite dev server and initializes the ModuleRunner. - * @param request - The incoming fetch request - * @returns Response with WebSocket - * @throws Error if the path is invalid or the module runner is already initialized */ override async fetch(request: Request) { const { pathname } = new URL(request.url); @@ -116,90 +98,68 @@ export class __VITE_RUNNER_OBJECT__ extends DurableObject { if (isParentEnvironment) { parentEnvironmentName = environmentName; + + globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async ( + envName: string, + id: string + ): Promise => { + const runner = moduleRunners.get(envName); + + if (!runner) { + throw new Error( + `Module runner not initialized for environment: "${envName}". Do you need to set \`childEnvironments: ["${envName}"]\` in the plugin config?` + ); + } + + return runInRunnerObject(this.env, () => runner.import(id)); + }; } const { 0: client, 1: server } = new WebSocketPair(); server.accept(); - const environmentState: EnvironmentState = { - webSocket: server, - concurrentModuleNodePromises: new Map(), - }; - this.#environments.set(environmentName, environmentState); - const moduleRunner = await createModuleRunner( this.env, - environmentState.webSocket, + server, environmentName ); + moduleRunners.set(environmentName, moduleRunner); + this.#webSockets.set(environmentName, server); return new Response(null, { status: 101, webSocket: client }); } + /** * Sends data to the Vite dev server via the WebSocket for a specific environment. - * @param environmentName - The environment name - * @param data - The data to send as a string - * @throws Error if the WebSocket is not initialized */ send(environmentName: string, data: string): void { - const environmentState = this.#environments.get(environmentName); + const webSocket = this.#webSockets.get(environmentName); - if (!environmentState) { + if (!webSocket) { throw new Error( - `Module runner WebSocket not initialized for environment: "${environmentName}"` + `Module runner not initialized for environment: "${environmentName}"` ); } - environmentState.webSocket.send(data); + webSocket.send(data); } + /** - * Based on the implementation of `cachedModule` from Vite's `ModuleRunner`. - * Running this in the DO enables us to share promises across invocations. - * @param environmentName - The environment name - * @param url - The module URL - * @param importer - The module's importer - * @returns The ID of the fetched module + * Executes a callback stored in the module-scope `pendingCallbacks` map. + * The callback runs in the DO's IoContext, ensuring all promises created + * during execution belong to the DO's shared context. */ - async getFetchedModuleId( - environmentName: string, - url: string, - importer: string | undefined - ): Promise { - const moduleRunner = moduleRunners.get(environmentName); + async executeCallback(id: number): Promise { + const callback = pendingCallbacks.get(id); + pendingCallbacks.delete(id); - if (!moduleRunner) { - throw new Error( - `Module runner not initialized for environment: "${environmentName}"` - ); - } - - const environmentState = this.#environments.get(environmentName); - if (!environmentState) { - throw new Error( - `Environment state not found for environment: "${environmentName}"` - ); - } - - let cached = environmentState.concurrentModuleNodePromises.get(url); - - if (!cached) { - const cachedModule = moduleRunner.evaluatedModules.getModuleByUrl(url); - cached = moduleRunner - // @ts-expect-error: `getModuleInformation` is private - .getModuleInformation(url, importer, cachedModule) - .finally(() => { - environmentState.concurrentModuleNodePromises.delete(url); - }) as Promise; - environmentState.concurrentModuleNodePromises.set(url, cached); - } else { - // @ts-expect-error: `debug` is private - moduleRunner.debug?.("[module runner] using cached module info for", url); + if (!callback) { + throw new Error(`No pending callback with id ${id}`); } - const module = await cached; - - return module.id; + const result = await callback(); + callbackResults.set(id, result); } } @@ -215,7 +175,7 @@ async function createModuleRunner( webSocket: WebSocket, environmentName: string ) { - return new CustomModuleRunner( + return new ModuleRunner( { sourcemapInterceptor: "prepareStackTrace", transport: { @@ -260,6 +220,13 @@ async function createModuleRunner( }, { async runInlinedModule(context, transformed, module) { + // Wrap dynamic imports to route deferred dynamic imports + // through the DO's IoContext. + const originalDynamicImport = context[ssrDynamicImportKey]; + context[ssrDynamicImportKey] = (dep) => { + return runInRunnerObject(env, () => originalDynamicImport(dep)); + }; + const code = `"use strict";async (${Object.keys(context).join(",")})=>{${transformed}}`; const fn = env.__VITE_UNSAFE_EVAL__.eval(code, module.id); await fn(...Object.values(context)); @@ -280,18 +247,12 @@ async function createModuleRunner( return import(filepath); }, - }, - env, - environmentName + } ); } /** * Retrieves a specific export from a Worker entry module using the module runner. - * @param workerEntryPath - Path to the Worker entry module - * @param exportName - Name of the export to retrieve - * @returns The requested export value - * @throws Error if the module runner has not been initialized or the module does not define the requested export */ export async function getWorkerEntryExport( workerEntryPath: string, @@ -301,18 +262,16 @@ export async function getWorkerEntryExport( throw new Error(`Parent environment not initialized`); } - const moduleRunner = moduleRunners.get(parentEnvironmentName); - - if (!moduleRunner) { - throw new Error(`Module runner not initialized`); - } + const module = await globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__( + parentEnvironmentName, + VIRTUAL_WORKER_ENTRY + ); - const module = await moduleRunner.import(VIRTUAL_WORKER_ENTRY); const exportValue = typeof module === "object" && module !== null && exportName in module && - module[exportName]; + (module as Record)[exportName]; if (!exportValue) { throw new Error( @@ -323,44 +282,24 @@ export async function getWorkerEntryExport( return exportValue; } +/** + * Retrieves the export types of the Worker entry module. + */ export async function getWorkerEntryExportTypes() { if (!parentEnvironmentName) { throw new Error(`Parent environment not initialized`); } - const moduleRunner = moduleRunners.get(parentEnvironmentName); - - if (!moduleRunner) { - throw new Error(`Module runner not initialized`); - } + const { getExportTypes } = + (await globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__( + parentEnvironmentName, + VIRTUAL_EXPORT_TYPES + )) as { getExportTypes: (module: unknown) => unknown }; - const { getExportTypes } = await moduleRunner.import(VIRTUAL_EXPORT_TYPES); - const module = await moduleRunner.import(VIRTUAL_WORKER_ENTRY); + const module = await globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__( + parentEnvironmentName, + VIRTUAL_WORKER_ENTRY + ); return getExportTypes(module); } - -/** - * Imports a module from a specific environment's module runner. - * @param environmentName - The name of the environment to import from - * @param id - The module ID to import - * @returns The imported module - * @throws Error if the environment's module runner has not been initialized - */ -async function importFromEnvironment( - environmentName: string, - id: string -): Promise { - const moduleRunner = moduleRunners.get(environmentName); - - if (!moduleRunner) { - throw new Error( - `Module runner not initialized for environment: "${environmentName}". Do you need to set \`childEnvironments: ["${environmentName}"]\` in the plugin config?` - ); - } - - return moduleRunner.import(id); -} - -// Register the import function globally for use from worker code -globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = importFromEnvironment;