fix(windows): canonicalize native watch paths

This commit is contained in:
Tianyi Cui
2026-08-08 19:29:43 +08:00
parent a6c129a3c6
commit 702e2d024a
21 changed files with 157 additions and 36 deletions

View File

@@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed
application files. Changes to framework-level dependencies fall back to
`loader.exit()`, letting the host process restart.
Module watches canonicalize their existing base directory before opening
Chokidar. Exact config watches likewise canonicalize the deepest existing
ancestor, then restore any missing suffix. Callbacks and diagnostics retain the
requested absolute filename, while the native backend receives one filesystem
spelling even when Windows supplied an 8.3 alias.
## Requirements
- `@cordisjs/plugin-loader`

View File

@@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug
import type { Include } from '@cordisjs/plugin-include'
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
import { dirname, relative, resolve } from 'node:path'
import { stat } from 'node:fs/promises'
import { realpath, stat } from 'node:fs/promises'
import { handleError } from './error.ts'
import type {} from '@cordisjs/plugin-timer'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -61,13 +61,18 @@ interface ConfigRegistration {
watcher: FSWatcher
}
async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> {
async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
let root = dirname(filename)
let depth = 0
while (true) {
try {
if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
return { root, depth }
const canonicalRoot = await realpath(root)
return {
filename: resolve(canonicalRoot, relative(root, filename)),
root: canonicalRoot,
depth,
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(root)
@@ -129,9 +134,11 @@ class Hmr extends Service {
async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
if (!this.watcher) throw new Error('HMR is not active')
filename = resolve(this.baseDir, filename)
if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`)
const target = await findWatchRoot(filename)
const watchFilename = target.filename
if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`)
const { root, depth } = await findWatchRoot(filename)
const { root, depth } = target
const watcher = watch(root, {
...this.config,
cwd: undefined,
@@ -140,9 +147,10 @@ class Hmr extends Service {
ignoreInitial: false,
})
const registration = { watcher }
this.configs.set(filename, registration)
this.configs.set(watchFilename, registration)
const onChange = (path: string) => {
if (resolve(path) !== filename) return
const observed = resolve(path)
if (observed !== filename && observed !== watchFilename) return
this.refreshConfig(registration, filename, refresh)
}
watcher.on('add', onChange)
@@ -167,12 +175,12 @@ class Hmr extends Service {
try {
await ready.promise
return this.ctx.effect(() => async () => {
if (this.configs.get(filename) === registration) this.configs.delete(filename)
if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename)
await watcher.close()
await this.configRefreshes.get(registration)?.running
}, 'hmr.registerConfig()')
} catch (error) {
this.configs.delete(filename)
this.configs.delete(watchFilename)
await watcher.close()
throw error
}
@@ -205,10 +213,11 @@ class Hmr extends Service {
}
const match = picomatch(ignored)
const watchBaseDir = await realpath(this.baseDir)
this.watcher = watch(root, {
...this.config,
cwd: this.baseDir,
ignored: path => match(relative(this.baseDir, path)),
cwd: watchBaseDir,
ignored: path => match(relative(watchBaseDir, path)),
// The initial scan re-announces files the boot just consumed: an `add`
// for a config file refreshes an include whose initial apply may still
// be in flight, and a failing apply then rolls this plugin back while