vendor(cordis): document the full plugin-author surface (@param/@returns everywhere)

Comment-only enrichment across cordis/src/*.ts — Context, EventsService (+ the
ctx merges), Fiber, RegistryService, ReflectService, Service, logger — so the
website API generator can render a complete reference and hard-error on any
future undocumented member (vendor sync included). Logged as local
modification 6 in vendor/README.md; retire it when upstreamed to the fork.
INHERITED_SERVICES/EVENTS source pointers refreshed for the shifted lines;
cordis catalogs regenerated.
This commit is contained in:
lintianle
2026-07-16 18:12:36 +08:00
parent 6ce9f16030
commit 83cb48441e
11 changed files with 587 additions and 55 deletions
+8 -8
View File
@@ -427,14 +427,14 @@ Source: [`packages/workflow/workflow/src/index.ts:62`](../../packages/workflow/w
The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence.
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:197`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:198`](../../vendor/cordis/src/events.ts))
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:199`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:200`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:201`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:202`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:203`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:204`](../../vendor/cordis/src/events.ts))
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts))
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts))
- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts))
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
+4 -4
View File
@@ -282,12 +282,12 @@ Source: [`packages/workflow/workflow/src/index.ts:210`](../../packages/workflow/
The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence.
- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts))
- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts))
- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts))
- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts))
- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts))
- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:164`](../../vendor/cordis/src/registry.ts))
- `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts))
- `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts))
- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:35`](../../vendor/cordis/src/context.ts))
- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:42`](../../vendor/cordis/src/context.ts))
- `ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts))
- `ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)` — Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts))
- `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts))
+12 -12
View File
@@ -310,14 +310,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
@@ -328,12 +328,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
]
export const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
+1
View File
@@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
6. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context`, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
## Sync procedure
+53 -4
View File
@@ -14,14 +14,21 @@ import { Fiber } from './fiber.ts'
* be read from `ctx`.
*/
export interface Context {
/** Isolation map: service name → scope label. Lookups for a name resolve within its label. */
[symbols.isolate]: Dict<symbol>
/** Intercept map: service name → config merged into that service's per-plugin config. */
[symbols.intercept]: Dict
/** @experimental */
root: this
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
baseUrl?: string
/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */
events: EventsService
/** The logging service. Call `ctx.logger(name)` for a named logger. */
logger: LoggerService
/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */
reflect: ReflectService
/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */
registry: RegistryService
}
@@ -33,12 +40,24 @@ export interface Context {
* contexts without mutating their parent.
*/
export class Context {
/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */
static readonly effect: unique symbol = symbols.effect
/** Symbol key for a context's listener filter, consulted on every event dispatch. */
static readonly filter: unique symbol = symbols.filter
/** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */
static readonly isolate: unique symbol = symbols.isolate
/** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */
static readonly intercept: unique symbol = symbols.intercept
/** Returns true for Cordis context proxies and context prototypes. */
/**
* Returns true for Cordis context proxies and context prototypes.
*
* Works across realms and across multiple copies of cordis, because the
* brand is keyed by a global symbol rather than by `instanceof`.
*
* @param value — the value to test.
* @returns `true` if `value` is a Cordis context, narrowing its type.
*/
static is(value: any): value is Context {
return !!value?.[Context.is as any]
}
@@ -68,7 +87,15 @@ export class Context {
return `Context <${this.fiber.name}>`
}
/** Create a child context with extra metadata on top of the current scope. */
/**
* Create a child context with extra metadata on top of the current scope.
*
* The child prototypally inherits every property of this context; own
* properties of `meta` shadow the inherited ones. The parent is not mutated.
*
* @param meta — own properties (including symbol keys) to define on the child.
* @returns a child context inheriting from this one.
*/
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this))
@@ -79,14 +106,36 @@ export class Context {
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
}
/** Create a child context with an independent service scope for `name`. */
/**
* Create a child context with an independent service scope for `name`.
*
* Below the returned context, reads and writes of the service `name`
* resolve against the new label instead of the parent's, so a different
* implementation can be provided without affecting the parent scope.
* Passing the same `label` to two `isolate()` calls joins their scopes.
*
* @param name — the service name to isolate.
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
/** Add service-specific intercept config for plugins started below this context. */
/**
* Add service-specific intercept config for plugins started below this
* context.
*
* Plugins loaded under the returned context see `config` merged into the
* service's resolved config (ancestor entries first; see
* `Service[symbols.resolveConfig]`). The parent context is not affected.
*
* @param name — the service name whose config to intercept.
* @param config — the intercept config to merge for that service.
* @returns a child context carrying the additional intercept entry.
*/
intercept<K extends InjectKey>(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this
intercept(name: string, config: any): this
intercept(name: string, config: any) {
+147 -9
View File
@@ -3,7 +3,12 @@ import { Context } from './context.ts'
import { Fiber, FiberState } from './fiber.ts'
import { DisposableList, symbols } from './utils.ts'
/** Return whether an event result should stop a bail-style dispatch. */
/**
* Return whether an event result should stop a bail-style dispatch.
*
* @param value — a listener's return value.
* @returns `true` unless `value` is `null`, `false`, or `undefined`.
*/
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
@@ -28,17 +33,75 @@ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
declare module './context.ts' {
export interface Context {
/* eslint-disable max-len */
/**
* Dispatch an event, running all listeners concurrently.
*
* @param name — the event name.
* @param args — arguments passed to every listener.
* @returns a promise resolving once every listener has settled.
*/
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
parallel<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promise<void>
/**
* Dispatch an event synchronously, ignoring listener return values.
*
* @param name — the event name.
* @param args — arguments passed to every listener.
*/
emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
emit<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): void
/**
* Dispatch an event, awaiting listeners in order until one bails.
*
* @param name — the event name.
* @param args — arguments passed to each listener.
* @returns the first bail value (non-null, non-false, non-undefined), if any.
*/
serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
serial<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
/**
* Dispatch an event, calling listeners in order until one bails.
*
* @param name — the event name.
* @param args — arguments passed to each listener.
* @returns the first bail value (non-null, non-false, non-undefined), if any.
*/
bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
bail<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
/**
* Dispatch an event whose last argument is a `next` continuation.
*
* Each listener wraps the rest of the chain: calling `next()` invokes the
* next listener (finally the built-in behavior); not calling it vetoes.
*
* @param name — the event name.
* @param args — listener arguments; the final one is the innermost `next`.
* @returns the outermost listener's return value.
*/
waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
waterfall<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
/**
* Register an event listener owned by the current fiber.
*
* @param name — the event name to listen for.
* @param listener — called with the dispatch arguments.
* @param options — listener options; a boolean is shorthand for `prepend`.
* @returns a disposer removing the listener; `true` if it was still registered.
*/
on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
/**
* Same as `on()`, but the listener disposes itself after its first call.
*
* @param name — the event name to listen for.
* @param listener — called at most once with the dispatch arguments.
* @param options — listener options; a boolean is shorthand for `prepend`.
* @returns a disposer removing the listener; `true` if it was still registered.
*/
once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
/* eslint-enable max-len */
}
@@ -91,7 +154,13 @@ export class EventsService {
}, { global: true, prepend: true })
}
/** Resolve listeners for one dispatch and apply context filtering. */
/**
* Resolve listeners for one dispatch and apply context filtering.
*
* @param type — the dispatch mode, reported on `internal/dispatch`.
* @param args — the raw dispatch arguments; consumed up to the event name.
* @returns the matching listener callbacks, bound to the dispatch `this`.
*/
dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
@@ -104,17 +173,31 @@ export class EventsService {
.map(hook => hook.callback.bind(thisArg))
}
/** Run listeners concurrently and wait for all of them. */
/**
* Run listeners concurrently and wait for all of them.
*
* @param args — optional `this`, the event name, then listener arguments.
* @returns a promise resolving once every listener has settled.
*/
async parallel(...args: any[]) {
await Promise.all(this.dispatch('emit', args).map(cb => cb(...args)))
}
/** Run listeners synchronously without waiting for returned promises. */
/**
* Run listeners synchronously without waiting for returned promises.
*
* @param args — optional `this`, the event name, then listener arguments.
*/
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args))
}
/** Run listeners in order until one returns a bail value. */
/**
* Run listeners in order, awaiting each, until one returns a bail value.
*
* @param args — optional `this`, the event name, then listener arguments.
* @returns the first bail value (see {@link isBailed}), if any.
*/
async serial(...args: any[]) {
for (const cb of this.dispatch('serial', args)) {
const result = await cb(...args)
@@ -122,7 +205,12 @@ export class EventsService {
}
}
/** Run listeners synchronously until one returns a bail value. */
/**
* Run listeners synchronously until one returns a bail value.
*
* @param args — optional `this`, the event name, then listener arguments.
* @returns the first bail value (see {@link isBailed}), if any.
*/
bail(...args: any[]) {
for (const cb of this.dispatch('bail', args)) {
const result = cb(...args)
@@ -130,7 +218,16 @@ export class EventsService {
}
}
/** Compose listeners around the final `next` callback. */
/**
* Compose listeners around the final `next` callback.
*
* The last dispatch argument is treated as the innermost `next`. Listeners
* run outermost-first; a listener that does not call `next()` vetoes the
* rest of the chain, including the built-in behavior.
*
* @param args — optional `this`, the event name, listener arguments, then `next`.
* @returns the outermost listener's return value.
*/
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop()
@@ -142,6 +239,15 @@ export class EventsService {
return next()
}
/**
* Store a listener record as an effect on the current fiber.
*
* @param label — effect label shown in fiber diagnostics.
* @param hooks — the listener list for one event.
* @param callback — the listener to store.
* @param options — placement and filtering options.
* @returns a disposer that unregisters the listener.
*/
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
@@ -150,6 +256,13 @@ export class EventsService {
}, label)
}
/**
* Remove a stored listener record.
*
* @param hooks — the listener list for one event.
* @param callback — the listener to remove.
* @returns `true` if the listener was found and removed.
*/
unregister(hooks: Hook[], callback: any) {
const index = hooks.findIndex(hook => hook.callback === callback)
if (index >= 0) {
@@ -158,7 +271,17 @@ export class EventsService {
}
}
/** Register an event listener owned by the current fiber. */
/**
* Register an event listener owned by the current fiber.
*
* The listener is removed automatically when the fiber unloads. Throws
* `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed.
*
* @param name — the event name to listen for.
* @param listener — called with the dispatch arguments.
* @param options — listener options; a boolean is shorthand for `prepend`.
* @returns a disposer removing the listener; `true` if it was still registered.
*/
on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {
if (typeof options !== 'object') {
options = { prepend: options }
@@ -175,7 +298,14 @@ export class EventsService {
return this.register(label, hooks, listener, options)
}
/** Register an event listener that disposes itself after the first call. */
/**
* Register an event listener that disposes itself after the first call.
*
* @param name — the event name to listen for.
* @param listener — called at most once with the dispatch arguments.
* @param options — listener options; a boolean is shorthand for `prepend`.
* @returns a disposer removing the listener; `true` if it was still registered.
*/
once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) {
const dispose = this.on(name, function (...args: any[]) {
dispose()
@@ -194,12 +324,20 @@ export class EventsService {
* diagnostics before public events are delivered.
*/
export interface Events {
/** A plugin fiber was created or its uid was cleared on disposal. */
'internal/plugin'(fiber: Fiber): void
/** A fiber changed lifecycle state; receives the fiber and its previous state. */
'internal/status'(fiber: Fiber, oldValue: FiberState): void
/** Interception hook for a service binding (no core producer). */
'internal/service'(this: Context, name: string, value: any): void
/** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
/** Waterfall: a service is being read through the context proxy. */
'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
/** Waterfall: a service is being written through the context proxy. */
'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean
/** Bail: a listener is being registered; a non-null result replaces registration. */
'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void
/** An event is being dispatched to listeners (fired for non-internal events only). */
'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
}
+107 -10
View File
@@ -7,6 +7,7 @@ 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
}
}
@@ -17,6 +18,11 @@ const kValidationError = Symbol.for('ValidationError')
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) {
@@ -32,7 +38,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, {
value: true,
})
/** Validate and normalize config for a plugin runtime before it starts. */
/**
* 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
@@ -51,10 +64,21 @@ interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends P
(): T
}
/** Function returned by an effect to release resources during disposal. */
/**
* 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. */
/**
* 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>
@@ -69,7 +93,9 @@ type AsyncEffect<T = any> =
/** 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[]
}
@@ -80,7 +106,14 @@ interface EffectRunner<T> {
getOuterStack: () => string[]
}
/** Lifecycle state for one plugin fiber. */
/**
* 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,
@@ -92,6 +125,10 @@ export const enum FiberState {
/** 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])
}
@@ -115,12 +152,19 @@ const INACTIVE = '__INACTIVE__'
* 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)
@@ -133,6 +177,16 @@ export class Fiber {
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,
@@ -226,6 +280,7 @@ export class Fiber {
}
}
/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */
get name() {
let fiber: Fiber = this
do {
@@ -235,7 +290,12 @@ export class Fiber {
return 'root'
}
/** Throw if the fiber has already been disposed. */
/**
* 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')
@@ -287,8 +347,21 @@ export class Fiber {
}, runner.getOuterStack)
}
/** Register a cleanup-aware effect on this fiber. */
/**
* 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()
@@ -355,7 +428,11 @@ export class Fiber {
return wrapper
}
/** Return metadata for currently registered effects. */
/**
* 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])
@@ -474,7 +551,12 @@ export class Fiber {
})
}
/** Wait for current lifecycle work and rethrow startup errors. */
/**
* 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
@@ -483,7 +565,12 @@ export class Fiber {
return this
}
/** Dispose and immediately reload this plugin with its current config. */
/**
* 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)
@@ -491,7 +578,17 @@ export class Fiber {
await this.await()
}
/** Validate and apply new config, then restart the plugin. */
/**
* 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)
+9 -1
View File
@@ -62,8 +62,11 @@ export const defaultFormatters: Record<string, Formatter> = {
/** Options used when creating a named logger facade. */
export interface LoggerOptions {
/** The logger name shown with each message. */
name: string
/** Message fields merged into every record from this logger. */
meta?: Partial<Message>
/** Default maximum level exported when an exporter has no own threshold. */
level?: number
}
@@ -220,7 +223,12 @@ export class LoggerService {
return self
}
/** Register an exporter and dispose it with the current fiber. */
/**
* Register an exporter and dispose it with the current fiber.
*
* @param exporter — the sink that receives structured log messages.
* @returns a disposer that removes the exporter.
*/
exporter(exporter: Exporter) {
return this.ctx.effect(() => {
this.exporters.set(++this._snExporter, exporter)
+125
View File
@@ -5,14 +5,66 @@ import { Fiber, FiberState } from './fiber.ts'
declare module './context.ts' {
interface Context {
/**
* Read a service from the store without the inject requirement.
*
* @param name — the service name.
* @param strict — when `true` (default), only return implementations
* whose providing fiber is currently active.
* @returns the service value, or `undefined` when not (yet) provided.
*/
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
/** Same as above for service names outside the typed `Context` surface. */
get(name: string, strict?: boolean): any
/**
* Overwrite a provided service's value.
*
* Only the fiber that provided the service may set it; setting an
* unprovided name throws.
*
* @param name — the service name.
* @param value — the new service value.
*/
set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
/** Same as above for service names outside the typed `Context` surface. */
set(name: string, value: any): void
/**
* Register a service implementation owned by the current fiber.
*
* The service becomes visible to dependents in the same isolation scope
* once the fiber is active; it is unregistered (waking dependents) when
* the returned disposer runs or the fiber unloads. Throws if the name is
* already provided in this scope or declared as an accessor.
*
* @param name — the service name.
* @param value — the service value.
* @returns a disposer that unregisters the service.
*/
provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void
/** Same as above for service names outside the typed `Context` surface. */
provide(name: string, value?: any): () => void
/**
* Define a computed context property backed by get/set hooks.
*
* The accessor is removed when the current fiber unloads. Throws if the
* name is already declared.
*
* @param name — the context property name.
* @param options — the `get` hook and optional `set` hook.
*/
accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
/**
* Expose selected members of a service directly on `ctx`.
*
* Each mixed-in key becomes an accessor that forwards to the service
* (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`.
* Mixins are removed when the current fiber unloads.
*
* @param name — the context property holding the source service.
* @param mixins — keys to forward, or a source-key → ctx-key map.
*/
mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
/** Same as above with a source object instead of a context property name. */
mixin<T extends {}>(source: T, mixins: (keyof this & keyof T)[] | Dict<string>): void
}
}
@@ -44,22 +96,30 @@ export type Property = Property.Service | Property.Accessor
export namespace Property {
/** Service property backed by a provided implementation. */
export interface Service {
/** Discriminator. */
type: 'service'
}
/** Computed context property backed by custom get/set hooks. */
export interface Accessor {
/** Discriminator. */
type: 'accessor'
/** Compute the property value; `error` carries the caller stack for diagnostics. */
get: (this: Context, receiver: any, error: Error) => any
/** Optional setter; return `false` to reject the write. */
set?: (this: Context, value: any, receiver: any, error: Error) => boolean
}
}
/** Concrete service implementation record stored in the root reflect service. */
export interface Impl {
/** The service name. */
name: string
/** The fiber that provided the service (owns its lifetime). */
fiber: Fiber
/** The current service value. */
value?: any
/** Optional availability predicate consulted before dependents may load. */
check?: () => boolean
}
@@ -70,6 +130,7 @@ export interface Impl {
* the mixins that expose core service methods directly on `ctx`.
*/
export class ReflectService {
/** Proxy traps implementing service resolution for every context object. */
static handler: ProxyHandler<Context> = {
get: (target, prop, ctx: Context) => {
if (isSpecialProperty(prop)) {
@@ -143,7 +204,9 @@ export class ReflectService {
},
}
/** Service implementations, keyed by isolation label. */
public store: Dict<Impl, symbol> = Object.create(null)
/** Declared context properties (services and accessors), by name. */
public props: Dict<Property> = Object.create(null)
constructor(public ctx: Context) {
@@ -158,6 +221,14 @@ export class ReflectService {
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall'])
}
/**
* Read a service from the store without the inject requirement.
*
* @param name — the service name.
* @param strict — when `true`, only return implementations whose providing
* fiber is currently active.
* @returns the service value, or `undefined` when not (yet) provided.
*/
get(name: string, strict = true) {
return getTraceable(this.ctx, this._getImpl(name, strict)?.value)
}
@@ -170,6 +241,15 @@ export class ReflectService {
return impl
}
/**
* Overwrite a provided service's value.
*
* @param name — the service name.
* @param value — the new service value.
* @param error — carrier for the caller stack in diagnostics.
* @returns `true` on success.
* @throws when `name` was never provided, or was provided by another fiber.
*/
set(name: string, value: any, error?: Error) {
const key = this.ctx[symbols.isolate][name]
const impl = this.store[key]
@@ -183,6 +263,16 @@ export class ReflectService {
return true
}
/**
* Register a service implementation owned by the current fiber.
*
* See the `ctx.provide()` overload above for the full contract.
*
* @param name — the service name.
* @param value — the service value.
* @param check — optional availability predicate for dependents.
* @returns a disposer that unregisters the service.
*/
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
if (!this.props[name]) {
@@ -213,6 +303,13 @@ export class ReflectService {
}, `ctx.provide(${JSON.stringify(name)})`)
}
/**
* Re-evaluate every fiber that requires one of the given services.
*
* @param names — the service names that changed.
* @param filter — restricts notification to matching isolation scopes.
* @returns the fibers whose dependency state was refreshed.
*/
notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {
const fibers: Fiber[] = []
for (const runtime of this.ctx.registry.values()) {
@@ -232,6 +329,13 @@ export class ReflectService {
return fibers
}
/**
* Define a computed context property backed by get/set hooks.
*
* @param name — the context property name.
* @param options — the `get` hook and optional `set` hook.
* @returns a disposer that removes the accessor.
*/
accessor(name: string, options: Omit<Property.Accessor, 'type'>) {
return this.ctx.fiber.effect(() => {
if (name in this.props) {
@@ -242,6 +346,15 @@ export class ReflectService {
}, `ctx.accessor(${JSON.stringify(name)})`)
}
/**
* Expose selected members of a service directly on `ctx`.
*
* See the `ctx.mixin()` overload above for the full contract.
*
* @param source — a context property name or a source object.
* @param mixins — keys to forward, or a source-key → ctx-key map.
* @returns a disposer that removes all created accessors.
*/
mixin(source: any, mixins: string[] | Dict<string>) {
const self = this
return this.ctx.fiber.effect(function* () {
@@ -270,10 +383,22 @@ export class ReflectService {
}, `ctx.mixin(${JSON.stringify(source)})`)
}
/**
* Attach this context's tracing wrapper to a value.
*
* @param value — the value to wrap.
* @returns the traceable wrapper (or the value itself when not applicable).
*/
trace<T>(value: T) {
return getTraceable(this.ctx, value)
}
/**
* Wrap a callback so calls trace `this` and arguments to this context.
*
* @param callback — the function to wrap.
* @returns a proxy delegating to `callback` with traced values.
*/
bind<T extends Function>(callback: T) {
return new Proxy(callback, {
apply: (target, thisArg, args) => {
+92 -5
View File
@@ -28,6 +28,11 @@ export type InjectKey = keyof {
* 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') {
@@ -55,7 +60,13 @@ export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends
/** Utilities for normalizing plugin dependency declarations. */
export namespace Inject {
/** Convert array/object/class-inherited inject metadata into a plain map. */
/**
* 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)) {
@@ -86,10 +97,15 @@ export type Plugin<T = any> =
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>
}
@@ -117,9 +133,13 @@ export namespace Plugin {
/** 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
}
}
@@ -142,7 +162,25 @@ type GetPluginConfig<P> =
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>
}
}
@@ -164,15 +202,22 @@ export class RegistryService {
})
}
/** 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. */
/**
* 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 {
@@ -181,17 +226,34 @@ export class RegistryService {
} 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. */
/**
* 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)
@@ -203,28 +265,53 @@ export class RegistryService {
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. */
/**
* 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. */
/**
* 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)
+29 -2
View File
@@ -9,19 +9,36 @@ import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts'
* registered immediately and is automatically removed with the owning fiber.
*/
export abstract class Service<out T = never> {
/** Symbol key of an instance method run after construction (class plugins). */
static readonly init: unique symbol = symbols.init
/** Symbol key of the availability predicate passed to `ctx.provide()`. */
static readonly check: unique symbol = symbols.check
/** Symbol key of the phantom intercept-config type parameter. */
static readonly config: unique symbol = symbols.config
/** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */
static readonly invoke: unique symbol = symbols.invoke
/** Symbol key of the helper deriving an extended service instance. */
static readonly extend: unique symbol = symbols.extend
/** Symbol key of the tracker metadata used for context tracing. */
static readonly tracker: unique symbol = symbols.tracker
/** Symbol key of the intercept-config resolution helper below. */
static readonly resolveConfig: unique symbol = symbols.resolveConfig
declare [symbols.config]: T
/** The service name this instance is registered under. */
public name!: string
/** Register this instance as `name` in the current context. */
/**
* Register this instance as `name` in the current context.
*
* Calls `ctx.reflect.provide(name, this, this[Service.check])`, so the
* service is unregistered automatically when the owning fiber unloads.
* Services with a `[Service.invoke]` body return a callable instance.
*
* @param ctx — the context to register in (stored as `this.ctx`).
* @param name — the service name; defaults to the static `provide` field.
*/
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
@@ -55,7 +72,17 @@ export abstract class Service<out T = never> {
return Object.assign(self, props)
}
/** Merge intercept config from ancestors with optional base and head values. */
/**
* Merge intercept config from ancestors with optional base and head values.
*
* Entries added closer to the root apply first; `base` is prepended and
* `head` appended. Uses `Config.merge` when the service declares one,
* otherwise a shallow `Object.assign`.
*
* @param base — lowest-precedence config merged before all intercepts.
* @param head — highest-precedence config merged after all intercepts.
* @returns the merged config.
*/
[symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept]
const configs: any[] = []