| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742 |
- 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<Fiber, 'effect'> {
- /** 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<T extends Awaitable<void> = Awaitable<void>> 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 = any> = () => 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<T = any> =
- | SyncEffect<T>
- | AsyncEffect<T>
- type SyncEffect<T = any> =
- | Disposable<T>
- | Iterable<Disposable<T>, void, void>
- type AsyncEffect<T = any> =
- | Promise<Disposable<T>>
- | AsyncIterable<Disposable<T>, 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<T> {
- 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<Disposable, () => void | Promise<void>>()
- 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<void>
- /** Snapshot of required service implementations while loaded; `undefined` otherwise. */
- public store: Dict<Impl> | undefined
- /** The in-flight load/unload transition, if one is currently running. */
- public inertia: Promise<void> | undefined
- public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
- public readonly _disposables = new DisposableList<Disposable>()
- // Same as `this.ctx`, but with a more specific type.
- protected context: Context
- private _error: any
- private _runner: EffectRunner<string>
- private _store: Dict<Impl> = 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<any>,
- 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<T>(runner: EffectRunner<T>) {
- 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<Promise<void>>
- /** Same as above for async effects; the disposer is also awaitable. */
- effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
- 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<void>
- const dispose = () => {
- if (disposing) return disposalTask
- disposing = true
- let task!: void | Promise<void>
- 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<boolean> = {
- 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<void>
- let executing = true
- let resolveSetup: (() => void) | undefined
- let rejectSetup: ((reason: unknown) => void) | undefined
- let setupBarrier: Promise<void> | undefined
- let setupFailed = false
- let inFlight: void | Promise<void>
- let removeWrapper = () => false
- const waitForSetup = () => {
- setupBarrier ??= new Promise<void>((resolve, reject) => {
- resolveSetup = resolve
- rejectSetup = reject
- })
- return setupBarrier
- }
- const disposeAfter = (setup: PromiseLike<void>) => {
- return Promise.resolve(setup).then(
- () => dispose(),
- async (reason) => {
- await dispose()
- throw reason
- },
- )
- }
- const finalizeDisposal = (callback: () => void | Promise<void>) => {
- let result: void | Promise<void>
- 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<void>
- 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<EffectMeta>(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()
- })
- }
- }
|