diff --git a/packages/plugin-vite/src/mod.ts b/packages/plugin-vite/src/mod.ts index c49c244526c..b70433b58db 100644 --- a/packages/plugin-vite/src/mod.ts +++ b/packages/plugin-vite/src/mod.ts @@ -26,6 +26,91 @@ import { checkImports } from "./plugins/verify_imports.ts"; import { isBuiltin } from "node:module"; import { load as stdLoadEnv } from "@std/dotenv"; import path from "node:path"; +import * as fs from "node:fs"; + +// Packages that must always be bundled in the SSR build to avoid +// duplicate module instances (e.g. preact's component registry). +const SSR_BUNDLE_ALLOWLIST = new Set([ + "preact", + "preact/hooks", + "preact/compat", + "preact/jsx-runtime", + "preact/jsx-dev-runtime", + "preact/test-utils", + "preact/debug", + "preact/devtools", + "@preact/signals", + "@preact/signals-core", +]); + +/** + * Scan node_modules for CJS-only packages and return their names. + * A package is CJS-only if it has no ESM entry point (no "type": "module", + * no "module" field, no "import" condition in "exports"). + */ +function findCjsOnlyPackages(root: string): string[] { + const result: string[] = []; + const nodeModulesDir = path.join(root, "node_modules"); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true }); + } catch { + return result; + } + + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + + if (entry.name.startsWith("@")) { + // Scoped package — check subdirectories + const scopeDir = path.join(nodeModulesDir, entry.name); + let scopeEntries: fs.Dirent[]; + try { + scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true }); + } catch { + continue; + } + for (const scopeEntry of scopeEntries) { + if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) { + continue; + } + const packageName = `${entry.name}/${scopeEntry.name}`; + if (isCjsOnly(nodeModulesDir, packageName)) { + result.push(packageName); + } + } + } else if (entry.name.startsWith(".")) { + continue; + } else { + if (isCjsOnly(nodeModulesDir, entry.name)) { + result.push(entry.name); + } + } + } + + return result; +} + +function isCjsOnly(nodeModulesDir: string, packageName: string): boolean { + if (SSR_BUNDLE_ALLOWLIST.has(packageName)) return false; + + const pkgJsonPath = path.join(nodeModulesDir, packageName, "package.json"); + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + if (pkg.type === "module") return false; + if (pkg.module) return false; + if (pkg.exports) { + const exportsStr = JSON.stringify(pkg.exports); + if (exportsStr.includes('"import"')) return false; + } + // Must have a main entry (otherwise it's not a real package) + if (!pkg.main && !pkg.exports) return false; + return true; + } catch { + return false; + } +} export type { FreshViteConfig }; export type { @@ -82,6 +167,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] { }); let isDev = false; + let resolvedRoot = process.cwd(); const plugins: Plugin[] = [ { @@ -89,6 +175,16 @@ export function fresh(config?: FreshViteConfig): Plugin[] { sharedDuringBuild: true, config(config, env) { isDev = env.command === "serve"; + resolvedRoot = config.root ? path.resolve(config.root) : process.cwd(); + + // Scan node_modules for CJS-only packages to externalize + // in the SSR build. + const cjsPackages = findCjsOnlyPackages(resolvedRoot); + const cjsExternalList = cjsPackages.map((pkg) => + new RegExp( + `^${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\/.*)?$`, + ) + ); return { server: { @@ -178,6 +274,12 @@ export function fresh(config?: FreshViteConfig): Plugin[] { : null) ?? "_fresh/server", rollupOptions: { + // Externalize CJS-only npm packages so they're + // loaded at runtime by Deno's Node compat layer. + // This avoids the CJS-to-ESM transform that can + // cause TDZ errors when Rollup reorders bundled + // declarations. + external: cjsExternalList, onwarn(warning, handler) { // Ignore "use client"; warnings if (warning.code === "MODULE_LEVEL_DIRECTIVE") { diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index 6fc9ec823b6..7350ec7b09b 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -13,6 +13,7 @@ import { usingEnv, } from "./test_utils.ts"; import * as path from "@std/path"; +import { walk } from "@std/fs/walk"; const viteResult = await buildVite(DEMO_DIR); @@ -656,3 +657,49 @@ integrationTest( ); }, ); + +// Issue: https://github.com/denoland/fresh/issues/3653 +integrationTest( + "vite build - CJS-only dependencies are externalized in SSR", + async () => { + const cjsFixture = path.join(FIXTURE_DIR, "cjs_dependency"); + const result = await buildVite(cjsFixture); + + // Read all server build files to verify the CJS module is externalized + // (appears as an external import, not inlined) + const serverDir = path.join(result.tmp, "_fresh", "server"); + let allServerCode = ""; + for await ( + const entry of walk(serverDir, { + exts: [".mjs", ".js"], + includeFiles: true, + includeDirs: false, + }) + ) { + allServerCode += await Deno.readTextFile(entry.path); + } + + // The CJS module should be externalized — its implementation + // should NOT be inlined. The bundle should reference it as an + // external import. + expect(allServerCode).not.toContain("str.toUpperCase()"); + expect(allServerCode).toContain("cjs-test-module"); + + // Symlink node_modules so the externalized import resolves at runtime + await Deno.symlink( + path.join(cjsFixture, "node_modules"), + path.join(result.tmp, "node_modules"), + ); + + // Verify the built server actually works with the externalized CJS dep + await launchProd( + { cwd: result.tmp }, + async (address) => { + const res = await fetch(address); + const text = await res.text(); + expect(text).toContain("HELLO, FRESH!"); + expect(text).toContain("1.0.0"); + }, + ); + }, +); diff --git a/packages/plugin-vite/tests/fixtures/cjs_dependency/main.ts b/packages/plugin-vite/tests/fixtures/cjs_dependency/main.ts new file mode 100644 index 00000000000..e5cf428a2cd --- /dev/null +++ b/packages/plugin-vite/tests/fixtures/cjs_dependency/main.ts @@ -0,0 +1,5 @@ +import { App, staticFiles } from "@fresh/core"; + +export const app = new App() + .use(staticFiles()) + .fsRoutes(); diff --git a/packages/plugin-vite/tests/fixtures/cjs_dependency/routes/index.tsx b/packages/plugin-vite/tests/fixtures/cjs_dependency/routes/index.tsx new file mode 100644 index 00000000000..d69b835c8d2 --- /dev/null +++ b/packages/plugin-vite/tests/fixtures/cjs_dependency/routes/index.tsx @@ -0,0 +1,12 @@ +import type { FreshContext } from "@fresh/core"; +// deno-lint-ignore no-external-import +import { greet, version } from "cjs-test-module"; + +export default function Home(_ctx: FreshContext) { + return ( +
{greet("Fresh")}
+{version}
+