| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- import { defineProperty } from 'cosmokit'
- import type { Dict } from 'cosmokit'
- import type { StandardSchemaV1 } from '@standard-schema/spec'
- import { Context } from './context.ts'
- import { Fiber } from './fiber.ts'
- import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts'
- function isApplicable(object: Plugin) {
- return object && typeof object === 'object' && typeof object.apply === 'function'
- }
- /**
- * Service dependency declaration accepted by plugins and the `@Inject`
- * decorator.
- *
- * Array form requests services without intercept config. Object form maps each
- * service name to optional intercept config for the plugin context.
- */
- export type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
- /** Context keys that correspond to services with typed intercept config. */
- export type InjectKey = keyof {
- [K in keyof Context & string as Context[K] extends { [symbols.config]: any } ? K : never]: any
- }
- /**
- * Decorator for declaring service dependencies on classes or class methods.
- *
- * On classes it contributes to the plugin's static `inject` map. On methods it
- * delays the method call until the declared services are available.
- */
- /**
- * @param name — the required service name.
- * @param config — optional intercept config applied for that service.
- * @returns the class or method decorator.
- */
- export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) {
- return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {
- if (decorator.kind === 'class') {
- if (!Object.hasOwn(value, 'inject')) {
- defineProperty(value, 'inject', Object.create(Object.getPrototypeOf(value).inject ?? null))
- defineProperty(value.inject, symbols.checkProto, true)
- }
- value.inject[name] = config
- } else if (decorator.kind === 'method') {
- const inject = (value[symbols.metadata] ??= {}).inject ??= Object.create(null)
- inject[name] = config
- decorator.addInitializer(function () {
- const property = this[symbols.tracker]?.property
- ;(this[symbols.initHooks] ??= []).push(() => {
- (this.ctx as Context).inject(inject, (ctx) => {
- return value.call(property ? withProps(this, { [property]: ctx }) : this)
- })
- })
- })
- } else {
- throw new Error('@Inject() can only be used on class or class methods')
- }
- }
- }
- /** Utilities for normalizing plugin dependency declarations. */
- export namespace Inject {
- /**
- * Convert array/object/class-inherited inject metadata into a plain map.
- *
- * @param inject — the declaration to normalize; `null`/`undefined` add nothing.
- * @param result — the map to fill (service name → intercept config or `null`).
- * @returns `result`.
- */
- export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
- if (!inject) return result
- if (Array.isArray(inject)) {
- for (const name of inject) {
- result[name] = null
- }
- } else if (Reflect.has(inject, symbols.checkProto)) {
- Object.assign(result, resolve(Object.getPrototypeOf(inject)))
- for (const name of Object.keys(inject)) {
- result[name] = inject[name] ?? null
- }
- } else {
- for (const name of Object.keys(inject)) {
- result[name] = inject[name] ?? null
- }
- }
- return result
- }
- }
- /** Supported plugin entrypoint shapes. */
- export type Plugin<T = any> =
- | Plugin.Function<T>
- | Plugin.Constructor<T>
- | Plugin.Object<T>
- /** Types associated with plugin entrypoints and runtime records. */
- export namespace Plugin {
- /** Shared metadata understood by the plugin registry and related tooling. */
- export interface Base<T = any> {
- /** Display name used for fiber diagnostics and logger names. */
- name?: string
- /** Standard-schema validator applied to config before the plugin starts. */
- Config?: StandardSchemaV1<any, T>
- /** Services the plugin requires; it only loads while all are available. */
- inject?: Inject
- /** Service name(s) the plugin provides (read by `Service` and by loaders). */
- provide?: string | string[]
- /** Service names whose intercept config the plugin declares it consumes. */
- intercept?: Dict<boolean>
- }
- export interface Transform<S, T> {
- /** Marks the transform object as a schema/config transform. */
- schema?: true
- /** Convert user-facing config to runtime config. */
- Config: (config: S) => T
- }
- /** Function plugin called with `(ctx, config)`. */
- export interface Function<T = any> extends Base<T> {
- (ctx: Context, config: T): any
- }
- /** Class plugin constructed with `(ctx, config)`. */
- export interface Constructor<T = any> extends Base<T> {
- new (ctx: Context, config: T): any
- }
- /** Object plugin with an `apply(ctx, config)` method. */
- export interface Object<T = any> extends Base<T> {
- apply(ctx: Context, config: T): any
- }
- /** Mutable registry record shared by all fibers of one plugin callback. */
- export interface Runtime {
- /** Display name copied from the first registered plugin shape. */
- name?: string
- /** Every live fiber of this plugin (one per `ctx.plugin()` call). */
- fibers: DisposableList<Fiber>
- /** The executable entrypoint all fibers share (registry identity key). */
- callback: globalThis.Function
- /** Standard-schema validator applied to each fiber's config. */
- Config?: StandardSchemaV1
- }
- }
- type Spread<T> = undefined extends T ? [config?: T] : [config: T]
- type GetPluginParameters<P> =
- | P extends (ctx: Context, ...args: infer R) => any
- ? R
- : P extends new (ctx: Context, ...args: infer R) => any
- ? R
- : P extends { apply(ctx: Context, ...args: infer R): any }
- ? R
- : never
- type GetPluginConfig<P> =
- | P extends Plugin.Transform<infer S, any>
- ? S
- : GetPluginParameters<P>[0]
- declare module './context.ts' {
- export interface Context {
- /**
- * Run a callback once the requested services are available.
- *
- * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback
- * is unloaded and re-run whenever a required service changes.
- *
- * @param deps — required services, as an array or a name → config map.
- * @param callback — plugin body called with `(ctx, config)`.
- * @returns the fiber; awaiting it settles once loading finished.
- */
- inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
- /**
- * Load a plugin in the current context.
- *
- * @param plugin — a function, class, or `{ apply }` object plugin.
- * @param args — the plugin config, validated against its `Config` schema.
- * @returns the fiber; awaiting it settles once loading finished
- * (rejecting on config or startup errors).
- */
- plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
- }
- }
- /**
- * Plugin registry installed as `ctx.registry` and mixed into every context.
- *
- * It normalizes plugin shapes, tracks plugin runtimes, starts fibers, and
- * exposes map-like inspection over active plugin callbacks.
- */
- export class RegistryService {
- private _counter = 0
- private _internal = new Map<Function, Plugin.Runtime>()
- constructor(public ctx: Context) {
- defineProperty(this, symbols.tracker, {
- property: 'ctx',
- noShadow: true,
- })
- }
- /** Allocate the next fiber uid (increments on every read). */
- get counter() {
- return ++this._counter
- }
- /** Number of registered plugin runtimes. */
- get size() {
- return this._internal.size
- }
- /**
- * Resolve a supported plugin shape to its executable callback.
- *
- * @param plugin — a function, class, or `{ apply }` object plugin.
- * @returns the callback identifying the plugin, or `undefined` if invalid.
- */
- resolve(plugin: Plugin): Function | undefined {
- // plugin.apply may throw
- try {
- if (typeof plugin === 'function') return plugin
- if (isApplicable(plugin)) return plugin.apply
- } catch {}
- }
- /**
- * Look up the runtime record for a plugin.
- *
- * @param plugin — any supported plugin shape.
- * @returns the runtime, or `undefined` when the plugin is not registered.
- */
- get(plugin: Plugin) {
- const key = this.resolve(plugin)
- return key && this._internal.get(key)
- }
- /**
- * Check whether a plugin has a registered runtime.
- *
- * @param plugin — any supported plugin shape.
- * @returns `true` when at least one fiber of the plugin exists.
- */
- has(plugin: Plugin) {
- const key = this.resolve(plugin)
- return !!key && this._internal.has(key)
- }
- /**
- * Dispose every running fiber for a plugin and remove its runtime record.
- *
- * @param plugin — any supported plugin shape.
- * @returns the removed runtime, or `undefined` when none was registered.
- */
- delete(plugin: Plugin) {
- const key = this.resolve(plugin)
- const runtime = key && this._internal.get(key)
- if (!runtime) return
- this._internal.delete(key)
- for (const fiber of runtime.fibers) {
- fiber.dispose()
- }
- return runtime
- }
- /** Iterate the registered plugin callbacks. */
- keys() {
- return this._internal.keys()
- }
- /** Iterate the registered plugin runtimes. */
- values() {
- return this._internal.values()
- }
- /** Iterate `[callback, runtime]` pairs. */
- entries() {
- return this._internal.entries()
- }
- /**
- * Visit every registered runtime.
- *
- * @param callback — receives each runtime and its identifying callback.
- */
- forEach(callback: (value: Plugin.Runtime, key: Function) => void) {
- return this._internal.forEach(callback)
- }
- /**
- * Start a callback once the requested dependencies are available.
- *
- * @param inject — required services, as an array or a name → config map.
- * @param callback — plugin body called with `(ctx, config)`.
- * @returns the fiber; awaiting it settles once loading finished.
- */
- inject(inject: Inject, callback: Plugin.Function<void>) {
- return this.plugin({ inject, apply: callback, name: callback.name })
- }
- /**
- * Start a plugin in the current context and return its fiber.
- *
- * Creates (or reuses) the plugin's runtime record, then starts a new fiber
- * under the current context. Throws if `plugin` is not a supported shape or
- * if the current fiber is already disposed.
- *
- * @param plugin — a function, class, or `{ apply }` object plugin.
- * @param config — the plugin config, validated against its `Config` schema.
- * @param getOuterStack — captures the caller stack for effect diagnostics.
- * @returns the fiber; awaiting it settles once loading finished.
- */
- plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
- // check if it's a valid plugin
- const callback = this.resolve(plugin)
- if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin)
- this.ctx.fiber.assertActive()
- let runtime = this._internal.get(callback)
- if (!runtime) {
- let name = plugin.name
- if (name === 'apply') name = undefined
- runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
- this._internal.set(callback, runtime)
- }
- const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
- const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>
- wrapped.then = (onFulfilled, onRejected) => {
- return fiber.await().then(onFulfilled, onRejected)
- }
- return wrapped
- }
- }
|