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
102 changes: 102 additions & 0 deletions packages/plugin-vite/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -82,13 +167,24 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
});

let isDev = false;
let resolvedRoot = process.cwd();

const plugins: Plugin[] = [
{
name: "fresh",
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: {
Expand Down Expand Up @@ -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") {
Expand Down
47 changes: 47 additions & 0 deletions packages/plugin-vite/tests/build_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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");
},
);
},
);
5 changes: 5 additions & 0 deletions packages/plugin-vite/tests/fixtures/cjs_dependency/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { App, staticFiles } from "@fresh/core";

export const app = new App()
.use(staticFiles())
.fsRoutes();
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p class="greeting">{greet("Fresh")}</p>
<p class="version">{version}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";

export default defineConfig({
plugins: [fresh()],
});
Loading