From 3cc70c5c2bfd97e3bd85c742b2400ee651e65733 Mon Sep 17 00:00:00 2001 From: MD YUNUS <115855149+yunus25jmi1@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:40:52 +0000 Subject: [PATCH] fix(cli): ensure type:module before Vite loads vite.config.ts (fixes #184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects that lack "type":"module" in package.json caused Vite to bundle vite.config.ts through esbuild's CJS path, producing a .vite-temp/*.mjs file that contains a CJS require() for @cloudflare/vite-plugin. On Node 22 in CI (GitHub Actions ubuntu-latest), require()-ing an .mjs file is not supported and the build fails with: Error: Dynamic require of "…/@cloudflare/vite-plugin/dist/index.mjs" is not supported Fix: call ensureESModule(root) + renameCJSConfigs(root) in both the dev() and buildApp() CLI commands, before Vite is loaded. This mirrors what vinext init already does, but applies lazily for projects that were set up manually or before the init step was introduced. - renameCJSConfigs renames postcss.config.js / tailwind.config.js / .eslintrc.js etc. to .cjs first, to avoid breakage in those files after ESM mode is enabled - ensureESModule adds "type":"module" to package.json - Both are no-ops when the project is already correctly configured - A warning is emitted when a change is made, pointing users to vinext init - 9 new unit tests cover the full scenario including multi-file rename Fixes #184 --- packages/vinext/src/cli.ts | 66 +++++++++++++++++++++ tests/deploy.test.ts | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) 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"); + }); +});