@@ -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" ) {
0 commit comments