import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' import { Context } from './context' import { Plugin } from './registry' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils' import { Impl } from './reflect' import { StandardSchemaV1 } from '@standard-schema/spec' declare module './context' { export interface Context extends Pick { fiber: Fiber } } const kValidationError = Symbol.for('ValidationError') /** Error raised when plugin configuration fails standard-schema validation. */ export class ValidationError extends TypeError { name = 'ValidationError' constructor(issues: readonly StandardSchemaV1.Issue[]) { super(`invalid config:\n` + issues.map(issue => { if (issue.path) { return ` - ${issue.message} (at ${issue.path.join('.')})` } else { return ` - ${issue.message}` } }).join('\n')) } } Object.defineProperty(ValidationError.prototype, kValidationError, { value: true, }) /** Validate and normalize config for a plugin runtime before it starts. */ export function resolveConfig(runtime: Plugin.Runtime, config: any) { if (!runtime.Config) return config // TODO: async validation const result = runtime.Config['~standard'].validate(config) if ('then' in result) { throw new TypeError('Async config validation is not supported') } if (result.issues) { throw new ValidationError(result.issues) } else { return result.value } } interface AsyncDisposable = Awaitable> extends PromiseLike<() => T> { (): T } /** Function returned by an effect to release resources during disposal. */ export type Disposable = () => T /** Effect body result accepted by `ctx.effect()` and plugin startup. */ export type Effect = | SyncEffect | AsyncEffect type SyncEffect = | Disposable | Iterable, void, void> type AsyncEffect = | Promise> | AsyncIterable, void, void> /** Tree node used to expose nested effect labels for diagnostics. */ export interface EffectMeta { label: string children: EffectMeta[] } interface EffectRunner { epoch: T execute: () => any collect: (dispose: Disposable) => void getOuterStack: () => string[] } /** Lifecycle state for one plugin fiber. */ export const enum FiberState { PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING, } /** Framework error with a stable machine-readable code. */ export class CordisError extends Error { constructor(public code: CordisError.Code, message?: string) { super(message ?? CordisError.Code[code]) } } /** Cordis error code definitions. */ export namespace CordisError { export type Code = keyof typeof Code export const Code = { INACTIVE_EFFECT: 'cannot create effect on inactive context', } as const } const INACTIVE = '__INACTIVE__' /** * Runtime instance of one plugin application. * * A fiber tracks dependency state, validated config, lifecycle effects, and * cleanup for the plugin context returned by `ctx.plugin()`. */ export class Fiber { public uid: number | null public readonly ctx: Context public config: any public state = FiberState.PENDING public readonly dispose: () => Promise public store: Dict | undefined public inertia: Promise | undefined public readonly _hooks: Dict> = Object.create(null) public readonly _disposables = new DisposableList() // Same as `this.ctx`, but with a more specific type. protected context: Context private _error: any private _runner: EffectRunner private _store: Dict = Object.create(null) constructor( public parent: Context, config: any, public inject: Dict, public runtime: Plugin.Runtime | null, getOuterStack: () => string[], ) { const collect = (dispose: Disposable) => { this._disposables.push(dispose) } if (runtime) { this.uid = parent.registry.counter this.ctx = this.context = parent.extend({ fiber: this }) const injectEntries = Object.entries(this.inject) if (injectEntries.length) { this.ctx[Context.intercept] = Object.create(parent[Context.intercept]) for (const [name, config] of injectEntries) { if (isNullable(config)) continue this.ctx[Context.intercept][name] = config } } this._runner = { epoch: INACTIVE, getOuterStack, execute: () => { if (isConstructor(runtime.callback)) { // eslint-disable-next-line new-cap const instance = new runtime.callback(this.ctx, this.config) for (const hook of instance?.[symbols.initHooks] ?? []) { hook() } return instance?.[symbols.init]?.() } else { return runtime.callback(this.ctx, this.config) } }, collect, } this.context.emit('internal/plugin', this) for (const name of Object.keys(this.inject)) { this._checkImpl(name) } this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) try { this.config = resolveConfig(runtime, config) this._refresh() } catch (error) { this.ctx.logger.error(error) this._error = error } return async () => { this.uid = null this.context.emit('internal/plugin', this) if (this.ctx.registry.has(runtime.callback)) { remove() if (!runtime.fibers.length) { this.ctx.registry.delete(runtime.callback) } } this._setEpoch(INACTIVE) // `this.inertia` itself should never reject — both `_reload` and // `_unload` swallow their own work errors via `ctx.logger.error`. // If it *does* reject, the only remaining cause is the logger // itself failing, which we can't recover from in this exact spot // (calling the logger again is what just failed). Let the // rejection propagate; process-level crash is the honest outcome. while (this.inertia) { await this.inertia } } }, 'ctx.plugin()') } else { this.uid = 0 this.ctx = this.context = parent this.state = FiberState.ACTIVE this.store = Object.create(null) this._runner = { epoch: '', getOuterStack, execute: () => {}, collect, } this.dispose = () => this.restart() } } get name() { let fiber: Fiber = this do { if (fiber.runtime?.name) return fiber.runtime.name fiber = fiber.parent.fiber } while (fiber !== fiber.parent.fiber) return 'root' } /** Throw if the fiber has already been disposed. */ assertActive() { if (this.uid !== null) return throw new CordisError('INACTIVE_EFFECT') } private _execute(runner: EffectRunner) { const oldEpoch = runner.epoch return composeError((info) => { const safeCollect = (dispose: void | Disposable) => { if (typeof dispose === 'function') { runner.collect(dispose) } else if (!isNullable(dispose)) { throw new TypeError('Invalid effect') } } const effect: Effect = runner.execute() if (typeof effect === 'function') { return runner.collect(effect) } else if (isNullable(effect)) { // return } else if (!isObject(effect)) { throw new TypeError('Invalid effect') } else if ('then' in effect) { return effect.then(safeCollect) } else if (Symbol.iterator in effect) { info.error = new Error() const iter = effect[Symbol.iterator]() while (true) { const result = iter.next() safeCollect(result.value) if (result.done) return } } else if (Symbol.asyncIterator in effect) { const iter = effect[Symbol.asyncIterator]() return (async () => { // force async stack trace await Promise.resolve() info.error = new Error() while (true) { if (runner.epoch !== oldEpoch) return const result = await iter.next() safeCollect(result.value) if (result.done) return } })() } else { throw new TypeError('Invalid effect') } }, runner.getOuterStack) } /** Register a cleanup-aware effect on this fiber. */ effect(execute: () => SyncEffect, label?: string): Disposable> effect(execute: () => Effect, label?: string): AsyncDisposable> effect(execute: () => Effect, label = 'anonymous'): any { this.assertActive() const disposables: Disposable[] = [] const dispose = () => { let task!: void | Promise for (const dispose of disposables.splice(0).reverse()) { if (task) { task = task.then(dispose) } else { const result = dispose() if (isObject(result) && 'then' in result) { task = result as any } } } return task } const meta: EffectMeta = { label, children: [] } const runner: EffectRunner = { execute, epoch: true, collect: (dispose) => { disposables.push(dispose) this._disposables.delete(dispose) if (dispose[symbols.effect]) { meta.children.push(dispose[symbols.effect]) } }, getOuterStack: buildOuterStack(), } let task: void | Promise try { task = this._execute(runner) } catch (reason) { dispose() throw reason } // prevent unhandled rejection — both from `task` itself and from the // disposer chain if it fails to settle cleanly. task?.catch(dispose).catch((error) => this.ctx.logger.error(error)) const wrapper = defineProperty(() => { if (!runner.epoch) return runner.epoch = false return task ? task.then(dispose) : dispose() }, symbols.effect, meta) as AsyncDisposable const disposeAsync = () => { if (!runner.epoch) return runner.epoch = false return dispose() } wrapper.then = async (onFulfilled, onRejected) => { return Promise.resolve(task) .then(() => disposeAsync) .then(onFulfilled, onRejected) } disposables.push(this._disposables.push(wrapper)) return wrapper } /** Return metadata for currently registered effects. */ getEffects() { return [...this._disposables] .map(dispose => dispose[symbols.effect]) .filter(Boolean) } private _getState() { if (this.uid === null) return FiberState.DISPOSED if (this._error) return FiberState.FAILED if (this._runner.epoch !== INACTIVE) return FiberState.ACTIVE return FiberState.PENDING } private _updateState(callback: () => void | FiberState) { const oldState = this.state this.state = callback() ?? this._getState() if (oldState === this.state) return // FIXME internal/fiber-info this.context.emit('internal/status', this, oldState) // only notify changes between ACTIVE and NON-ACTIVE states if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return for (const key of Reflect.ownKeys(this.ctx.reflect.store)) { const impl = this.ctx.reflect.store[key as symbol] if (impl.fiber !== this) continue this.ctx.reflect.notify([impl.name]) } } _checkImpl(name: string) { const impl = this.ctx.reflect._getImpl(name, true) if (!impl) return delete this._store[name] try { if (impl.check && !impl.check.call(getTraceable(this.ctx, impl.value))) { return delete this._store[name] } } catch (error) { impl.fiber.ctx.logger.error(error) return delete this._store[name] } this._store[name] = impl } _refresh() { let epoch: string | boolean = false epoch = '' for (const name of Object.keys(this.inject)) { const impl = this._store[name] if (!impl) { epoch = INACTIVE break } epoch += ':' + impl.fiber.uid } this._setEpoch(epoch) } private _setEpoch(epoch: string) { const oldEpoch = this._runner.epoch if (epoch === oldEpoch) return this._runner.epoch = epoch if (this.inertia) return this._updateState(() => { if (epoch !== INACTIVE && oldEpoch === INACTIVE) { this.inertia = this._reload() return FiberState.LOADING } else { this.inertia = this._unload() return FiberState.UNLOADING } }) } private async _reload() { this.store = { ...this._store } const oldEpoch = this._runner.epoch try { await Promise.resolve() await this._execute(this._runner) } catch (reason) { // impl guarantees that the error is non-null (?) this.ctx.logger.error(reason) this._error = reason this._runner.epoch = INACTIVE } this._updateState(() => { if (this._runner.epoch === oldEpoch) { this.inertia = undefined } else { this.inertia = this._unload() return FiberState.UNLOADING } }) } private async _unload() { await Promise.all(this._disposables.clear().map(async (dispose) => { try { await composeError(async (info) => { await Promise.resolve() info.error = new Error() await dispose() }, this._runner.getOuterStack) } catch (reason) { this.ctx.logger.error(reason) } })) this.store = undefined this._updateState(() => { if (this._runner.epoch === INACTIVE) { this.inertia = undefined } else { this.inertia = this._reload() return FiberState.LOADING } }) } /** Wait for current lifecycle work and rethrow startup errors. */ async await() { while (this.inertia) { await this.inertia } if (this._error) throw this._error return this } /** Dispose and immediately reload this plugin with its current config. */ async restart() { this.assertActive() this._setEpoch(INACTIVE) this._refresh() await this.await() } /** Validate and apply new config, then restart the plugin. */ update(config: any, noSave = false) { this.assertActive() config = resolveConfig(this.runtime!, config) this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() }) } }