@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.
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 +666 -35
  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 +396 -5
  41. package/src/runtime/core/modules/pages.ts +667 -35
  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
@@ -0,0 +1,370 @@
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
+
35
+ /** Disposes a single registration (idempotent — safe to call more than once). */
36
+ export type Disposer = () => void;
37
+
38
+ /** The deliberate dispatch semantics a channel declares. */
39
+ export type DispatchMode = "waterfall" | "serial" | "parallel" | "emit";
40
+
41
+ /** A waterfall listener: around-middleware over `next`. Call `next(value)` to
42
+ * continue the chain (optionally transforming `value` first), return without
43
+ * calling it to short-circuit, or wrap `next(...)` in try/catch to recover. */
44
+ export type Middleware<T, R> = (value: T, next: (value: T) => Promise<R>) => R | Promise<R>;
45
+
46
+ /** A serial/parallel/emit listener: observes (and may act on) the payload. Typed
47
+ * to return `void` (the DOM/Node listener convention): the void-return position
48
+ * lets both an `async` handler (its `Promise<void>` is awaited by serial/parallel
49
+ * dispatch) and a value-returning one-liner register without friction. */
50
+ export type Listener<T> = (payload: T) => void;
51
+
52
+ /** Where contained listener throws are reported. */
53
+ export type ErrorSink = (err: unknown, info: { event: string; mode: DispatchMode }) => void;
54
+
55
+ export interface EventBusOptions {
56
+ /** Reports a contained listener throw. Defaults to `console.warn`. The runtime
57
+ * injects `host.log("warn", …)` so contained throws land in structured logs. */
58
+ onError?: ErrorSink;
59
+ }
60
+
61
+ const defaultErrorSink: ErrorSink = (err, info) => {
62
+ console.warn(`[urban events] listener for "${info.event}" (${info.mode}) threw and was contained`, err);
63
+ };
64
+
65
+ /** Whether a value is a thenable (any promise-like, including non-native / cross-realm
66
+ * promises that are not `instanceof Promise`). `Promise.resolve` adopts such a value,
67
+ * so routing it through `Promise.resolve(...).catch(...)` contains its rejection. */
68
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
69
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
70
+ }
71
+
72
+ /** An ordered set of listeners plus the ladder bookkeeping shared by channels. A
73
+ * channel owns one of these; the bus owns the global dispose ladder that every
74
+ * channel pushes its per-listener disposers onto. */
75
+ class ChannelBase<F> {
76
+ protected readonly listeners = new Map<F, Disposer>();
77
+ readonly event: string;
78
+ readonly mode: DispatchMode;
79
+ private readonly ladder: Set<Disposer>;
80
+ protected readonly onError: ErrorSink;
81
+
82
+ constructor(event: string, mode: DispatchMode, ladder: Set<Disposer>, onError: ErrorSink) {
83
+ this.event = event;
84
+ this.mode = mode;
85
+ this.ladder = ladder;
86
+ this.onError = onError;
87
+ }
88
+
89
+ /** Register `listener` as an effect: it joins this channel AND the bus dispose
90
+ * ladder. The returned disposer removes it from both and is idempotent.
91
+ * Registration is itself idempotent per listener — registering the same
92
+ * function twice coalesces to one ladder entry and hands back the *same*
93
+ * disposer, so `listenerCount` never drifts from the actual listener set and
94
+ * disposing one registration can't silently strand a phantom ladder entry. */
95
+ protected register(listener: F): Disposer {
96
+ const existing = this.listeners.get(listener);
97
+ if (existing !== undefined) return existing;
98
+ let disposed = false;
99
+ const dispose: Disposer = () => {
100
+ if (disposed) return;
101
+ disposed = true;
102
+ this.listeners.delete(listener);
103
+ this.ladder.delete(dispose);
104
+ };
105
+ this.listeners.set(listener, dispose);
106
+ this.ladder.add(dispose);
107
+ return dispose;
108
+ }
109
+
110
+ /** Contain a listener throw at the pipeline boundary. */
111
+ protected contain(err: unknown): void {
112
+ try {
113
+ this.onError(err, { event: this.event, mode: this.mode });
114
+ } catch {
115
+ // The error sink itself must never strand the pipeline.
116
+ }
117
+ }
118
+
119
+ /** How many listeners are currently registered (for leak assertions). */
120
+ get size(): number {
121
+ return this.listeners.size;
122
+ }
123
+ }
124
+
125
+ /** **emit** — synchronous fire-and-forget notifications. */
126
+ export class EmitChannel<T> extends ChannelBase<Listener<T>> {
127
+ on(listener: Listener<T>): Disposer {
128
+ return this.register(listener);
129
+ }
130
+
131
+ /** Notify every listener synchronously; a throw is contained, the rest still
132
+ * run. A listener may return a promise, but `emit` does not await it — its
133
+ * rejection is still contained (fire-and-forget `.catch`) so an `async`
134
+ * listener can never surface as an unhandled rejection. Listeners are
135
+ * snapshotted at dispatch start (as in `parallel`/`waterfall`), so a listener
136
+ * registered mid-emission is not observed until the next `emit`. */
137
+ emit(payload: T): void {
138
+ for (const listener of [...this.listeners.keys()]) {
139
+ try {
140
+ const result: unknown = listener(payload);
141
+ if (isThenable(result)) {
142
+ // `Promise.resolve` returns a native Promise unchanged and adopts any other
143
+ // thenable, so a non-native promise's rejection is contained too.
144
+ Promise.resolve(result).catch((err: unknown) => {
145
+ this.contain(err);
146
+ });
147
+ }
148
+ } catch (err) {
149
+ this.contain(err);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ /** **serial** — listeners awaited in registration order (ordered checkpoint). */
156
+ export class SerialChannel<T> extends ChannelBase<Listener<T>> {
157
+ on(listener: Listener<T>): Disposer {
158
+ return this.register(listener);
159
+ }
160
+
161
+ /** Run every listener in order, awaiting each. A throw is contained and the
162
+ * next listener still runs — one failing checkpoint never strands the rest.
163
+ * Listeners are snapshotted at dispatch start (as in `parallel`/`waterfall`),
164
+ * so a listener registered mid-checkpoint is not observed until the next
165
+ * `run`. */
166
+ async run(payload: T): Promise<void> {
167
+ for (const listener of [...this.listeners.keys()]) {
168
+ try {
169
+ await listener(payload);
170
+ } catch (err) {
171
+ this.contain(err);
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /** **parallel** — fan-out where every listener gets an independent chance. */
178
+ export class ParallelChannel<T> extends ChannelBase<Listener<T>> {
179
+ on(listener: Listener<T>): Disposer {
180
+ return this.register(listener);
181
+ }
182
+
183
+ /** Run every listener concurrently and await them all. Each is independently
184
+ * contained, so one throw never denies another listener its turn (cf. the
185
+ * `session/flush` durability checkpoint). */
186
+ async run(payload: T): Promise<void> {
187
+ await Promise.all(
188
+ [...this.listeners.keys()].map(async (listener) => {
189
+ try {
190
+ await listener(payload);
191
+ } catch (err) {
192
+ this.contain(err);
193
+ }
194
+ }),
195
+ );
196
+ }
197
+ }
198
+
199
+ /** **waterfall** — around-middleware chain (transform / short-circuit / recover). */
200
+ export class WaterfallChannel<T, R> extends ChannelBase<Middleware<T, R>> {
201
+ on(middleware: Middleware<T, R>): Disposer {
202
+ return this.register(middleware);
203
+ }
204
+
205
+ /** Compose the middlewares around `base`, outermost-first in registration
206
+ * order. A middleware that never calls `next` short-circuits the chain; one
207
+ * that throws *without* producing a result is contained and delegated past
208
+ * (the chain continues with the unchanged value) so it never strands the
209
+ * pipeline. */
210
+ async run(seed: T, base: (value: T) => R | Promise<R>): Promise<R> {
211
+ const middlewares = [...this.listeners.keys()];
212
+ const dispatch = async (index: number, value: T): Promise<R> => {
213
+ if (index >= middlewares.length) return base(value);
214
+ const middleware = middlewares[index];
215
+ let advanced: Promise<R> | undefined;
216
+ const next = (forwarded: T): Promise<R> => {
217
+ // Advance the chain at most once per middleware invocation: a middleware
218
+ // that calls next() again gets the same downstream promise, so downstream
219
+ // side effects never run twice and the first promise is never orphaned.
220
+ if (advanced === undefined) advanced = dispatch(index + 1, forwarded);
221
+ return advanced;
222
+ };
223
+ try {
224
+ return await middleware(value, next);
225
+ } catch (err) {
226
+ this.contain(err);
227
+ // Delegate: honor the downstream result if the middleware already
228
+ // advanced the chain, else continue past it with the unchanged value.
229
+ return advanced !== undefined ? advanced : dispatch(index + 1, value);
230
+ }
231
+ };
232
+ return dispatch(0, seed);
233
+ }
234
+ }
235
+
236
+ /** The microkernel: a factory of typed channels sharing one dispose ladder. Each
237
+ * channel is created directly (and typed at its call site), so the bus never has
238
+ * to store — or cast back — a heterogeneous channel collection. Declare each seam
239
+ * exactly once and hold the returned typed reference (see the Urban taxonomy in
240
+ * `extensions.ts`); the bus guards against a name being declared twice under a
241
+ * different mode. */
242
+ export class EventBus {
243
+ private readonly ladder = new Set<Disposer>();
244
+ private readonly declared = new Map<string, DispatchMode>();
245
+ private readonly onError: ErrorSink;
246
+
247
+ constructor(options: EventBusOptions = {}) {
248
+ this.onError = options.onError ?? defaultErrorSink;
249
+ }
250
+
251
+ private reserve(event: string, mode: DispatchMode): void {
252
+ const existing = this.declared.get(event);
253
+ if (existing !== undefined) {
254
+ throw new Error(
255
+ existing === mode
256
+ ? `event "${event}" already declared; hold the channel returned by the first declaration`
257
+ : `event "${event}" already declared as "${existing}"; cannot redeclare as "${mode}"`,
258
+ );
259
+ }
260
+ this.declared.set(event, mode);
261
+ }
262
+
263
+ /** Declare an **emit** channel. */
264
+ emit<T>(event: string): EmitChannel<T> {
265
+ this.reserve(event, "emit");
266
+ return new EmitChannel<T>(event, "emit", this.ladder, this.onError);
267
+ }
268
+
269
+ /** Declare a **serial** channel. */
270
+ serial<T>(event: string): SerialChannel<T> {
271
+ this.reserve(event, "serial");
272
+ return new SerialChannel<T>(event, "serial", this.ladder, this.onError);
273
+ }
274
+
275
+ /** Declare a **parallel** channel. */
276
+ parallel<T>(event: string): ParallelChannel<T> {
277
+ this.reserve(event, "parallel");
278
+ return new ParallelChannel<T>(event, "parallel", this.ladder, this.onError);
279
+ }
280
+
281
+ /** Declare a **waterfall** channel. */
282
+ waterfall<T, R>(event: string): WaterfallChannel<T, R> {
283
+ this.reserve(event, "waterfall");
284
+ return new WaterfallChannel<T, R>(event, "waterfall", this.ladder, this.onError);
285
+ }
286
+
287
+ /** Open a rollback point over the dispose ladder: returns a disposer that unwinds
288
+ * every registration made *after* this call (LIFO), leaving earlier ones intact.
289
+ * It makes a unit of work atomic — e.g. one extension's `setup()` — so a throw
290
+ * after partial registration can roll back exactly what that unit added and
291
+ * nothing else. Contained end-to-end, like `dispose()`; the ladder entries it
292
+ * runs are idempotent, so a later `dispose()` won't double-run them. Keeps
293
+ * unwinding until no registration made after the scope point remains, so an
294
+ * effect a rolled-back disposer registers *during* rollback is still torn down
295
+ * rather than stranded live (mirrors `dispose()`). */
296
+ scope(): Disposer {
297
+ const before = new Set(this.ladder);
298
+ return () => {
299
+ for (;;) {
300
+ let entry: Disposer | undefined;
301
+ for (const candidate of this.ladder) {
302
+ if (!before.has(candidate)) entry = candidate;
303
+ }
304
+ if (entry === undefined) return;
305
+ this.ladder.delete(entry);
306
+ try {
307
+ entry();
308
+ } catch (err) {
309
+ this.contain(err);
310
+ }
311
+ }
312
+ };
313
+ }
314
+
315
+ /** Register an arbitrary disposable effect (a timer, a subscription, a
316
+ * registration made elsewhere) directly onto the dispose ladder, so
317
+ * `dispose()` unwinds it with everything else — the dispose-ladder contract is
318
+ * not limited to channel listeners. Returns an idempotent disposer that detaches
319
+ * it (and runs the cleanup) early; a throw from the cleanup is contained. */
320
+ effect(dispose: Disposer): Disposer {
321
+ let disposed = false;
322
+ const entry: Disposer = () => {
323
+ if (disposed) return;
324
+ disposed = true;
325
+ this.ladder.delete(entry);
326
+ try {
327
+ dispose();
328
+ } catch (err) {
329
+ this.contain(err);
330
+ }
331
+ };
332
+ this.ladder.add(entry);
333
+ return entry;
334
+ }
335
+
336
+ /** Total live registrations on the dispose ladder — every channel listener AND
337
+ * every `effect()` (arbitrary disposable), not only channel listeners. This is
338
+ * the leak-assertion metric: it reads 0 exactly when the ladder is fully
339
+ * unwound. */
340
+ get listenerCount(): number {
341
+ return this.ladder.size;
342
+ }
343
+
344
+ /** Unwind the whole dispose ladder LIFO: every registration made through this
345
+ * bus is torn down. Contained end-to-end, so one bad disposer can't strand the
346
+ * rest. Idempotent — a second call is a no-op. This is what makes a
347
+ * `start → stop → start` cycle (and dev-server HMR) leak-free. Keeps unwinding
348
+ * until the ladder is empty, so an effect a disposer registers *during*
349
+ * teardown is still run rather than silently dropped. */
350
+ dispose(): void {
351
+ while (this.ladder.size > 0) {
352
+ const remaining = [...this.ladder];
353
+ const dispose = remaining[remaining.length - 1];
354
+ this.ladder.delete(dispose);
355
+ try {
356
+ dispose();
357
+ } catch (err) {
358
+ this.contain(err);
359
+ }
360
+ }
361
+ }
362
+
363
+ private contain(err: unknown): void {
364
+ try {
365
+ this.onError(err, { event: "(bus.dispose)", mode: "emit" });
366
+ } catch {
367
+ // never strand teardown
368
+ }
369
+ }
370
+ }