1
import { defineProperty } from '@deepseek-ai/cosmokit'2
import type { Dict } from '@deepseek-ai/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'8
function isApplicable(object: Plugin) {9
return object && typeof object === 'object' && typeof object.apply === 'function'10
}12
/**13
* Service dependency declaration accepted by plugins and the `@Inject`14
* decorator.15
*16
* Array form requests services without intercept config. Object form maps each17
* service name to optional intercept config for the plugin context.18
*/19
export type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }21
/** Context keys that correspond to services with typed intercept config. */22
export type InjectKey = keyof {23
[K in keyof Context & string as Context[K] extends { [symbols.config]: any } ? K : never]: any24
}26
/**27
* Decorator for declaring service dependencies on classes or class methods.28
*29
* On classes it contributes to the plugin's static `inject` map. On methods it30
* delays the method call until the declared services are available.31
*/32
/**33
* @param name — the required service name.34
* @param config — optional intercept config applied for that service.35
* @returns the class or method decorator.36
*/37
export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) {38
return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {39
if (decorator.kind === 'class') {40
if (!Object.hasOwn(value, 'inject')) {41
defineProperty(value, 'inject', Object.create(Object.getPrototypeOf(value).inject ?? null))42
defineProperty(value.inject, symbols.checkProto, true)43
}44
value.inject[name] = config45
} else if (decorator.kind === 'method') {46
const inject = (value[symbols.metadata] ??= {}).inject ??= Object.create(null)47
inject[name] = config48
decorator.addInitializer(function () {49
const property = this[symbols.tracker]?.property50
;(this[symbols.initHooks] ??= []).push(() => {51
(this.ctx as Context).inject(inject, (ctx) => {52
return value.call(property ? withProps(this, { [property]: ctx }) : this)53
})54
})55
})56
} else {57
throw new Error('@Inject() can only be used on class or class methods')58
}59
}60
}62
/** Utilities for normalizing plugin dependency declarations. */63
export namespace Inject {64
/**65
* Convert array/object/class-inherited inject metadata into a plain map.66
*67
* @param inject — the declaration to normalize; `null`/`undefined` add nothing.68
* @param result — the map to fill (service name → intercept config or `null`).69
* @returns `result`.70
*/71
export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {72
if (!inject) return result73
if (Array.isArray(inject)) {74
for (const name of inject) {75
result[name] = null76
}77
} else if (Reflect.has(inject, symbols.checkProto)) {78
Object.assign(result, resolve(Object.getPrototypeOf(inject)))79
for (const name of Object.keys(inject)) {80
result[name] = inject[name] ?? null81
}82
} else {83
for (const name of Object.keys(inject)) {84
result[name] = inject[name] ?? null85
}86
}87
return result88
}89
}91
/** Supported plugin entrypoint shapes. */92
export type Plugin<T = any> =93
| Plugin.Function<T>94
| Plugin.Constructor<T>95
| Plugin.Object<T>97
/** Types associated with plugin entrypoints and runtime records. */98
export namespace Plugin {99
/** Shared metadata understood by the plugin registry and related tooling. */100
export interface Base<T = any> {101
/** Display name used for fiber diagnostics and logger names. */102
name?: string103
/** Standard-schema validator applied to config before the plugin starts. */104
Config?: StandardSchemaV1<any, T>105
/** Services the plugin requires; it only loads while all are available. */106
inject?: Inject107
/** Service name(s) the plugin provides (read by `Service` and by loaders). */108
provide?: string | string[]109
/** Service names whose intercept config the plugin declares it consumes. */110
intercept?: Dict<boolean>111
}113
export interface Transform<S, T> {114
/** Marks the transform object as a schema/config transform. */115
schema?: true116
/** Convert user-facing config to runtime config. */117
Config: (config: S) => T118
}120
/** Function plugin called with `(ctx, config)`. */121
export interface Function<T = any> extends Base<T> {122
(ctx: Context, config: T): any123
}125
/** Class plugin constructed with `(ctx, config)`. */126
export interface Constructor<T = any> extends Base<T> {127
new (ctx: Context, config: T): any128
}130
/** Object plugin with an `apply(ctx, config)` method. */131
export interface Object<T = any> extends Base<T> {132
apply(ctx: Context, config: T): any133
}135
/** Mutable registry record shared by all fibers of one plugin callback. */136
export interface Runtime {137
/** Display name copied from the first registered plugin shape. */138
name?: string139
/** Every live fiber of this plugin (one per `ctx.plugin()` call). */140
fibers: DisposableList<Fiber>141
/** The executable entrypoint all fibers share (registry identity key). */142
callback: globalThis.Function143
/** Standard-schema validator applied to each fiber's config. */144
Config?: StandardSchemaV1145
}146
}148
type Spread<T> = undefined extends T ? [config?: T] : [config: T]150
type GetPluginParameters<P> =151
| P extends (ctx: Context, ...args: infer R) => any152
? R153
: P extends new (ctx: Context, ...args: infer R) => any154
? R155
: P extends { apply(ctx: Context, ...args: infer R): any }156
? R157
: never159
type GetPluginConfig<P> =160
| P extends Plugin.Transform<infer S, any>161
? S162
: GetPluginParameters<P>[0]164
declare module './context.ts' {165
export interface Context {166
/**167
* Run a callback once the requested services are available.168
*169
* Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback170
* is unloaded and re-run whenever a required service changes.171
*172
* @param deps — required services, as an array or a name → config map.173
* @param callback — plugin body called with `(ctx, config)`.174
* @returns the fiber; awaiting it settles once loading finished.175
*/176
inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>177
/**178
* Load a plugin in the current context.179
*180
* @param plugin — a function, class, or `{ apply }` object plugin.181
* @param args — the plugin config, validated against its `Config` schema.182
* @returns the fiber; awaiting it settles once loading finished183
* (rejecting on config or startup errors).184
*/185
plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>186
}187
}189
/**190
* Plugin registry installed as `ctx.registry` and mixed into every context.191
*192
* It normalizes plugin shapes, tracks plugin runtimes, starts fibers, and193
* exposes map-like inspection over active plugin callbacks.194
*/195
export class RegistryService {196
private _counter = 0197
private _internal = new Map<Function, Plugin.Runtime>()199
constructor(public ctx: Context) {200
defineProperty(this, symbols.tracker, {201
property: 'ctx',202
noShadow: true,203
})204
}206
/** Allocate the next fiber uid (increments on every read). */207
get counter() {208
return ++this._counter209
}211
/** Number of registered plugin runtimes. */212
get size() {213
return this._internal.size214
}216
/**217
* Resolve a supported plugin shape to its executable callback.218
*219
* @param plugin — a function, class, or `{ apply }` object plugin.220
* @returns the callback identifying the plugin, or `undefined` if invalid.221
*/222
resolve(plugin: Plugin): Function | undefined {223
// plugin.apply may throw224
try {225
if (typeof plugin === 'function') return plugin226
if (isApplicable(plugin)) return plugin.apply227
} catch {}228
}230
/**231
* Look up the runtime record for a plugin.232
*233
* @param plugin — any supported plugin shape.234
* @returns the runtime, or `undefined` when the plugin is not registered.235
*/236
get(plugin: Plugin) {237
const key = this.resolve(plugin)238
return key && this._internal.get(key)239
}241
/**242
* Check whether a plugin has a registered runtime.243
*244
* @param plugin — any supported plugin shape.245
* @returns `true` when at least one fiber of the plugin exists.246
*/247
has(plugin: Plugin) {248
const key = this.resolve(plugin)249
return !!key && this._internal.has(key)250
}252
/**253
* Dispose every running fiber for a plugin and remove its runtime record.254
*255
* @param plugin — any supported plugin shape.256
* @returns the removed runtime, or `undefined` when none was registered.257
*/258
delete(plugin: Plugin) {259
const key = this.resolve(plugin)260
const runtime = key && this._internal.get(key)261
if (!runtime) return262
this._internal.delete(key)263
for (const fiber of runtime.fibers) {264
fiber.dispose()265
}266
return runtime267
}269
/** Iterate the registered plugin callbacks. */270
keys() {271
return this._internal.keys()272
}274
/** Iterate the registered plugin runtimes. */275
values() {276
return this._internal.values()277
}279
/** Iterate `[callback, runtime]` pairs. */280
entries() {281
return this._internal.entries()282
}284
/**285
* Visit every registered runtime.286
*287
* @param callback — receives each runtime and its identifying callback.288
*/289
forEach(callback: (value: Plugin.Runtime, key: Function) => void) {290
return this._internal.forEach(callback)291
}293
/**294
* Start a callback once the requested dependencies are available.295
*296
* @param inject — required services, as an array or a name → config map.297
* @param callback — plugin body called with `(ctx, config)`.298
* @returns the fiber; awaiting it settles once loading finished.299
*/300
inject(inject: Inject, callback: Plugin.Function<void>) {301
return this.plugin({ inject, apply: callback, name: callback.name })302
}304
/**305
* Start a plugin in the current context and return its fiber.306
*307
* Creates (or reuses) the plugin's runtime record, then starts a new fiber308
* under the current context. Throws if `plugin` is not a supported shape or309
* if the current fiber is already disposed.310
*311
* @param plugin — a function, class, or `{ apply }` object plugin.312
* @param config — the plugin config, validated against its `Config` schema.313
* @param getOuterStack — captures the caller stack for effect diagnostics.314
* @returns the fiber; awaiting it settles once loading finished.315
*/316
plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {317
// check if it's a valid plugin318
const callback = this.resolve(plugin)319
if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin)320
this.ctx.fiber.assertActive()322
let runtime = this._internal.get(callback)323
if (!runtime) {324
let name = plugin.name325
if (name === 'apply') name = undefined326
runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }327
this._internal.set(callback, runtime)328
}330
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)331
const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>332
wrapped.then = (onFulfilled, onRejected) => {333
return fiber.await().then(onFulfilled, onRejected)334
}335
return wrapped336
}337
}