Skip to content
Open
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
66 changes: 66 additions & 0 deletions packages/vinext/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { deploy as runDeploy, parseDeployArgs } from "./deploy.js";
import { runCheck, formatReport } from "./check.js";
import { init as runInit } from "./init.js";
import { loadDotenv } from "./config/dotenv.js";
import { ensureESModule, renameCJSConfigs, hasViteConfig as hasViteConfigInRoot } from "./utils/project.js";

// ─── Resolve Vite from the project root ────────────────────────────────────────
//
Expand Down Expand Up @@ -177,6 +178,61 @@ function buildViteConfig(overrides: Record<string, unknown> = {}) {
return config;
}

/**
* Ensure the project's package.json has `"type": "module"` before Vite loads
* the vite.config.ts. This prevents the esbuild CJS-bundling path that Vite
* takes for projects without `"type": "module"`, which produces a `.mjs` temp
* file containing `require()` calls — calls that fail on Node 22 when
* targeting pure-ESM packages like `@cloudflare/vite-plugin`.
*
* This mirrors what `vinext init` does, but is applied lazily at dev/build
* time for projects that were set up before `vinext init` added the step, or
* that were migrated manually.
*
* Side effects: may rename `.js` config files like `postcss.config.js` to
* `.cjs` to avoid CJS/ESM confusion after `"type": "module"` is added (the
* same rename logic used by `vinext init`).
*/
function ensureViteConfigCompatibility(root: string): void {
// Only act when there is a vite.config — auto-config mode sets
// configFile: false and doesn't go through Vite's file-loading path.
if (!hasViteConfigInRoot(root)) return;

const pkgPath = path.join(root, "package.json");
if (!fs.existsSync(pkgPath)) return;

let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
} catch {
return;
}

// Already correct — nothing to do.
if (pkg.type === "module") return;

// Respect explicit "type": "commonjs" — the user chose this deliberately.
if (pkg.type !== undefined) return;

// Rename any `.js` CJS config files first so they don't break after we
// add "type": "module".
const renamed = renameCJSConfigs(root);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CJS config renames happen even when type:module is already set. The early return if (pkg.type === "module") return; at line 212 prevents this in the normal case, but if package.json parsing fails (line 207 catch), the function returns early before renames. That's fine. However, if someone has "type": "commonjs" explicitly set, this code will rename their CJS configs and add "type": "module" — overriding an explicit user choice. You might want to check for pkg.type !== undefined (any explicit value) rather than just pkg.type === "module".

for (const [oldName, newName] of renamed) {
console.warn(
` [vinext] Renamed ${oldName} → ${newName} (required for "type": "module")`
);
}

// Add "type": "module" so Vite loads vite.config.ts as ESM.
const added = ensureESModule(root);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ensureESModule will re-read and re-parse package.json here, despite having already parsed it at line 206. This is harmless but redundant. If ensureESModule fails for any reason (returns false), this code silently proceeds after having already renamed CJS configs — the user could end up with renamed configs but no type:module, which is the inconsistent state the previous reviewer flagged.

One option: since you already have the parsed pkg object, write type:module directly here instead of delegating to ensureESModule:

Suggested change
const added = ensureESModule(root);
// Add "type": "module" so Vite loads vite.config.ts as ESM.
try {
pkg.type = "module";
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
console.warn(
` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` +
` Run \`vinext init\` to review all project configuration.`
);
} catch {
// If we can't write, Vite will fail with a clearer error downstream.
}

This eliminates the double read, and if the write fails, at least the CJS renames are still valid (they just mean .cjs extension with no semantic change since type:module wasn't added).

if (added) {
console.warn(
` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` +
` Run \`vinext init\` to review all project configuration.`
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ensureViteConfigCompatibility duplicates package.json parsing.

This function reads and parses package.json itself (lines 201-212), then calls ensureESModule(root) which reads and parses the same package.json again. More importantly, if ensureESModule returns false for any reason (file disappeared, parse error in its own try/catch), the if (added) check silently swallows the failure — the user gets no feedback that something went wrong after CJS configs were already renamed.

This redundant parsing also means there's a TOCTOU race: the function checks pkg.type === "module" at line 212, then ensureESModule checks it again. If something else modifies the file between the two reads, behavior is unpredictable.

Consider removing the duplicate parsing and just calling ensureESModule directly, letting it handle the idempotency check internally (which it already does):

Suggested change
}
function ensureViteConfigCompatibility(root: string): void {
// Only act when there is a vite.config — auto-config mode sets
// configFile: false and doesn't go through Vite's file-loading path.
if (!hasViteConfig()) return;
const pkgPath = path.join(root, "package.json");
if (!fs.existsSync(pkgPath)) return;
// Rename any `.js` CJS config files first so they don't break after we
// add "type": "module".
const renamed = renameCJSConfigs(root);
for (const [oldName, newName] of renamed) {
console.warn(
` [vinext] Renamed ${oldName}${newName} (required for "type": "module")`
);
}
// Add "type": "module" so Vite loads vite.config.ts as ESM.
const added = ensureESModule(root);
if (added) {
console.warn(
` [vinext] Added "type": "module" to package.json so Vite can load` +
` vite.config.ts as ESM.\n` +
` Run \`vinext init\` to review all project configuration.`
);
}
}


// ─── Commands ─────────────────────────────────────────────────────────────────

async function dev() {
Expand All @@ -188,6 +244,11 @@ async function dev() {
mode: "development",
});

// Ensure "type": "module" in package.json before Vite loads vite.config.ts.
// Without this, Vite bundles the config as CJS and tries require() on pure-ESM
// packages like @cloudflare/vite-plugin, which fails on Node 22.
ensureViteConfigCompatibility(process.cwd());

const vite = await loadVite();

const port = parsed.port ?? 3000;
Expand All @@ -213,6 +274,11 @@ async function buildApp() {
mode: "production",
});

// Ensure "type": "module" in package.json before Vite loads vite.config.ts.
// Without this, Vite bundles the config as CJS and tries require() on pure-ESM
// packages like @cloudflare/vite-plugin, which fails on Node 22.
ensureViteConfigCompatibility(process.cwd());

const vite = await loadVite();

console.log(`\n vinext build (Vite ${getViteVersion()})\n`);
Expand Down
115 changes: 115 additions & 0 deletions tests/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,3 +1790,118 @@ describe("Cloudflare closeBundle lazy chunk injection", () => {
expect(lazy).not.toContain("assets/framework.js");
});
});

// ─── ESM config compatibility (issue #184) ──────────────────────────────────
//
// Regression tests for ensureViteConfigCompatibility() — the wrapper in cli.ts
// that calls ensureESModule + renameCJSConfigs before Vite loads the config.
//
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test coverage. This describe("ESM config compatibility — issue #184") block adds tests for ensureESModule and renameCJSConfigs that are largely identical to the existing describe("ensureESModule") (line 860) and describe("renameCJSConfigs") (line 906) blocks, and also to the second new block describe("ensureESModule + renameCJSConfigs — issue #184 compatibility") (line 1868).

For example:

  • "adds type:module when vite.config.ts is present" — same as existing test at line 861
  • "is idempotent" — same as existing test at line 872
  • "renames CJS postcss.config.js" — same as existing test at line 907

Consider removing one of the two new describe blocks entirely (they test the same functions) and focusing the remaining tests on scenarios not already covered — like the monorepo case and the "preserves all fields" case, which are genuinely new.

// Scenario from issue #184:
// Project has vite.config.ts importing @cloudflare/vite-plugin.
// package.json lacks "type":"module".
// Vite bundles the config with esbuild → outputs CJS require() for the plugin.
// Node 22 throws: Error: Dynamic require of "…index.mjs" is not supported
//
// The underlying ensureESModule() and renameCJSConfigs() are already tested
// above. These tests cover the unique scenarios from the issue and the
// integration of both functions together.

describe("ESM config compatibility — issue #184", () => {
it("full fix scenario: renames CJS postcss config + adds type:module", () => {
// Yarn 1 monorepo scenario: no "type":"module", CJS postcss config
writeFile(
tmpDir,
"package.json",
JSON.stringify({ name: "web", version: "1.0.0" }),
);
writeFile(
tmpDir,
"vite.config.ts",
'import { cloudflare } from "@cloudflare/vite-plugin";\nexport default { plugins: [cloudflare()] };',
);
writeFile(
tmpDir,
"postcss.config.js",
"module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };",
);

// Simulate what ensureViteConfigCompatibility does
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Simulate what ensureViteConfigCompatibility does" — but it's calling the two utilities directly rather than testing the actual wrapper function. This means the wrapper's own logic (the vite config existence check, the pkg.type !== undefined guard for explicit CJS) has no test coverage.

If the function can't easily be exported from cli.ts, consider at minimum adding a test that verifies the "type": "commonjs" guard doesn't get overridden — that's the most important new behavior in the wrapper that isn't covered by existing tests of the underlying utilities.

const renamed = renameCJSConfigs(tmpDir);
const added = ensureESModule(tmpDir);

expect(added).toBe(true);
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
expect(pkg.type).toBe("module");

expect(renamed).toEqual([["postcss.config.js", "postcss.config.cjs"]]);
expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(false);
});

it("handles a workspaces monorepo: only updates the leaf package.json", () => {
const webDir = path.join(tmpDir, "apps", "web");
fs.mkdirSync(webDir, { recursive: true });

// Root package.json (CJS)
writeFile(tmpDir, "package.json", JSON.stringify({ name: "monorepo", workspaces: ["apps/*"] }));
// Workspace package.json (no type:module)
writeFile(webDir, "package.json", JSON.stringify({ name: "web", version: "1.0.0" }));
writeFile(webDir, "vite.config.ts", "export default {};");

ensureESModule(webDir);

const rootPkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
const webPkg = JSON.parse(fs.readFileSync(path.join(webDir, "package.json"), "utf-8"));

// Only the workspace package should be modified
expect(webPkg.type).toBe("module");
expect(rootPkg.type).toBeUndefined();
});

it("preserves all existing package.json fields when adding type:module", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test ("preserves all existing package.json fields") is near-identical to the existing test at line 884. Consider removing it and keeping only the tests that provide new coverage — the monorepo scenario (line 1841) and the full integration scenario (line 1810) are the genuinely new ones.

writeFile(
tmpDir,
"package.json",
JSON.stringify({
name: "web",
version: "0.1.0",
private: true,
scripts: { dev: "vinext dev", build: "vinext build" },
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
devDependencies: { vinext: "^0.0.13", "@cloudflare/vite-plugin": "^1.0.0" },
}),
);

ensureESModule(tmpDir);

const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
expect(pkg.type).toBe("module");
expect(pkg.name).toBe("web");
expect(pkg.version).toBe("0.1.0");
expect(pkg.private).toBe(true);
expect(pkg.scripts.build).toBe("vinext build");
expect(pkg.dependencies.react).toBe("^19.0.0");
expect(pkg.devDependencies.vinext).toBe("^0.0.13");
});

it("handles multiple CJS config files simultaneously", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test ("handles multiple CJS config files simultaneously") is near-identical to the existing test at line 931. Consider removing this duplicate.

writeFile(
tmpDir,
"package.json",
JSON.stringify({ name: "my-app", version: "1.0.0" }),
);
writeFile(tmpDir, "postcss.config.js", "module.exports = {};");
writeFile(tmpDir, "tailwind.config.js", "module.exports = {};");
writeFile(tmpDir, ".eslintrc.js", "module.exports = {};");

const renamed = renameCJSConfigs(tmpDir);
ensureESModule(tmpDir);

expect(renamed).toHaveLength(3);
expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, "tailwind.config.cjs"))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, ".eslintrc.cjs"))).toBe(true);
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
expect(pkg.type).toBe("module");
});
});
Loading