index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { EntryGroup, EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  2. import { Context, Service } from '@deepseek-ai/cordis'
  3. import { extname } from 'node:path'
  4. import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
  5. import { setTimeout as delay } from 'node:timers/promises'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import * as yaml from 'js-yaml'
  8. const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
  9. kind: 'scalar',
  10. resolve: (data) => typeof data === 'string',
  11. construct: (data) => ({ __jsExpr: data }),
  12. predicate: isJsExpr,
  13. represent: (data) => data['__jsExpr'],
  14. })
  15. /**
  16. * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
  17. * the Loader evaluates at entry activation. Exported so config tooling
  18. * (`dsh --dump-config`) parses and prints exactly the dialect this include
  19. * mounts.
  20. */
  21. export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
  22. const schema = entryListSchema
  23. const writable: Record<string, string> = {
  24. '.json': 'application/json',
  25. '.yaml': 'application/yaml',
  26. '.yml': 'application/yaml',
  27. }
  28. const supported = new Set(Object.keys(writable))
  29. const WRITE_RETRY_LIMIT = 10
  30. const WRITE_RETRY_DELAY_MS = 50
  31. function retryableWriteError(error: unknown): boolean {
  32. const code = (error as NodeJS.ErrnoException | null)?.code
  33. return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM'
  34. }
  35. /**
  36. * Apply patch lists to an entry list — THE patch semantics of this include,
  37. * shared by mounting (`applyPatches`) and offline config tooling
  38. * (`dsh --dump-config`) so a dump can never drift from what boots. The input
  39. * is never mutated and the result is always detached from it (even with no
  40. * patches): patching or mounting shared entry objects would bake earlier
  41. * values into the cached parse, so repeated application (config hot-reloads)
  42. * could never revert a removed or changed patch. Inserted entries are indexed
  43. * as they are added, so a later patch in the same list can target a row an
  44. * earlier patch inserted. A patch that matches nothing warns and is skipped.
  45. * @param data - the parsed entry list (JSON-safe plain data).
  46. * @param patches - the patch list to apply, in order.
  47. * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
  48. * @returns a detached entry list with every applicable patch applied.
  49. */
  50. export function applyEntryPatches(
  51. data: EntryOptions[],
  52. patches: PatchOptions[] | undefined,
  53. warn: (message: string, ...args: any[]) => void,
  54. ): EntryOptions[] {
  55. data = structuredClone(data)
  56. if (!patches?.length) return data
  57. const entryMap = new Map<string, EntryOptions>()
  58. const buildMap = (entries: EntryOptions[]) => {
  59. for (const entry of entries) {
  60. if (entry.id) entryMap.set(entry.id, entry)
  61. if (entry.group && Array.isArray(entry.config)) {
  62. buildMap(entry.config)
  63. }
  64. }
  65. }
  66. buildMap(data)
  67. for (const patch of patches) {
  68. const { id, insert, name, ...overrides } = patch
  69. if (insert) {
  70. if (id) {
  71. const target = entryMap.get(id)
  72. if (!target) {
  73. warn('patch insert: entry %C not found', id)
  74. continue
  75. }
  76. if (!target.group) {
  77. warn('patch insert: entry %C is not a group', id)
  78. continue
  79. }
  80. if (!Array.isArray(target.config)) target.config = []
  81. target.config.push(...insert)
  82. } else {
  83. data.push(...insert)
  84. }
  85. // Index what this patch added so a LATER patch in the same list can
  86. // target it. Patch lists compose one layer per source (each bundle
  87. // layer, then the user's, then `--patch` overlays), and a layer must be
  88. // able to configure or disable a row an earlier layer inserted; without
  89. // this, inserted rows were silently unpatchable.
  90. buildMap(insert)
  91. continue
  92. }
  93. if (!id) {
  94. warn('patch: id is required for non-insert patches')
  95. continue
  96. }
  97. const target = entryMap.get(id)
  98. if (!target) {
  99. warn('patch: entry %C not found', id)
  100. continue
  101. }
  102. if (name && name !== target.name) {
  103. warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)
  104. continue
  105. }
  106. for (const [key, value] of Object.entries(overrides)) {
  107. if (key === 'id') continue
  108. target[key] = value
  109. }
  110. }
  111. return data
  112. }
  113. type ConfigUpdateStage = 'read' | 'parse' | 'validate'
  114. interface ReadCandidate {
  115. content: string
  116. data: EntryOptions[]
  117. }
  118. class ConfigFileError extends Error {
  119. constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
  120. super(`failed to ${stage} config file ${path}`, { cause })
  121. this.name = 'ConfigFileError'
  122. }
  123. }
  124. /** Runtime patch applied to entries loaded from an included config file. */
  125. export interface PatchOptions {
  126. id?: string
  127. insert?: EntryOptions[]
  128. name?: string
  129. config?: any
  130. group?: boolean | null
  131. disabled?: boolean | null
  132. inject?: any
  133. intercept?: any
  134. isolate?: any
  135. [key: string]: any
  136. }
  137. /** Config namespace for the file-backed include loader. */
  138. export namespace Include {
  139. /** Config for a file-backed loader subtree. */
  140. export interface Config {
  141. /** YAML or JSON path resolved from `ctx.baseUrl`. */
  142. path: string
  143. /** Entry list written when the file does not already exist. */
  144. initial?: any[]
  145. /** Runtime patches applied after reading the file. */
  146. patches?: PatchOptions[]
  147. /** Enables loader apply/reload/unload logs for this subtree. */
  148. enableLogs?: boolean
  149. }
  150. }
  151. /** Loader entry tree backed by a YAML or JSON file. */
  152. export class Include extends EntryTree {
  153. static inject = ['loader']
  154. // Tree-carrier marker (the Group plugin declares the same): this config is
  155. // entry and patch lists, so the Loader's `internal/config` interpolation
  156. // keeps it literal — a `!!js` expression inside a nested row's config
  157. // belongs to that row's fiber, resolving lazily in the row's own context.
  158. // Include's own fields (`path`, `enableLogs`) therefore stay literal too.
  159. static readonly [EntryGroup.key] = true
  160. public filename: string
  161. private type?: string
  162. private readonly: boolean
  163. private content?: string
  164. private data?: EntryOptions[]
  165. private writeTask?: NodeJS.Timeout | undefined
  166. private pendingWrite?: EntryOptions[]
  167. private writeQueue: Promise<void> = Promise.resolve()
  168. private applyQueue: Promise<unknown> = Promise.resolve()
  169. constructor(ctx: Context, public config: Include.Config) {
  170. super(ctx)
  171. this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
  172. this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
  173. const ext = extname(this.filename)
  174. if (!supported.has(ext)) {
  175. throw new Error(`extension "${ext}" not supported`)
  176. }
  177. this.type = writable[ext]
  178. this.readonly = !this.type
  179. this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
  180. ctx.on('internal/update', async (config, _, next) => {
  181. if (config.path !== this.config.path) return next()
  182. await this.enqueue(async () => {
  183. const data = this.applyPatches(this.data!, config.patches)
  184. await this.root.update(data)
  185. this.config = config
  186. })
  187. })
  188. }
  189. /**
  190. * Serialize one child-tree mutation behind every earlier one. The group's
  191. * transactional `update` is not reentrant: two concurrent applies (the init
  192. * apply racing an HMR-triggered refresh from the watcher's initial scan)
  193. * interleave create and rollback on the same entries and strand the include
  194. * fiber without settling, so every apply path funnels through this queue.
  195. * A predecessor's failure is its own caller's outcome and never gates the
  196. * next task.
  197. */
  198. private enqueue<T>(task: () => Promise<T>): Promise<T> {
  199. const run = this.applyQueue.then(task, task)
  200. this.applyQueue = run.then(() => {}, () => {})
  201. return run
  202. }
  203. private async checkAccess() {
  204. if (!this.type) return
  205. try {
  206. await access(this.filename, constants.W_OK)
  207. } catch {
  208. this.readonly = true
  209. }
  210. }
  211. private async read(forced = false): Promise<ReadCandidate | undefined> {
  212. let content: string
  213. try {
  214. content = await readFile(this.filename, 'utf8')
  215. } catch (error) {
  216. throw new ConfigFileError('read', this.filename, error)
  217. }
  218. if (!forced && this.content === content) return
  219. let data: any
  220. try {
  221. if (this.type === 'application/yaml') {
  222. data = yaml.load(content, { schema })
  223. } else if (this.type === 'application/json') {
  224. data = JSON.parse(content)
  225. } else {
  226. const module = await import(/* @vite-ignore */ this.filename)
  227. data = module.default || module
  228. }
  229. } catch (error) {
  230. throw new ConfigFileError('parse', this.filename, error)
  231. }
  232. if (!Array.isArray(data)) {
  233. throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
  234. }
  235. return { content, data }
  236. }
  237. private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] {
  238. return applyEntryPatches(data, patches, (message, ...args) => {
  239. this.ctx.root.logger?.('loader').warn(message, ...args)
  240. })
  241. }
  242. async* [Service.init]() {
  243. let candidate: ReadCandidate
  244. try {
  245. candidate = (await this.read(true))!
  246. } catch (error) {
  247. if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
  248. if (this.config.initial) {
  249. await this._writeFile(this.config.initial as any)
  250. candidate = (await this.read(true))!
  251. } else {
  252. throw new Error(`config file not found: ${this.filename}`)
  253. }
  254. }
  255. yield () => this.stop()
  256. await this.apply(candidate)
  257. }
  258. async stop() {
  259. await this.root.stop()
  260. await this.flushWrite()
  261. }
  262. /**
  263. * Re-read the file and transactionally refresh child entries when content changed.
  264. * @returns a promise resolving after the new tree commits, or immediately when unchanged.
  265. * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
  266. */
  267. async refresh() {
  268. // Read inside the queue so the changed-content check compares against the
  269. // predecessor's committed state, not a mid-apply snapshot.
  270. await this.enqueue(async () => {
  271. const candidate = await this.read()
  272. if (!candidate) return
  273. await this._apply(candidate)
  274. })
  275. }
  276. private apply(candidate: ReadCandidate) {
  277. return this.enqueue(() => this._apply(candidate))
  278. }
  279. private async _apply(candidate: ReadCandidate) {
  280. const data = this.applyPatches(candidate.data, this.config.patches)
  281. await this.root.update(data)
  282. this.content = candidate.content
  283. this.data = candidate.data
  284. await this.checkAccess()
  285. }
  286. private async _writeFile(config: EntryOptions[]) {
  287. if (this.readonly) {
  288. throw new Error(`cannot overwrite readonly config`)
  289. }
  290. if (this.type === 'application/yaml') {
  291. this.content = yaml.dump(config, { schema })
  292. } else if (this.type === 'application/json') {
  293. this.content = JSON.stringify(config, null, 2)
  294. }
  295. await writeFile(this.filename + '.tmp', this.content!)
  296. for (let retry = 0; ; retry++) {
  297. try {
  298. await rename(this.filename + '.tmp', this.filename)
  299. return
  300. } catch (error) {
  301. if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error
  302. await delay((retry + 1) * WRITE_RETRY_DELAY_MS)
  303. }
  304. }
  305. }
  306. private writeFile(config: EntryOptions[]) {
  307. clearTimeout(this.writeTask)
  308. this.pendingWrite = config
  309. this.writeTask = setTimeout(() => {
  310. void this.flushWrite()
  311. }, 0)
  312. }
  313. private flushWrite(): Promise<void> {
  314. clearTimeout(this.writeTask)
  315. this.writeTask = undefined
  316. const config = this.pendingWrite
  317. this.pendingWrite = undefined
  318. if (config === undefined) return this.writeQueue
  319. const run = this.writeQueue.then(
  320. () => this._writeFile(config),
  321. () => this._writeFile(config),
  322. )
  323. this.writeQueue = run
  324. void run.catch((error) => {
  325. this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename)
  326. this.ctx.root.logger?.('loader').warn(error)
  327. })
  328. return run
  329. }
  330. /** Schedule a write of the current root entry data. */
  331. write() {
  332. this.context.emit('loader/config-update')
  333. return this.writeFile(this.root.data)
  334. }
  335. }
  336. export default Include