Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ninety-queens-count.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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`
Expand All @@ -24,69 +23,52 @@ declare global {
) => Promise<unknown>;
}

/**
* 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<EvaluatedModuleNode> {
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<string, CustomModuleRunner>();
const moduleRunners = new Map<string, ModuleRunner>();

/** The parent environment name (set explicitly via IS_PARENT_ENVIRONMENT_HEADER) */
let parentEnvironmentName: string | undefined;

interface EnvironmentState {
webSocket: WebSocket;
concurrentModuleNodePromises: Map<string, Promise<EvaluatedModuleNode>>;
let nextCallbackId = 0;
const pendingCallbacks = new Map<number, () => Promise<unknown>>();
const callbackResults = new Map<number, unknown>();

/**
* 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<unknown>
): Promise<unknown> {
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<WrapperEnv> {
/** Per-environment state containing WebSocket and concurrent module node promises */
#environments = new Map<string, EnvironmentState>();
/** Per-environment WebSockets */
#webSockets = new Map<string, WebSocket>();

/**
* 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);
Expand Down Expand Up @@ -116,90 +98,68 @@ export class __VITE_RUNNER_OBJECT__ extends DurableObject<WrapperEnv> {

if (isParentEnvironment) {
parentEnvironmentName = environmentName;

globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async (
envName: string,
id: string
): Promise<unknown> => {
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<string> {
const moduleRunner = moduleRunners.get(environmentName);
async executeCallback(id: number): Promise<void> {
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<EvaluatedModuleNode>;
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);
}
}

Expand All @@ -215,7 +175,7 @@ async function createModuleRunner(
webSocket: WebSocket,
environmentName: string
) {
return new CustomModuleRunner(
return new ModuleRunner(
{
sourcemapInterceptor: "prepareStackTrace",
transport: {
Expand Down Expand Up @@ -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));
};
Comment thread
jamesopstad marked this conversation as resolved.

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));
Expand All @@ -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,
Expand All @@ -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<string, unknown>)[exportName];

if (!exportValue) {
throw new Error(
Expand All @@ -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<unknown> {
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;
Loading