@nanobpm/urban 0.52.0 → 0.53.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.
Files changed (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +397 -20
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +309 -5
  41. package/src/runtime/core/modules/pages.ts +398 -20
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
package/README.md CHANGED
@@ -214,31 +214,57 @@ so this works out of the box.
214
214
 
215
215
  Deployables are discovered by convention: with **no `models`** block in the
216
216
  manifest, `@nanobpm/urban` deploys everything under `resources/` — walked
217
- **shallow, one level deep** (`resources/*` and `resources/<subdir>/*`, never
218
- deeper). Content type is by extension: `.bpmn`/`.dmn` → BPMN/DMN, `.form` →
219
- form-js form, anything else → a `GenericScript` resource (this is how agent
220
- prompts are deployed — see below).
217
+ **recursively**, every file at any depth. Content type is inferred by extension:
218
+ `.bpmn`/`.dmn` → BPMN/DMN, `.form` → form-js form, `.md` → `text/markdown`,
219
+ `.json` → `application/json`, `.txt` → `text/plain`, and anything else → an
220
+ `application/octet-stream` generic resource. Non-model files (`.md`, `.txt`,
221
+ `.json`, unknown) deploy as **generic resources** (this is how agent prompts,
222
+ RPA/script files, etc. are deployed — see below).
221
223
 
222
224
  ```
223
225
  resources/
224
- processes/ order.bpmn ← deployed as a BPMN process
225
- decisions/ route.dmn ← deployed as a DMN decision
226
- forms/ approve.form ← deployed as a form
227
- prompts/ review.md ← deployed as a GenericScript (an agent prompt)
226
+ processes/ order.bpmn ← deployed as a BPMN process
227
+ decisions/ route.dmn ← deployed as a DMN decision
228
+ forms/ approve.form ← deployed as a form
229
+ prompts/ review.md ← generic resource, resourceId "prompts/review.md"
230
+ prompts/pr/ summarize.md ← generic resource, resourceId "prompts/pr/summarize.md"
228
231
  ```
229
232
 
230
233
  `resources/` is **deploy-only**: everything under it deploys, and nothing outside
231
234
  it ever does — so docs (`docs/`, `AGENTS.md`, top-level `*.md`) live **outside**
232
- `resources/` and are never swept into a deployment. Resources are keyed by their
233
- **basename** (filename), so two files with the same name in different sub-dirs are
234
- a **hard error** — rename or relocate one. `urban gen` follows the same
235
- convention: with no `models`, its `nano:shape`/code-first model scan defaults to a
236
- **shallow** `resources/` walk — `.bpmn`/`.dmn` directly under `resources/` and one
237
- level down in `resources/<subdir>/`, never deeper — and derived models are written to
238
- `resources/processes/`, exactly where the convention deploy then finds them.
235
+ `resources/` and are never swept into a deployment.
236
+
237
+ A convention resource's **`resourceId` is its path relative to `resources/`**
238
+ (POSIX-normalised), *including the extension* — `resources/prompts/review.md` →
239
+ `prompts/review.md`. Using the relative path (not the bare filename) preserves
240
+ sub-directory structure, so `resources/a/x.md` and `resources/b/x.md` deploy as
241
+ two **distinct** resources (`a/x.md`, `b/x.md`) rather than colliding. A service
242
+ task links a generic resource by that exact id:
243
+
244
+ ```xml
245
+ <zeebe:linkedResource linkName="prompt" resourceType="GenericScript"
246
+ bindingType="latest" resourceId="prompts/review.md" />
247
+ ```
248
+
249
+ Content is deployed **verbatim** — there is no deploy-time `{{token}}`
250
+ substitution (removed in ADR 0062), so a prompt/script file's text is deployed
251
+ as-is. The deploy pipeline is **UTF-8 text only**, end-to-end (`host.readTextFile()`
252
+ → `content: string`), so "verbatim" means the UTF-8 text is passed through
253
+ unchanged — not a promise of byte-for-byte *binary* fidelity; a non-UTF-8/binary
254
+ file swept into `resources/` is not a supported input (`application/octet-stream`
255
+ is only a conservative MIME label for an unrecognised *text* resource).
256
+ Re-deploying an **unchanged** file is a no-op (the engine's
257
+ name+checksum duplicate rule skips it — no version bump); **changing** its
258
+ content deploys a **new version** and the `bindingType:latest` pointer advances,
259
+ so a running process picks up the new prompt on its next job activation with **no
260
+ model redeploy**. `urban gen` follows the same convention: with no `models`, its
261
+ `nano:shape`/code-first model scan also walks `resources/` recursively for
262
+ `.bpmn`/`.dmn`, and derived models are written to `resources/processes/`, exactly
263
+ where the convention deploy then finds them.
239
264
 
240
265
  To opt out of the convention, declare `models` globs — they are used verbatim and
241
- the `resources/` walk is skipped:
266
+ the `resources/` walk is skipped (override resources are keyed by basename, so a
267
+ basename collision across the declared globs is a hard error):
242
268
 
243
269
  ```jsonc
244
270
  {
@@ -253,13 +279,14 @@ the `resources/` walk is skipped:
253
279
 
254
280
  To keep a large agent prompt out of a model's XML, author it as its own file under
255
281
  `resources/prompts/` (deployed as a `GenericScript`) and **link** it into the
256
- service task with a `zeebe:linkedResource` bound to the latest deployed version;
257
- the runtime resolves it with the `appendPrompt` FEEL helper at execution time:
282
+ service task with a `zeebe:linkedResource` bound to the latest deployed version by
283
+ its relative-path `resourceId`; the runtime resolves it with the `appendPrompt`
284
+ FEEL helper at execution time:
258
285
 
259
286
  ```xml
260
- <!-- resources/processes/agent.bpmn -->
287
+ <!-- resources/processes/agent.bpmn, linking resources/prompts/review.md -->
261
288
  <zeebe:linkedResource linkName="prompt" resourceType="GenericScript"
262
- bindingType="latest" resourceId="review" />
289
+ bindingType="latest" resourceId="prompts/review.md" />
263
290
  ```
264
291
 
265
292
  This is the **only** supported prompt-modularity mechanism — there is no
@@ -0,0 +1,135 @@
1
+ /** Disposes a single registration (idempotent — safe to call more than once). */
2
+ export type Disposer = () => void;
3
+ /** The deliberate dispatch semantics a channel declares. */
4
+ export type DispatchMode = "waterfall" | "serial" | "parallel" | "emit";
5
+ /** A waterfall listener: around-middleware over `next`. Call `next(value)` to
6
+ * continue the chain (optionally transforming `value` first), return without
7
+ * calling it to short-circuit, or wrap `next(...)` in try/catch to recover. */
8
+ export type Middleware<T, R> = (value: T, next: (value: T) => Promise<R>) => R | Promise<R>;
9
+ /** A serial/parallel/emit listener: observes (and may act on) the payload. Typed
10
+ * to return `void` (the DOM/Node listener convention): the void-return position
11
+ * lets both an `async` handler (its `Promise<void>` is awaited by serial/parallel
12
+ * dispatch) and a value-returning one-liner register without friction. */
13
+ export type Listener<T> = (payload: T) => void;
14
+ /** Where contained listener throws are reported. */
15
+ export type ErrorSink = (err: unknown, info: {
16
+ event: string;
17
+ mode: DispatchMode;
18
+ }) => void;
19
+ export interface EventBusOptions {
20
+ /** Reports a contained listener throw. Defaults to `console.warn`. The runtime
21
+ * injects `host.log("warn", …)` so contained throws land in structured logs. */
22
+ onError?: ErrorSink;
23
+ }
24
+ /** An ordered set of listeners plus the ladder bookkeeping shared by channels. A
25
+ * channel owns one of these; the bus owns the global dispose ladder that every
26
+ * channel pushes its per-listener disposers onto. */
27
+ declare class ChannelBase<F> {
28
+ protected readonly listeners: Map<F, Disposer>;
29
+ readonly event: string;
30
+ readonly mode: DispatchMode;
31
+ private readonly ladder;
32
+ protected readonly onError: ErrorSink;
33
+ constructor(event: string, mode: DispatchMode, ladder: Set<Disposer>, onError: ErrorSink);
34
+ /** Register `listener` as an effect: it joins this channel AND the bus dispose
35
+ * ladder. The returned disposer removes it from both and is idempotent.
36
+ * Registration is itself idempotent per listener — registering the same
37
+ * function twice coalesces to one ladder entry and hands back the *same*
38
+ * disposer, so `listenerCount` never drifts from the actual listener set and
39
+ * disposing one registration can't silently strand a phantom ladder entry. */
40
+ protected register(listener: F): Disposer;
41
+ /** Contain a listener throw at the pipeline boundary. */
42
+ protected contain(err: unknown): void;
43
+ /** How many listeners are currently registered (for leak assertions). */
44
+ get size(): number;
45
+ }
46
+ /** **emit** — synchronous fire-and-forget notifications. */
47
+ export declare class EmitChannel<T> extends ChannelBase<Listener<T>> {
48
+ on(listener: Listener<T>): Disposer;
49
+ /** Notify every listener synchronously; a throw is contained, the rest still
50
+ * run. A listener may return a promise, but `emit` does not await it — its
51
+ * rejection is still contained (fire-and-forget `.catch`) so an `async`
52
+ * listener can never surface as an unhandled rejection. Listeners are
53
+ * snapshotted at dispatch start (as in `parallel`/`waterfall`), so a listener
54
+ * registered mid-emission is not observed until the next `emit`. */
55
+ emit(payload: T): void;
56
+ }
57
+ /** **serial** — listeners awaited in registration order (ordered checkpoint). */
58
+ export declare class SerialChannel<T> extends ChannelBase<Listener<T>> {
59
+ on(listener: Listener<T>): Disposer;
60
+ /** Run every listener in order, awaiting each. A throw is contained and the
61
+ * next listener still runs — one failing checkpoint never strands the rest.
62
+ * Listeners are snapshotted at dispatch start (as in `parallel`/`waterfall`),
63
+ * so a listener registered mid-checkpoint is not observed until the next
64
+ * `run`. */
65
+ run(payload: T): Promise<void>;
66
+ }
67
+ /** **parallel** — fan-out where every listener gets an independent chance. */
68
+ export declare class ParallelChannel<T> extends ChannelBase<Listener<T>> {
69
+ on(listener: Listener<T>): Disposer;
70
+ /** Run every listener concurrently and await them all. Each is independently
71
+ * contained, so one throw never denies another listener its turn (cf. the
72
+ * `session/flush` durability checkpoint). */
73
+ run(payload: T): Promise<void>;
74
+ }
75
+ /** **waterfall** — around-middleware chain (transform / short-circuit / recover). */
76
+ export declare class WaterfallChannel<T, R> extends ChannelBase<Middleware<T, R>> {
77
+ on(middleware: Middleware<T, R>): Disposer;
78
+ /** Compose the middlewares around `base`, outermost-first in registration
79
+ * order. A middleware that never calls `next` short-circuits the chain; one
80
+ * that throws *without* producing a result is contained and delegated past
81
+ * (the chain continues with the unchanged value) so it never strands the
82
+ * pipeline. */
83
+ run(seed: T, base: (value: T) => R | Promise<R>): Promise<R>;
84
+ }
85
+ /** The microkernel: a factory of typed channels sharing one dispose ladder. Each
86
+ * channel is created directly (and typed at its call site), so the bus never has
87
+ * to store — or cast back — a heterogeneous channel collection. Declare each seam
88
+ * exactly once and hold the returned typed reference (see the Urban taxonomy in
89
+ * `extensions.ts`); the bus guards against a name being declared twice under a
90
+ * different mode. */
91
+ export declare class EventBus {
92
+ private readonly ladder;
93
+ private readonly declared;
94
+ private readonly onError;
95
+ constructor(options?: EventBusOptions);
96
+ private reserve;
97
+ /** Declare an **emit** channel. */
98
+ emit<T>(event: string): EmitChannel<T>;
99
+ /** Declare a **serial** channel. */
100
+ serial<T>(event: string): SerialChannel<T>;
101
+ /** Declare a **parallel** channel. */
102
+ parallel<T>(event: string): ParallelChannel<T>;
103
+ /** Declare a **waterfall** channel. */
104
+ waterfall<T, R>(event: string): WaterfallChannel<T, R>;
105
+ /** Open a rollback point over the dispose ladder: returns a disposer that unwinds
106
+ * every registration made *after* this call (LIFO), leaving earlier ones intact.
107
+ * It makes a unit of work atomic — e.g. one extension's `setup()` — so a throw
108
+ * after partial registration can roll back exactly what that unit added and
109
+ * nothing else. Contained end-to-end, like `dispose()`; the ladder entries it
110
+ * runs are idempotent, so a later `dispose()` won't double-run them. Keeps
111
+ * unwinding until no registration made after the scope point remains, so an
112
+ * effect a rolled-back disposer registers *during* rollback is still torn down
113
+ * rather than stranded live (mirrors `dispose()`). */
114
+ scope(): Disposer;
115
+ /** Register an arbitrary disposable effect (a timer, a subscription, a
116
+ * registration made elsewhere) directly onto the dispose ladder, so
117
+ * `dispose()` unwinds it with everything else — the dispose-ladder contract is
118
+ * not limited to channel listeners. Returns an idempotent disposer that detaches
119
+ * it (and runs the cleanup) early; a throw from the cleanup is contained. */
120
+ effect(dispose: Disposer): Disposer;
121
+ /** Total live registrations on the dispose ladder — every channel listener AND
122
+ * every `effect()` (arbitrary disposable), not only channel listeners. This is
123
+ * the leak-assertion metric: it reads 0 exactly when the ladder is fully
124
+ * unwound. */
125
+ get listenerCount(): number;
126
+ /** Unwind the whole dispose ladder LIFO: every registration made through this
127
+ * bus is torn down. Contained end-to-end, so one bad disposer can't strand the
128
+ * rest. Idempotent — a second call is a no-op. This is what makes a
129
+ * `start → stop → start` cycle (and dev-server HMR) leak-free. Keeps unwinding
130
+ * until the ladder is empty, so an effect a disposer registers *during*
131
+ * teardown is still run rather than silently dropped. */
132
+ dispose(): void;
133
+ private contain;
134
+ }
135
+ export {};
@@ -0,0 +1,329 @@
1
+ // events — the Urban extension-event microkernel: a tiny, defensive, typed event
2
+ // bus whose extension points are *typed channels* with a **deliberate dispatch
3
+ // mode**. Cribbed from the DeepSeek Harness Cordis microkernel taxonomy (issue
4
+ // #262): instead of wiring modules directly into `runtime.ts`, a seam is a named
5
+ // channel, and *how* its listeners run is a property of the channel, not of each
6
+ // call site.
7
+ //
8
+ // Four dispatch modes, each for a different kind of seam:
9
+ //
10
+ // - **waterfall** (around-middleware): a listener wraps `next`, so it can
11
+ // transform the value, short-circuit (never call `next`), recover (catch a
12
+ // downstream throw), or wrap the result. For policy / permission / transform
13
+ // seams.
14
+ // - **serial**: listeners awaited in registration order — for ordered
15
+ // checkpoints (e.g. extension registration).
16
+ // - **parallel**: listeners awaited as a fan-out — every listener gets an
17
+ // *independent* chance to run (one throw never denies another its turn).
18
+ // - **emit**: synchronous fire-and-forget — for notifications (lifecycle,
19
+ // errors, observations).
20
+ //
21
+ // Two contracts make this HMR- and disposal-friendly (the payoff the issue steals
22
+ // from `cordis`):
23
+ //
24
+ // 1. **Defensive dispatch.** A listener exception is *contained* at the pipeline
25
+ // boundary: the bus warns via `onError` and the pipeline continues — a
26
+ // throwing extension never strands app boot or a request.
27
+ // 2. **Dispose ladder.** Every registration is an *effect*: `on()` returns a
28
+ // `Disposer` AND is pushed onto the bus's ladder. `bus.dispose()` unwinds the
29
+ // whole ladder LIFO, so a `start → stop → start` cycle (or a dev-server HMR
30
+ // reload) leaks no listeners.
31
+ //
32
+ // This file is runtime-agnostic (core purity): it takes an injected `onError`
33
+ // sink so the host's structured logger, not `console`, reports contained throws.
34
+ const defaultErrorSink = (err, info) => {
35
+ console.warn(`[urban events] listener for "${info.event}" (${info.mode}) threw and was contained`, err);
36
+ };
37
+ /** Whether a value is a thenable (any promise-like, including non-native / cross-realm
38
+ * promises that are not `instanceof Promise`). `Promise.resolve` adopts such a value,
39
+ * so routing it through `Promise.resolve(...).catch(...)` contains its rejection. */
40
+ function isThenable(value) {
41
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
42
+ }
43
+ /** An ordered set of listeners plus the ladder bookkeeping shared by channels. A
44
+ * channel owns one of these; the bus owns the global dispose ladder that every
45
+ * channel pushes its per-listener disposers onto. */
46
+ class ChannelBase {
47
+ listeners = new Map();
48
+ event;
49
+ mode;
50
+ ladder;
51
+ onError;
52
+ constructor(event, mode, ladder, onError) {
53
+ this.event = event;
54
+ this.mode = mode;
55
+ this.ladder = ladder;
56
+ this.onError = onError;
57
+ }
58
+ /** Register `listener` as an effect: it joins this channel AND the bus dispose
59
+ * ladder. The returned disposer removes it from both and is idempotent.
60
+ * Registration is itself idempotent per listener — registering the same
61
+ * function twice coalesces to one ladder entry and hands back the *same*
62
+ * disposer, so `listenerCount` never drifts from the actual listener set and
63
+ * disposing one registration can't silently strand a phantom ladder entry. */
64
+ register(listener) {
65
+ const existing = this.listeners.get(listener);
66
+ if (existing !== undefined)
67
+ return existing;
68
+ let disposed = false;
69
+ const dispose = () => {
70
+ if (disposed)
71
+ return;
72
+ disposed = true;
73
+ this.listeners.delete(listener);
74
+ this.ladder.delete(dispose);
75
+ };
76
+ this.listeners.set(listener, dispose);
77
+ this.ladder.add(dispose);
78
+ return dispose;
79
+ }
80
+ /** Contain a listener throw at the pipeline boundary. */
81
+ contain(err) {
82
+ try {
83
+ this.onError(err, { event: this.event, mode: this.mode });
84
+ }
85
+ catch {
86
+ // The error sink itself must never strand the pipeline.
87
+ }
88
+ }
89
+ /** How many listeners are currently registered (for leak assertions). */
90
+ get size() {
91
+ return this.listeners.size;
92
+ }
93
+ }
94
+ /** **emit** — synchronous fire-and-forget notifications. */
95
+ export class EmitChannel extends ChannelBase {
96
+ on(listener) {
97
+ return this.register(listener);
98
+ }
99
+ /** Notify every listener synchronously; a throw is contained, the rest still
100
+ * run. A listener may return a promise, but `emit` does not await it — its
101
+ * rejection is still contained (fire-and-forget `.catch`) so an `async`
102
+ * listener can never surface as an unhandled rejection. Listeners are
103
+ * snapshotted at dispatch start (as in `parallel`/`waterfall`), so a listener
104
+ * registered mid-emission is not observed until the next `emit`. */
105
+ emit(payload) {
106
+ for (const listener of [...this.listeners.keys()]) {
107
+ try {
108
+ const result = listener(payload);
109
+ if (isThenable(result)) {
110
+ // `Promise.resolve` returns a native Promise unchanged and adopts any other
111
+ // thenable, so a non-native promise's rejection is contained too.
112
+ Promise.resolve(result).catch((err) => {
113
+ this.contain(err);
114
+ });
115
+ }
116
+ }
117
+ catch (err) {
118
+ this.contain(err);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ /** **serial** — listeners awaited in registration order (ordered checkpoint). */
124
+ export class SerialChannel extends ChannelBase {
125
+ on(listener) {
126
+ return this.register(listener);
127
+ }
128
+ /** Run every listener in order, awaiting each. A throw is contained and the
129
+ * next listener still runs — one failing checkpoint never strands the rest.
130
+ * Listeners are snapshotted at dispatch start (as in `parallel`/`waterfall`),
131
+ * so a listener registered mid-checkpoint is not observed until the next
132
+ * `run`. */
133
+ async run(payload) {
134
+ for (const listener of [...this.listeners.keys()]) {
135
+ try {
136
+ await listener(payload);
137
+ }
138
+ catch (err) {
139
+ this.contain(err);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ /** **parallel** — fan-out where every listener gets an independent chance. */
145
+ export class ParallelChannel extends ChannelBase {
146
+ on(listener) {
147
+ return this.register(listener);
148
+ }
149
+ /** Run every listener concurrently and await them all. Each is independently
150
+ * contained, so one throw never denies another listener its turn (cf. the
151
+ * `session/flush` durability checkpoint). */
152
+ async run(payload) {
153
+ await Promise.all([...this.listeners.keys()].map(async (listener) => {
154
+ try {
155
+ await listener(payload);
156
+ }
157
+ catch (err) {
158
+ this.contain(err);
159
+ }
160
+ }));
161
+ }
162
+ }
163
+ /** **waterfall** — around-middleware chain (transform / short-circuit / recover). */
164
+ export class WaterfallChannel extends ChannelBase {
165
+ on(middleware) {
166
+ return this.register(middleware);
167
+ }
168
+ /** Compose the middlewares around `base`, outermost-first in registration
169
+ * order. A middleware that never calls `next` short-circuits the chain; one
170
+ * that throws *without* producing a result is contained and delegated past
171
+ * (the chain continues with the unchanged value) so it never strands the
172
+ * pipeline. */
173
+ async run(seed, base) {
174
+ const middlewares = [...this.listeners.keys()];
175
+ const dispatch = async (index, value) => {
176
+ if (index >= middlewares.length)
177
+ return base(value);
178
+ const middleware = middlewares[index];
179
+ let advanced;
180
+ const next = (forwarded) => {
181
+ // Advance the chain at most once per middleware invocation: a middleware
182
+ // that calls next() again gets the same downstream promise, so downstream
183
+ // side effects never run twice and the first promise is never orphaned.
184
+ if (advanced === undefined)
185
+ advanced = dispatch(index + 1, forwarded);
186
+ return advanced;
187
+ };
188
+ try {
189
+ return await middleware(value, next);
190
+ }
191
+ catch (err) {
192
+ this.contain(err);
193
+ // Delegate: honor the downstream result if the middleware already
194
+ // advanced the chain, else continue past it with the unchanged value.
195
+ return advanced !== undefined ? advanced : dispatch(index + 1, value);
196
+ }
197
+ };
198
+ return dispatch(0, seed);
199
+ }
200
+ }
201
+ /** The microkernel: a factory of typed channels sharing one dispose ladder. Each
202
+ * channel is created directly (and typed at its call site), so the bus never has
203
+ * to store — or cast back — a heterogeneous channel collection. Declare each seam
204
+ * exactly once and hold the returned typed reference (see the Urban taxonomy in
205
+ * `extensions.ts`); the bus guards against a name being declared twice under a
206
+ * different mode. */
207
+ export class EventBus {
208
+ ladder = new Set();
209
+ declared = new Map();
210
+ onError;
211
+ constructor(options = {}) {
212
+ this.onError = options.onError ?? defaultErrorSink;
213
+ }
214
+ reserve(event, mode) {
215
+ const existing = this.declared.get(event);
216
+ if (existing !== undefined) {
217
+ throw new Error(existing === mode
218
+ ? `event "${event}" already declared; hold the channel returned by the first declaration`
219
+ : `event "${event}" already declared as "${existing}"; cannot redeclare as "${mode}"`);
220
+ }
221
+ this.declared.set(event, mode);
222
+ }
223
+ /** Declare an **emit** channel. */
224
+ emit(event) {
225
+ this.reserve(event, "emit");
226
+ return new EmitChannel(event, "emit", this.ladder, this.onError);
227
+ }
228
+ /** Declare a **serial** channel. */
229
+ serial(event) {
230
+ this.reserve(event, "serial");
231
+ return new SerialChannel(event, "serial", this.ladder, this.onError);
232
+ }
233
+ /** Declare a **parallel** channel. */
234
+ parallel(event) {
235
+ this.reserve(event, "parallel");
236
+ return new ParallelChannel(event, "parallel", this.ladder, this.onError);
237
+ }
238
+ /** Declare a **waterfall** channel. */
239
+ waterfall(event) {
240
+ this.reserve(event, "waterfall");
241
+ return new WaterfallChannel(event, "waterfall", this.ladder, this.onError);
242
+ }
243
+ /** Open a rollback point over the dispose ladder: returns a disposer that unwinds
244
+ * every registration made *after* this call (LIFO), leaving earlier ones intact.
245
+ * It makes a unit of work atomic — e.g. one extension's `setup()` — so a throw
246
+ * after partial registration can roll back exactly what that unit added and
247
+ * nothing else. Contained end-to-end, like `dispose()`; the ladder entries it
248
+ * runs are idempotent, so a later `dispose()` won't double-run them. Keeps
249
+ * unwinding until no registration made after the scope point remains, so an
250
+ * effect a rolled-back disposer registers *during* rollback is still torn down
251
+ * rather than stranded live (mirrors `dispose()`). */
252
+ scope() {
253
+ const before = new Set(this.ladder);
254
+ return () => {
255
+ for (;;) {
256
+ let entry;
257
+ for (const candidate of this.ladder) {
258
+ if (!before.has(candidate))
259
+ entry = candidate;
260
+ }
261
+ if (entry === undefined)
262
+ return;
263
+ this.ladder.delete(entry);
264
+ try {
265
+ entry();
266
+ }
267
+ catch (err) {
268
+ this.contain(err);
269
+ }
270
+ }
271
+ };
272
+ }
273
+ /** Register an arbitrary disposable effect (a timer, a subscription, a
274
+ * registration made elsewhere) directly onto the dispose ladder, so
275
+ * `dispose()` unwinds it with everything else — the dispose-ladder contract is
276
+ * not limited to channel listeners. Returns an idempotent disposer that detaches
277
+ * it (and runs the cleanup) early; a throw from the cleanup is contained. */
278
+ effect(dispose) {
279
+ let disposed = false;
280
+ const entry = () => {
281
+ if (disposed)
282
+ return;
283
+ disposed = true;
284
+ this.ladder.delete(entry);
285
+ try {
286
+ dispose();
287
+ }
288
+ catch (err) {
289
+ this.contain(err);
290
+ }
291
+ };
292
+ this.ladder.add(entry);
293
+ return entry;
294
+ }
295
+ /** Total live registrations on the dispose ladder — every channel listener AND
296
+ * every `effect()` (arbitrary disposable), not only channel listeners. This is
297
+ * the leak-assertion metric: it reads 0 exactly when the ladder is fully
298
+ * unwound. */
299
+ get listenerCount() {
300
+ return this.ladder.size;
301
+ }
302
+ /** Unwind the whole dispose ladder LIFO: every registration made through this
303
+ * bus is torn down. Contained end-to-end, so one bad disposer can't strand the
304
+ * rest. Idempotent — a second call is a no-op. This is what makes a
305
+ * `start → stop → start` cycle (and dev-server HMR) leak-free. Keeps unwinding
306
+ * until the ladder is empty, so an effect a disposer registers *during*
307
+ * teardown is still run rather than silently dropped. */
308
+ dispose() {
309
+ while (this.ladder.size > 0) {
310
+ const remaining = [...this.ladder];
311
+ const dispose = remaining[remaining.length - 1];
312
+ this.ladder.delete(dispose);
313
+ try {
314
+ dispose();
315
+ }
316
+ catch (err) {
317
+ this.contain(err);
318
+ }
319
+ }
320
+ }
321
+ contain(err) {
322
+ try {
323
+ this.onError(err, { event: "(bus.dispose)", mode: "emit" });
324
+ }
325
+ catch {
326
+ // never strand teardown
327
+ }
328
+ }
329
+ }