diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 0b4f253e..05ddfcf0 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -24,6 +24,7 @@ import { deploy as runDeploy, parseDeployArgs } from "./deploy.js"; import { runCheck, formatReport } from "./check.js"; import { init as runInit, getReactUpgradeDeps } 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 ──────────────────────────────────────── // @@ -178,6 +179,61 @@ function buildViteConfig(overrides: Record = {}) { 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; + 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); + 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 (required for Vite ESM config loading).\n` + + ` Run \`vinext init\` to review all project configuration.` + ); + } +} + // ─── Commands ───────────────────────────────────────────────────────────────── async function dev() { @@ -189,6 +245,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; @@ -214,6 +275,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`); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index a9f939ca..8f1f8c38 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1988,3 +1988,118 @@ describe("findInNodeModules", () => { expect(result).toBe(path.join(appDir, "node_modules", "@cloudflare", "vite-plugin")); }); }); + +// ─── ESM config compatibility (issue #184) ────────────────────────────────── +// +// Regression tests for ensureViteConfigCompatibility() — the wrapper in cli.ts +// that calls ensureESModule + renameCJSConfigs before Vite loads the config. +// +// 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 + 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", () => { + 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", () => { + 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"); + }); +});