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/evil-crabs-start.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/fifty-radios-nail.md
Original file line number Diff line number Diff line change
@@ -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/*", "."]`).
59 changes: 59 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/hmr-events.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 19 additions & 4 deletions packages/vite-plugin-cloudflare/src/cloudflare-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const MAIN_ENTRY_NAME = "index";

interface WebSocketContainer {
webSocket?: WebSocket;
messageBuffers?: string[];
}

const webSocketUndefinedError = "The WebSocket is undefined";
Expand Down Expand Up @@ -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();
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<h1>Hello World</h1>",
"packages/my-app/package.json": JSON.stringify({ name: "my-app" }),
"packages/my-app/index.html": "<h1>Hello World</h1>",
});

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": "<h1>Hello World</h1>",
});

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,
}) => {
Expand Down
40 changes: 38 additions & 2 deletions packages/wrangler/src/autoconfig/c3-vendor/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type InstallConfig = {
doneText?: string;
dev?: boolean;
force?: boolean;
isWorkspaceRoot?: boolean;
};

/**
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -74,14 +78,14 @@ export const installPackages = async (
saveFlag = dev ? "--save-dev" : "";
break;
}

await runCommand(
[
type,
cmd,
...(saveFlag ? [saveFlag] : []),
...packages,
...(force === true ? ["--force"] : []),
...getWorkspaceInstallRootFlag(type, isWorkspaceRoot),
],
{
startText,
Expand Down Expand Up @@ -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"
)}`,
Expand Down
27 changes: 19 additions & 8 deletions packages/wrangler/src/autoconfig/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ async function detectFramework(
): Promise<{
detectedFramework: DetectedFramework | undefined;
packageManager: PackageManager;
isWorkspaceRoot?: boolean;
}> {
const fs = new NodeFS();

Expand All @@ -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);
Expand Down Expand Up @@ -259,7 +270,7 @@ async function detectFramework(
};
}

return { detectedFramework, packageManager };
return { detectedFramework, packageManager, isWorkspaceRoot };
}

/**
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -456,6 +465,7 @@ export async function getDetailsForAutoConfig({
return {
...baseDetails,
configured: true,
isWorkspaceRoot,
};
}

Expand Down Expand Up @@ -498,6 +508,7 @@ export async function getDetailsForAutoConfig({
...baseDetails,
outputDir,
configured: false,
isWorkspaceRoot,
};
}

Expand Down
9 changes: 7 additions & 2 deletions packages/wrangler/src/autoconfig/frameworks/angular.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export class Angular extends Framework {
outputDir,
dryRun,
packageManager,
isWorkspaceRoot,
}: ConfigurationOptions): Promise<ConfigurationResults> {
if (!dryRun) {
await updateAngularJson(workerName);
await overrideServerFile();
await installAdditionalDependencies(packageManager);
await installAdditionalDependencies(packageManager, isWorkspaceRoot);
}
return {
wranglerConfig: {
Expand Down Expand Up @@ -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,
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/wrangler/src/autoconfig/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ConfigurationOptions = {
workerName: string;
dryRun: boolean;
packageManager: PackageManager;
isWorkspaceRoot: boolean;
};

export type PackageJsonScriptsOverrides = {
Expand Down
2 changes: 2 additions & 0 deletions packages/wrangler/src/autoconfig/frameworks/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ export class Nuxt extends Framework {
dryRun,
projectPath,
packageManager,
isWorkspaceRoot,
}: ConfigurationOptions): Promise<ConfigurationResults> {
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);
}
Expand Down
Loading
Loading