Skip to content

Commit d0ec823

Browse files
committed
fix(hmr): allow watch-only mode without loader internals
1 parent 8cc9e33 commit d0ec823

2 files changed

Lines changed: 164 additions & 51 deletions

File tree

packages/hmr/src/index.ts

Lines changed: 97 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4456
interface Reload {
4557
filename: string
4658
runtime?: Plugin.Runtime
@@ -51,8 +63,24 @@ interface Reload {
5163
class 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]

packages/hmr/tests/index.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Context, Fiber } from 'cordis'
22
import Loader from '@cordisjs/plugin-loader'
33
import Logger from '@cordisjs/plugin-logger-console'
4+
import Timer from '@cordisjs/plugin-timer'
5+
import Hmr from '@cordisjs/plugin-hmr'
46
import { writeFileSync, readFileSync, unlinkSync } from 'node:fs'
57
import { resolve } from 'node:path'
68
import { expect, describe, it, beforeAll, afterAll, afterEach } from 'vitest'
@@ -72,6 +74,18 @@ async function createContext(configFile: string): Promise<{ ctx: Context; fiber:
7274
return { ctx, fiber }
7375
}
7476

77+
// Helper: standalone context (Logger + Timer + Loader) for the watch-only
78+
// regression cases, which need direct control over the HMR config. Returns
79+
// the Loader fiber for disposal, matching the createContext pattern.
80+
async function createStandaloneContext(): Promise<{ ctx: Context; fiber: Fiber<Context> }> {
81+
const ctx = new Context()
82+
ctx.baseUrl = pathToFileURL(resolve(testDir) + '/').href
83+
await ctx.plugin(Logger)
84+
await ctx.plugin(Timer)
85+
const fiber = await ctx.plugin(Loader)
86+
return { ctx, fiber }
87+
}
88+
7589
// Settle time after file restore, to let any triggered HMR finish
7690
const SETTLE_MS = 500
7791

@@ -797,4 +811,57 @@ export function apply(ctx: Context) {
797811
expect(ctx.bail('hmr-test/get-value')).to.equal('stash-test-2')
798812
}, 10000)
799813
})
814+
815+
// ===== Watch-only without loader internals =====
816+
// Regression: the constructor used to require `loader.internal`
817+
// unconditionally, but watch-only mode (root: []) never touches module
818+
// reload, so it must boot even when the native helper binding is missing.
819+
describe('watch-only without loader internals', () => {
820+
it('should start in watch-only mode without loader internals', async () => {
821+
const { ctx, fiber } = await createStandaloneContext()
822+
// Simulate a missing native helper binding: fromInternal() returns
823+
// undefined in production when the addon cannot resolve.
824+
ctx.loader.internal = undefined
825+
826+
await ctx.plugin(Hmr, { root: [], debounce: 100, ignored: [] })
827+
828+
expect(ctx.hmr).to.be.ok
829+
// Watch-only mode must not create a watcher (guarded by root.length).
830+
expect((ctx.hmr as unknown as { watcher?: unknown }).watcher).to.be.undefined
831+
fiber?.dispose()
832+
await new Promise(r => setTimeout(r, SETTLE_MS))
833+
}, 10000)
834+
835+
it('should start in watch-only mode when loader internals are available', async (t) => {
836+
const { ctx, fiber } = await createStandaloneContext()
837+
// This case pins the "internal present × watch-only" cell. The repo test
838+
// runner runs with --expose-internals, so the premise holds in CI; when
839+
// the runner lacks the flag, skip instead of failing (the "internal
840+
// missing" cell is already covered by the first case).
841+
if (!ctx.loader.internal) {
842+
fiber?.dispose()
843+
t.skip()
844+
return
845+
}
846+
expect(ctx.loader.internal).to.be.ok
847+
await ctx.plugin(Hmr, { root: [], debounce: 100, ignored: [] })
848+
849+
expect(ctx.hmr).to.be.ok
850+
// Watch-only mode must not create a watcher, regardless of internals.
851+
expect((ctx.hmr as unknown as { watcher?: unknown }).watcher).to.be.undefined
852+
fiber?.dispose()
853+
await new Promise(r => setTimeout(r, SETTLE_MS))
854+
}, 10000)
855+
856+
it('should still require loader internals for module reload', async () => {
857+
const { ctx, fiber } = await createStandaloneContext()
858+
ctx.loader.internal = undefined
859+
860+
await expect(ctx.plugin(Hmr, { root: ['.'], debounce: 100, ignored: [] }))
861+
.rejects.toThrow(/loader internals/)
862+
863+
fiber?.dispose()
864+
await new Promise(r => setTimeout(r, SETTLE_MS))
865+
}, 10000)
866+
})
800867
})

0 commit comments

Comments
 (0)