diff --git a/packages/plugin-vite/demo/routes/tests/cjs_npm.tsx b/packages/plugin-vite/demo/routes/tests/cjs_npm.tsx new file mode 100644 index 00000000000..e8a333346ba --- /dev/null +++ b/packages/plugin-vite/demo/routes/tests/cjs_npm.tsx @@ -0,0 +1,6 @@ +import qs from "qs"; + +export default function Page() { + const parsed = qs.parse("a=1&b=2"); + return

{parsed.a === "1" ? "qs-ok" : "qs-fail"}

; +} diff --git a/packages/plugin-vite/src/plugins/deno.ts b/packages/plugin-vite/src/plugins/deno.ts index 016529bb593..2ab1e22ac95 100644 --- a/packages/plugin-vite/src/plugins/deno.ts +++ b/packages/plugin-vite/src/plugins/deno.ts @@ -23,6 +23,37 @@ export function deno(): Plugin { let isDev = false; + // Cache for package.json "type" field lookups. Per Node.js semantics, + // .js files in node_modules are CJS unless the nearest package.json + // has "type": "module". + const pkgTypeCache = new Map(); + async function isEsmPackage(filePath: string): Promise { + let dir = path.dirname(filePath); + while (true) { + const cached = pkgTypeCache.get(dir); + if (cached !== undefined) return cached; + + try { + const text = await Deno.readTextFile(path.join(dir, "package.json")); + const isEsm = JSON.parse(text).type === "module"; + pkgTypeCache.set(dir, isEsm); + return isEsm; + } catch { + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return false; + } + + // Detect actual ESM export/import statements at the statement level. + // More robust than code.includes("export ") which matches comments + // and strings (e.g. "// Remove export in next version" would trick it). + // Handles both formatted and minified ESM (e.g. "import{" with no space). + const ESM_STMT_RE = + /(?:^|[\n;])\s*(?:export\s*[{*]|export\s+(?:default|const|let|var|function|class|async)\b|import\s*[{*"']|import\s+[a-zA-Z_$])/; + return { name: "deno", sharedDuringBuild: true, @@ -186,27 +217,33 @@ export function deno(): Plugin { // - SSR: module runner evaluates as ESM, needs module/exports/require // - Client: browser evaluates as ESM, needs module/exports // In build mode, Rollup's @rollup/plugin-commonjs handles CJS. + // + // CJS detection uses Node.js semantics (package.json "type" field) + // instead of content heuristics, which can be fooled by comments + // or strings containing "export"/"import". if ( isDev && !id.startsWith("\0") && id.includes("node_modules") && /\.(c?js|cjs)$/.test(id) ) { - try { - const code = await Deno.readTextFile(id); - // Quick heuristic: if file has CJS patterns and no ESM - if ( - !code.includes("export ") && - !code.includes("import ") && - (code.includes("module.exports") || - code.includes("exports.") || - code.includes("require(")) - ) { - const isServer = this.environment.config.consumer === "server"; - - if (isServer) { - // SSR: use Node.js createRequire for full CJS compat - const wrapped = ` + // .cjs is always CJS. For .js files, check the nearest + // package.json "type" field first (Node.js semantics), then + // fall back to content-based detection for dual CJS/ESM + // packages that ship ESM in .js without "type": "module". + if (id.endsWith(".cjs") || !(await isEsmPackage(id))) { + try { + const code = await Deno.readTextFile(id); + + // Skip if the file contains actual ESM syntax. Some packages + // (e.g. @opentelemetry/api) ship both CJS and ESM as .js + // without "type": "module" in package.json. + if (!ESM_STMT_RE.test(code)) { + const isServer = this.environment.config.consumer === "server"; + + if (isServer) { + // SSR: use Node.js createRequire for full CJS compat + const wrapped = ` import { createRequire as __cjs_createRequire } from "node:module"; import { fileURLToPath as __cjs_fileURLToPath } from "node:url"; import { dirname as __cjs_dirname } from "node:path"; @@ -220,26 +257,26 @@ ${code} export default module.exports; `; - return { code: wrapped }; - } - - // Client: convert require() calls to ESM imports so - // browsers can load them. Hoist static require() calls - // to import statements at the top. - const imports: string[] = []; - let idx = 0; - const transformed = code.replace( - /\brequire\(["']([^"']+)["']\)/g, - (_match: string, spec: string) => { - const varName = `__cjs_import_${idx++}`; - imports.push( - `import ${varName} from ${JSON.stringify(spec)};`, - ); - return `(${varName}.default ?? ${varName})`; - }, - ); - - const wrapped = `${imports.join("\n")} + return { code: wrapped }; + } + + // Client: convert require() calls to ESM imports so + // browsers can load them. Hoist static require() calls + // to import statements at the top. + const imports: string[] = []; + let idx = 0; + const transformed = code.replace( + /\brequire\(["']([^"']+)["']\)/g, + (_match: string, spec: string) => { + const varName = `__cjs_import_${idx++}`; + imports.push( + `import ${varName} from ${JSON.stringify(spec)};`, + ); + return `(${varName}.default ?? ${varName})`; + }, + ); + + const wrapped = `${imports.join("\n")} var module = { exports: {} }; var exports = module.exports; var __filename = ""; @@ -249,10 +286,11 @@ ${transformed} export default module.exports; `; - return { code: wrapped }; + return { code: wrapped }; + } + } catch { + // Fall through to default loading } - } catch { - // Fall through to default loading } } diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index ffc30f92ee2..3eb1d8f6bd9 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -452,6 +452,17 @@ Deno.test({ sanitizeResources: false, }); +Deno.test({ + name: "vite dev - CJS npm package (qs)", + fn: async () => { + const res = await fetch(`${demoServer.address()}/tests/cjs_npm`); + const text = await res.text(); + expect(text).toContain("

qs-ok

"); + }, + sanitizeOps: false, + sanitizeResources: false, +}); + // issue: https://github.com/denoland/fresh/issues/3666 integrationTest( "vite dev - basePath does not intercept Vite URLs",