registry.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { defineProperty } from 'cosmokit'
  2. import type { Dict } from 'cosmokit'
  3. import type { StandardSchemaV1 } from '@standard-schema/spec'
  4. import { Context } from './context.ts'
  5. import { Fiber } from './fiber.ts'
  6. import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts'
  7. function isApplicable(object: Plugin) {
  8. return object && typeof object === 'object' && typeof object.apply === 'function'
  9. }
  10. /**
  11. * Service dependency declaration accepted by plugins and the `@Inject`
  12. * decorator.
  13. *
  14. * Array form requests services without intercept config. Object form maps each
  15. * service name to optional intercept config for the plugin context.
  16. */
  17. export type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
  18. /** Context keys that correspond to services with typed intercept config. */
  19. export type InjectKey = keyof {
  20. [K in keyof Context & string as Context[K] extends { [symbols.config]: any } ? K : never]: any
  21. }
  22. /**
  23. * Decorator for declaring service dependencies on classes or class methods.
  24. *
  25. * On classes it contributes to the plugin's static `inject` map. On methods it
  26. * delays the method call until the declared services are available.
  27. */
  28. /**
  29. * @param name — the required service name.
  30. * @param config — optional intercept config applied for that service.
  31. * @returns the class or method decorator.
  32. */
  33. export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) {
  34. return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {
  35. if (decorator.kind === 'class') {
  36. if (!Object.hasOwn(value, 'inject')) {
  37. defineProperty(value, 'inject', Object.create(Object.getPrototypeOf(value).inject ?? null))
  38. defineProperty(value.inject, symbols.checkProto, true)
  39. }
  40. value.inject[name] = config
  41. } else if (decorator.kind === 'method') {
  42. const inject = (value[symbols.metadata] ??= {}).inject ??= Object.create(null)
  43. inject[name] = config
  44. decorator.addInitializer(function () {
  45. const property = this[symbols.tracker]?.property
  46. ;(this[symbols.initHooks] ??= []).push(() => {
  47. (this.ctx as Context).inject(inject, (ctx) => {
  48. return value.call(property ? withProps(this, { [property]: ctx }) : this)
  49. })
  50. })
  51. })
  52. } else {
  53. throw new Error('@Inject() can only be used on class or class methods')
  54. }
  55. }
  56. }
  57. /** Utilities for normalizing plugin dependency declarations. */
  58. export namespace Inject {
  59. /**
  60. * Convert array/object/class-inherited inject metadata into a plain map.
  61. *
  62. * @param inject — the declaration to normalize; `null`/`undefined` add nothing.
  63. * @param result — the map to fill (service name → intercept config or `null`).
  64. * @returns `result`.
  65. */
  66. export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
  67. if (!inject) return result
  68. if (Array.isArray(inject)) {
  69. for (const name of inject) {
  70. result[name] = null
  71. }
  72. } else if (Reflect.has(inject, symbols.checkProto)) {
  73. Object.assign(result, resolve(Object.getPrototypeOf(inject)))
  74. for (const name of Object.keys(inject)) {
  75. result[name] = inject[name] ?? null
  76. }
  77. } else {
  78. for (const name of Object.keys(inject)) {
  79. result[name] = inject[name] ?? null
  80. }
  81. }
  82. return result
  83. }
  84. }
  85. /** Supported plugin entrypoint shapes. */
  86. export type Plugin<T = any> =
  87. | Plugin.Function<T>
  88. | Plugin.Constructor<T>
  89. | Plugin.Object<T>
  90. /** Types associated with plugin entrypoints and runtime records. */
  91. export namespace Plugin {
  92. /** Shared metadata understood by the plugin registry and related tooling. */
  93. export interface Base<T = any> {
  94. /** Display name used for fiber diagnostics and logger names. */
  95. name?: string
  96. /** Standard-schema validator applied to config before the plugin starts. */
  97. Config?: StandardSchemaV1<any, T>
  98. /** Services the plugin requires; it only loads while all are available. */
  99. inject?: Inject
  100. /** Service name(s) the plugin provides (read by `Service` and by loaders). */
  101. provide?: string | string[]
  102. /** Service names whose intercept config the plugin declares it consumes. */
  103. intercept?: Dict<boolean>
  104. }
  105. export interface Transform<S, T> {
  106. /** Marks the transform object as a schema/config transform. */
  107. schema?: true
  108. /** Convert user-facing config to runtime config. */
  109. Config: (config: S) => T
  110. }
  111. /** Function plugin called with `(ctx, config)`. */
  112. export interface Function<T = any> extends Base<T> {
  113. (ctx: Context, config: T): any
  114. }
  115. /** Class plugin constructed with `(ctx, config)`. */
  116. export interface Constructor<T = any> extends Base<T> {
  117. new (ctx: Context, config: T): any
  118. }
  119. /** Object plugin with an `apply(ctx, config)` method. */
  120. export interface Object<T = any> extends Base<T> {
  121. apply(ctx: Context, config: T): any
  122. }
  123. /** Mutable registry record shared by all fibers of one plugin callback. */
  124. export interface Runtime {
  125. /** Display name copied from the first registered plugin shape. */
  126. name?: string
  127. /** Every live fiber of this plugin (one per `ctx.plugin()` call). */
  128. fibers: DisposableList<Fiber>
  129. /** The executable entrypoint all fibers share (registry identity key). */
  130. callback: globalThis.Function
  131. /** Standard-schema validator applied to each fiber's config. */
  132. Config?: StandardSchemaV1
  133. }
  134. }
  135. type Spread<T> = undefined extends T ? [config?: T] : [config: T]
  136. type GetPluginParameters<P> =
  137. | P extends (ctx: Context, ...args: infer R) => any
  138. ? R
  139. : P extends new (ctx: Context, ...args: infer R) => any
  140. ? R
  141. : P extends { apply(ctx: Context, ...args: infer R): any }
  142. ? R
  143. : never
  144. type GetPluginConfig<P> =
  145. | P extends Plugin.Transform<infer S, any>
  146. ? S
  147. : GetPluginParameters<P>[0]
  148. declare module './context.ts' {
  149. export interface Context {
  150. /**
  151. * Run a callback once the requested services are available.
  152. *
  153. * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback
  154. * is unloaded and re-run whenever a required service changes.
  155. *
  156. * @param deps — required services, as an array or a name → config map.
  157. * @param callback — plugin body called with `(ctx, config)`.
  158. * @returns the fiber; awaiting it settles once loading finished.
  159. */
  160. inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
  161. /**
  162. * Load a plugin in the current context.
  163. *
  164. * @param plugin — a function, class, or `{ apply }` object plugin.
  165. * @param args — the plugin config, validated against its `Config` schema.
  166. * @returns the fiber; awaiting it settles once loading finished
  167. * (rejecting on config or startup errors).
  168. */
  169. plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
  170. }
  171. }
  172. /**
  173. * Plugin registry installed as `ctx.registry` and mixed into every context.
  174. *
  175. * It normalizes plugin shapes, tracks plugin runtimes, starts fibers, and
  176. * exposes map-like inspection over active plugin callbacks.
  177. */
  178. export class RegistryService {
  179. private _counter = 0
  180. private _internal = new Map<Function, Plugin.Runtime>()
  181. constructor(public ctx: Context) {
  182. defineProperty(this, symbols.tracker, {
  183. property: 'ctx',
  184. noShadow: true,
  185. })
  186. }
  187. /** Allocate the next fiber uid (increments on every read). */
  188. get counter() {
  189. return ++this._counter
  190. }
  191. /** Number of registered plugin runtimes. */
  192. get size() {
  193. return this._internal.size
  194. }
  195. /**
  196. * Resolve a supported plugin shape to its executable callback.
  197. *
  198. * @param plugin — a function, class, or `{ apply }` object plugin.
  199. * @returns the callback identifying the plugin, or `undefined` if invalid.
  200. */
  201. resolve(plugin: Plugin): Function | undefined {
  202. // plugin.apply may throw
  203. try {
  204. if (typeof plugin === 'function') return plugin
  205. if (isApplicable(plugin)) return plugin.apply
  206. } catch {}
  207. }
  208. /**
  209. * Look up the runtime record for a plugin.
  210. *
  211. * @param plugin — any supported plugin shape.
  212. * @returns the runtime, or `undefined` when the plugin is not registered.
  213. */
  214. get(plugin: Plugin) {
  215. const key = this.resolve(plugin)
  216. return key && this._internal.get(key)
  217. }
  218. /**
  219. * Check whether a plugin has a registered runtime.
  220. *
  221. * @param plugin — any supported plugin shape.
  222. * @returns `true` when at least one fiber of the plugin exists.
  223. */
  224. has(plugin: Plugin) {
  225. const key = this.resolve(plugin)
  226. return !!key && this._internal.has(key)
  227. }
  228. /**
  229. * Dispose every running fiber for a plugin and remove its runtime record.
  230. *
  231. * @param plugin — any supported plugin shape.
  232. * @returns the removed runtime, or `undefined` when none was registered.
  233. */
  234. delete(plugin: Plugin) {
  235. const key = this.resolve(plugin)
  236. const runtime = key && this._internal.get(key)
  237. if (!runtime) return
  238. this._internal.delete(key)
  239. for (const fiber of runtime.fibers) {
  240. fiber.dispose()
  241. }
  242. return runtime
  243. }
  244. /** Iterate the registered plugin callbacks. */
  245. keys() {
  246. return this._internal.keys()
  247. }
  248. /** Iterate the registered plugin runtimes. */
  249. values() {
  250. return this._internal.values()
  251. }
  252. /** Iterate `[callback, runtime]` pairs. */
  253. entries() {
  254. return this._internal.entries()
  255. }
  256. /**
  257. * Visit every registered runtime.
  258. *
  259. * @param callback — receives each runtime and its identifying callback.
  260. */
  261. forEach(callback: (value: Plugin.Runtime, key: Function) => void) {
  262. return this._internal.forEach(callback)
  263. }
  264. /**
  265. * Start a callback once the requested dependencies are available.
  266. *
  267. * @param inject — required services, as an array or a name → config map.
  268. * @param callback — plugin body called with `(ctx, config)`.
  269. * @returns the fiber; awaiting it settles once loading finished.
  270. */
  271. inject(inject: Inject, callback: Plugin.Function<void>) {
  272. return this.plugin({ inject, apply: callback, name: callback.name })
  273. }
  274. /**
  275. * Start a plugin in the current context and return its fiber.
  276. *
  277. * Creates (or reuses) the plugin's runtime record, then starts a new fiber
  278. * under the current context. Throws if `plugin` is not a supported shape or
  279. * if the current fiber is already disposed.
  280. *
  281. * @param plugin — a function, class, or `{ apply }` object plugin.
  282. * @param config — the plugin config, validated against its `Config` schema.
  283. * @param getOuterStack — captures the caller stack for effect diagnostics.
  284. * @returns the fiber; awaiting it settles once loading finished.
  285. */
  286. plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
  287. // check if it's a valid plugin
  288. const callback = this.resolve(plugin)
  289. if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin)
  290. this.ctx.fiber.assertActive()
  291. let runtime = this._internal.get(callback)
  292. if (!runtime) {
  293. let name = plugin.name
  294. if (name === 'apply') name = undefined
  295. runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
  296. this._internal.set(callback, runtime)
  297. }
  298. const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
  299. const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>
  300. wrapped.then = (onFulfilled, onRejected) => {
  301. return fiber.await().then(onFulfilled, onRejected)
  302. }
  303. return wrapped
  304. }
  305. }