import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' import { Context } from './context.ts' import { Plugin } from './registry.ts' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' import { Impl } from './reflect.ts' import { StandardSchemaV1 } from '@standard-schema/spec' declare module './context.ts' { export interface Context extends Pick { /** The fiber (plugin runtime instance) that owns this context. */ fiber: Fiber } } const kValidationError = Symbol.for('ValidationError') /** Error raised when plugin configuration fails standard-schema validation. */ export class ValidationError extends TypeError { name = 'ValidationError' /** * Build the aggregated message from schema issues. * * @param issues — the standard-schema issues, one message line each. */ 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. * * @param runtime — the plugin runtime whose `Config` schema to apply. * @param config — the raw user config. * @returns the validated config, or `config` unchanged if the runtime has no schema. * @throws {ValidationError} when validation reports issues. */ 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. * * Disposers run in reverse registration order when the owning fiber unloads; * they may be async, in which case unloading awaits them. */ export type Disposable = () => T /** * Effect body result accepted by `ctx.effect()` and plugin startup. * * Either a single disposer, a promise of one, or a (possibly async) iterable * yielding several — generator effects register each yielded disposer as it * is produced. */ 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 { /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ label: string /** Metadata of nested effects registered while this effect ran. */ children: EffectMeta[] } interface EffectRunner { epoch: T execute: () => any collect: (dispose: Disposable) => void getOuterStack: () => string[] } // Public effect disposers remain single-shot, but structural owners and outer // effects must still be able to join a cleanup that another caller started. const effectInertia = new WeakMap void | Promise>() function runDisposable(dispose: Disposable) { const result = dispose() return effectInertia.get(dispose)?.() ?? result } /** Notify plugin teardown without allowing one observer to break ownership cleanup. */ function emitPluginDisposed(context: Context, fiber: Fiber) { const args: any[] = ['internal/plugin', fiber] let callbacks: Function[] try { callbacks = context.events.dispatch('emit', args) } catch (error) { context.logger.error(error) return } for (const callback of callbacks) { try { const returned = callback(...args) void Promise.resolve(returned).catch(error => context.logger.error(error)) } catch (error) { context.logger.error(error) } } } /** * Lifecycle state for one plugin fiber. * * `PENDING` — waiting for required services; `LOADING` — the plugin callback * is running; `ACTIVE` — loaded and providing; `FAILED` — the callback or its * config threw; `UNLOADING` — disposers are running; `DISPOSED` — the fiber * was removed and cannot restart. */ export const enum FiberState { PENDING, LOADING, ACTIVE, FAILED, DISPOSED, UNLOADING, } /** Framework error with a stable machine-readable code. */ export class CordisError extends Error { /** * @param code — the stable error code; also the default message. * @param message — optional human-readable override. */ 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 { /** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ public uid: number | null /** The context this fiber's plugin runs in (extends the parent context). */ public readonly ctx: Context /** The validated plugin config (updated by `update()`). */ public config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ public readonly dispose: () => Promise /** Snapshot of required service implementations while loaded; `undefined` otherwise. */ public store: Dict | undefined /** The in-flight load/unload transition, if one is currently running. */ 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) /** * Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()` * rather than constructing them directly. * * @param parent — the context the plugin was loaded from. * @param config — raw config, validated against the runtime's schema. * @param inject — resolved dependency map (service name → intercept config). * @param runtime — the shared plugin runtime, or `null` for the root fiber. * @param getOuterStack — captures the caller stack for effect diagnostics. */ 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: function () { 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, } let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) try { this.config = resolveConfig(runtime, config) shouldRefresh = true } catch (error) { this.ctx.logger.error(error) this._error = error } return async () => { this.uid = null emitPluginDisposed(this.context, this) if (this.ctx.registry.has(runtime.callback)) { remove() if (!runtime.fibers.length) { this.ctx.registry.delete(runtime.callback) } } this._setEpoch(INACTIVE) // A PENDING fiber can already own effects registered by an // internal/plugin observer. Its epoch is still INACTIVE, so // _setEpoch() has no transition to drive; explicitly unload that // pre-activation work before reporting disposal complete. if (!this.inertia) { this._updateState(() => { this.inertia = this._unload() return FiberState.UNLOADING }) } // `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()') try { // Publish only after the parent owns a fully assigned disposer. A // synchronous observer may dispose either this fiber or its parent. this.context.emit('internal/plugin', this) } catch (error) { // Publication failed synchronously. The disposer removes the child // from both the parent and runtime before control escapes. void Promise.resolve(this.dispose()).catch(reason => this.ctx.logger.error(reason)) throw error } // Keep the initial notification's historical PENDING view. The loader // may also extend `inject` in that notification, so resolve dependencies // only after publication. A reentrant parent unload makes the child // disposer responsible for draining any PENDING effects instead. if (this.uid !== null && parent.fiber.state !== FiberState.UNLOADING) { for (const name of Object.keys(this.inject)) { this._checkImpl(name) } if (shouldRefresh) this._refresh() } } 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() } } /** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ 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. * * @returns nothing when the fiber is still active. * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. */ 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.call(this) 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. * * `execute` runs immediately; the disposers it produces are collected and * run (in reverse order) either when the returned disposer is called or * when the fiber unloads, whichever comes first. Calling the disposer twice * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is * already disposed, and `TypeError` if `execute` returns an invalid shape. * * @param execute — the effect body; see {@link Effect} for accepted shapes. * @param label — effect label shown in `getEffects()` diagnostics. * @returns a disposer that tears the effect down and settles once done. */ effect(execute: () => SyncEffect, label?: string): Disposable> /** Same as above for async effects; the disposer is also awaitable. */ effect(execute: () => Effect, label?: string): AsyncDisposable> effect(execute: () => Effect, label = 'anonymous'): any { this.assertActive() if (this.state === FiberState.UNLOADING) { throw new CordisError('INACTIVE_EFFECT') } const disposables: Disposable[] = [] let disposing = false let disposalTask: void | Promise const dispose = () => { if (disposing) return disposalTask disposing = true let task!: void | Promise for (const disposable of disposables.splice(0).reverse()) { if (task) { task = task.then(() => runDisposable(disposable)) } else { const result = runDisposable(disposable) if (isObject(result) && 'then' in result) { task = result as any } } } return disposalTask = 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 let executing = true let resolveSetup: (() => void) | undefined let rejectSetup: ((reason: unknown) => void) | undefined let setupBarrier: Promise | undefined let setupFailed = false let inFlight: void | Promise let removeWrapper = () => false const waitForSetup = () => { setupBarrier ??= new Promise((resolve, reject) => { resolveSetup = resolve rejectSetup = reject }) return setupBarrier } const disposeAfter = (setup: PromiseLike) => { return Promise.resolve(setup).then( () => dispose(), async (reason) => { await dispose() throw reason }, ) } const finalizeDisposal = (callback: () => void | Promise) => { let result: void | Promise try { result = callback() } catch (error) { removeWrapper() throw error } if (isObject(result) && 'then' in result) { const pending = Promise.resolve(result).finally(() => { removeWrapper() if (inFlight === pending) inFlight = undefined }) return inFlight = pending } removeWrapper() return result } const wrapper = defineProperty(() => { // A synchronous setup failure can race an owner unload that already // captured this wrapper but has not invoked it yet. The failed effect is // never returned publicly, so let that internal caller await rollback. if (!runner.epoch) return setupFailed ? inFlight : undefined runner.epoch = false return finalizeDisposal(() => { if (executing) return disposeAfter(waitForSetup()) return task ? disposeAfter(task) : dispose() }) }, symbols.effect, meta) as AsyncDisposable effectInertia.set(wrapper, () => inFlight) // Make the effect visible to a reentrant owner unload before execute() // runs any plugin code. Async teardown stays owner-visible until it // settles, allowing an outer effect to join cleanup another caller began. removeWrapper = this._disposables.push(wrapper) try { task = this._execute(runner) } catch (reason) { executing = false setupFailed = true runner.epoch = false let cleanup: void | Promise try { cleanup = finalizeDisposal(dispose) } finally { rejectSetup?.(reason) } if (isObject(cleanup) && 'then' in cleanup) { cleanup.catch(error => this.ctx.logger.error(error)) } throw reason } executing = false if (setupBarrier) { Promise.resolve(task).then(resolveSetup, rejectSetup) } // prevent unhandled rejection — both from `task` itself and from the // disposer chain if it fails to settle cleanly. task?.catch(() => { if (!runner.epoch) return dispose() return finalizeDisposal(dispose) }).catch((error) => this.ctx.logger.error(error)) const disposeAsync = () => { if (!runner.epoch) return runner.epoch = false return finalizeDisposal(dispose) } wrapper.then = async (onFulfilled, onRejected) => { return Promise.resolve(task) .then(() => disposeAsync) .then(onFulfilled, onRejected) } return wrapper } /** * Return metadata for currently registered effects. * * @returns one {@link EffectMeta} tree per labeled live effect. */ 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() // A disposer queued before this checkpoint may already have invalidated // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { 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 runDisposable(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. * * @returns this fiber, once it has settled into a stable state. * @throws the config-validation or plugin-startup error, if any. */ 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. * * @returns a promise resolving once the reload settled. * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. */ async restart() { this.assertActive() this._setEpoch(INACTIVE) this._refresh() await this.await() } /** * Validate and apply new config, then restart the plugin. * * Runs the `internal/update` waterfall first, so update hooks (and HMR) * can veto or replace the restart. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. * @returns nothing; the restart runs behind the `internal/update` waterfall. * @throws {ValidationError} when the new config fails validation. */ 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() }) } }