diff --git a/.changeset/evil-crabs-start.md b/.changeset/evil-crabs-start.md
new file mode 100644
index 000000000000..65a3d38fc6a5
--- /dev/null
+++ b/.changeset/evil-crabs-start.md
@@ -0,0 +1,7 @@
+---
+"@cloudflare/vite-plugin": patch
+---
+
+Fix crash when plugins send HMR events before runner initialization
+
+Previously, if another Vite plugin (such as `vite-plugin-vue-devtools`) sent HMR events during `configureServer` before the Cloudflare plugin had initialized its runner, the dev server would crash with `AssertionError: The WebSocket is undefined`. The environment's WebSocket send operations are now deferred until the runner is fully initialized, allowing early HMR events to be handled gracefully.
diff --git a/.changeset/fifty-radios-nail.md b/.changeset/fifty-radios-nail.md
new file mode 100644
index 000000000000..41c9e522d132
--- /dev/null
+++ b/.changeset/fifty-radios-nail.md
@@ -0,0 +1,9 @@
+---
+"wrangler": patch
+---
+
+Fix autoconfig package installation always failing at workspace roots
+
+When running autoconfig at the root of a monorepo workspace, package installation commands now include the appropriate workspace root flags (`--workspace-root` for pnpm, `-W` for yarn). This prevents errors like "Running this command will add the dependency to the workspace root" that previously occurred when configuring projects at the workspace root.
+
+Additionally, autoconfig now allows running at the workspace root if the root directory itself is listed as a workspace package (e.g., `workspaces: ["packages/*", "."]`).
diff --git a/packages/vite-plugin-cloudflare/src/__tests__/hmr-events.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/hmr-events.spec.ts
new file mode 100644
index 000000000000..654b20a4f320
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/src/__tests__/hmr-events.spec.ts
@@ -0,0 +1,59 @@
+import { fileURLToPath } from "node:url";
+import { cloudflare } from "@cloudflare/vite-plugin";
+import { createServer } from "vite";
+import { afterEach, describe, test } from "vitest";
+import type { Plugin, ViteDevServer } from "vite";
+
+const fixturesPath = fileURLToPath(new URL("./fixtures", import.meta.url));
+
+describe("HMR events", () => {
+ let server: ViteDevServer | undefined;
+
+ afterEach(async () => {
+ await server?.close();
+ server = undefined;
+ });
+
+ // Reference: https://github.com/cloudflare/workers-sdk/issues/11063
+ test("the environment handles early HMR events before runner initialization", async ({
+ expect,
+ }) => {
+ // Create a plugin that triggers HMR events early in configureServer.
+ // This mimics what vite-plugin-vue-devtools does - it sends HMR events
+ // before the cloudflare plugin has had a chance to call initRunner().
+ // Before the fix, this would crash with "AssertionError: The WebSocket is undefined"
+ const earlyHmrPlugin: Plugin = {
+ name: "early-hmr-plugin",
+ configureServer(viteDevServer) {
+ // Access the worker environment and try to send HMR events
+ // BEFORE the cloudflare plugin has called initRunner()
+ const workerEnv = viteDevServer.environments.my_worker;
+ if (workerEnv) {
+ workerEnv.hot.send("test-event", { data: "test" });
+ }
+ },
+ };
+
+ server = await createServer({
+ root: fixturesPath,
+ logLevel: "silent",
+ plugins: [
+ // Place the early HMR plugin BEFORE cloudflare to trigger HMR
+ // events before the runner is initialized
+ earlyHmrPlugin,
+ cloudflare({ inspectorPort: false, persistState: false }),
+ ],
+ });
+
+ await server.listen();
+
+ // Verify the server is responsive by making a request.
+ const address = server.resolvedUrls?.local[0];
+ if (!address) {
+ throw new Error("Server address is undefined");
+ }
+
+ const response = await fetch(address);
+ expect(response.ok).toBe(true);
+ });
+});
diff --git a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts
index 9c9159b9949c..fc22adf10808 100644
--- a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts
+++ b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts
@@ -27,6 +27,7 @@ export const MAIN_ENTRY_NAME = "index";
interface WebSocketContainer {
webSocket?: WebSocket;
+ messageBuffers?: string[];
}
const webSocketUndefinedError = "The WebSocket is undefined";
@@ -57,9 +58,15 @@ function createHotChannel(
return {
send(payload) {
const webSocket = webSocketContainer.webSocket;
- assert(webSocket, webSocketUndefinedError);
+ const message = JSON.stringify(payload);
- webSocket.send(JSON.stringify(payload));
+ if (!webSocket) {
+ webSocketContainer.messageBuffers ??= [];
+ webSocketContainer.messageBuffers.push(message);
+ return;
+ }
+
+ webSocket.send(message);
},
on(event: string, listener: vite.HotChannelListener) {
const listeners = listenersMap.get(event) ?? new Set();
@@ -86,11 +93,11 @@ function createHotChannel(
}
export class CloudflareDevEnvironment extends vite.DevEnvironment {
- #webSocketContainer: { webSocket?: WebSocket };
+ #webSocketContainer: WebSocketContainer;
constructor(name: string, config: vite.ResolvedConfig) {
// It would be good if we could avoid passing this object around and mutating it
- const webSocketContainer = {};
+ const webSocketContainer: WebSocketContainer = {};
super(name, config, {
hot: true,
transport: createHotChannel(webSocketContainer),
@@ -124,6 +131,14 @@ export class CloudflareDevEnvironment extends vite.DevEnvironment {
assert(webSocket, "Failed to establish WebSocket");
webSocket.accept();
this.#webSocketContainer.webSocket = webSocket;
+
+ if (this.#webSocketContainer.messageBuffers) {
+ for (const bufferedMessage of this.#webSocketContainer.messageBuffers) {
+ webSocket.send(bufferedMessage);
+ }
+
+ delete this.#webSocketContainer.messageBuffers;
+ }
}
async fetchWorkerExportTypes(
diff --git a/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts
index fdc4be0a7ed5..ce405e7cb767 100644
--- a/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts
+++ b/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts
@@ -118,10 +118,46 @@ describe("autoconfig details - getDetailsForAutoConfig()", () => {
await expect(
details.getDetailsForAutoConfig()
).rejects.toThrowErrorMatchingInlineSnapshot(
- `[Error: The Wrangler application detection logic has been run in the root of a workspace, this is not supported. Change your working directory to one of the applications in the workspace and try again.]`
+ `[Error: The Wrangler application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again.]`
);
});
+ it("should not bail when run in the root of a workspace if the root is included as a workspace package", async ({
+ expect,
+ }) => {
+ await seed({
+ "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n - '.'\n",
+ "package.json": JSON.stringify({
+ name: "my-workspace",
+ workspaces: ["packages/*", "."],
+ }),
+ "index.html": "
Hello World
",
+ "packages/my-app/package.json": JSON.stringify({ name: "my-app" }),
+ "packages/my-app/index.html": "Hello World
",
+ });
+
+ const result = await details.getDetailsForAutoConfig();
+
+ expect(result.isWorkspaceRoot).toBe(true);
+ expect(result.framework?.id).toBe("static");
+ });
+
+ it("should set isWorkspaceRoot to false for non-workspace projects", async ({
+ expect,
+ }) => {
+ await seed({
+ "package.json": JSON.stringify({
+ name: "my-app",
+ }),
+ "package-lock.json": JSON.stringify({ lockfileVersion: 3 }),
+ "index.html": "Hello World
",
+ });
+
+ const result = await details.getDetailsForAutoConfig();
+
+ expect(result.isWorkspaceRoot).toBe(false);
+ });
+
it("should warn when no lock file is detected (project may be inside a workspace)", async ({
expect,
}) => {
diff --git a/packages/wrangler/src/autoconfig/c3-vendor/packages.ts b/packages/wrangler/src/autoconfig/c3-vendor/packages.ts
index da25ac19c4c4..6eedbc716b57 100644
--- a/packages/wrangler/src/autoconfig/c3-vendor/packages.ts
+++ b/packages/wrangler/src/autoconfig/c3-vendor/packages.ts
@@ -11,6 +11,7 @@ type InstallConfig = {
doneText?: string;
dev?: boolean;
force?: boolean;
+ isWorkspaceRoot?: boolean;
};
/**
@@ -30,6 +31,7 @@ export const installPackages = async (
) => {
const { type } = packageManager;
const { force, dev, startText, doneText } = config;
+ const isWorkspaceRoot = config.isWorkspaceRoot ?? false;
if (packages.length === 0) {
let cmd;
@@ -50,8 +52,10 @@ export const installPackages = async (
...packages,
...(type === "pnpm" ? ["--no-frozen-lockfile"] : []),
...(force === true ? ["--force"] : []),
+ ...getWorkspaceInstallRootFlag(type, isWorkspaceRoot),
],
{
+ cwd: process.cwd(),
startText,
doneText,
silent: true,
@@ -74,7 +78,6 @@ export const installPackages = async (
saveFlag = dev ? "--save-dev" : "";
break;
}
-
await runCommand(
[
type,
@@ -82,6 +85,7 @@ export const installPackages = async (
...(saveFlag ? [saveFlag] : []),
...packages,
...(force === true ? ["--force"] : []),
+ ...getWorkspaceInstallRootFlag(type, isWorkspaceRoot),
],
{
startText,
@@ -113,15 +117,47 @@ export const installPackages = async (
}
};
+/**
+ * Returns the potential flag(/s) that need to be added to a package manager's install command when it is
+ * run at the root of a workspace.
+ *
+ * @param packageManagerType The type of package manager
+ * @param isWorkspaceRoot Flag indicating whether the install command is being run at the root of a workspace
+ * @returns an array containing the flag(/s) to use, or an empty array if not supported or not running in the workspace root.
+ */
+const getWorkspaceInstallRootFlag = (
+ packageManagerType: PackageManager["type"],
+ isWorkspaceRoot: boolean
+): string[] => {
+ if (!isWorkspaceRoot) {
+ return [];
+ }
+
+ switch (packageManagerType) {
+ case "pnpm":
+ return ["--workspace-root"];
+ case "yarn":
+ return ["-W"];
+ case "npm":
+ case "bun":
+ // npm and bun don't have the workspace check
+ return [];
+ }
+};
+
/**
* Installs the latest version of wrangler in the project directory if it isn't already.
*/
-export const installWrangler = async (packageManager: PackageManager) => {
+export const installWrangler = async (
+ packageManager: PackageManager,
+ isWorkspaceRoot: boolean
+) => {
const { type } = packageManager;
// Even if Wrangler is already installed, make sure we install the latest version, as some framework CLIs are pinned to an older version
await installPackages(packageManager, [`wrangler@latest`], {
dev: true,
+ isWorkspaceRoot,
startText: `Installing wrangler ${dim(
"A command line tool for building Cloudflare Workers"
)}`,
diff --git a/packages/wrangler/src/autoconfig/details.ts b/packages/wrangler/src/autoconfig/details.ts
index d69ce0e61974..01a690e055ec 100644
--- a/packages/wrangler/src/autoconfig/details.ts
+++ b/packages/wrangler/src/autoconfig/details.ts
@@ -206,6 +206,7 @@ async function detectFramework(
): Promise<{
detectedFramework: DetectedFramework | undefined;
packageManager: PackageManager;
+ isWorkspaceRoot?: boolean;
}> {
const fs = new NodeFS();
@@ -220,10 +221,20 @@ async function detectFramework(
const buildSettings = await project.getBuildSettings();
- if (project.workspace?.isRoot) {
- throw new UserError(
- "The Wrangler application detection logic has been run in the root of a workspace, this is not supported. Change your working directory to one of the applications in the workspace and try again."
+ const isWorkspaceRoot = !!project.workspace?.isRoot;
+
+ if (isWorkspaceRoot) {
+ const resolvedProjectPath = resolve(projectPath);
+
+ const workspaceRootIncludesProject = project.workspace?.packages.some(
+ (pkg) => resolve(pkg.path) === resolvedProjectPath
);
+
+ if (!workspaceRootIncludesProject) {
+ throw new UserError(
+ "The Wrangler application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again."
+ );
+ }
}
const detectedFramework = findDetectedFramework(buildSettings);
@@ -259,7 +270,7 @@ async function detectFramework(
};
}
- return { detectedFramework, packageManager };
+ return { detectedFramework, packageManager, isWorkspaceRoot };
}
/**
@@ -402,10 +413,8 @@ export async function getDetailsForAutoConfig({
};
}
- const { detectedFramework, packageManager } = await detectFramework(
- projectPath,
- wranglerConfig
- );
+ const { detectedFramework, packageManager, isWorkspaceRoot } =
+ await detectFramework(projectPath, wranglerConfig);
const framework = getFramework(detectedFramework?.framework?.id);
const packageJsonPath = resolve(projectPath, "package.json");
@@ -456,6 +465,7 @@ export async function getDetailsForAutoConfig({
return {
...baseDetails,
configured: true,
+ isWorkspaceRoot,
};
}
@@ -498,6 +508,7 @@ export async function getDetailsForAutoConfig({
...baseDetails,
outputDir,
configured: false,
+ isWorkspaceRoot,
};
}
diff --git a/packages/wrangler/src/autoconfig/frameworks/angular.ts b/packages/wrangler/src/autoconfig/frameworks/angular.ts
index 193ae77d33fb..55999929bed8 100644
--- a/packages/wrangler/src/autoconfig/frameworks/angular.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/angular.ts
@@ -15,11 +15,12 @@ export class Angular extends Framework {
outputDir,
dryRun,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
if (!dryRun) {
await updateAngularJson(workerName);
await overrideServerFile();
- await installAdditionalDependencies(packageManager);
+ await installAdditionalDependencies(packageManager, isWorkspaceRoot);
}
return {
wranglerConfig: {
@@ -80,11 +81,15 @@ async function overrideServerFile() {
);
}
-async function installAdditionalDependencies(packageManager: PackageManager) {
+async function installAdditionalDependencies(
+ packageManager: PackageManager,
+ isWorkspaceRoot: boolean
+) {
await installPackages(packageManager, ["xhr2"], {
dev: true,
startText: "Installing additional dependencies",
doneText: `${brandColor("installed")}`,
+ isWorkspaceRoot,
});
}
diff --git a/packages/wrangler/src/autoconfig/frameworks/index.ts b/packages/wrangler/src/autoconfig/frameworks/index.ts
index 028da9b5165c..b0388496d702 100644
--- a/packages/wrangler/src/autoconfig/frameworks/index.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/index.ts
@@ -8,6 +8,7 @@ export type ConfigurationOptions = {
workerName: string;
dryRun: boolean;
packageManager: PackageManager;
+ isWorkspaceRoot: boolean;
};
export type PackageJsonScriptsOverrides = {
diff --git a/packages/wrangler/src/autoconfig/frameworks/nuxt.ts b/packages/wrangler/src/autoconfig/frameworks/nuxt.ts
index c0bc99ccab13..d26a84124a18 100644
--- a/packages/wrangler/src/autoconfig/frameworks/nuxt.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/nuxt.ts
@@ -56,12 +56,14 @@ export class Nuxt extends Framework {
dryRun,
projectPath,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
if (!dryRun) {
await installPackages(packageManager, ["nitro-cloudflare-dev"], {
dev: true,
startText: "Installing the Cloudflare dev module",
doneText: `${brandColor(`installed`)} ${dim("nitro-cloudflare-dev")}`,
+ isWorkspaceRoot,
});
updateNuxtConfig(projectPath);
}
diff --git a/packages/wrangler/src/autoconfig/frameworks/react-router.ts b/packages/wrangler/src/autoconfig/frameworks/react-router.ts
index cfbc9799077b..5add1341d615 100644
--- a/packages/wrangler/src/autoconfig/frameworks/react-router.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/react-router.ts
@@ -147,6 +147,7 @@ export class ReactRouter extends Framework {
dryRun,
projectPath,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
const viteEnvironmentKey = configPropertyName(projectPath);
if (!dryRun) {
@@ -154,6 +155,7 @@ export class ReactRouter extends Framework {
dev: true,
startText: "Installing the Cloudflare Vite plugin",
doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`,
+ isWorkspaceRoot,
});
mkdirSync("workers");
@@ -191,6 +193,7 @@ export class ReactRouter extends Framework {
dev: true,
startText: "Installing the isbot package",
doneText: `${brandColor(`installed`)} ${dim("isbot")}`,
+ isWorkspaceRoot,
});
if (!existsSync("app/entry.server.tsx")) {
diff --git a/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts b/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts
index 94705ec50f28..ef7f7b05f93c 100644
--- a/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/sveltekit.ts
@@ -9,6 +9,7 @@ export class SvelteKit extends Framework {
async configure({
dryRun,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
const { dlx } = packageManager;
if (!dryRun) {
@@ -34,6 +35,7 @@ export class SvelteKit extends Framework {
await installPackages(packageManager, [], {
startText: "Installing packages",
doneText: `${brandColor("installed")}`,
+ isWorkspaceRoot,
});
}
return {
diff --git a/packages/wrangler/src/autoconfig/frameworks/tanstack.ts b/packages/wrangler/src/autoconfig/frameworks/tanstack.ts
index 55bc2c46cc5f..ae15b88ab654 100644
--- a/packages/wrangler/src/autoconfig/frameworks/tanstack.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/tanstack.ts
@@ -9,12 +9,14 @@ export class TanstackStart extends Framework {
dryRun,
projectPath,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
if (!dryRun) {
await installPackages(packageManager, ["@cloudflare/vite-plugin"], {
dev: true,
startText: "Installing the Cloudflare Vite plugin",
doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`,
+ isWorkspaceRoot,
});
transformViteConfig(projectPath, { viteEnvironmentName: "ssr" });
diff --git a/packages/wrangler/src/autoconfig/frameworks/vike.ts b/packages/wrangler/src/autoconfig/frameworks/vike.ts
index b3b0b3b8be70..1c5898a0f0ff 100644
--- a/packages/wrangler/src/autoconfig/frameworks/vike.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/vike.ts
@@ -18,6 +18,7 @@ export class Vike extends Framework {
projectPath,
dryRun,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
const vikeServerIsInstalled = isPackageInstalled(
"vike-server",
@@ -41,10 +42,12 @@ export class Vike extends Framework {
{
startText: "Installing vike-photon and @photonjs/cloudflare",
doneText: `${brandColor(`installed`)} photon packages`,
+ isWorkspaceRoot,
}
);
await installPackages(packageManager, ["@cloudflare/vite-plugin"], {
dev: true,
+ isWorkspaceRoot,
});
addVikePhotonToConfigFile(projectPath);
diff --git a/packages/wrangler/src/autoconfig/frameworks/vite.ts b/packages/wrangler/src/autoconfig/frameworks/vite.ts
index a6e69464615e..cbabbeae035f 100644
--- a/packages/wrangler/src/autoconfig/frameworks/vite.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/vite.ts
@@ -16,12 +16,14 @@ export class Vite extends Framework {
dryRun,
projectPath,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
if (!dryRun) {
await installPackages(packageManager, ["@cloudflare/vite-plugin"], {
dev: true,
startText: "Installing the Cloudflare Vite plugin",
doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`,
+ isWorkspaceRoot,
});
transformViteConfig(projectPath);
diff --git a/packages/wrangler/src/autoconfig/frameworks/waku.ts b/packages/wrangler/src/autoconfig/frameworks/waku.ts
index e0c98ac79836..dd2e5bf3d39c 100644
--- a/packages/wrangler/src/autoconfig/frameworks/waku.ts
+++ b/packages/wrangler/src/autoconfig/frameworks/waku.ts
@@ -23,6 +23,7 @@ export class Waku extends Framework {
dryRun,
projectPath,
packageManager,
+ isWorkspaceRoot,
}: ConfigurationOptions): Promise {
validateMinimumWakuVersion(projectPath);
@@ -34,6 +35,7 @@ export class Waku extends Framework {
dev: true,
startText: "Installing additional dependencies",
doneText: `${brandColor("installed")}`,
+ isWorkspaceRoot,
}
);
diff --git a/packages/wrangler/src/autoconfig/run.ts b/packages/wrangler/src/autoconfig/run.ts
index 62c83f7be528..be68fbfb1d07 100644
--- a/packages/wrangler/src/autoconfig/run.ts
+++ b/packages/wrangler/src/autoconfig/run.ts
@@ -103,11 +103,14 @@ export async function runAutoConfig(
const { packageManager } = autoConfigDetails;
+ const isWorkspaceRoot = autoConfigDetails.isWorkspaceRoot ?? false;
+
const dryRunConfigurationResults =
await autoConfigDetails.framework.configure({
outputDir: autoConfigDetails.outputDir,
projectPath: autoConfigDetails.projectPath,
workerName: autoConfigDetails.workerName,
+ isWorkspaceRoot,
dryRun: true,
packageManager,
});
@@ -165,13 +168,14 @@ export async function runAutoConfig(
);
if (autoConfigSummary.wranglerInstall && enableWranglerInstallation) {
- await installWrangler(packageManager);
+ await installWrangler(packageManager, isWorkspaceRoot);
}
const configurationResults = await autoConfigDetails.framework.configure({
outputDir: autoConfigDetails.outputDir,
projectPath: autoConfigDetails.projectPath,
workerName: autoConfigDetails.workerName,
+ isWorkspaceRoot,
dryRun: false,
packageManager,
});
diff --git a/packages/wrangler/src/autoconfig/types.ts b/packages/wrangler/src/autoconfig/types.ts
index 16e2d06b21b7..e28b85baa9d3 100644
--- a/packages/wrangler/src/autoconfig/types.ts
+++ b/packages/wrangler/src/autoconfig/types.ts
@@ -20,6 +20,8 @@ type AutoConfigDetailsBase = {
outputDir: string;
/** The detected package manager for the project */
packageManager: PackageManager;
+ /** Whether the current path is at the root of a workspace */
+ isWorkspaceRoot?: boolean;
};
export type AutoConfigDetailsForConfiguredProject = Optional<