@lambdot/core 0.1.0 → 0.1.1
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 +6 -0
- package/README.md +135 -0
- package/package.json +15 -10
- package/src/effect.ts +2 -33
- package/src/index.ts +26 -36
- package/src/kernel.ts +170 -249
- package/src/message.ts +37 -0
- package/src/plugin.ts +138 -186
- package/src/state.ts +4 -23
- package/src/stream.ts +281 -0
- package/src/context.ts +0 -120
- package/src/events.ts +0 -147
- package/src/fiber.ts +0 -41
package/src/stream.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { Disposer } from "./effect.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one thing plugins exchange: an async iterable. Streams broadcast like
|
|
5
|
+
* an event bus — every consumer sees every item, in order, at its own pace —
|
|
6
|
+
* so a namespace value can feed several downstream plugins (a reply stream
|
|
7
|
+
* consumed by two outputs; a message stream consumed by a feature and a
|
|
8
|
+
* logger). Sequential per consumer: the per-item `await` in a `mapStream`
|
|
9
|
+
* mapper or `pumpStream` consumer is the processing-order guarantee.
|
|
10
|
+
*/
|
|
11
|
+
export type Stream<T> = AsyncIterable<T>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The primitive underneath {@link shareStream}: a pushable buffer bridging
|
|
15
|
+
* callbacks (sockets, readline, HTTP handlers) into the pull world. Package
|
|
16
|
+
* authors pushing from callbacks create one channel, wrap `stream` with
|
|
17
|
+
* `shareStream`, and emit the shared view.
|
|
18
|
+
*/
|
|
19
|
+
export interface Channel<T> {
|
|
20
|
+
/** Single-consumer stream view of the buffer; wrap with `shareStream` to broadcast. */
|
|
21
|
+
readonly stream: Stream<T>;
|
|
22
|
+
/** Buffer an item (or hand it to a waiting consumer). No-op once closed. */
|
|
23
|
+
push(item: T): void;
|
|
24
|
+
/** End the stream: consumers finish after draining the buffer. */
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function channel<T>(): Channel<T> {
|
|
29
|
+
const buffer: T[] = [];
|
|
30
|
+
let waiting: ((result: IteratorResult<T>) => void) | null = null;
|
|
31
|
+
let closed = false;
|
|
32
|
+
return {
|
|
33
|
+
stream: {
|
|
34
|
+
[Symbol.asyncIterator]() {
|
|
35
|
+
return {
|
|
36
|
+
next(): Promise<IteratorResult<T>> {
|
|
37
|
+
if (buffer.length > 0)
|
|
38
|
+
return Promise.resolve({ value: buffer.shift() as T, done: false });
|
|
39
|
+
if (closed) return Promise.resolve({ value: undefined, done: true });
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
waiting = resolve;
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
push(item) {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
if (waiting) {
|
|
50
|
+
const resolve = waiting;
|
|
51
|
+
waiting = null;
|
|
52
|
+
resolve({ value: item, done: false });
|
|
53
|
+
} else {
|
|
54
|
+
buffer.push(item);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
close() {
|
|
58
|
+
closed = true;
|
|
59
|
+
if (waiting) {
|
|
60
|
+
const resolve = waiting;
|
|
61
|
+
waiting = null;
|
|
62
|
+
resolve({ value: undefined, done: true });
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Multicast a stream: every consumer gets every item, in order, at its own
|
|
70
|
+
* pace. Pulling starts when the first consumer attaches, pauses when the
|
|
71
|
+
* last one detaches (the source buffers), and resumes on the next attach.
|
|
72
|
+
* Consumers attaching mid-stream see items from that point on —
|
|
73
|
+
* subscription semantics, like an event bus listener. A source error ends
|
|
74
|
+
* the stream: waiting and future reads reject with it (after draining
|
|
75
|
+
* what was already buffered), so consumers like `pumpStream` can forward
|
|
76
|
+
* it instead of hanging.
|
|
77
|
+
*/
|
|
78
|
+
export function shareStream<T>(stream: Stream<T>): Stream<T> {
|
|
79
|
+
interface Consumer {
|
|
80
|
+
buffer: T[];
|
|
81
|
+
waiting: {
|
|
82
|
+
resolve: (result: IteratorResult<T>) => void;
|
|
83
|
+
reject: (error: unknown) => void;
|
|
84
|
+
} | null;
|
|
85
|
+
}
|
|
86
|
+
const consumers = new Set<Consumer>();
|
|
87
|
+
let closed = false;
|
|
88
|
+
let failed = false;
|
|
89
|
+
let failure: unknown;
|
|
90
|
+
let pulling = false;
|
|
91
|
+
let iterator: AsyncIterator<T> | undefined;
|
|
92
|
+
let held: { value: T } | undefined;
|
|
93
|
+
|
|
94
|
+
const push = (item: T): void => {
|
|
95
|
+
for (const consumer of consumers) {
|
|
96
|
+
if (consumer.waiting) {
|
|
97
|
+
const { resolve } = consumer.waiting;
|
|
98
|
+
consumer.waiting = null;
|
|
99
|
+
resolve({ value: item, done: false });
|
|
100
|
+
} else {
|
|
101
|
+
consumer.buffer.push(item);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const pull = (): void => {
|
|
107
|
+
if (pulling || closed) return;
|
|
108
|
+
pulling = true;
|
|
109
|
+
void (async () => {
|
|
110
|
+
try {
|
|
111
|
+
// One iterator for the stream's lifetime: pausing via `break`
|
|
112
|
+
// out of `for await` would finish generator-backed sources
|
|
113
|
+
// permanently, so they could never resume.
|
|
114
|
+
iterator ??= stream[Symbol.asyncIterator]();
|
|
115
|
+
const source = iterator;
|
|
116
|
+
// Deliver an item pulled while the last consumer detached.
|
|
117
|
+
if (held) {
|
|
118
|
+
push(held.value);
|
|
119
|
+
held = undefined;
|
|
120
|
+
}
|
|
121
|
+
let sourceDone = false;
|
|
122
|
+
// Check before pulling: with nobody listening, pause (the
|
|
123
|
+
// source buffers) without consuming an item; the next attach
|
|
124
|
+
// resumes this same iterator.
|
|
125
|
+
while (consumers.size > 0) {
|
|
126
|
+
const result = await source.next();
|
|
127
|
+
if (result.done) {
|
|
128
|
+
sourceDone = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
// The last consumer detached while this pull was in
|
|
132
|
+
// flight: hold the item instead of dropping it.
|
|
133
|
+
if (consumers.size === 0) {
|
|
134
|
+
held = { value: result.value };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
push(result.value);
|
|
138
|
+
}
|
|
139
|
+
if (!sourceDone) return;
|
|
140
|
+
closed = true;
|
|
141
|
+
for (const consumer of consumers) {
|
|
142
|
+
if (consumer.waiting) {
|
|
143
|
+
const { resolve } = consumer.waiting;
|
|
144
|
+
consumer.waiting = null;
|
|
145
|
+
resolve({ value: undefined, done: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// A source error ends the stream: settle waiting consumers
|
|
150
|
+
// with the rejection and fail future next() calls (after
|
|
151
|
+
// they drain what was already buffered), so consumers like
|
|
152
|
+
// pumpStream see the error instead of hanging forever.
|
|
153
|
+
failed = true;
|
|
154
|
+
failure = error;
|
|
155
|
+
closed = true;
|
|
156
|
+
for (const consumer of consumers) {
|
|
157
|
+
if (consumer.waiting) {
|
|
158
|
+
const { reject } = consumer.waiting;
|
|
159
|
+
consumer.waiting = null;
|
|
160
|
+
reject(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
pulling = false;
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
[Symbol.asyncIterator]() {
|
|
171
|
+
const consumer: Consumer = { buffer: [], waiting: null };
|
|
172
|
+
consumers.add(consumer);
|
|
173
|
+
pull();
|
|
174
|
+
return {
|
|
175
|
+
next(): Promise<IteratorResult<T>> {
|
|
176
|
+
if (consumer.buffer.length > 0)
|
|
177
|
+
return Promise.resolve({
|
|
178
|
+
value: consumer.buffer.shift() as T,
|
|
179
|
+
done: false,
|
|
180
|
+
});
|
|
181
|
+
if (failed) return Promise.reject(failure);
|
|
182
|
+
if (closed) return Promise.resolve({ value: undefined, done: true });
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
consumer.waiting = { resolve, reject };
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
return(): Promise<IteratorResult<T>> {
|
|
188
|
+
consumers.delete(consumer);
|
|
189
|
+
if (consumer.waiting) {
|
|
190
|
+
const { resolve } = consumer.waiting;
|
|
191
|
+
consumer.waiting = null;
|
|
192
|
+
resolve({ value: undefined, done: true });
|
|
193
|
+
}
|
|
194
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Transform each item; the mapper may be async (items stay sequential). */
|
|
202
|
+
export function mapStream<T, U>(stream: Stream<T>, map: (item: T) => U | Promise<U>): Stream<U> {
|
|
203
|
+
async function* mapped(): AsyncGenerator<U> {
|
|
204
|
+
for await (const item of stream) yield await map(item);
|
|
205
|
+
}
|
|
206
|
+
return shareStream(mapped());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Keep only matching items. The type-guard form narrows the item type. */
|
|
210
|
+
export function filterStream<T, U extends T>(
|
|
211
|
+
stream: Stream<T>,
|
|
212
|
+
predicate: (item: T) => item is U,
|
|
213
|
+
): Stream<U>;
|
|
214
|
+
export function filterStream<T>(
|
|
215
|
+
stream: Stream<T>,
|
|
216
|
+
predicate: (item: T) => boolean | Promise<boolean>,
|
|
217
|
+
): Stream<T>;
|
|
218
|
+
export function filterStream<T>(
|
|
219
|
+
stream: Stream<T>,
|
|
220
|
+
predicate: (item: T) => boolean | Promise<boolean>,
|
|
221
|
+
): Stream<T> {
|
|
222
|
+
async function* filtered(): AsyncGenerator<T> {
|
|
223
|
+
for await (const item of stream) if (await predicate(item)) yield item;
|
|
224
|
+
}
|
|
225
|
+
return shareStream(filtered());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Interleave several streams into one, in arrival order. */
|
|
229
|
+
export function mergeStreams<T>(...streams: readonly Stream<T>[]): Stream<T> {
|
|
230
|
+
async function* merged(): AsyncGenerator<T> {
|
|
231
|
+
interface Indexed {
|
|
232
|
+
index: number;
|
|
233
|
+
result: IteratorResult<T>;
|
|
234
|
+
}
|
|
235
|
+
const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
|
|
236
|
+
const pending = new Map<number, Promise<Indexed>>();
|
|
237
|
+
const arm = (index: number): void => {
|
|
238
|
+
const iterator = iterators[index];
|
|
239
|
+
if (!iterator) return;
|
|
240
|
+
pending.set(
|
|
241
|
+
index,
|
|
242
|
+
iterator.next().then((result) => ({ index, result })),
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
for (let index = 0; index < iterators.length; index++) arm(index);
|
|
246
|
+
while (pending.size > 0) {
|
|
247
|
+
const { index, result } = await Promise.race(pending.values());
|
|
248
|
+
pending.delete(index);
|
|
249
|
+
if (result.done) continue;
|
|
250
|
+
arm(index);
|
|
251
|
+
yield result.value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return shareStream(merged());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Consume a stream in the background, sequentially. Errors go to `onError`.
|
|
259
|
+
* The returned disposer detaches after the in-flight item; with every
|
|
260
|
+
* consumer detached, the stream pauses until one attaches again.
|
|
261
|
+
*/
|
|
262
|
+
export function pumpStream<T>(
|
|
263
|
+
stream: Stream<T>,
|
|
264
|
+
consume: (item: T) => void | Promise<void>,
|
|
265
|
+
onError: (error: unknown) => void,
|
|
266
|
+
): Disposer {
|
|
267
|
+
let stopped = false;
|
|
268
|
+
void (async () => {
|
|
269
|
+
try {
|
|
270
|
+
for await (const item of stream) {
|
|
271
|
+
if (stopped) return;
|
|
272
|
+
await consume(item);
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (!stopped) onError(error);
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
return () => {
|
|
279
|
+
stopped = true;
|
|
280
|
+
};
|
|
281
|
+
}
|
package/src/context.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
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/events.ts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|