fix(cordis): make config reload transactional
This commit is contained in:
164
vendor/hmr/src/index.ts
vendored
164
vendor/hmr/src/index.ts
vendored
@@ -1,9 +1,10 @@
|
||||
import { Context, Inject, Service, type Plugin } from 'cordis'
|
||||
import { Context, Service, type Plugin } from 'cordis'
|
||||
import type { Dict } from 'cosmokit'
|
||||
import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { handleError } from './error.ts'
|
||||
import type {} from '@cordisjs/plugin-timer'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
@@ -19,6 +20,13 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
'hmr/change'(url: string): void
|
||||
'hmr/reload'(reloads: Map<Plugin, Reload>): void
|
||||
/**
|
||||
* A watched config-file refresh failed.
|
||||
* @param filename - Absolute path observed by HMR.
|
||||
* @param error - Normalized refresh failure.
|
||||
* @mode parallel
|
||||
*/
|
||||
'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +52,42 @@ interface Reload {
|
||||
runtime?: Plugin.Runtime
|
||||
}
|
||||
|
||||
@Inject('loader')
|
||||
@Inject('timer')
|
||||
interface ConfigRefresh {
|
||||
dirty: boolean
|
||||
running?: Promise<void>
|
||||
}
|
||||
|
||||
interface ConfigRegistration {
|
||||
watcher: FSWatcher
|
||||
}
|
||||
|
||||
async function findWatchRoot(filename: string): Promise<{ 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 }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const parent = dirname(root)
|
||||
if (parent === root) throw error
|
||||
root = parent
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Hmr extends Service {
|
||||
static inject = ['loader', 'timer']
|
||||
|
||||
public baseDir: string
|
||||
|
||||
private internal: ModuleLoader
|
||||
private watcher!: FSWatcher
|
||||
private readonly configs = new Map<string, ConfigRegistration>()
|
||||
private readonly configRefreshes = new WeakMap<object, ConfigRefresh>()
|
||||
private readonly refreshTasks = new Set<Promise<void>>()
|
||||
|
||||
/**
|
||||
* Changes from externals will always trigger a full reload.
|
||||
@@ -82,6 +119,65 @@ class Hmr extends Service {
|
||||
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch one exact config path outside the configured module roots.
|
||||
* @param filename - Config path, resolved against the HMR base directory.
|
||||
* @param refresh - Refresh callback run serially on add, change, or unlink.
|
||||
* @returns an asynchronous disposer once the exact watch is ready.
|
||||
* @throws when HMR is inactive, the path is already registered, or watcher startup fails.
|
||||
*/
|
||||
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 { root, depth } = await findWatchRoot(filename)
|
||||
const watcher = watch(root, {
|
||||
...this.config,
|
||||
cwd: undefined,
|
||||
depth,
|
||||
ignored: undefined,
|
||||
ignoreInitial: false,
|
||||
})
|
||||
const registration = { watcher }
|
||||
this.configs.set(filename, registration)
|
||||
const onChange = (path: string) => {
|
||||
if (resolve(path) !== filename) return
|
||||
this.refreshConfig(registration, filename, refresh)
|
||||
}
|
||||
watcher.on('add', onChange)
|
||||
watcher.on('change', onChange)
|
||||
watcher.on('unlink', onChange)
|
||||
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let readyState: 'pending' | 'resolved' | 'rejected' = 'pending'
|
||||
watcher.once('ready', () => {
|
||||
readyState = 'resolved'
|
||||
ready.resolve()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
if (readyState === 'pending') {
|
||||
readyState = 'rejected'
|
||||
ready.reject(error)
|
||||
} else {
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await ready.promise
|
||||
return this.ctx.effect(() => async () => {
|
||||
if (this.configs.get(filename) === registration) this.configs.delete(filename)
|
||||
await watcher.close()
|
||||
await this.configRefreshes.get(registration)?.running
|
||||
}, 'hmr.registerConfig()')
|
||||
} catch (error) {
|
||||
this.configs.delete(filename)
|
||||
await watcher.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a module specifier to a URL, compatible with Node 22-24.
|
||||
*/
|
||||
@@ -93,7 +189,12 @@ class Hmr extends Service {
|
||||
}
|
||||
|
||||
async* [Service.init]() {
|
||||
yield () => this.watcher?.close()
|
||||
yield async () => {
|
||||
await this.watcher?.close()
|
||||
await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close()))
|
||||
this.configs.clear()
|
||||
await Promise.allSettled([...this.refreshTasks])
|
||||
}
|
||||
|
||||
const { loader } = this.ctx
|
||||
const { root, ignored } = this.config
|
||||
@@ -122,9 +223,18 @@ class Hmr extends Service {
|
||||
|
||||
const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
|
||||
|
||||
this.watcher.on('change', async (path) => {
|
||||
this.ctx.logger.debug('change detected at %C', path)
|
||||
const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
|
||||
this.ctx.logger.debug('%s detected at %C', kind, path)
|
||||
const filename = resolve(this.baseDir, path)
|
||||
// Config reload: the file is a loader config file (e.g. cordis.yml).
|
||||
for (const entry of loader.entries()) {
|
||||
const include = entry.subtree as Include | undefined
|
||||
if (include?.filename !== filename) continue
|
||||
this.refreshConfig(include, filename, () => include.refresh())
|
||||
return
|
||||
}
|
||||
|
||||
if (kind !== 'change') return
|
||||
const url = pathToFileURL(filename).href
|
||||
|
||||
// Full reload: the changed file is part of the framework
|
||||
@@ -138,16 +248,40 @@ class Hmr extends Service {
|
||||
return partialReload()
|
||||
}
|
||||
|
||||
// Config reload: the file is a loader config file (e.g. cordis.yml)
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
const include = entry.subtree as Include | undefined
|
||||
if (include?.filename !== filename) continue
|
||||
await include.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
this.ctx.emit('hmr/change', url)
|
||||
}
|
||||
this.watcher.on('add', path => onChange('add', path))
|
||||
this.watcher.on('change', path => onChange('change', path))
|
||||
this.watcher.on('unlink', path => onChange('unlink', path))
|
||||
}
|
||||
|
||||
private refreshConfig(key: object, filename: string, refresh: () => Promise<void> | void) {
|
||||
const state = this.configRefreshes.get(key) ?? { dirty: false }
|
||||
this.configRefreshes.set(key, state)
|
||||
state.dirty = true
|
||||
if (state.running) return
|
||||
const task = (async () => {
|
||||
do {
|
||||
state.dirty = false
|
||||
try {
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
|
||||
this.ctx.logger.warn('config reload at %C failed', filename)
|
||||
this.ctx.logger.warn(error)
|
||||
try {
|
||||
await this.ctx.parallel('hmr/config-update-failed', filename, error)
|
||||
} catch (rejection) {
|
||||
this.ctx.logger.warn(rejection)
|
||||
}
|
||||
}
|
||||
} while (state.dirty)
|
||||
})().finally(() => {
|
||||
state.running = undefined
|
||||
this.refreshTasks.delete(task)
|
||||
})
|
||||
state.running = task
|
||||
this.refreshTasks.add(task)
|
||||
}
|
||||
|
||||
// hide stack trace from HMR
|
||||
|
||||
Reference in New Issue
Block a user