index.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { EntryOptions, EntryTree, isJsExpr } 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. const schema = yaml.JSON_SCHEMA.extend(JsExpr)
  15. const writable: Record<string, string> = {
  16. '.json': 'application/json',
  17. '.yaml': 'application/yaml',
  18. '.yml': 'application/yaml',
  19. }
  20. const supported = new Set(Object.keys(writable))
  21. /** Runtime patch applied to entries loaded from an included config file. */
  22. export interface PatchOptions {
  23. id?: string
  24. insert?: EntryOptions[]
  25. name?: string
  26. config?: any
  27. group?: boolean | null
  28. disabled?: boolean | null
  29. inject?: any
  30. intercept?: any
  31. isolate?: any
  32. [key: string]: any
  33. }
  34. /** Config namespace for the file-backed include loader. */
  35. export namespace Include {
  36. /** Config for a file-backed loader subtree. */
  37. export interface Config {
  38. /** YAML or JSON path resolved from `ctx.baseUrl`. */
  39. path: string
  40. /** Entry list written when the file does not already exist. */
  41. initial?: any[]
  42. /** Runtime patches applied after reading the file. */
  43. patches?: PatchOptions[]
  44. /** Enables loader apply/reload/unload logs for this subtree. */
  45. enableLogs?: boolean
  46. }
  47. }
  48. /** Loader entry tree backed by a YAML or JSON file. */
  49. export class Include extends EntryTree {
  50. static inject = ['loader']
  51. public filename: string
  52. private type?: string
  53. private readonly: boolean
  54. private content?: string
  55. private data?: EntryOptions[]
  56. private writeTask?: NodeJS.Timeout
  57. constructor(ctx: Context, public config: Include.Config) {
  58. super(ctx)
  59. this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
  60. this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
  61. const ext = extname(this.filename)
  62. if (!supported.has(ext)) {
  63. throw new Error(`extension "${ext}" not supported`)
  64. }
  65. this.type = writable[ext]
  66. this.readonly = !this.type
  67. this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
  68. ctx.on('internal/update', (config, _, next) => {
  69. if (config.path !== this.config.path) return next()
  70. // Veto the fiber restart (children update in place), but persist the new
  71. // config ourselves — `Fiber.update` only assigns `this.config` behind
  72. // `next()`, and a stale `this.config.patches` would make the next
  73. // `refresh()` re-apply the old overlay.
  74. this.config = config
  75. this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
  76. this.ctx.logger.warn('config update at %C failed', this.filename)
  77. this.ctx.logger.warn(error)
  78. })
  79. })
  80. }
  81. private async checkAccess() {
  82. if (!this.type) return
  83. try {
  84. await access(this.filename, constants.W_OK)
  85. } catch {
  86. this.readonly = true
  87. }
  88. }
  89. private async read(forced = false) {
  90. const content = await readFile(this.filename, 'utf8')
  91. if (!forced && this.content === content) return false
  92. let data: any
  93. if (this.type === 'application/yaml') {
  94. data = yaml.load(content, { schema })
  95. } else if (this.type === 'application/json') {
  96. data = JSON.parse(content)
  97. } else {
  98. const module = await import(/* @vite-ignore */ this.filename)
  99. data = module.default || module
  100. }
  101. // An empty or truncated file (common mid-edit: editors and `sed -i` write
  102. // through temp states) parses to `undefined`, not an error; reject every
  103. // non-array shape here so callers see one "invalid file" signal. Content
  104. // and data commit only on success, so an edit that is later reverted to
  105. // the exact last good content correctly reads as "unchanged".
  106. if (!Array.isArray(data)) {
  107. throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
  108. }
  109. this.content = content
  110. this.data = data
  111. await this.checkAccess()
  112. return true
  113. }
  114. private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
  115. // Always detach from the cached parse: patching shared entry objects would
  116. // bake earlier patch values into `this.data`, so repeated application
  117. // (config hot-reloads) could never revert a removed or changed patch. The
  118. // supported extensions guarantee JSON-safe plain data, so `structuredClone`
  119. // cannot throw here.
  120. if (!patches?.length) return [...data]
  121. data = structuredClone(data)
  122. const entryMap = new Map<string, EntryOptions>()
  123. const buildMap = (entries: EntryOptions[]) => {
  124. for (const entry of entries) {
  125. if (entry.id) entryMap.set(entry.id, entry)
  126. if (entry.group && Array.isArray(entry.config)) {
  127. buildMap(entry.config)
  128. }
  129. }
  130. }
  131. buildMap(data)
  132. for (const patch of patches) {
  133. const { id, insert, name, ...overrides } = patch
  134. if (insert) {
  135. if (id) {
  136. const target = entryMap.get(id)
  137. if (!target) {
  138. this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id)
  139. continue
  140. }
  141. if (!target.group) {
  142. this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id)
  143. continue
  144. }
  145. if (!Array.isArray(target.config)) target.config = []
  146. target.config.push(...insert)
  147. } else {
  148. data.push(...insert)
  149. }
  150. continue
  151. }
  152. if (!id) {
  153. this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches')
  154. continue
  155. }
  156. const target = entryMap.get(id)
  157. if (!target) {
  158. this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id)
  159. continue
  160. }
  161. if (name && name !== target.name) {
  162. this.ctx.root.logger?.('loader').warn(
  163. 'patch: name mismatch for %C (expected %C, got %C), skipping',
  164. id, target.name, name,
  165. )
  166. continue
  167. }
  168. for (const [key, value] of Object.entries(overrides)) {
  169. if (key === 'id') continue
  170. target[key] = value
  171. }
  172. }
  173. return data
  174. }
  175. async* [Service.init]() {
  176. try {
  177. await this.read()
  178. } catch (error) {
  179. // Only a missing file falls back to `initial` (or the not-found error):
  180. // an existing-but-invalid file must fail loud with its real parse error,
  181. // never be mislabelled as absent or silently overwritten.
  182. if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
  183. if (this.config.initial) {
  184. this.writeFile(this.config.initial as any)
  185. await this.read()
  186. } else {
  187. throw new Error(`config file not found: ${this.filename}`)
  188. }
  189. }
  190. yield () => this.stop()
  191. await this.root.update(this.applyPatches(this.data!))
  192. }
  193. stop() {
  194. this.root.stop()
  195. }
  196. /**
  197. * Re-read the file and refresh child entries when content changed. An
  198. * unreadable or unparsable file logs a warning and keeps the last good
  199. * tree: a hot-reload of a live app must never take the process down.
  200. */
  201. async refresh() {
  202. try {
  203. if (!await this.read()) return
  204. await this.root.update(this.applyPatches(this.data!))
  205. } catch (error) {
  206. this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
  207. this.ctx.logger.warn(error)
  208. }
  209. }
  210. private async _writeFile(config: EntryOptions[]) {
  211. if (this.readonly) {
  212. throw new Error(`cannot overwrite readonly config`)
  213. }
  214. if (this.type === 'application/yaml') {
  215. this.content = yaml.dump(config, { schema })
  216. } else if (this.type === 'application/json') {
  217. this.content = JSON.stringify(config, null, 2)
  218. }
  219. await writeFile(this.filename + '.tmp', this.content!)
  220. await rename(this.filename + '.tmp', this.filename)
  221. }
  222. private writeFile(config: EntryOptions[]) {
  223. clearTimeout(this.writeTask)
  224. this.writeTask = setTimeout(() => {
  225. this.writeTask = undefined
  226. this._writeFile(config)
  227. }, 0)
  228. }
  229. /** Schedule a write of the current root entry data. */
  230. write() {
  231. this.context.emit('loader/config-update')
  232. return this.writeFile(this.root.data)
  233. }
  234. }
  235. export default Include