@lambdot/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/core-v0.0.1...core-v0.1.0) (2026-08-29)
4
+
5
+
6
+ ### Miscellaneous Chores
7
+
8
+ * **core:** Synchronize lambdot versions
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@lambdot/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "publishConfig": {
9
+ "access": "public"
10
+ }
11
+ }
package/src/context.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { Disposer } from "./effect.ts";
2
+ import type { BotEvent, EventMap, IngressListener, Listener, OnOptions } from "./events.ts";
3
+ import { INGRESS } from "./events.ts";
4
+ import type { StateView } from "./state.ts";
5
+
6
+ /**
7
+ * A typed return address. Produced by the input half of a platform pair,
8
+ * consumed by the output half. The core never inspects it beyond the
9
+ * `platform` routing tag.
10
+ */
11
+ export interface Address<TPlatform extends string = string> {
12
+ readonly platform: TPlatform;
13
+ }
14
+
15
+ /** An output platform's contract: the addresses it accepts and the content it can send. */
16
+ export interface OutputContract<TAddress = unknown, TContent = unknown> {
17
+ address: TAddress;
18
+ content: TContent;
19
+ }
20
+
21
+ /** platform → contract. Folded across all registered output plugins. */
22
+ export type OutputContractMap = Record<string, OutputContract<any, any>>;
23
+
24
+ type AllAddresses<TOutputs extends OutputContractMap> = TOutputs[keyof TOutputs]["address"];
25
+
26
+ /**
27
+ * Content types accepted for a given address: the union of `content` from
28
+ * every registered output whose address type accepts `TAddress`. Resolves to
29
+ * `never` when no output matches — with no outputs registered at all,
30
+ * `send` is simply uncallable.
31
+ */
32
+ export type ContentFor<TOutputs extends OutputContractMap, TAddress> = {
33
+ [K in keyof TOutputs]: TAddress extends TOutputs[K]["address"] ? TOutputs[K]["content"] : never;
34
+ }[keyof TOutputs];
35
+
36
+ /**
37
+ * The typed surface every plugin sees. Parameterized by the plugin's own
38
+ * declared needs (event kinds it handles, output platforms it sends to,
39
+ * state schema it owns) — the kernel checks at `use()` time that the fold
40
+ * so far satisfies them — plus the capabilities it provides (`TProvides`),
41
+ * which makes `provide` type-checked for declared names.
42
+ */
43
+ export interface ContextView<
44
+ TEvents extends EventMap,
45
+ TOutputs extends OutputContractMap,
46
+ TState extends object,
47
+ TProvides extends object = {},
48
+ > {
49
+ /** Subscribe to ingress middleware (waterfall over every event). */
50
+ on(kind: typeof INGRESS, listener: IngressListener<TEvents>, options?: OnOptions): Disposer;
51
+ /** Subscribe to a specific event kind. */
52
+ on<TKind extends keyof TEvents & string>(
53
+ kind: TKind,
54
+ listener: Listener<BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>>,
55
+ options?: OnOptions,
56
+ ): Disposer;
57
+
58
+ emit<TKind extends keyof TEvents & string>(
59
+ kind: TKind,
60
+ event: BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>,
61
+ ): void;
62
+ parallel<TKind extends keyof TEvents & string>(
63
+ kind: TKind,
64
+ event: BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>,
65
+ ): Promise<void>;
66
+ serial<TKind extends keyof TEvents & string>(
67
+ kind: TKind,
68
+ event: BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>,
69
+ ): Promise<unknown>;
70
+ waterfall<TKind extends keyof TEvents & string>(
71
+ kind: TKind,
72
+ event: BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>,
73
+ inner: (
74
+ event: BotEvent<TKind, TEvents[TKind]["payload"], TEvents[TKind]["address"]>,
75
+ ) => Promise<unknown>,
76
+ ): Promise<unknown>;
77
+
78
+ /**
79
+ * Send content through the output platform that owns `to`'s address
80
+ * type. Compile error if the content doesn't match that platform's
81
+ * contract — or if the platform's output plugin was never registered.
82
+ */
83
+ send<TAddress extends AllAddresses<TOutputs>>(
84
+ to: TAddress,
85
+ content: ContentFor<TOutputs, TAddress>,
86
+ ): Promise<void>;
87
+
88
+ /**
89
+ * Typed per-plugin state. `never` unless some plugin declared a state
90
+ * schema; usable at runtime only while a state backend plugin is active.
91
+ */
92
+ readonly state: StateView<TState>;
93
+
94
+ /**
95
+ * Provide a named capability for `inject` gating. Names declared in the
96
+ * plugin's `TProvides` require a value of the declared type (the value is
97
+ * attached to the context — the dashboard-plugin pattern — and read back
98
+ * typed through the kernel's capability fold). Undeclared names take an
99
+ * optional untyped value: the runtime-only capability path. Returns the
100
+ * unregistering disposer.
101
+ */
102
+ provide<TName extends string>(
103
+ name: TName,
104
+ ...value: TName extends keyof TProvides & string
105
+ ? [value: TProvides[TName]]
106
+ : [value?: unknown]
107
+ ): Disposer;
108
+ }
109
+
110
+ /** Context seen by input plugins: adds the ability to push events into the pipeline. */
111
+ export interface InputContext<
112
+ TEvents extends EventMap,
113
+ TProvides extends object = {},
114
+ > extends ContextView<TEvents, {}, {}, TProvides> {
115
+ ingest<TKind extends keyof TEvents & string>(
116
+ kind: TKind,
117
+ payload: TEvents[TKind]["payload"],
118
+ address: TEvents[TKind]["address"],
119
+ ): Promise<void>;
120
+ }
package/src/effect.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A function that undoes a registration or allocation. Collected by the
3
+ * fiber that owns the plugin instance and run on unload/shutdown.
4
+ */
5
+ export type Disposer = () => void | Promise<void>;
6
+
7
+ /**
8
+ * Non-promise effect results. Kept separate from {@link Effect} so the
9
+ * promise variant stays non-recursive and easy for TS to flatten.
10
+ */
11
+ export type EffectResult = void | Disposer | Iterable<Disposer> | AsyncIterable<Disposer>;
12
+
13
+ /**
14
+ * The result of a plugin's `apply`. Everything a plugin contributes is an
15
+ * effect: disposers returned (or yielded) here are collected by the plugin's
16
+ * fiber and run when the plugin unloads. There are no lifecycle hooks.
17
+ */
18
+ export type Effect = EffectResult | Promise<EffectResult>;
19
+
20
+ /** Collect every disposer an effect produces into `sink`. */
21
+ export async function collectEffect(
22
+ effect: Effect,
23
+ sink: (disposer: Disposer) => void,
24
+ ): Promise<void> {
25
+ const result = await effect;
26
+ if (!result) return;
27
+ if (typeof result === "function") {
28
+ sink(result);
29
+ return;
30
+ }
31
+ if (Symbol.asyncIterator in result) {
32
+ for await (const disposer of result) sink(disposer);
33
+ return;
34
+ }
35
+ for (const disposer of result) sink(disposer);
36
+ }
37
+
38
+ /** Run disposers in reverse registration order, isolating failures. */
39
+ export async function runDisposers(
40
+ disposers: readonly Disposer[],
41
+ onError: (error: unknown) => void,
42
+ ): Promise<void> {
43
+ for (const dispose of [...disposers].reverse()) {
44
+ try {
45
+ await dispose();
46
+ } catch (error) {
47
+ onError(error);
48
+ }
49
+ }
50
+ }
package/src/events.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * An event kind's contract: the payload an input produces and the typed
3
+ * return address that routes replies back through a matching output.
4
+ */
5
+ export interface EventDef<TPayload = unknown, TAddress = unknown> {
6
+ payload: TPayload;
7
+ address: TAddress;
8
+ }
9
+
10
+ /** kind → contract. Folded across all registered input plugins. */
11
+ export type EventMap = Record<string, EventDef<any, any>>;
12
+
13
+ /**
14
+ * The single unit that flows through the framework. The core envelope is
15
+ * deliberately free of platform semantics: no reply references, no channel
16
+ * vocabulary — `address` is opaque to the core and meaningful only to the
17
+ * output whose platform produced it.
18
+ */
19
+ export interface BotEvent<TKind extends string = string, TPayload = unknown, TAddress = unknown> {
20
+ readonly kind: TKind;
21
+ readonly payload: TPayload;
22
+ readonly address: TAddress;
23
+ /** Unique id for dedup and tracing. */
24
+ readonly id: string;
25
+ /** Unix epoch milliseconds. */
26
+ readonly at: number;
27
+ }
28
+
29
+ /** Union over every registered event kind, narrowing `payload`/`address` per kind. */
30
+ export type AnyBotEvent<TEvents extends EventMap> = {
31
+ [K in keyof TEvents & string]: BotEvent<K, TEvents[K]["payload"], TEvents[K]["address"]>;
32
+ }[keyof TEvents & string];
33
+
34
+ /** Reserved kind: every ingested event passes through this waterfall first. */
35
+ export const INGRESS = "bot/ingress" as const;
36
+
37
+ /** Continue a waterfall chain. Call with no argument to pass the event through unchanged. */
38
+ export type NextFn<TEvent> = (event?: TEvent) => Promise<unknown>;
39
+
40
+ /**
41
+ * A listener. Plain observation listeners simply ignore `next`; waterfall
42
+ * (middleware) listeners MUST call `next()` to delegate — returning without
43
+ * calling it short-circuits the chain.
44
+ */
45
+ export type Listener<TEvent> = (event: TEvent, next: NextFn<TEvent>) => unknown;
46
+
47
+ /** Listener for the ingress waterfall; receives the union of all registered event kinds. */
48
+ export type IngressListener<TEvents extends EventMap> = Listener<AnyBotEvent<TEvents>>;
49
+
50
+ export interface OnOptions {
51
+ /** Run before ordinary (non-prepended) registrations. */
52
+ prepend?: boolean;
53
+ }
54
+
55
+ interface Registration {
56
+ listener: Listener<any>;
57
+ }
58
+
59
+ const noopNext: NextFn<any> = () => Promise.resolve(undefined);
60
+
61
+ /**
62
+ * The runtime event bus. Untyped internally; the typed surface is layered on
63
+ * by the kernel's context view. Modes:
64
+ *
65
+ * - `emit` fire-and-forget observation
66
+ * - `parallel` await all listeners (rejects with AggregateError)
67
+ * - `serial` await in order until a listener returns a non-undefined bail value
68
+ * - `waterfall` around-middleware: each listener receives `next`
69
+ */
70
+ export class EventBus {
71
+ private readonly listeners = new Map<string, Registration[]>();
72
+
73
+ constructor(private readonly onError: (error: unknown) => void) {}
74
+
75
+ on(kind: string, listener: Listener<any>, options?: OnOptions): () => void {
76
+ const regs = this.listeners.get(kind) ?? [];
77
+ const reg: Registration = { listener };
78
+ if (options?.prepend) regs.unshift(reg);
79
+ else regs.push(reg);
80
+ this.listeners.set(kind, regs);
81
+ return () => {
82
+ const current = this.listeners.get(kind);
83
+ if (!current) return;
84
+ const index = current.indexOf(reg);
85
+ if (index >= 0) current.splice(index, 1);
86
+ if (current.length === 0) this.listeners.delete(kind);
87
+ };
88
+ }
89
+
90
+ emit(kind: string, event: unknown): void {
91
+ const regs = this.listeners.get(kind);
92
+ if (!regs) return;
93
+ // snapshot: listeners may unregister themselves during dispatch
94
+ for (const reg of regs.slice()) {
95
+ try {
96
+ const result = reg.listener(event, noopNext);
97
+ if (result instanceof Promise) result.catch(this.onError);
98
+ } catch (error) {
99
+ this.onError(error);
100
+ }
101
+ }
102
+ }
103
+
104
+ async parallel(kind: string, event: unknown): Promise<void> {
105
+ const regs = this.listeners.get(kind);
106
+ if (!regs) return;
107
+ const results = await Promise.allSettled(
108
+ [...regs].map((reg) => reg.listener(event, noopNext)),
109
+ );
110
+ const errors = results
111
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
112
+ .map((result) => result.reason);
113
+ if (errors.length > 0)
114
+ throw new AggregateError(errors, `parallel dispatch of "${kind}" failed`);
115
+ }
116
+
117
+ async serial(kind: string, event: unknown): Promise<unknown> {
118
+ const regs = this.listeners.get(kind);
119
+ if (!regs) return undefined;
120
+ // snapshot: listeners may unregister themselves during dispatch
121
+ for (const reg of regs.slice()) {
122
+ const result = await reg.listener(event, noopNext);
123
+ if (result !== undefined) return result;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ async waterfall(
129
+ kind: string,
130
+ event: unknown,
131
+ inner: (event: any) => Promise<unknown>,
132
+ ): Promise<unknown> {
133
+ const regs = this.listeners.get(kind);
134
+ if (!regs || regs.length === 0) return inner(event);
135
+ const stack = [...regs];
136
+ const dispatch = (index: number, current: unknown): Promise<unknown> => {
137
+ const reg = stack[index];
138
+ if (!reg) return inner(current);
139
+ return Promise.resolve(
140
+ reg.listener(current, (nextEvent?: unknown) =>
141
+ dispatch(index + 1, nextEvent ?? current),
142
+ ),
143
+ );
144
+ };
145
+ return dispatch(0, event);
146
+ }
147
+ }
package/src/fiber.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { runDisposers, type Disposer } from "./effect.ts";
2
+ import type { AnyPlugin } from "./plugin.ts";
3
+
4
+ export type FiberState = "pending" | "activating" | "active" | "disposed";
5
+
6
+ /**
7
+ * One plugin application. Owns the disposers the plugin's effects produced.
8
+ * A fiber whose injected capabilities disappear is disposed back to
9
+ * `pending`, and reactivates when they return — activation order is derived
10
+ * from `inject`, never from boot sequencing.
11
+ */
12
+ export class Fiber {
13
+ state: FiberState = "pending";
14
+ private readonly disposers: Disposer[] = [];
15
+
16
+ constructor(
17
+ readonly plugin: AnyPlugin,
18
+ readonly config: unknown,
19
+ ) {}
20
+
21
+ get name(): string {
22
+ return this.plugin.name;
23
+ }
24
+
25
+ addDisposer(disposer: Disposer): void {
26
+ if (this.state !== "active") {
27
+ throw new Error(`cannot register disposer on ${this.state} fiber "${this.name}"`);
28
+ }
29
+ this.disposers.push(disposer);
30
+ }
31
+
32
+ async dispose(
33
+ nextState: "pending" | "disposed",
34
+ onError: (error: unknown) => void,
35
+ ): Promise<void> {
36
+ if (this.state !== "active") return;
37
+ this.state = nextState;
38
+ const disposers = this.disposers.splice(0);
39
+ await runDisposers(disposers, onError);
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ export type { Disposer, Effect, EffectResult } from "./effect.ts";
2
+ export type {
3
+ AnyBotEvent,
4
+ BotEvent,
5
+ EventDef,
6
+ EventMap,
7
+ IngressListener,
8
+ Listener,
9
+ NextFn,
10
+ OnOptions,
11
+ } from "./events.ts";
12
+ export { INGRESS } from "./events.ts";
13
+ export type {
14
+ Address,
15
+ ContentFor,
16
+ ContextView,
17
+ InputContext,
18
+ OutputContract,
19
+ OutputContractMap,
20
+ } from "./context.ts";
21
+ export type {
22
+ AnyPlugin,
23
+ CapsOf,
24
+ ConfigOf,
25
+ EventsOf,
26
+ FeaturePlugin,
27
+ InputPlugin,
28
+ InjectsOf,
29
+ OutputPlugin,
30
+ OutputsOf,
31
+ PluginMeta,
32
+ Spread,
33
+ StateOf,
34
+ Validate,
35
+ } from "./plugin.ts";
36
+ export { definePlugin } from "./plugin.ts";
37
+ export type { StandardSchemaV1 } from "./schema.ts";
38
+ export { ConfigValidationError } from "./schema.ts";
39
+ export type { StateAccessor, StateBackend, StateView } from "./state.ts";
40
+ export type { FiberState } from "./fiber.ts";
41
+ export type { KernelOptions } from "./kernel.ts";
42
+ export { createKernel, Kernel } from "./kernel.ts";
package/src/kernel.ts ADDED
@@ -0,0 +1,306 @@
1
+ import type { ContextView } from "./context.ts";
2
+ import type { OutputContractMap } from "./context.ts";
3
+ import { collectEffect, type Disposer } from "./effect.ts";
4
+ import { EventBus, INGRESS, type BotEvent, type OnOptions } from "./events.ts";
5
+ import type { EventMap } from "./events.ts";
6
+ import { Fiber } from "./fiber.ts";
7
+ import type {
8
+ AnyPlugin,
9
+ CapsOf,
10
+ ConfigOf,
11
+ EventsOf,
12
+ OutputsOf,
13
+ Spread,
14
+ StateOf,
15
+ Validate,
16
+ } from "./plugin.ts";
17
+ import { validateConfig } from "./schema.ts";
18
+ import { createStateAccessor, type StateBackend } from "./state.ts";
19
+
20
+ export interface KernelOptions {
21
+ /** Sink for errors thrown by fire-and-forget listeners and disposers. */
22
+ onError?: (error: unknown) => void;
23
+ }
24
+
25
+ /**
26
+ * The runtime context. Untyped internally; the kernel exposes it through
27
+ * {@link ContextView} parameterized by the folded type arguments.
28
+ */
29
+ class RuntimeContext {
30
+ readonly bus: EventBus;
31
+ private readonly outputs = new Map<
32
+ string,
33
+ { send(to: never, content: never): void | Promise<void> }
34
+ >();
35
+ private readonly provided = new Map<string, unknown>();
36
+ /** Events are processed sequentially, in ingestion order. */
37
+ private queue: Promise<void> = Promise.resolve();
38
+ onProvideChange: (name: string, available: boolean) => void = () => {};
39
+
40
+ constructor(private readonly onError: (error: unknown) => void) {
41
+ this.bus = new EventBus(onError);
42
+ }
43
+
44
+ on(kind: string, listener: never, options?: OnOptions): Disposer {
45
+ return this.bus.on(kind, listener, options);
46
+ }
47
+
48
+ emit(kind: string, event: unknown): void {
49
+ this.bus.emit(kind, event);
50
+ }
51
+
52
+ parallel(kind: string, event: unknown): Promise<void> {
53
+ return this.bus.parallel(kind, event);
54
+ }
55
+
56
+ serial(kind: string, event: unknown): Promise<unknown> {
57
+ return this.bus.serial(kind, event);
58
+ }
59
+
60
+ waterfall(
61
+ kind: string,
62
+ event: unknown,
63
+ inner: (event: any) => Promise<unknown>,
64
+ ): Promise<unknown> {
65
+ return this.bus.waterfall(kind, event, inner);
66
+ }
67
+
68
+ async send(to: { platform: string }, content: unknown): Promise<void> {
69
+ const output = this.outputs.get(to.platform);
70
+ if (!output) throw new Error(`no output registered for platform "${to.platform}"`);
71
+ await output.send(to as never, content as never);
72
+ }
73
+
74
+ registerOutput(
75
+ platform: string,
76
+ output: { send(to: never, content: never): void | Promise<void> },
77
+ ): Disposer {
78
+ if (this.outputs.has(platform))
79
+ throw new Error(`duplicate output for platform "${platform}"`);
80
+ this.outputs.set(platform, output);
81
+ return () => {
82
+ this.outputs.delete(platform);
83
+ };
84
+ }
85
+
86
+ readonly state = {
87
+ for: (plugin: string) => {
88
+ // State has no dedicated runtime slot: a state plugin is an
89
+ // ordinary feature plugin that provides its backend as the
90
+ // (runtime-gated) "state" capability.
91
+ const backend = this.provided.get("state") as StateBackend | undefined;
92
+ if (!backend)
93
+ throw new Error(
94
+ 'no state backend active — register a state plugin and add `inject: ["state"]`',
95
+ );
96
+ return createStateAccessor(backend, plugin);
97
+ },
98
+ };
99
+
100
+ provide(name: string, value?: unknown): Disposer {
101
+ if (this.provided.has(name)) throw new Error(`capability "${name}" is already provided`);
102
+ this.provided.set(name, value);
103
+ const defined = value !== undefined && !(name in this);
104
+ if (defined) {
105
+ Object.defineProperty(this, name, { value, configurable: true });
106
+ }
107
+ this.onProvideChange(name, true);
108
+ return () => {
109
+ this.provided.delete(name);
110
+ if (defined) {
111
+ delete (this as Record<string, unknown>)[name];
112
+ }
113
+ this.onProvideChange(name, false);
114
+ };
115
+ }
116
+
117
+ isProvided(name: string): boolean {
118
+ return this.provided.has(name);
119
+ }
120
+
121
+ ingest(kind: string, payload: unknown, address: unknown): Promise<void> {
122
+ const event: BotEvent = { kind, payload, address, id: crypto.randomUUID(), at: Date.now() };
123
+ const run = this.queue.then(() => this.process(event));
124
+ // A failing event rejects its own caller but never jams the queue.
125
+ this.queue = run.catch(this.onError);
126
+ return run;
127
+ }
128
+
129
+ private async process(event: BotEvent): Promise<void> {
130
+ await this.bus.waterfall(INGRESS, event, async (current: BotEvent) => {
131
+ await this.bus.parallel(current.kind, current);
132
+ });
133
+ }
134
+ }
135
+
136
+ interface FiberEntry {
137
+ readonly plugin: AnyPlugin;
138
+ readonly config: unknown;
139
+ readonly fiber: Fiber;
140
+ }
141
+
142
+ /**
143
+ * The kernel. Stateless: it owns no conversational data, only the fiber
144
+ * registry and the runtime wiring. Every `use()` folds the plugin's type
145
+ * contribution into the kernel's type parameters — the context type your
146
+ * plugins see is computed from the plugins you registered.
147
+ */
148
+ export class Kernel<
149
+ TEvents extends EventMap = {},
150
+ TOutputs extends OutputContractMap = {},
151
+ TCaps extends object = {},
152
+ TState extends object = {},
153
+ > {
154
+ /** The typed context. This is the fold of everything registered so far. */
155
+ readonly ctx: ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
156
+
157
+ private readonly runtime: RuntimeContext;
158
+ private readonly entries: FiberEntry[] = [];
159
+ private readonly onError: (error: unknown) => void;
160
+ private readonly inflightActivations = new Set<Promise<void>>();
161
+ private started = false;
162
+
163
+ constructor(options: KernelOptions = {}) {
164
+ this.onError = options.onError ?? ((error) => console.error("[lambdot]", error));
165
+ this.runtime = new RuntimeContext(this.onError);
166
+ this.runtime.onProvideChange = (name, available) => {
167
+ if (!available) void this.deactivateDependents(name).catch(this.onError);
168
+ if (available) void this.activateEligible().catch(this.onError);
169
+ };
170
+ this.ctx = this.runtime as unknown as ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
171
+ }
172
+
173
+ /**
174
+ * Register a plugin. Gated at compile time: every event kind a feature
175
+ * handles, every output platform it sends through, and every typed
176
+ * capability it injects must already be in the fold.
177
+ */
178
+ use<TPlugin extends AnyPlugin>(
179
+ plugin: TPlugin & Validate<TPlugin, TEvents, TOutputs, TCaps>,
180
+ ...config: Spread<ConfigOf<TPlugin>>
181
+ ): Kernel<
182
+ TEvents & EventsOf<TPlugin>,
183
+ TOutputs & OutputsOf<TPlugin>,
184
+ TCaps & CapsOf<TPlugin>,
185
+ TState & StateOf<TPlugin>
186
+ > {
187
+ const entry: FiberEntry = {
188
+ plugin,
189
+ config: config[0],
190
+ fiber: new Fiber(plugin, config[0]),
191
+ };
192
+ this.entries.push(entry);
193
+ if (this.started) void this.activateEligible().catch(this.onError);
194
+ return this as unknown as Kernel<
195
+ TEvents & EventsOf<TPlugin>,
196
+ TOutputs & OutputsOf<TPlugin>,
197
+ TCaps & CapsOf<TPlugin>,
198
+ TState & StateOf<TPlugin>
199
+ >;
200
+ }
201
+
202
+ /** Activate all plugins whose `inject` requirements are satisfiable. */
203
+ async start(): Promise<void> {
204
+ if (this.started) return;
205
+ this.started = true;
206
+ await this.activateEligible();
207
+ const stuck = this.entries
208
+ .filter((entry) => entry.fiber.state === "pending")
209
+ .map((entry) => entry.plugin.name);
210
+ if (stuck.length > 0)
211
+ console.warn(`[lambdot] plugins pending on missing capabilities: ${stuck.join(", ")}`);
212
+ }
213
+
214
+ /** Dispose every active fiber in reverse registration order. */
215
+ async stop(): Promise<void> {
216
+ this.started = false;
217
+ for (const entry of [...this.entries].reverse()) {
218
+ await entry.fiber.dispose("disposed", this.onError);
219
+ }
220
+ }
221
+
222
+ private eligible(plugin: AnyPlugin): boolean {
223
+ return (plugin.inject ?? []).every((name) => this.runtime.isProvided(name));
224
+ }
225
+
226
+ private async activateEligible(): Promise<void> {
227
+ if (!this.started) return;
228
+ let progressed = true;
229
+ while (progressed) {
230
+ progressed = false;
231
+ for (const entry of this.entries) {
232
+ if (entry.fiber.state !== "pending" || !this.eligible(entry.plugin)) continue;
233
+ await this.activate(entry);
234
+ progressed = true;
235
+ }
236
+ }
237
+ // Nested passes (triggered by `provide` during activation) may still
238
+ // have activations in flight; wait for quiescence so `start()` and
239
+ // `stop()` only observe settled fibers.
240
+ while (this.inflightActivations.size > 0) {
241
+ await Promise.allSettled(this.inflightActivations);
242
+ }
243
+ }
244
+
245
+ private activate(entry: FiberEntry): Promise<void> {
246
+ // Mark the fiber synchronously so a nested `activateEligible` pass
247
+ // cannot pick it up again while activation is in flight.
248
+ entry.fiber.state = "activating";
249
+ const activation = this.completeActivation(entry);
250
+ this.inflightActivations.add(activation);
251
+ const settled = () => this.inflightActivations.delete(activation);
252
+ activation.then(settled, settled);
253
+ return activation;
254
+ }
255
+
256
+ private async completeActivation(entry: FiberEntry): Promise<void> {
257
+ const { plugin, fiber } = entry;
258
+ let config = entry.config;
259
+ try {
260
+ if (plugin.Config) config = await validateConfig(plugin.name, plugin.Config, config);
261
+ } catch (error) {
262
+ fiber.state = "pending";
263
+ throw error;
264
+ }
265
+ // The kernel may have stopped, or a required capability may have been
266
+ // withdrawn, while we awaited; mid-activation fibers are invisible to
267
+ // `deactivateDependents` and `stop`.
268
+ if (!this.started || !this.eligible(plugin)) {
269
+ fiber.state = "pending";
270
+ return;
271
+ }
272
+
273
+ // Role-specific registration happens before `apply` so plugin code
274
+ // can rely on its own capability being live.
275
+ if (plugin.role === "output") {
276
+ fiber.state = "active";
277
+ fiber.addDisposer(this.runtime.registerOutput(plugin.platform, plugin));
278
+ } else {
279
+ fiber.state = "active";
280
+ }
281
+
282
+ if ("apply" in plugin && plugin.apply) {
283
+ await collectEffect(plugin.apply(this.runtime as never, config as never), (disposer) =>
284
+ fiber.addDisposer(disposer),
285
+ );
286
+ }
287
+
288
+ const provides = plugin.provide ? [plugin.provide].flat() : [];
289
+ for (const name of provides) {
290
+ fiber.addDisposer(this.runtime.provide(name));
291
+ }
292
+ }
293
+
294
+ private async deactivateDependents(capability: string): Promise<void> {
295
+ for (const entry of [...this.entries].reverse()) {
296
+ if (entry.fiber.state !== "active") continue;
297
+ if (!(entry.plugin.inject ?? []).includes(capability)) continue;
298
+ await entry.fiber.dispose("pending", this.onError);
299
+ }
300
+ }
301
+ }
302
+
303
+ /** Create an empty kernel. Fold plugins in with `.use(...)`. */
304
+ export function createKernel(options?: KernelOptions): Kernel {
305
+ return new Kernel(options);
306
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,225 @@
1
+ import type {
2
+ Address,
3
+ ContextView,
4
+ InputContext,
5
+ OutputContract,
6
+ OutputContractMap,
7
+ } from "./context.ts";
8
+ import type { Effect } from "./effect.ts";
9
+ import type { EventMap } from "./events.ts";
10
+ import type { StandardSchemaV1 } from "./schema.ts";
11
+
12
+ /**
13
+ * The `inject` requirement: once a plugin declares typed capability needs
14
+ * (`TInjects`), the runtime `inject` array is restricted to exactly those
15
+ * names — the runtime gate and the type-level gate cannot drift apart.
16
+ * Plugins with no declared needs keep the loose string form (the
17
+ * runtime-only capability path, e.g. `inject: ["state"]`).
18
+ */
19
+ type InjectNames<TInjects extends object> = [keyof TInjects & string] extends [never]
20
+ ? readonly string[]
21
+ : readonly (keyof TInjects & string)[];
22
+
23
+ /** Metadata understood by the kernel, shared by all plugin roles. */
24
+ export interface PluginMeta<TConfig, TInjects extends object = {}> {
25
+ readonly name: string;
26
+ /** Capabilities that must be provided before this plugin activates. */
27
+ readonly inject?: InjectNames<TInjects>;
28
+ /**
29
+ * Capability names this plugin provides once active. Provided valueless
30
+ * by the kernel after `apply`; to provide a typed value, declare
31
+ * `TProvides` and call `ctx.provide(name, value)` in `apply` instead.
32
+ */
33
+ readonly provide?: string | readonly string[];
34
+ /** Standard-Schema validator applied to config before `apply` runs. */
35
+ readonly Config?: StandardSchemaV1<unknown, TConfig>;
36
+ }
37
+
38
+ /**
39
+ * Produces events of the kinds in `TEvents`. Knows how to listen, nothing
40
+ * else. May declare typed capabilities it provides (`TProvides`) and
41
+ * consumes (`TInjects` — folded into its `apply` context, gated at `use()`).
42
+ */
43
+ export interface InputPlugin<
44
+ TEvents extends EventMap = EventMap,
45
+ TConfig = void,
46
+ TName extends string = string,
47
+ TProvides extends object = {},
48
+ TInjects extends object = {},
49
+ > extends PluginMeta<TConfig, TInjects> {
50
+ readonly role: "input";
51
+ readonly name: TName;
52
+ apply(ctx: InputContext<TEvents, TProvides> & TInjects, config: TConfig): Effect;
53
+ }
54
+
55
+ /** Consumes addresses of its platform. Reply semantics live in `TContent`, not the core. */
56
+ export interface OutputPlugin<
57
+ TPlatform extends string = string,
58
+ TAddress extends Address<TPlatform> = Address<TPlatform>,
59
+ TContent = unknown,
60
+ TConfig = void,
61
+ TName extends string = string,
62
+ TProvides extends object = {},
63
+ TInjects extends object = {},
64
+ > extends PluginMeta<TConfig, TInjects> {
65
+ readonly role: "output";
66
+ readonly name: TName;
67
+ readonly platform: TPlatform;
68
+ send(to: TAddress, content: TContent): void | Promise<void>;
69
+ apply?(ctx: ContextView<{}, {}, {}, TProvides> & TInjects, config: TConfig): Effect;
70
+ }
71
+
72
+ /**
73
+ * A unit of behavior. Declares the event kinds it handles (`TNeeds`), the
74
+ * output platforms it sends through (`TSends`), and optionally a state
75
+ * schema (`TStateSchema`) and typed capabilities: `TProvides` (read back
76
+ * through `CapsOf` at the kernel fold; `provide` is type-checked against
77
+ * it) and `TInjects` (folded into the `apply` context; the kernel checks
78
+ * at `use()` time that the fold so far provides them).
79
+ */
80
+ export interface FeaturePlugin<
81
+ TNeeds extends EventMap = {},
82
+ TSends extends OutputContractMap = {},
83
+ TStateSchema = undefined,
84
+ TConfig = void,
85
+ TName extends string = string,
86
+ TProvides extends object = {},
87
+ TInjects extends object = {},
88
+ > extends PluginMeta<TConfig, TInjects> {
89
+ readonly role?: "feature";
90
+ readonly name: TName;
91
+ apply(
92
+ ctx: ContextView<
93
+ TNeeds,
94
+ TSends,
95
+ TStateSchema extends undefined ? {} : { [K in TName]: TStateSchema },
96
+ TProvides
97
+ > &
98
+ TInjects,
99
+ config: TConfig,
100
+ ): Effect;
101
+ }
102
+
103
+ export type AnyPlugin =
104
+ | InputPlugin<any, any, any, any, any>
105
+ | OutputPlugin<any, any, any, any, any, any, any>
106
+ | FeaturePlugin<any, any, any, any, any, any, any>;
107
+
108
+ /* ------------------------------------------------------------------ */
109
+ /* Type-level fold: how each plugin shapes the kernel's type params */
110
+ /* ------------------------------------------------------------------ */
111
+
112
+ export type EventsOf<TPlugin> =
113
+ TPlugin extends InputPlugin<infer TEvents, any, any, any, any> ? TEvents : {};
114
+
115
+ export type OutputsOf<TPlugin> =
116
+ TPlugin extends OutputPlugin<
117
+ infer TPlatform,
118
+ infer TAddress,
119
+ infer TContent,
120
+ any,
121
+ any,
122
+ any,
123
+ any
124
+ >
125
+ ? { [K in TPlatform]: OutputContract<TAddress, TContent> }
126
+ : {};
127
+
128
+ export type StateOf<TPlugin> =
129
+ TPlugin extends FeaturePlugin<any, any, infer TStateSchema, any, infer TName, any, any>
130
+ ? TStateSchema extends undefined
131
+ ? {}
132
+ : { [K in TName]: TStateSchema }
133
+ : {};
134
+
135
+ /** Typed capabilities a plugin provides, folded into the kernel's `TCaps`. */
136
+ export type CapsOf<TPlugin> =
137
+ TPlugin extends FeaturePlugin<any, any, any, any, any, infer TProvides, any>
138
+ ? TProvides
139
+ : TPlugin extends InputPlugin<any, any, any, infer TProvides, any>
140
+ ? TProvides
141
+ : TPlugin extends OutputPlugin<any, any, any, any, any, infer TProvides, any>
142
+ ? TProvides
143
+ : {};
144
+
145
+ /** Typed capabilities a plugin consumes, gated against the fold at `use()`. */
146
+ export type InjectsOf<TPlugin> =
147
+ TPlugin extends FeaturePlugin<any, any, any, any, any, any, infer TInjects>
148
+ ? TInjects
149
+ : TPlugin extends InputPlugin<any, any, any, any, infer TInjects>
150
+ ? TInjects
151
+ : TPlugin extends OutputPlugin<any, any, any, any, any, any, infer TInjects>
152
+ ? TInjects
153
+ : {};
154
+
155
+ /** Config type: from the schema if present, else from `apply`'s second parameter. */
156
+ export type ConfigOf<TPlugin> = TPlugin extends { Config: StandardSchemaV1<any, infer TOutput> }
157
+ ? TOutput
158
+ : TPlugin extends { apply: (ctx: any, config: infer TConfig) => any }
159
+ ? unknown extends TConfig
160
+ ? void
161
+ : TConfig
162
+ : void;
163
+
164
+ /** Makes the config argument optional exactly when `void` is assignable to it. */
165
+ export type Spread<T> = [T] extends [void] ? [config?: T] : [config: T];
166
+
167
+ type NeedsOf<TPlugin> =
168
+ TPlugin extends FeaturePlugin<infer TNeeds, any, any, any, any, any, any> ? TNeeds : {};
169
+ type SendsOf<TPlugin> =
170
+ TPlugin extends FeaturePlugin<any, infer TSends, any, any, any, any, any> ? TSends : {};
171
+
172
+ type MissingKeys<TDeclared extends object, TRegistered extends object> = Exclude<
173
+ keyof TDeclared & string,
174
+ keyof TRegistered & string
175
+ >;
176
+
177
+ /** Declared capability keys whose value types don't match the folded capability. */
178
+ type MismatchedKeys<TDeclared extends object, TRegistered extends object> = {
179
+ [K in keyof TDeclared & string]: K extends keyof TRegistered & string
180
+ ? TDeclared[K] extends TRegistered[K]
181
+ ? never
182
+ : K
183
+ : never;
184
+ }[keyof TDeclared & string];
185
+
186
+ /**
187
+ * Compile-time gate on `use()`: a plugin can only be registered once every
188
+ * event kind it handles, every output platform it sends through, and every
189
+ * typed capability it injects is already in the fold — with a compatible
190
+ * value type.
191
+ */
192
+ export type Validate<
193
+ TPlugin,
194
+ TEvents extends EventMap,
195
+ TOutputs extends OutputContractMap,
196
+ TCaps extends object,
197
+ > = [MissingKeys<NeedsOf<TPlugin>, TEvents>] extends [never]
198
+ ? [MissingKeys<SendsOf<TPlugin>, TOutputs>] extends [never]
199
+ ? [MissingKeys<InjectsOf<TPlugin>, TCaps>] extends [never]
200
+ ? [MismatchedKeys<InjectsOf<TPlugin>, TCaps>] extends [never]
201
+ ? unknown
202
+ : {
203
+ readonly "mismatched capability types": MismatchedKeys<
204
+ InjectsOf<TPlugin>,
205
+ TCaps
206
+ >;
207
+ }
208
+ : { readonly "unprovided capabilities": MissingKeys<InjectsOf<TPlugin>, TCaps> }
209
+ : { readonly "unregistered output platforms": MissingKeys<SendsOf<TPlugin>, TOutputs> }
210
+ : { readonly "unregistered event kinds": MissingKeys<NeedsOf<TPlugin>, TEvents> };
211
+
212
+ /** Identity helper for authoring feature plugins with precise generics. */
213
+ export function definePlugin<
214
+ TNeeds extends EventMap = {},
215
+ TSends extends OutputContractMap = {},
216
+ TStateSchema = undefined,
217
+ TConfig = void,
218
+ TName extends string = string,
219
+ TProvides extends object = {},
220
+ TInjects extends object = {},
221
+ >(
222
+ plugin: FeaturePlugin<TNeeds, TSends, TStateSchema, TConfig, TName, TProvides, TInjects>,
223
+ ): typeof plugin {
224
+ return plugin;
225
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Minimal structural copy of the Standard Schema v1 interface
3
+ * (https://standardschema.dev). Types-only: any compliant validator
4
+ * (zod, valibot, arktype, schemastery, ...) plugs in without a runtime
5
+ * dependency on the framework.
6
+ */
7
+ export interface StandardSchemaV1<TInput = unknown, TOutput = TInput> {
8
+ readonly "~standard": {
9
+ readonly version: 1;
10
+ readonly vendor: string;
11
+ readonly validate: (value: unknown) => Result<TOutput> | Promise<Result<TOutput>>;
12
+ readonly types?: { input: TInput; output: TOutput } | undefined;
13
+ };
14
+ }
15
+
16
+ export type Result<TOutput> =
17
+ | { readonly value: TOutput; readonly issues?: undefined }
18
+ | { readonly issues: readonly Issue[] };
19
+
20
+ export interface Issue {
21
+ readonly message: string;
22
+ readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[] | undefined;
23
+ }
24
+
25
+ export class ConfigValidationError extends Error {
26
+ constructor(
27
+ readonly plugin: string,
28
+ readonly issues: readonly Issue[],
29
+ ) {
30
+ super(
31
+ `invalid config for plugin "${plugin}":\n${issues.map((issue) => ` - ${issue.message}`).join("\n")}`,
32
+ );
33
+ this.name = "ConfigValidationError";
34
+ }
35
+ }
36
+
37
+ /** Validate `value` against a plugin's schema, throwing on failure. */
38
+ export async function validateConfig(
39
+ plugin: string,
40
+ schema: StandardSchemaV1<unknown, unknown>,
41
+ value: unknown,
42
+ ): Promise<unknown> {
43
+ const result = await schema["~standard"].validate(value);
44
+ if (result.issues) throw new ConfigValidationError(plugin, result.issues);
45
+ return result.value;
46
+ }
package/src/state.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pluggable state. The framework core is stateless; state is an ordinary
3
+ * feature plugin providing its backend as the `"state"` capability (at most
4
+ * one may be active). Plugins declare their schema at the type level and
5
+ * receive a typed accessor namespaced to their plugin name.
6
+ */
7
+ export interface StateBackend {
8
+ get(namespace: string, key: string): Promise<unknown>;
9
+ set(namespace: string, key: string, value: unknown, ttlMs?: number): Promise<void>;
10
+ delete(namespace: string, key: string): Promise<void>;
11
+ }
12
+
13
+ /** Typed view of one plugin's state, bound to its namespace. */
14
+ export interface StateAccessor<TSchema> {
15
+ get<TKey extends keyof TSchema & string>(key: TKey): Promise<TSchema[TKey] | undefined>;
16
+ set<TKey extends keyof TSchema & string>(
17
+ key: TKey,
18
+ value: TSchema[TKey],
19
+ ttlMs?: number,
20
+ ): Promise<void>;
21
+ delete(key: keyof TSchema & string): Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Marker type for `ctx.state` when no plugin declared a state schema —
26
+ * "stateless by default" is enforced at compile time. Any access errors with
27
+ * this type's name in the message.
28
+ */
29
+ export interface NoStateDeclared {
30
+ readonly "no state schema declared": "register a feature plugin with a TStateSchema to enable ctx.state";
31
+ }
32
+
33
+ /**
34
+ * `ctx.state`. Folds to {@link NoStateDeclared} when no plugin declared a
35
+ * state schema.
36
+ */
37
+ export type StateView<TState extends object> = keyof TState extends never
38
+ ? NoStateDeclared
39
+ : {
40
+ for<TName extends keyof TState & string>(plugin: TName): StateAccessor<TState[TName]>;
41
+ };
42
+
43
+ export function createStateAccessor<TSchema>(
44
+ backend: StateBackend,
45
+ namespace: string,
46
+ ): StateAccessor<TSchema> {
47
+ return {
48
+ async get(key) {
49
+ return (await backend.get(namespace, key)) as TSchema[typeof key] | undefined;
50
+ },
51
+ async set(key, value, ttlMs) {
52
+ await backend.set(namespace, key, value, ttlMs);
53
+ },
54
+ async delete(key) {
55
+ await backend.delete(namespace, key);
56
+ },
57
+ };
58
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }