Skip to content

Commit cd13340

Browse files
bartlomiejuclaude
andcommitted
test: add CJS dependency externalization test
Adds a test fixture with a CJS-only npm package and verifies: 1. The build succeeds (no TDZ errors) 2. The CJS module is externalized (not inlined in the bundle) 3. The production server works with the externalized dependency Also fixes the externalization approach to use Rollup's external option (which receives bare specifiers) instead of Vite's resolve.external (which doesn't work with noExternal: true). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 808c3d3 commit cd13340

5 files changed

Lines changed: 137 additions & 41 deletions

File tree

packages/plugin-vite/src/mod.ts

Lines changed: 67 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,68 @@ const SSR_BUNDLE_ALLOWLIST = new Set([
4444
]);
4545

4646
/**
47-
* Check if a package is CJS-only (no ESM entry point).
48-
* Returns true if the package should be externalized in the SSR build.
47+
* Scan node_modules for CJS-only packages and return their names.
48+
* A package is CJS-only if it has no ESM entry point (no "type": "module",
49+
* no "module" field, no "import" condition in "exports").
4950
*/
50-
function isCjsOnlyPackage(id: string, root: string): boolean {
51-
// Extract bare package name (handle scoped packages)
52-
const parts = id.startsWith("@") ? id.split("/", 2) : id.split("/", 1);
53-
const packageName = parts.join("/");
51+
function findCjsOnlyPackages(root: string): string[] {
52+
const result: string[] = [];
53+
const nodeModulesDir = path.join(root, "node_modules");
5454

55-
if (SSR_BUNDLE_ALLOWLIST.has(packageName) || SSR_BUNDLE_ALLOWLIST.has(id)) {
56-
return false;
55+
let entries: fs.Dirent[];
56+
try {
57+
entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
58+
} catch {
59+
return result;
5760
}
5861

59-
// Look for the package's package.json
60-
const pkgJsonPath = path.join(
61-
root,
62-
"node_modules",
63-
packageName,
64-
"package.json",
65-
);
62+
for (const entry of entries) {
63+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
64+
65+
if (entry.name.startsWith("@")) {
66+
// Scoped package — check subdirectories
67+
const scopeDir = path.join(nodeModulesDir, entry.name);
68+
let scopeEntries: fs.Dirent[];
69+
try {
70+
scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
71+
} catch {
72+
continue;
73+
}
74+
for (const scopeEntry of scopeEntries) {
75+
if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) {
76+
continue;
77+
}
78+
const packageName = `${entry.name}/${scopeEntry.name}`;
79+
if (isCjsOnly(nodeModulesDir, packageName)) {
80+
result.push(packageName);
81+
}
82+
}
83+
} else if (entry.name.startsWith(".")) {
84+
continue;
85+
} else {
86+
if (isCjsOnly(nodeModulesDir, entry.name)) {
87+
result.push(entry.name);
88+
}
89+
}
90+
}
91+
92+
return result;
93+
}
94+
95+
function isCjsOnly(nodeModulesDir: string, packageName: string): boolean {
96+
if (SSR_BUNDLE_ALLOWLIST.has(packageName)) return false;
97+
98+
const pkgJsonPath = path.join(nodeModulesDir, packageName, "package.json");
6699
try {
67100
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
68-
// If type is "module", it's ESM — keep bundled
69101
if (pkg.type === "module") return false;
70-
// If it has an ESM entry via "module" or "exports" with import condition,
71-
// keep it bundled so Vite can resolve the ESM version
72102
if (pkg.module) return false;
73103
if (pkg.exports) {
74104
const exportsStr = JSON.stringify(pkg.exports);
75105
if (exportsStr.includes('"import"')) return false;
76106
}
77-
// CJS-only package — externalize it
107+
// Must have a main entry (otherwise it's not a real package)
108+
if (!pkg.main && !pkg.exports) return false;
78109
return true;
79110
} catch {
80111
return false;
@@ -136,13 +167,24 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
136167
});
137168

138169
let isDev = false;
170+
let resolvedRoot = process.cwd();
139171

140172
const plugins: Plugin[] = [
141173
{
142174
name: "fresh",
143175
sharedDuringBuild: true,
144176
config(config, env) {
145177
isDev = env.command === "serve";
178+
resolvedRoot = config.root ? path.resolve(config.root) : process.cwd();
179+
180+
// Scan node_modules for CJS-only packages to externalize
181+
// in the SSR build.
182+
const cjsPackages = findCjsOnlyPackages(resolvedRoot);
183+
const cjsExternalList = cjsPackages.map((pkg) =>
184+
new RegExp(
185+
`^${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\/.*)?$`,
186+
)
187+
);
146188

147189
return {
148190
esbuild: {
@@ -215,28 +257,12 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
215257
: null) ??
216258
"_fresh/server",
217259
rollupOptions: {
218-
// Externalize CJS-only npm packages in the SSR build.
219-
// These will be loaded at runtime by Deno's Node compat
220-
// layer, avoiding the CJS-to-ESM transform that can cause
221-
// TDZ errors when Rollup reorders bundled declarations.
222-
external(id) {
223-
// Never externalize virtual modules, relative paths,
224-
// absolute paths, or Node builtins (Vite handles those)
225-
if (
226-
id.startsWith("\0") || id.startsWith(".") ||
227-
id.startsWith("/") || isBuiltin(id)
228-
) {
229-
return false;
230-
}
231-
// Never externalize fresh internals or jsr: specifiers
232-
if (
233-
id.startsWith("fresh") || id.startsWith("@fresh/") ||
234-
id.startsWith("jsr:")
235-
) {
236-
return false;
237-
}
238-
return isCjsOnlyPackage(id, config.root ?? process.cwd());
239-
},
260+
// Externalize CJS-only npm packages so they're
261+
// loaded at runtime by Deno's Node compat layer.
262+
// This avoids the CJS-to-ESM transform that can
263+
// cause TDZ errors when Rollup reorders bundled
264+
// declarations.
265+
external: cjsExternalList,
240266
onwarn(warning, handler) {
241267
// Ignore "use client"; warnings
242268
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {

packages/plugin-vite/tests/build_test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
usingEnv,
1414
} from "./test_utils.ts";
1515
import * as path from "@std/path";
16+
import { walk } from "@std/fs/walk";
1617

1718
const viteResult = await buildVite(DEMO_DIR);
1819

@@ -606,3 +607,49 @@ integrationTest(
606607
);
607608
},
608609
);
610+
611+
// Issue: https://github.com/denoland/fresh/issues/3653
612+
integrationTest(
613+
"vite build - CJS-only dependencies are externalized in SSR",
614+
async () => {
615+
const cjsFixture = path.join(FIXTURE_DIR, "cjs_dependency");
616+
const result = await buildVite(cjsFixture);
617+
618+
// Read all server build files to verify the CJS module is externalized
619+
// (appears as an external import, not inlined)
620+
const serverDir = path.join(result.tmp, "_fresh", "server");
621+
let allServerCode = "";
622+
for await (
623+
const entry of walk(serverDir, {
624+
exts: [".mjs", ".js"],
625+
includeFiles: true,
626+
includeDirs: false,
627+
})
628+
) {
629+
allServerCode += await Deno.readTextFile(entry.path);
630+
}
631+
632+
// The CJS module should be externalized — its implementation
633+
// should NOT be inlined. The bundle should reference it as an
634+
// external import.
635+
expect(allServerCode).not.toContain("str.toUpperCase()");
636+
expect(allServerCode).toContain("cjs-test-module");
637+
638+
// Symlink node_modules so the externalized import resolves at runtime
639+
await Deno.symlink(
640+
path.join(cjsFixture, "node_modules"),
641+
path.join(result.tmp, "node_modules"),
642+
);
643+
644+
// Verify the built server actually works with the externalized CJS dep
645+
await launchProd(
646+
{ cwd: result.tmp },
647+
async (address) => {
648+
const res = await fetch(address);
649+
const text = await res.text();
650+
expect(text).toContain("HELLO, FRESH!");
651+
expect(text).toContain("1.0.0");
652+
},
653+
);
654+
},
655+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { App, staticFiles } from "@fresh/core";
2+
3+
export const app = new App()
4+
.use(staticFiles())
5+
.fsRoutes();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { FreshContext } from "@fresh/core";
2+
// deno-lint-ignore no-external-import
3+
import { greet, version } from "cjs-test-module";
4+
5+
export default function Home(_ctx: FreshContext) {
6+
return (
7+
<div>
8+
<p class="greeting">{greet("Fresh")}</p>
9+
<p class="version">{version}</p>
10+
</div>
11+
);
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from "vite";
2+
import { fresh } from "@fresh/plugin-vite";
3+
4+
export default defineConfig({
5+
plugins: [fresh()],
6+
});

0 commit comments

Comments
 (0)