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

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. 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/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:198`](../../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:199`](../../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:200`](../../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:201`](../../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:202`](../../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:203`](../../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:204`](../../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/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)) - `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)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))

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. 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.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: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:34`](../../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.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.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.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.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.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)) - `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts))

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. * sibling check is N/A; keep them current on a vendor bump.
*/ */
const INHERITED_EVENTS: InheritedEntry[] = [ const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' }, { 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:198' }, { 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:199' }, { 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:200' }, { 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:201' }, { 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:202' }, { 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:203' }, { 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:204' }, { 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/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: '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' }, { 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[] = [ 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.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: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:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, { 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.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.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.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.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' }, { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },

1
vendor/README.md vendored
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. 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'`. 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. 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 ## Sync procedure

View File

@@ -14,14 +14,21 @@ import { Fiber } from './fiber.ts'
* be read from `ctx`. * be read from `ctx`.
*/ */
export interface Context { export interface Context {
/** Isolation map: service name → scope label. Lookups for a name resolve within its label. */
[symbols.isolate]: Dict<symbol> [symbols.isolate]: Dict<symbol>
/** Intercept map: service name → config merged into that service's per-plugin config. */
[symbols.intercept]: Dict [symbols.intercept]: Dict
/** @experimental */ /** @experimental */
root: this root: this
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
baseUrl?: string baseUrl?: string
/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */
events: EventsService events: EventsService
/** The logging service. Call `ctx.logger(name)` for a named logger. */
logger: LoggerService logger: LoggerService
/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */
reflect: ReflectService reflect: ReflectService
/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */
registry: RegistryService registry: RegistryService
} }
@@ -33,12 +40,24 @@ export interface Context {
* contexts without mutating their parent. * contexts without mutating their parent.
*/ */
export class Context { export class Context {
/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */
static readonly effect: unique symbol = symbols.effect 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 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 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 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 { static is(value: any): value is Context {
return !!value?.[Context.is as any] return !!value?.[Context.is as any]
} }
@@ -68,7 +87,15 @@ export class Context {
return `Context <${this.fiber.name}>` 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 { extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this)) const self = Object.create(getTraceable(this, this))
@@ -79,14 +106,36 @@ export class Context {
return Object.assign(Object.create(self), { [symbols.shadow]: shadow }) 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) { isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate]) const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name) shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow }) 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<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): this
intercept(name: string, config: any) { intercept(name: string, config: any) {

View File

@@ -3,7 +3,12 @@ import { Context } from './context.ts'
import { Fiber, FiberState } from './fiber.ts' import { Fiber, FiberState } from './fiber.ts'
import { DisposableList, symbols } from './utils.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) { export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined return value !== null && value !== false && value !== undefined
} }
@@ -28,17 +33,75 @@ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
declare module './context.ts' { declare module './context.ts' {
export interface Context { export interface Context {
/* eslint-disable max-len */ /* 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> 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> 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 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 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]>> 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]>> 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]> 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]> 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]> 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]> 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 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 once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
/* eslint-enable max-len */ /* eslint-enable max-len */
} }
@@ -91,7 +154,13 @@ export class EventsService {
}, { global: true, prepend: true }) }, { 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[]) { dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift() const name: string = args.shift()
@@ -104,17 +173,31 @@ export class EventsService {
.map(hook => hook.callback.bind(thisArg)) .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[]) { async parallel(...args: any[]) {
await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) 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[]) { emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args)) 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[]) { async serial(...args: any[]) {
for (const cb of this.dispatch('serial', args)) { for (const cb of this.dispatch('serial', args)) {
const result = await cb(...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[]) { bail(...args: any[]) {
for (const cb of this.dispatch('bail', args)) { for (const cb of this.dispatch('bail', args)) {
const result = cb(...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[]) { waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args) const cbs = this.dispatch('waterfall', args)
const inner = args.pop() const inner = args.pop()
@@ -142,6 +239,15 @@ export class EventsService {
return next() 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 { register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push' const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => { return this.ctx.fiber.effect(() => {
@@ -150,6 +256,13 @@ export class EventsService {
}, label) }, 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) { unregister(hooks: Hook[], callback: any) {
const index = hooks.findIndex(hook => hook.callback === callback) const index = hooks.findIndex(hook => hook.callback === callback)
if (index >= 0) { 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) { on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {
if (typeof options !== 'object') { if (typeof options !== 'object') {
options = { prepend: options } options = { prepend: options }
@@ -175,7 +298,14 @@ export class EventsService {
return this.register(label, hooks, listener, options) 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) { once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) {
const dispose = this.on(name, function (...args: any[]) { const dispose = this.on(name, function (...args: any[]) {
dispose() dispose()
@@ -194,12 +324,20 @@ export class EventsService {
* diagnostics before public events are delivered. * diagnostics before public events are delivered.
*/ */
export interface Events { export interface Events {
/** A plugin fiber was created or its uid was cleared on disposal. */
'internal/plugin'(fiber: Fiber): void 'internal/plugin'(fiber: Fiber): void
/** A fiber changed lifecycle state; receives the fiber and its previous state. */
'internal/status'(fiber: Fiber, oldValue: FiberState): void '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 '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 '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 '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 '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 '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 'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
} }

View File

@@ -7,6 +7,7 @@ import { StandardSchemaV1 } from '@standard-schema/spec'
declare module './context.ts' { declare module './context.ts' {
export interface Context extends Pick<Fiber, 'effect'> { export interface Context extends Pick<Fiber, 'effect'> {
/** The fiber (plugin runtime instance) that owns this context. */
fiber: Fiber fiber: Fiber
} }
} }
@@ -17,6 +18,11 @@ const kValidationError = Symbol.for('ValidationError')
export class ValidationError extends TypeError { export class ValidationError extends TypeError {
name = 'ValidationError' name = 'ValidationError'
/**
* Build the aggregated message from schema issues.
*
* @param issues — the standard-schema issues, one message line each.
*/
constructor(issues: readonly StandardSchemaV1.Issue[]) { constructor(issues: readonly StandardSchemaV1.Issue[]) {
super(`invalid config:\n` + issues.map(issue => { super(`invalid config:\n` + issues.map(issue => {
if (issue.path) { if (issue.path) {
@@ -32,7 +38,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, {
value: true, 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) { export function resolveConfig(runtime: Plugin.Runtime, config: any) {
if (!runtime.Config) return config if (!runtime.Config) return config
// TODO: async validation // TODO: async validation
@@ -51,10 +64,21 @@ interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends P
(): T (): 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 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> = export type Effect<T = any> =
| SyncEffect<T> | SyncEffect<T>
| AsyncEffect<T> | AsyncEffect<T>
@@ -69,7 +93,9 @@ type AsyncEffect<T = any> =
/** Tree node used to expose nested effect labels for diagnostics. */ /** Tree node used to expose nested effect labels for diagnostics. */
export interface EffectMeta { export interface EffectMeta {
/** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */
label: string label: string
/** Metadata of nested effects registered while this effect ran. */
children: EffectMeta[] children: EffectMeta[]
} }
@@ -80,7 +106,14 @@ interface EffectRunner<T> {
getOuterStack: () => string[] 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 { export const enum FiberState {
PENDING, PENDING,
LOADING, LOADING,
@@ -92,6 +125,10 @@ export const enum FiberState {
/** Framework error with a stable machine-readable code. */ /** Framework error with a stable machine-readable code. */
export class CordisError extends Error { 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) { constructor(public code: CordisError.Code, message?: string) {
super(message ?? CordisError.Code[code]) super(message ?? CordisError.Code[code])
} }
@@ -115,12 +152,19 @@ const INACTIVE = '__INACTIVE__'
* cleanup for the plugin context returned by `ctx.plugin()`. * cleanup for the plugin context returned by `ctx.plugin()`.
*/ */
export class Fiber { export class Fiber {
/** Unique id within the registry; 0 for the root fiber, `null` once disposed. */
public uid: number | null public uid: number | null
/** The context this fiber's plugin runs in (extends the parent context). */
public readonly ctx: Context public readonly ctx: Context
/** The validated plugin config (updated by `update()`). */
public config: any public config: any
/** Current lifecycle state; transitions emit `internal/status`. */
public state = FiberState.PENDING public state = FiberState.PENDING
/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */
public readonly dispose: () => Promise<void> public readonly dispose: () => Promise<void>
/** Snapshot of required service implementations while loaded; `undefined` otherwise. */
public store: Dict<Impl> | undefined public store: Dict<Impl> | undefined
/** The in-flight load/unload transition, if one is currently running. */
public inertia: Promise<void> | undefined public inertia: Promise<void> | undefined
public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null) public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
@@ -133,6 +177,16 @@ export class Fiber {
private _runner: EffectRunner<string> private _runner: EffectRunner<string>
private _store: Dict<Impl> = Object.create(null) 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( constructor(
public parent: Context, public parent: Context,
config: any, config: any,
@@ -226,6 +280,7 @@ export class Fiber {
} }
} }
/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */
get name() { get name() {
let fiber: Fiber = this let fiber: Fiber = this
do { do {
@@ -235,7 +290,12 @@ export class Fiber {
return 'root' 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() { assertActive() {
if (this.uid !== null) return if (this.uid !== null) return
throw new CordisError('INACTIVE_EFFECT') throw new CordisError('INACTIVE_EFFECT')
@@ -287,8 +347,21 @@ export class Fiber {
}, runner.getOuterStack) }, 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>> 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?: string): AsyncDisposable<Promise<void>>
effect(execute: () => Effect, label = 'anonymous'): any { effect(execute: () => Effect, label = 'anonymous'): any {
this.assertActive() this.assertActive()
@@ -355,7 +428,11 @@ export class Fiber {
return wrapper return wrapper
} }
/** Return metadata for currently registered effects. */ /**
* Return metadata for currently registered effects.
*
* @returns one {@link EffectMeta} tree per labeled live effect.
*/
getEffects() { getEffects() {
return [...this._disposables] return [...this._disposables]
.map<EffectMeta>(dispose => dispose[symbols.effect]) .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() { async await() {
while (this.inertia) { while (this.inertia) {
await this.inertia await this.inertia
@@ -483,7 +565,12 @@ export class Fiber {
return this 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() { async restart() {
this.assertActive() this.assertActive()
this._setEpoch(INACTIVE) this._setEpoch(INACTIVE)
@@ -491,7 +578,17 @@ export class Fiber {
await this.await() 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) { update(config: any, noSave = false) {
this.assertActive() this.assertActive()
config = resolveConfig(this.runtime!, config) config = resolveConfig(this.runtime!, config)

View File

@@ -62,8 +62,11 @@ export const defaultFormatters: Record<string, Formatter> = {
/** Options used when creating a named logger facade. */ /** Options used when creating a named logger facade. */
export interface LoggerOptions { export interface LoggerOptions {
/** The logger name shown with each message. */
name: string name: string
/** Message fields merged into every record from this logger. */
meta?: Partial<Message> meta?: Partial<Message>
/** Default maximum level exported when an exporter has no own threshold. */
level?: number level?: number
} }
@@ -220,7 +223,12 @@ export class LoggerService {
return self 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) { exporter(exporter: Exporter) {
return this.ctx.effect(() => { return this.ctx.effect(() => {
this.exporters.set(++this._snExporter, exporter) this.exporters.set(++this._snExporter, exporter)

View File

@@ -5,14 +5,66 @@ import { Fiber, FiberState } from './fiber.ts'
declare module './context.ts' { declare module './context.ts' {
interface Context { 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] 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 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 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 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 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 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 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 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 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 { export namespace Property {
/** Service property backed by a provided implementation. */ /** Service property backed by a provided implementation. */
export interface Service { export interface Service {
/** Discriminator. */
type: 'service' type: 'service'
} }
/** Computed context property backed by custom get/set hooks. */ /** Computed context property backed by custom get/set hooks. */
export interface Accessor { export interface Accessor {
/** Discriminator. */
type: 'accessor' type: 'accessor'
/** Compute the property value; `error` carries the caller stack for diagnostics. */
get: (this: Context, receiver: any, error: Error) => any 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 set?: (this: Context, value: any, receiver: any, error: Error) => boolean
} }
} }
/** Concrete service implementation record stored in the root reflect service. */ /** Concrete service implementation record stored in the root reflect service. */
export interface Impl { export interface Impl {
/** The service name. */
name: string name: string
/** The fiber that provided the service (owns its lifetime). */
fiber: Fiber fiber: Fiber
/** The current service value. */
value?: any value?: any
/** Optional availability predicate consulted before dependents may load. */
check?: () => boolean check?: () => boolean
} }
@@ -70,6 +130,7 @@ export interface Impl {
* the mixins that expose core service methods directly on `ctx`. * the mixins that expose core service methods directly on `ctx`.
*/ */
export class ReflectService { export class ReflectService {
/** Proxy traps implementing service resolution for every context object. */
static handler: ProxyHandler<Context> = { static handler: ProxyHandler<Context> = {
get: (target, prop, ctx: Context) => { get: (target, prop, ctx: Context) => {
if (isSpecialProperty(prop)) { if (isSpecialProperty(prop)) {
@@ -143,7 +204,9 @@ export class ReflectService {
}, },
} }
/** Service implementations, keyed by isolation label. */
public store: Dict<Impl, symbol> = Object.create(null) public store: Dict<Impl, symbol> = Object.create(null)
/** Declared context properties (services and accessors), by name. */
public props: Dict<Property> = Object.create(null) public props: Dict<Property> = Object.create(null)
constructor(public ctx: Context) { constructor(public ctx: Context) {
@@ -158,6 +221,14 @@ export class ReflectService {
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall']) 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) { get(name: string, strict = true) {
return getTraceable(this.ctx, this._getImpl(name, strict)?.value) return getTraceable(this.ctx, this._getImpl(name, strict)?.value)
} }
@@ -170,6 +241,15 @@ export class ReflectService {
return impl 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) { set(name: string, value: any, error?: Error) {
const key = this.ctx[symbols.isolate][name] const key = this.ctx[symbols.isolate][name]
const impl = this.store[key] const impl = this.store[key]
@@ -183,6 +263,16 @@ export class ReflectService {
return true 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) { provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => { return this.ctx.fiber.effect(() => {
if (!this.props[name]) { if (!this.props[name]) {
@@ -213,6 +303,13 @@ export class ReflectService {
}, `ctx.provide(${JSON.stringify(name)})`) }, `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]) { notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {
const fibers: Fiber[] = [] const fibers: Fiber[] = []
for (const runtime of this.ctx.registry.values()) { for (const runtime of this.ctx.registry.values()) {
@@ -232,6 +329,13 @@ export class ReflectService {
return fibers 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'>) { accessor(name: string, options: Omit<Property.Accessor, 'type'>) {
return this.ctx.fiber.effect(() => { return this.ctx.fiber.effect(() => {
if (name in this.props) { if (name in this.props) {
@@ -242,6 +346,15 @@ export class ReflectService {
}, `ctx.accessor(${JSON.stringify(name)})`) }, `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>) { mixin(source: any, mixins: string[] | Dict<string>) {
const self = this const self = this
return this.ctx.fiber.effect(function* () { return this.ctx.fiber.effect(function* () {
@@ -270,10 +383,22 @@ export class ReflectService {
}, `ctx.mixin(${JSON.stringify(source)})`) }, `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) { trace<T>(value: T) {
return getTraceable(this.ctx, value) 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) { bind<T extends Function>(callback: T) {
return new Proxy(callback, { return new Proxy(callback, {
apply: (target, thisArg, args) => { apply: (target, thisArg, args) => {

View File

@@ -28,6 +28,11 @@ export type InjectKey = keyof {
* On classes it contributes to the plugin's static `inject` map. On methods it * On classes it contributes to the plugin's static `inject` map. On methods it
* delays the method call until the declared services are available. * 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) { 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>) { return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {
if (decorator.kind === 'class') { 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. */ /** Utilities for normalizing plugin dependency declarations. */
export namespace Inject { 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)) { export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
if (!inject) return result if (!inject) return result
if (Array.isArray(inject)) { if (Array.isArray(inject)) {
@@ -86,10 +97,15 @@ export type Plugin<T = any> =
export namespace Plugin { export namespace Plugin {
/** Shared metadata understood by the plugin registry and related tooling. */ /** Shared metadata understood by the plugin registry and related tooling. */
export interface Base<T = any> { export interface Base<T = any> {
/** Display name used for fiber diagnostics and logger names. */
name?: string name?: string
/** Standard-schema validator applied to config before the plugin starts. */
Config?: StandardSchemaV1<any, T> Config?: StandardSchemaV1<any, T>
/** Services the plugin requires; it only loads while all are available. */
inject?: Inject inject?: Inject
/** Service name(s) the plugin provides (read by `Service` and by loaders). */
provide?: string | string[] provide?: string | string[]
/** Service names whose intercept config the plugin declares it consumes. */
intercept?: Dict<boolean> intercept?: Dict<boolean>
} }
@@ -117,9 +133,13 @@ export namespace Plugin {
/** Mutable registry record shared by all fibers of one plugin callback. */ /** Mutable registry record shared by all fibers of one plugin callback. */
export interface Runtime { export interface Runtime {
/** Display name copied from the first registered plugin shape. */
name?: string name?: string
/** Every live fiber of this plugin (one per `ctx.plugin()` call). */
fibers: DisposableList<Fiber> fibers: DisposableList<Fiber>
/** The executable entrypoint all fibers share (registry identity key). */
callback: globalThis.Function callback: globalThis.Function
/** Standard-schema validator applied to each fiber's config. */
Config?: StandardSchemaV1 Config?: StandardSchemaV1
} }
} }
@@ -142,7 +162,25 @@ type GetPluginConfig<P> =
declare module './context.ts' { declare module './context.ts' {
export interface Context { 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> 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<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() { get counter() {
return ++this._counter return ++this._counter
} }
/** Number of registered plugin runtimes. */
get size() { get size() {
return this._internal.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 { resolve(plugin: Plugin): Function | undefined {
// plugin.apply may throw // plugin.apply may throw
try { try {
@@ -181,17 +226,34 @@ export class RegistryService {
} catch {} } 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) { get(plugin: Plugin) {
const key = this.resolve(plugin) const key = this.resolve(plugin)
return key && this._internal.get(key) 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) { has(plugin: Plugin) {
const key = this.resolve(plugin) const key = this.resolve(plugin)
return !!key && this._internal.has(key) 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) { delete(plugin: Plugin) {
const key = this.resolve(plugin) const key = this.resolve(plugin)
const runtime = key && this._internal.get(key) const runtime = key && this._internal.get(key)
@@ -203,28 +265,53 @@ export class RegistryService {
return runtime return runtime
} }
/** Iterate the registered plugin callbacks. */
keys() { keys() {
return this._internal.keys() return this._internal.keys()
} }
/** Iterate the registered plugin runtimes. */
values() { values() {
return this._internal.values() return this._internal.values()
} }
/** Iterate `[callback, runtime]` pairs. */
entries() { entries() {
return this._internal.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) { forEach(callback: (value: Plugin.Runtime, key: Function) => void) {
return this._internal.forEach(callback) 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>) { inject(inject: Inject, callback: Plugin.Function<void>) {
return this.plugin({ inject, apply: callback, name: callback.name }) 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()) { plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
// check if it's a valid plugin // check if it's a valid plugin
const callback = this.resolve(plugin) const callback = this.resolve(plugin)

View File

@@ -9,19 +9,36 @@ import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts'
* registered immediately and is automatically removed with the owning fiber. * registered immediately and is automatically removed with the owning fiber.
*/ */
export abstract class Service<out T = never> { 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 static readonly init: unique symbol = symbols.init
/** Symbol key of the availability predicate passed to `ctx.provide()`. */
static readonly check: unique symbol = symbols.check static readonly check: unique symbol = symbols.check
/** Symbol key of the phantom intercept-config type parameter. */
static readonly config: unique symbol = symbols.config 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 static readonly invoke: unique symbol = symbols.invoke
/** Symbol key of the helper deriving an extended service instance. */
static readonly extend: unique symbol = symbols.extend static readonly extend: unique symbol = symbols.extend
/** Symbol key of the tracker metadata used for context tracing. */
static readonly tracker: unique symbol = symbols.tracker static readonly tracker: unique symbol = symbols.tracker
/** Symbol key of the intercept-config resolution helper below. */
static readonly resolveConfig: unique symbol = symbols.resolveConfig static readonly resolveConfig: unique symbol = symbols.resolveConfig
declare [symbols.config]: T declare [symbols.config]: T
/** The service name this instance is registered under. */
public name!: string 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) { constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string name ??= this.constructor['provide'] as string
@@ -55,7 +72,17 @@ export abstract class Service<out T = never> {
return Object.assign(self, props) 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 { [symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept] let intercept = this.ctx[Context.intercept]
const configs: any[] = [] const configs: any[] = []