@@ -32,7 +32,7 @@ async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
3232 const dependencies = new Set < string > ( )
3333 async function traverse ( job : ModuleJob ) {
3434 if ( ignored . has ( job . url ) || dependencies . has ( job . url ) ) return
35- if ( job . url . startsWith ( 'node:' ) || job . url . includes ( '/node_modules/' ) ) return
35+ if ( isBuiltinOrExternal ( job . url ) ) return
3636 dependencies . add ( job . url )
3737 const children = await job . linked
3838 await Promise . all ( Array . prototype . map . call ( children , traverse ) )
@@ -41,6 +41,18 @@ async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
4141 return dependencies
4242}
4343
44+ const LOADER_INTERNALS_ERROR =
45+ 'HMR module reload requires loader internals: run with --expose-internals (Node process flag) or use watch-only mode (root: [])'
46+
47+ /**
48+ * True for Node builtins (`node:` scheme) and anything inside node_modules —
49+ * i.e. framework / dependency code that should never be treated as user code
50+ * for HMR purposes. Shared by `loadDependencies` and `analyzeChanges`.
51+ */
52+ function isBuiltinOrExternal ( url : string ) : boolean {
53+ return url . startsWith ( 'node:' ) || url . includes ( '/node_modules/' )
54+ }
55+
4456interface Reload {
4557 filename : string
4658 runtime ?: Plugin . Runtime
@@ -51,8 +63,24 @@ interface Reload {
5163class Hmr extends Service {
5264 public baseDir : string
5365
54- private internal : ModuleLoader
55- private watcher ! : FSWatcher
66+ private internal : ModuleLoader | undefined
67+ private watcher : FSWatcher | undefined
68+
69+ /**
70+ * Non-null accessor for module-reload paths. The constructor guarantees
71+ * internals exist whenever `root.length > 0`, so callers in that mode can
72+ * rely on this being defined; this getter turns that runtime guarantee into
73+ * a compile-time fact, eliminating scattered non-null assertions.
74+ * In watch-only mode (`root: []`) callers must use optional chaining or the
75+ * `partialReload` guard instead.
76+ */
77+ private get requiredInternal ( ) : ModuleLoader {
78+ if ( ! this . internal ) {
79+ // Only reachable via a future caller that skipped the guard — fail fast.
80+ throw new Error ( LOADER_INTERNALS_ERROR )
81+ }
82+ return this . internal
83+ }
5684
5785 /**
5886 * Changes from externals will always trigger a full reload.
@@ -77,8 +105,8 @@ class Hmr extends Service {
77105
78106 constructor ( ctx : Context , public config : Hmr . Config ) {
79107 super ( ctx , 'hmr' )
80- if ( ! this . ctx . loader . internal ) {
81- throw new Error ( '--expose-internals is required for HMR service' )
108+ if ( this . config . root . length && ! this . ctx . loader . internal ) {
109+ throw new Error ( LOADER_INTERNALS_ERROR )
82110 }
83111 this . internal = this . ctx . loader . internal
84112 this . baseDir = fileURLToPath ( new URL ( config . base || '.' , ctx . baseUrl ) )
@@ -88,9 +116,12 @@ class Hmr extends Service {
88116 * Resolve a module specifier to a URL, compatible with Node 22-24.
89117 */
90118 private async _resolve ( specifier : string , parentURL : string , attrs : ImportAttributes ) : Promise < ResolveResult > {
91- switch ( this . internal . version ) {
92- case 'v1' : return await this . internal . resolve ( specifier , parentURL , attrs )
93- case 'v2' : return this . internal . resolveSync ( parentURL , { specifier, attributes : attrs } )
119+ const internal = this . requiredInternal
120+ const version = internal . version
121+ switch ( version ) {
122+ case 'v1' : return await internal . resolve ( specifier , parentURL , attrs )
123+ case 'v2' : return internal . resolveSync ( parentURL , { specifier, attributes : attrs } )
124+ default : throw new Error ( `unsupported loader internal version: ${ String ( version ) } ` )
94125 }
95126 }
96127
@@ -105,51 +136,60 @@ class Hmr extends Service {
105136 this . ctx . logger . info ( 'watching %o in %s' , root , this . baseDir )
106137 }
107138
108- const match = picomatch ( ignored )
109- this . watcher = watch ( root , {
110- ...this . config ,
111- cwd : this . baseDir ,
112- ignored : path => match ( relative ( this . baseDir , path ) ) ,
113- } )
114-
115139 // Collect externals: framework modules reachable from the main entry.
116140 // Changes to these files require a full process restart, not HMR.
117- const mainUrl = pathToFileURL ( resolve ( process . argv [ 1 ] ) ) . href
118- const mainJob = this . internal . loadCache . get ( mainUrl )
119- if ( mainJob ) {
120- this . externals = await loadDependencies ( mainJob )
141+ // In watch-only mode (root: []) no module reload happens, so externals
142+ // tracking is meaningless and loader internals may be unavailable.
143+ if ( this . config . root . length ) {
144+ // Track externals from the main entry. In embedded hosts the entry may
145+ // be absent (process.argv[1] undefined); skip tracking instead of
146+ // crashing — an empty externals set only disables full-reload detection.
147+ const entry = process . argv [ 1 ]
148+ const mainJob = entry && this . requiredInternal . loadCache . get ( pathToFileURL ( resolve ( entry ) ) . href )
149+ this . externals = mainJob ? await loadDependencies ( mainJob ) : new Set ( )
121150 } else {
122151 this . externals = new Set ( )
123152 }
124153
125- const partialReload = this . ctx . debounce ( ( ) => this . partialReload ( ) , this . config . debounce )
154+ // Only create a watcher when there are roots to watch; watch-only mode
155+ // (root: []) never fires change events, so no watcher or handler is needed.
156+ if ( this . config . root . length ) {
157+ const match = picomatch ( ignored )
158+ this . watcher = watch ( root , {
159+ ...this . config ,
160+ cwd : this . baseDir ,
161+ ignored : path => match ( relative ( this . baseDir , path ) ) ,
162+ } )
126163
127- this . watcher . on ( 'change' , async ( path ) => {
128- this . ctx . logger . debug ( 'change detected at %C' , path )
129- const filename = resolve ( this . baseDir , path )
130- const url = pathToFileURL ( filename ) . href
164+ const partialReload = this . ctx . debounce ( ( ) => this . partialReload ( ) , this . config . debounce )
131165
132- // Full reload: the changed file is part of the framework
133- if ( this . externals . has ( url ) ) return loader . exit ( )
166+ this . watcher . on ( 'change' , async ( path ) => {
167+ this . ctx . logger . debug ( 'change detected at %C' , path )
168+ const filename = resolve ( this . baseDir , path )
169+ const url = pathToFileURL ( filename ) . href
134170
135- // Partial reload: the file is in the ESM loadCache
136- // In Node 24, both CJS and ESM modules imported via import() end up
137- // in loadCache, so this check covers all module formats.
138- if ( loader . internal ! . loadCache . has ( url ) ) {
139- this . stashed . add ( url )
140- return partialReload ( )
141- }
171+ // Full reload: the changed file is part of the framework
172+ if ( this . externals . has ( url ) ) return loader . exit ( )
142173
143- // Config reload: the file is a loader config file (e.g. cordis.yml)
144- for ( const entry of this . ctx . loader . entries ( ) ) {
145- const include = entry . subtree as Include | undefined
146- if ( include ?. filename !== filename ) continue
147- await include . refresh ( )
148- return
149- }
174+ // Partial reload: the file is in the ESM loadCache
175+ // In Node 24, both CJS and ESM modules imported via import() end up
176+ // in loadCache, so this check covers all module formats.
177+ if ( this . internal ?. loadCache . has ( url ) ) {
178+ this . stashed . add ( url )
179+ return partialReload ( )
180+ }
181+
182+ // Config reload: the file is a loader config file (e.g. cordis.yml)
183+ for ( const entry of this . ctx . loader . entries ( ) ) {
184+ const include = entry . subtree as Include | undefined
185+ if ( include ?. filename !== filename ) continue
186+ await include . refresh ( )
187+ return
188+ }
150189
151- this . ctx . emit ( 'hmr/change' , url )
152- } )
190+ this . ctx . emit ( 'hmr/change' , url )
191+ } )
192+ }
153193 }
154194
155195 // hide stack trace from HMR
@@ -158,7 +198,7 @@ class Hmr extends Service {
158198 ]
159199
160200 async getLinked ( url : string ) {
161- const job = this . internal . loadCache . get ( url )
201+ const job = this . internal ? .loadCache . get ( url )
162202 if ( ! job ) return [ ]
163203 const linked = await job . linked
164204 return Array . prototype . map . call ( linked , ( job : ModuleJob ) => job . url ) as string [ ]
@@ -177,12 +217,10 @@ class Hmr extends Service {
177217 this . accepted = new Set ( this . stashed )
178218 this . declined = new Set ( this . externals )
179219
180- const isExcluded = ( url : string ) => url . startsWith ( 'node:' ) || url . includes ( '/node_modules/' )
181-
182220 await Promise . all ( [ ...this . stashed ] . map ( async ( url ) => {
183221 const children = await this . getLinked ( url )
184222 for ( const child of children ) {
185- if ( this . accepted . has ( child ) || this . declined . has ( child ) || isExcluded ( child ) ) continue
223+ if ( this . accepted . has ( child ) || this . declined . has ( child ) || isBuiltinOrExternal ( child ) ) continue
186224 pending . push ( child )
187225 }
188226 } ) )
@@ -194,7 +232,7 @@ class Hmr extends Service {
194232 const children = await this . getLinked ( url )
195233 let isDeclined = true , isAccepted = false
196234 for ( const child of children ) {
197- if ( this . declined . has ( child ) || isExcluded ( child ) ) continue
235+ if ( this . declined . has ( child ) || isBuiltinOrExternal ( child ) ) continue
198236 if ( this . accepted . has ( child ) ) {
199237 isAccepted = true
200238 break
@@ -227,6 +265,13 @@ class Hmr extends Service {
227265 }
228266
229267 private async partialReload ( ) {
268+ // Defensive: partial reload is only reachable in module-reload mode where
269+ // the constructor guarantees `internal`, but fail gracefully instead of a
270+ // bare TypeError if the watcher ever fires without it.
271+ if ( ! this . internal ) {
272+ this . ctx . logger . warn ( 'partial reload skipped: loader internals unavailable' )
273+ return
274+ }
230275 await this . analyzeChanges ( )
231276
232277 const pending = new Map < ModuleJob , Plugin > ( )
@@ -245,7 +290,7 @@ class Hmr extends Service {
245290 try {
246291 const { url } = await this . _resolve ( name , baseUrl , { } )
247292 if ( this . declined . has ( url ) ) continue
248- const job = this . internal . loadCache . get ( url )
293+ const job = this . requiredInternal . loadCache . get ( url )
249294 const plugin = this . ctx . loader . unwrapExports ( job ?. module ?. getNamespace ( ) )
250295 if ( ! job || ! plugin ) continue
251296 pending . set ( job , plugin )
@@ -290,11 +335,12 @@ class Hmr extends Service {
290335 const esmBackup : Dict = Object . create ( null )
291336 const cjsBackup : Dict = Object . create ( null )
292337 const require = createRequire ( import . meta. url )
338+ const internal = this . requiredInternal
293339 for ( const filename of this . accepted ) {
294340 // Backup and clear ESM loadCache
295- const job = Map . prototype . get . call ( this . internal . loadCache , filename )
341+ const job = Map . prototype . get . call ( internal . loadCache , filename )
296342 esmBackup [ filename ] = job
297- Map . prototype . delete . call ( this . internal . loadCache , filename )
343+ Map . prototype . delete . call ( internal . loadCache , filename )
298344
299345 // Backup and clear CJS Module._cache
300346 try {
@@ -310,7 +356,7 @@ class Hmr extends Service {
310356
311357 const rollback = ( ) => {
312358 for ( const filename in esmBackup ) {
313- Map . prototype . set . call ( this . internal . loadCache , filename , esmBackup [ filename ] )
359+ Map . prototype . set . call ( internal . loadCache , filename , esmBackup [ filename ] )
314360 }
315361 for ( const filepath in cjsBackup ) {
316362 require . cache [ filepath ] = cjsBackup [ filepath ]
0 commit comments