index.ts 11 KB

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