@nanobpm/urban 0.52.0 → 0.54.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/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +666 -35
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +396 -5
- package/src/runtime/core/modules/pages.ts +667 -35
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- 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
|
-
**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
225
|
-
decisions/ route.dmn
|
|
226
|
-
forms/ approve.form
|
|
227
|
-
prompts/ review.md
|
|
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.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`
|
|
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`
|
|
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
|
+
}
|