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