Skip to content

Commit c0fafa7

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

2 files changed

Lines changed: 59 additions & 17 deletions

File tree

packages/hmr/src/index.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ interface Reload {
5151
class Hmr extends Service {
5252
public baseDir: string
5353

54-
private internal: ModuleLoader
54+
private internal: ModuleLoader | undefined
5555
private watcher!: FSWatcher
5656

5757
/**
@@ -77,8 +77,8 @@ class Hmr extends Service {
7777

7878
constructor(ctx: Context, public config: Hmr.Config) {
7979
super(ctx, 'hmr')
80-
if (!this.ctx.loader.internal) {
81-
throw new Error('--expose-internals is required for HMR service')
80+
if (this.config.root.length && !this.ctx.loader.internal) {
81+
throw new Error('HMR module reload requires loader internals: run with --expose-internals or use watch-only mode (root: [])')
8282
}
8383
this.internal = this.ctx.loader.internal
8484
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
@@ -88,9 +88,9 @@ class Hmr extends Service {
8888
* Resolve a module specifier to a URL, compatible with Node 22-24.
8989
*/
9090
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 })
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 })
9494
}
9595
}
9696

@@ -114,10 +114,12 @@ class Hmr extends Service {
114114

115115
// Collect externals: framework modules reachable from the main entry.
116116
// Changes to these files require a full process restart, not HMR.
117+
// In watch-only mode (root: []) no module reload happens, so externals
118+
// tracking is meaningless and loader internals may be unavailable.
117119
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)
120+
if (this.config.root.length) {
121+
const mainJob = this.internal!.loadCache.get(mainUrl)
122+
this.externals = mainJob ? await loadDependencies(mainJob) : new Set()
121123
} else {
122124
this.externals = new Set()
123125
}
@@ -135,7 +137,7 @@ class Hmr extends Service {
135137
// Partial reload: the file is in the ESM loadCache
136138
// In Node 24, both CJS and ESM modules imported via import() end up
137139
// in loadCache, so this check covers all module formats.
138-
if (loader.internal!.loadCache.has(url)) {
140+
if (this.internal?.loadCache.has(url)) {
139141
this.stashed.add(url)
140142
return partialReload()
141143
}
@@ -158,7 +160,7 @@ class Hmr extends Service {
158160
]
159161

160162
async getLinked(url: string) {
161-
const job = this.internal.loadCache.get(url)
163+
const job = this.internal?.loadCache.get(url)
162164
if (!job) return []
163165
const linked = await job.linked
164166
return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[]
@@ -245,7 +247,7 @@ class Hmr extends Service {
245247
try {
246248
const { url } = await this._resolve(name, baseUrl, {})
247249
if (this.declined.has(url)) continue
248-
const job = this.internal.loadCache.get(url)
250+
const job = this.internal!.loadCache.get(url)
249251
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace())
250252
if (!job || !plugin) continue
251253
pending.set(job, plugin)
@@ -292,9 +294,9 @@ class Hmr extends Service {
292294
const require = createRequire(import.meta.url)
293295
for (const filename of this.accepted) {
294296
// Backup and clear ESM loadCache
295-
const job = Map.prototype.get.call(this.internal.loadCache, filename)
297+
const job = Map.prototype.get.call(this.internal!.loadCache, filename)
296298
esmBackup[filename] = job
297-
Map.prototype.delete.call(this.internal.loadCache, filename)
299+
Map.prototype.delete.call(this.internal!.loadCache, filename)
298300

299301
// Backup and clear CJS Module._cache
300302
try {
@@ -310,7 +312,7 @@ class Hmr extends Service {
310312

311313
const rollback = () => {
312314
for (const filename in esmBackup) {
313-
Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename])
315+
Map.prototype.set.call(this.internal!.loadCache, filename, esmBackup[filename])
314316
}
315317
for (const filepath in cjsBackup) {
316318
require.cache[filepath] = cjsBackup[filepath]

packages/hmr/tests/index.spec.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
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'
7-
import { pathToFileURL } from 'node:url'
9+
import { pathToFileURL, fileURLToPath } from 'node:url'
810

9-
const testDir = new URL('.', import.meta.url).pathname
11+
const testDir = fileURLToPath(new URL('.', import.meta.url))
1012

1113
// Helper: read and backup a file, returning restore function
1214
function backupFile(filename: string) {
@@ -797,4 +799,42 @@ export function apply(ctx: Context) {
797799
expect(ctx.bail('hmr-test/get-value')).to.equal('stash-test-2')
798800
}, 10000)
799801
})
802+
803+
// ===== Watch-only without loader internals =====
804+
// Regression: the constructor used to require `loader.internal`
805+
// unconditionally, but watch-only mode (root: []) never touches module
806+
// reload, so it must boot even when the native helper binding is missing.
807+
describe('watch-only without loader internals', () => {
808+
it('should start in watch-only mode without loader internals', async () => {
809+
const ctx = new Context()
810+
ctx.baseUrl = pathToFileURL(resolve(testDir) + '/').href
811+
await ctx.plugin(Logger)
812+
await ctx.plugin(Timer)
813+
const fiber = await ctx.plugin(Loader)
814+
// Simulate a missing native helper binding: fromInternal() returns
815+
// undefined in production when the addon cannot resolve.
816+
ctx.loader.internal = undefined
817+
818+
await ctx.plugin(Hmr, { root: [], debounce: 100, ignored: [] })
819+
820+
expect(ctx.hmr).to.be.ok
821+
fiber?.dispose()
822+
await new Promise(r => setTimeout(r, 200))
823+
}, 10000)
824+
825+
it('should still require loader internals for module reload', async () => {
826+
const ctx = new Context()
827+
ctx.baseUrl = pathToFileURL(resolve(testDir) + '/').href
828+
await ctx.plugin(Logger)
829+
await ctx.plugin(Timer)
830+
const fiber = await ctx.plugin(Loader)
831+
ctx.loader.internal = undefined
832+
833+
await expect(ctx.plugin(Hmr, { root: ['.'], debounce: 100, ignored: [] }))
834+
.rejects.toThrow(/loader internals/)
835+
836+
fiber?.dispose()
837+
await new Promise(r => setTimeout(r, 200))
838+
}, 10000)
839+
})
800840
})

0 commit comments

Comments
 (0)