@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/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
- }