entry.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { Context, Fiber, Inject } from '@deepseek-ai/cordis'
  2. import { deepEqual, isNullable } from '@deepseek-ai/cosmokit'
  3. import { Loader } from '../index.ts'
  4. import { EntryGroup } from './group.ts'
  5. import { EntryTree } from './tree.ts'
  6. import { evaluate, isJsExpr } from './utils.ts'
  7. /** Serialized plugin entry options stored in loader config files. */
  8. export interface EntryOptions {
  9. /** Stable id inside the containing entry tree. */
  10. id: string
  11. /** Module specifier imported by the entry tree. */
  12. name: string
  13. /** Config passed to the plugin. */
  14. config?: any
  15. /** Marks this entry as a nested group. */
  16. group?: boolean | null
  17. /** Prevents this entry and descendants from running. */
  18. disabled?: boolean | null
  19. /** Required services or service intercept config for this entry. */
  20. inject?: Inject | null
  21. }
  22. /** Failure of the latest entry import, activation, or option update. */
  23. export interface EntryFailure {
  24. stage: 'import' | 'activation' | 'update'
  25. error: unknown
  26. }
  27. function takeEntries(object: {}, keys: string[]) {
  28. const result: [string, any][] = []
  29. for (const key of keys) {
  30. if (!(key in object)) continue
  31. result.push([key, object[key]])
  32. delete object[key]
  33. }
  34. return result
  35. }
  36. function sortKeys<T extends {}>(object: T, prepend = ['id', 'name'], append = ['config']): T {
  37. const part1 = takeEntries(object, prepend)
  38. const part2 = takeEntries(object, append)
  39. const rest = takeEntries(object, Object.keys(object)).sort(([a], [b]) => a.localeCompare(b))
  40. return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2]))
  41. }
  42. /** One configured plugin node inside an `EntryTree`. */
  43. export class Entry {
  44. static readonly key = Symbol.for('cordis.entry')
  45. public ctx: Context
  46. public fiber?: Fiber
  47. /** Latest failed attempt; an active fiber can still run its previous config. */
  48. public lastFailure?: EntryFailure
  49. public parent!: EntryGroup
  50. // safety: call `entry.update()` immediately after creating an entry
  51. public options = {} as EntryOptions
  52. public subgroup?: EntryGroup
  53. public subtree?: EntryTree
  54. _initTask?: Promise<void>
  55. constructor(public loader: Loader) {
  56. this.ctx = loader.ctx.extend({ [Entry.key]: this })
  57. this.context.emit('loader/entry-init', this)
  58. }
  59. get context(): Context {
  60. return this.ctx
  61. }
  62. get id() {
  63. let id = this.options.id
  64. if (this.parent.tree.ctx.fiber.entry) {
  65. id = this.parent.tree.ctx.fiber.entry.id + EntryTree.sep + id
  66. }
  67. return id
  68. }
  69. /** True when this entry or any owning parent entry is disabled. */
  70. get disabled() {
  71. // group is always enabled
  72. if (this.options.group) return false
  73. let entry: Entry | undefined = this
  74. do {
  75. if (this.disabledOf(entry.options)) return true
  76. entry = entry.parent.ctx.fiber.entry
  77. } while (entry)
  78. return false
  79. }
  80. /**
  81. * Effective disabled state: a `!!js` expression evaluates against the loader
  82. * context. The raw node stays in the options, so write-back keeps the form.
  83. */
  84. private disabledOf(options: EntryOptions): boolean {
  85. return isJsExpr(options.disabled)
  86. ? Boolean(this.evaluate(options.disabled.__jsExpr))
  87. : Boolean(options.disabled)
  88. }
  89. evaluate(expr: string) {
  90. return evaluate(this.ctx, expr)
  91. }
  92. private _patchContext(diff: string[]) {
  93. this.context.waterfall('loader/patch-context', this, () => {
  94. Object.setPrototypeOf(this.ctx, this.parent.ctx)
  95. if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
  96. this.fiber.update(this.options.config, true)
  97. }
  98. })
  99. }
  100. async refresh() {
  101. if (this.fiber) return
  102. if (this.disabled) return
  103. await this.init()
  104. }
  105. /** Merge new options, restart as needed, and persist through the parent tree. */
  106. async update(options: Partial<EntryOptions>, create = false, force = false) {
  107. try {
  108. await this.updateOptions(options, create, force)
  109. } catch (error) {
  110. if (this.lastFailure?.error !== error) this.lastFailure = { stage: 'update', error }
  111. throw error
  112. }
  113. }
  114. private async updateOptions(options: Partial<EntryOptions>, create: boolean, force: boolean) {
  115. const legacy = { ...this.options }
  116. // step 1: update options
  117. if (create) {
  118. this.options = options as EntryOptions
  119. } else {
  120. for (const [key, value] of Object.entries(options)) {
  121. if (isNullable(value)) {
  122. delete this.options[key]
  123. } else {
  124. this.options[key] = value
  125. }
  126. }
  127. }
  128. sortKeys(this.options)
  129. // step 2: execute
  130. if (this.disabled) {
  131. this.lastFailure = undefined
  132. this.fiber?.dispose()
  133. return
  134. }
  135. // step 3: check if options are changed
  136. if (this.fiber?.uid) {
  137. const diff = Object
  138. .keys({ ...this.options, ...legacy })
  139. .filter(key => !deepEqual(this.options[key], legacy[key]))
  140. if (!diff.length && !force) return
  141. if (diff.includes('config') || this.options.group) this.lastFailure = undefined
  142. this.context.emit('loader/partial-dispose', this, legacy, true)
  143. this._patchContext(diff)
  144. } else {
  145. await this.init()
  146. }
  147. }
  148. getOuterStack = () => {
  149. let entry: Entry | undefined = this
  150. const result: string[] = []
  151. do {
  152. result.push(` at ${entry.parent.tree.ctx.baseUrl}#${entry.options.id}`)
  153. entry = entry.parent.ctx.fiber.entry
  154. } while (entry)
  155. return result
  156. }
  157. /** Import and start the configured plugin if it is not already running. */
  158. async init() {
  159. try {
  160. await (this._initTask ??= this._init())
  161. } finally {
  162. this._initTask = undefined
  163. }
  164. const notify = () => {
  165. if (this.loader.getTasks().length) return
  166. this.ctx.reflect.notify(['loader'])
  167. }
  168. this.fiber?.await().then(notify, notify)
  169. }
  170. private async _init() {
  171. this.lastFailure = undefined
  172. let exports: any
  173. try {
  174. exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
  175. } catch (error) {
  176. this.lastFailure = { stage: 'import', error }
  177. this.ctx.logger.error(error)
  178. return
  179. } finally {
  180. this._initTask = undefined
  181. }
  182. try {
  183. const plugin = this.loader.unwrapExports(exports)
  184. this._patchContext([])
  185. this.loader.showLog(this, 'apply')
  186. this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
  187. } catch (error) {
  188. this.lastFailure = { stage: 'activation', error }
  189. throw error
  190. }
  191. }
  192. }