index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import { Context, Service, type Plugin } from 'cordis'
  2. import type { Dict } from 'cosmokit'
  3. import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader'
  4. import type { Include } from '@cordisjs/plugin-include'
  5. import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
  6. import { dirname, relative, resolve } from 'node:path'
  7. import { stat } from 'node:fs/promises'
  8. import { handleError } from './error.ts'
  9. import type {} from '@cordisjs/plugin-timer'
  10. import { fileURLToPath, pathToFileURL } from 'node:url'
  11. import { createRequire } from 'node:module'
  12. import picomatch from 'picomatch'
  13. import z from 'schemastery'
  14. declare module 'cordis' {
  15. interface Context {
  16. hmr: Hmr
  17. }
  18. interface Events {
  19. 'hmr/change'(url: string): void
  20. 'hmr/reload'(reloads: Map<Plugin, Reload>): void
  21. /**
  22. * A watched config-file refresh failed.
  23. * @param filename - Absolute path observed by HMR.
  24. * @param error - Normalized refresh failure.
  25. * @mode parallel
  26. */
  27. 'hmr/config-update-failed'(filename: string, error: Error): Promise<void> | void
  28. }
  29. }
  30. /**
  31. * Recursively collect all module dependencies from a ModuleJob.
  32. * Skips node: builtins and node_modules to focus on user code.
  33. */
  34. async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
  35. const dependencies = new Set<string>()
  36. async function traverse(job: ModuleJob) {
  37. if (ignored.has(job.url) || dependencies.has(job.url)) return
  38. if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return
  39. dependencies.add(job.url)
  40. const children = await job.linked
  41. await Promise.all(Array.prototype.map.call(children, traverse))
  42. }
  43. await traverse(job)
  44. return dependencies
  45. }
  46. interface Reload {
  47. filename: string
  48. runtime?: Plugin.Runtime
  49. }
  50. interface ConfigRefresh {
  51. dirty: boolean
  52. running?: Promise<void>
  53. }
  54. interface ConfigRegistration {
  55. watcher: FSWatcher
  56. }
  57. async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> {
  58. let root = dirname(filename)
  59. let depth = 0
  60. while (true) {
  61. try {
  62. if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
  63. return { root, depth }
  64. } catch (error) {
  65. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  66. const parent = dirname(root)
  67. if (parent === root) throw error
  68. root = parent
  69. depth += 1
  70. }
  71. }
  72. }
  73. class Hmr extends Service {
  74. static inject = ['loader', 'timer']
  75. public baseDir: string
  76. private internal: ModuleLoader
  77. private watcher!: FSWatcher
  78. private readonly configs = new Map<string, ConfigRegistration>()
  79. private readonly configRefreshes = new WeakMap<object, ConfigRefresh>()
  80. private readonly refreshTasks = new Set<Promise<void>>()
  81. /**
  82. * Changes from externals will always trigger a full reload.
  83. * Externals are the dependency tree of the CLI worker entry point.
  84. */
  85. private externals!: Set<string>
  86. /**
  87. * Files that should be reloaded (accepted changes).
  88. * Includes all stashed files and their dependents.
  89. */
  90. private accepted!: Set<string>
  91. /**
  92. * Files that should NOT be reloaded.
  93. * Includes externals and files whose dependents are all declined.
  94. */
  95. private declined!: Set<string>
  96. /** Stashed file changes waiting to be processed */
  97. private stashed = new Set<string>()
  98. constructor(ctx: Context, public config: Hmr.Config) {
  99. super(ctx, 'hmr')
  100. if (!this.ctx.loader.internal) {
  101. throw new Error('--expose-internals is required for HMR service')
  102. }
  103. this.internal = this.ctx.loader.internal
  104. this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
  105. }
  106. /**
  107. * Watch one exact config path outside the configured module roots.
  108. * @param filename - Config path, resolved against the HMR base directory.
  109. * @param refresh - Refresh callback run serially on add, change, or unlink.
  110. * @returns an asynchronous disposer once the exact watch is ready.
  111. * @throws when HMR is inactive, the path is already registered, or watcher startup fails.
  112. */
  113. async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
  114. if (!this.watcher) throw new Error('HMR is not active')
  115. filename = resolve(this.baseDir, filename)
  116. if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`)
  117. const { root, depth } = await findWatchRoot(filename)
  118. const watcher = watch(root, {
  119. ...this.config,
  120. cwd: undefined,
  121. depth,
  122. ignored: undefined,
  123. ignoreInitial: false,
  124. })
  125. const registration = { watcher }
  126. this.configs.set(filename, registration)
  127. const onChange = (path: string) => {
  128. if (resolve(path) !== filename) return
  129. this.refreshConfig(registration, filename, refresh)
  130. }
  131. watcher.on('add', onChange)
  132. watcher.on('change', onChange)
  133. watcher.on('unlink', onChange)
  134. const ready = Promise.withResolvers<void>()
  135. let readyState: 'pending' | 'resolved' | 'rejected' = 'pending'
  136. watcher.once('ready', () => {
  137. readyState = 'resolved'
  138. ready.resolve()
  139. })
  140. watcher.on('error', (error) => {
  141. if (readyState === 'pending') {
  142. readyState = 'rejected'
  143. ready.reject(error)
  144. } else {
  145. this.ctx.logger.warn(error)
  146. }
  147. })
  148. try {
  149. await ready.promise
  150. return this.ctx.effect(() => async () => {
  151. if (this.configs.get(filename) === registration) this.configs.delete(filename)
  152. await watcher.close()
  153. await this.configRefreshes.get(registration)?.running
  154. }, 'hmr.registerConfig()')
  155. } catch (error) {
  156. this.configs.delete(filename)
  157. await watcher.close()
  158. throw error
  159. }
  160. }
  161. /**
  162. * Resolve a module specifier to a URL, compatible with Node 22-24.
  163. */
  164. private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise<ResolveResult> {
  165. switch (this.internal.version) {
  166. case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
  167. case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
  168. }
  169. }
  170. async* [Service.init]() {
  171. yield async () => {
  172. await this.watcher?.close()
  173. await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close()))
  174. this.configs.clear()
  175. await Promise.allSettled([...this.refreshTasks])
  176. }
  177. const { loader } = this.ctx
  178. const { root, ignored } = this.config
  179. if (!this.config.base) {
  180. this.ctx.logger.info('watching %o', root)
  181. } else {
  182. this.ctx.logger.info('watching %o in %s', root, this.baseDir)
  183. }
  184. const match = picomatch(ignored)
  185. this.watcher = watch(root, {
  186. ...this.config,
  187. cwd: this.baseDir,
  188. ignored: path => match(relative(this.baseDir, path)),
  189. // The initial scan re-announces files the boot just consumed: an `add`
  190. // for a config file refreshes an include whose initial apply may still
  191. // be in flight, and a failing apply then rolls this plugin back while
  192. // the scan-triggered refresh waits on that apply — a teardown deadlock
  193. // that strands boot without a diagnostic. Only events after the scan
  194. // matter here; `registerConfig` keeps its own initial scan because a
  195. // personal config present at registration must apply once.
  196. ignoreInitial: true,
  197. })
  198. // Collect externals: framework modules reachable from the main entry.
  199. // Changes to these files require a full process restart, not HMR.
  200. const mainUrl = pathToFileURL(resolve(process.argv[1])).href
  201. const mainJob = this.internal.loadCache.get(mainUrl)
  202. if (mainJob) {
  203. this.externals = await loadDependencies(mainJob)
  204. } else {
  205. this.externals = new Set()
  206. }
  207. const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
  208. const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => {
  209. this.ctx.logger.debug('%s detected at %C', kind, path)
  210. const filename = resolve(this.baseDir, path)
  211. // Config reload: the file is a loader config file (e.g. cordis.yml).
  212. for (const entry of loader.entries()) {
  213. const include = entry.subtree as Include | undefined
  214. if (include?.filename !== filename) continue
  215. this.refreshConfig(include, filename, () => include.refresh())
  216. return
  217. }
  218. if (kind !== 'change') return
  219. const url = pathToFileURL(filename).href
  220. // Full reload: the changed file is part of the framework
  221. if (this.externals.has(url)) return loader.exit()
  222. // Partial reload: the file is in the ESM loadCache
  223. // In Node 24, both CJS and ESM modules imported via import() end up
  224. // in loadCache, so this check covers all module formats.
  225. if (loader.internal!.loadCache.has(url)) {
  226. this.stashed.add(url)
  227. return partialReload()
  228. }
  229. this.ctx.emit('hmr/change', url)
  230. }
  231. this.watcher.on('add', path => onChange('add', path))
  232. this.watcher.on('change', path => onChange('change', path))
  233. this.watcher.on('unlink', path => onChange('unlink', path))
  234. }
  235. private refreshConfig(key: object, filename: string, refresh: () => Promise<void> | void) {
  236. const state = this.configRefreshes.get(key) ?? { dirty: false }
  237. this.configRefreshes.set(key, state)
  238. state.dirty = true
  239. if (state.running) return
  240. const task = (async () => {
  241. do {
  242. state.dirty = false
  243. try {
  244. await refresh()
  245. } catch (reason) {
  246. const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
  247. this.ctx.logger.warn('config reload at %C failed', filename)
  248. this.ctx.logger.warn(error)
  249. try {
  250. await this.ctx.parallel('hmr/config-update-failed', filename, error)
  251. } catch (rejection) {
  252. this.ctx.logger.warn(rejection)
  253. }
  254. }
  255. } while (state.dirty)
  256. })().finally(() => {
  257. state.running = undefined
  258. this.refreshTasks.delete(task)
  259. })
  260. state.running = task
  261. this.refreshTasks.add(task)
  262. }
  263. // hide stack trace from HMR
  264. getOuterStack = (): string[] => [
  265. // ' at HMR.partialReload (<anonymous>)',
  266. ]
  267. async getLinked(url: string) {
  268. const job = this.internal.loadCache.get(url)
  269. if (!job) return []
  270. const linked = await job.linked
  271. return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[]
  272. }
  273. /**
  274. * Classify changed files into accepted (should reload) and declined (should not).
  275. *
  276. * A file is accepted if it's directly changed (stashed) or if any of its
  277. * dependents are accepted. A file is declined if all its dependents are
  278. * declined or if it's an external.
  279. */
  280. private async analyzeChanges() {
  281. const pending: string[] = []
  282. this.accepted = new Set(this.stashed)
  283. this.declined = new Set(this.externals)
  284. const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/')
  285. await Promise.all([...this.stashed].map(async (url) => {
  286. const children = await this.getLinked(url)
  287. for (const child of children) {
  288. if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue
  289. pending.push(child)
  290. }
  291. }))
  292. while (pending.length) {
  293. let index = 0, hasUpdate = false
  294. while (index < pending.length) {
  295. const url = pending[index]
  296. const children = await this.getLinked(url)
  297. let isDeclined = true, isAccepted = false
  298. for (const child of children) {
  299. if (this.declined.has(child) || isExcluded(child)) continue
  300. if (this.accepted.has(child)) {
  301. isAccepted = true
  302. break
  303. } else {
  304. isDeclined = false
  305. if (!pending.includes(child)) {
  306. hasUpdate = true
  307. pending.push(child)
  308. }
  309. }
  310. }
  311. if (isAccepted || isDeclined) {
  312. hasUpdate = true
  313. pending.splice(index, 1)
  314. if (isAccepted) {
  315. this.accepted.add(url)
  316. } else {
  317. this.declined.add(url)
  318. }
  319. } else {
  320. index++
  321. }
  322. }
  323. if (!hasUpdate) break
  324. }
  325. for (const url of pending) {
  326. this.declined.add(url)
  327. }
  328. }
  329. private async partialReload() {
  330. await this.analyzeChanges()
  331. const pending = new Map<ModuleJob, Plugin>()
  332. const reloads = new Map<Plugin, Reload>()
  333. // Build a map of plugin names per config tree URL.
  334. // Plugin entry files are treated as atomic reload units.
  335. const nameMap: Dict<Set<string>> = Object.create(null)
  336. for (const entry of this.ctx.loader.entries()) {
  337. (nameMap[entry.parent.tree.ctx.baseUrl!] ??= new Set()).add(entry.options.name)
  338. }
  339. // Resolve each plugin name to its file URL and check if it needs reload
  340. for (const baseUrl in nameMap) {
  341. for (const name of nameMap[baseUrl]) {
  342. try {
  343. const { url } = await this._resolve(name, baseUrl, {})
  344. if (this.declined.has(url)) continue
  345. const job = this.internal.loadCache.get(url)
  346. const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace())
  347. if (!job || !plugin) continue
  348. pending.set(job, plugin)
  349. this.declined.add(url)
  350. } catch (err) {
  351. this.ctx.logger.warn(err)
  352. }
  353. }
  354. }
  355. // Check each pending plugin's dependency tree for accepted files
  356. for (const [job, plugin] of pending) {
  357. this.declined.delete(job.url)
  358. const dependencies = [...await loadDependencies(job, this.declined)]
  359. this.declined.add(job.url)
  360. if (!dependencies.some(dep => this.accepted.has(dep))) continue
  361. dependencies.forEach(dep => this.accepted.add(dep))
  362. reloads.set(plugin, {
  363. filename: job.url,
  364. runtime: this.ctx.registry.get(plugin),
  365. })
  366. }
  367. /**
  368. * Clear module caches for all accepted files before re-importing.
  369. *
  370. * We need to clear both:
  371. * 1. ESM loadCache — managed by Node's internal ModuleLoader
  372. * 2. CJS Module._cache — for CJS modules that were imported via import()
  373. *
  374. * In Node 24, CJS modules loaded via import() appear in both caches.
  375. * If we only clear loadCache, the CJS cache may serve stale modules.
  376. *
  377. * We use Map.prototype methods directly on loadCache because:
  378. * - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
  379. * - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
  380. * where .delete() only sets the type slot to undefined (doesn't remove the entry)
  381. * Using Map.prototype.delete ensures complete removal in both versions.
  382. */
  383. const esmBackup: Dict = Object.create(null)
  384. const cjsBackup: Dict = Object.create(null)
  385. const require = createRequire(import.meta.url)
  386. for (const filename of this.accepted) {
  387. // Backup and clear ESM loadCache
  388. const job = Map.prototype.get.call(this.internal.loadCache, filename)
  389. esmBackup[filename] = job
  390. Map.prototype.delete.call(this.internal.loadCache, filename)
  391. // Backup and clear CJS Module._cache
  392. try {
  393. const filepath = fileURLToPath(filename)
  394. if (require.cache[filepath]) {
  395. cjsBackup[filepath] = require.cache[filepath]
  396. delete require.cache[filepath]
  397. }
  398. } catch {
  399. // filename might not be a file: URL (e.g. node: protocol), ignore
  400. }
  401. }
  402. const rollback = () => {
  403. for (const filename in esmBackup) {
  404. Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename])
  405. }
  406. for (const filepath in cjsBackup) {
  407. require.cache[filepath] = cjsBackup[filepath]
  408. }
  409. }
  410. // Attempt to re-import all plugin entry files
  411. const attempts: Dict = {}
  412. try {
  413. for (const [, { filename }] of reloads) {
  414. attempts[filename] = this.ctx.loader.unwrapExports(await this.ctx.loader.import(filename, this.getOuterStack))
  415. }
  416. } catch (e) {
  417. handleError(this.ctx, e)
  418. return rollback()
  419. }
  420. const reload = (plugin: any, runtime: Plugin.Runtime) => {
  421. if (!runtime) return
  422. for (const oldFiber of runtime.fibers) {
  423. const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack)
  424. fiber.entry = oldFiber.entry
  425. if (fiber.entry) fiber.entry.fiber = fiber
  426. }
  427. }
  428. try {
  429. for (const [plugin, { filename, runtime }] of reloads) {
  430. if (!runtime) continue
  431. const path = relative(this.baseDir, fileURLToPath(filename))
  432. try {
  433. this.ctx.registry.delete(plugin)
  434. } catch (err) {
  435. this.ctx.logger.warn('failed to dispose plugin at %C', path)
  436. this.ctx.logger.warn(err)
  437. }
  438. try {
  439. reload(attempts[filename], runtime)
  440. this.ctx.logger.info('reload plugin at %C', path)
  441. } catch (err) {
  442. this.ctx.logger.warn('failed to reload plugin at %C', path)
  443. this.ctx.logger.warn(err)
  444. throw err
  445. }
  446. }
  447. } catch {
  448. // Rollback: restore caches and re-register old plugins
  449. rollback()
  450. for (const [plugin, { filename, runtime }] of reloads) {
  451. if (!runtime) continue
  452. try {
  453. this.ctx.registry.delete(attempts[filename])
  454. reload(plugin, runtime)
  455. } catch (err) {
  456. this.ctx.logger.warn(err)
  457. }
  458. }
  459. return
  460. }
  461. this.ctx.emit('hmr/reload', reloads)
  462. this.stashed = new Set()
  463. }
  464. }
  465. namespace Hmr {
  466. export interface Config extends ChokidarOptions {
  467. base?: string
  468. root: string[]
  469. debounce: number
  470. ignored: string[]
  471. }
  472. export const Config: z<Config> = z.object({
  473. base: z.string(),
  474. root: z.array(String).role('table').default(['.']),
  475. ignored: z.array(String).role('table').default([
  476. '**/node_modules',
  477. '**/.*',
  478. 'cache',
  479. 'data',
  480. ]),
  481. debounce: z.natural().role('ms').default(100),
  482. })
  483. // [deepseek-harness] vendored modification: removed `.i18n({ 'en-US': enUS, 'zh-CN': zhCN })`
  484. // and the corresponding `./locales/*.yml` imports, to avoid a runtime YAML import hook
  485. // (@cordisjs/unyaml) that we don't vendor. See vendor/README.md.
  486. }
  487. export default Hmr