import { Context, Fiber, Inject } from 'cordis' import { deepEqual, isNullable } from 'cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' import { evaluate, interpolate } from './utils.ts' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { /** Stable id inside the containing entry tree. */ id: string /** Module specifier imported by the entry tree. */ name: string /** Config passed to the plugin. */ config?: any /** Marks this entry as a nested group. */ group?: boolean | null /** Prevents this entry and descendants from running. */ disabled?: boolean | null /** Required services or service intercept config for this entry. */ inject?: Inject | null } function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { if (!(key in object)) continue result.push([key, object[key]]) delete object[key] } return result } function sortKeys(object: T, prepend = ['id', 'name'], append = ['config']): T { const part1 = takeEntries(object, prepend) const part2 = takeEntries(object, append) const rest = takeEntries(object, Object.keys(object)).sort(([a], [b]) => a.localeCompare(b)) return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') public ctx: Context public fiber?: Fiber public parent!: EntryGroup // safety: call `entry.update()` immediately after creating an entry public options = {} as EntryOptions public subgroup?: EntryGroup public subtree?: EntryTree _initTask?: Promise constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) this.context.emit('loader/entry-init', this) } get context(): Context { return this.ctx } get id() { let id = this.options.id if (this.parent.tree.ctx.fiber.entry) { id = this.parent.tree.ctx.fiber.entry.id + EntryTree.sep + id } return id } /** True when this entry or any owning parent entry is disabled. */ get disabled() { // group is always enabled if (this.options.group) return false let entry: Entry | undefined = this do { if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry } while (entry) return false } evaluate(expr: string) { return evaluate(this.ctx, expr) } _resolveConfig(plugin: any): [any, any?] { if (plugin[EntryGroup.key]) return this.options.config return interpolate(this.ctx, this.options.config) } private _patchContext(diff: string[]) { this.context.waterfall('loader/patch-context', this, () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) } }) } async refresh() { if (this.fiber) return if (this.disabled) return await this.init() } /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { const legacy = { ...this.options } // step 1: update options if (create) { this.options = options as EntryOptions } else { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { delete this.options[key] } else { this.options[key] = value } } } sortKeys(this.options) // step 2: execute if (this.disabled) { this.fiber?.dispose() return } // step 3: check if options are changed if (this.fiber?.uid) { const diff = Object .keys({ ...this.options, ...legacy }) .filter(key => !deepEqual(this.options[key], legacy[key])) if (!diff.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) this._patchContext(diff) } else { await this.init() } } getOuterStack = () => { let entry: Entry | undefined = this const result: string[] = [] do { result.push(` at ${entry.parent.tree.ctx.baseUrl}#${entry.options.id}`) entry = entry.parent.ctx.fiber.entry } while (entry) return result } /** Import and start the configured plugin if it is not already running. */ async init() { try { await (this._initTask ??= this._init()) } finally { this._initTask = undefined } this.fiber?.await().finally(() => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) }) } private async _init() { let exports: any try { exports = await this.parent.tree.import(this.options.name, this.getOuterStack) } catch (error) { this.ctx.logger.error(error) return } finally { this._initTask = undefined } const plugin = this.loader.unwrapExports(exports) this._patchContext([]) this.loader.showLog(this, 'apply') this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) } }