@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 +8 -0
- package/package.json +11 -0
- package/src/context.ts +120 -0
- package/src/effect.ts +50 -0
- package/src/events.ts +147 -0
- package/src/fiber.ts +41 -0
- package/src/index.ts +42 -0
- package/src/kernel.ts +306 -0
- package/src/plugin.ts +225 -0
- package/src/schema.ts +46 -0
- package/src/state.ts +58 -0
- package/tsconfig.json +4 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
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