@nwire/runtime 0.12.1 → 0.13.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/dist/runtime.js CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Runtime — Container, dispatch hook, FrameworkHooks registry, telemetry
3
- * stream, plugin lifecycle. The dispatch hook composes user middleware
2
+ * Runtime — Container, dispatch hook, delivery / plugin-extension hook
3
+ * registry, telemetry stream. The dispatch hook composes user middleware
4
4
  * via `runtime.use(...)` around an inner pinned step that calls the
5
- * registered handler. Plugins materialise additional FrameworkHooks
6
- * slots via `runtime.defineHook(name)` and TS module augmentation.
5
+ * registered handler. Plugins materialise additional `FrameworkHooks` slots
6
+ * via `runtime.defineHook(name)` and TS module augmentation. The runtime is
7
+ * a stateless substrate — it has no boot/shutdown lifecycle; the App owns
8
+ * that (see `AppHooks` / `AppLifecycle` in `@nwire/app`).
7
9
  */
8
10
  import { createContainer } from "@nwire/container/awilix";
9
11
  import { hook } from "@nwire/hooks";
10
12
  import { NoopLogger } from "@nwire/logger";
11
13
  import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
12
- import { createFrameworkHooks, isBuiltInHook } from "./framework-hooks.js";
14
+ import { createFrameworkHooks, isBuiltInHook, } from "./framework-hooks.js";
13
15
  export function serializeError(err) {
14
16
  if (err instanceof Error) {
15
17
  const out = {
@@ -26,6 +28,17 @@ export function serializeError(err) {
26
28
  }
27
29
  return { name: "NonError", message: String(err) };
28
30
  }
31
+ /** Build a {@link MessageRef} from a minted envelope + target metadata. */
32
+ export function messageRef(envelope, name, kind) {
33
+ return {
34
+ messageId: envelope.messageId,
35
+ correlationId: envelope.correlationId,
36
+ causationId: envelope.causationId,
37
+ name,
38
+ kind,
39
+ tenant: envelope.tenant,
40
+ };
41
+ }
29
42
  export class Runtime {
30
43
  container;
31
44
  _logger;
@@ -53,9 +66,12 @@ export class Runtime {
53
66
  dispatchHook;
54
67
  userMiddlewareCount = 0;
55
68
  /**
56
- * Per-runtime framework-hook registry. Built-in slots (App*, Plugin*,
57
- * Wire*) are pre-instantiated; plugins augment the `FrameworkHooks`
58
- * interface and materialise their slots via `defineHook(name)`.
69
+ * Per-runtime delivery / plugin-extension hook registry. The one built-in
70
+ * slot (`LocalDelivery`) is pre-instantiated; plugins augment the
71
+ * `FrameworkHooks` interface and materialise their slots via
72
+ * `defineHook(name)` (forge adds its Action* / Event* slots this way).
73
+ * App / plugin lifecycle hooks are an App concern — see `AppHooks` in
74
+ * `@nwire/app`.
59
75
  */
60
76
  hooks;
61
77
  telemetryListeners = [];
@@ -69,7 +85,7 @@ export class Runtime {
69
85
  handlers = new Map();
70
86
  /**
71
87
  * Per-event subscriber registry — keyed by `event.name`. Subscribers
72
- * attach via `runtime.subscribe(event, fn)` and fire on `runtime.emit(
88
+ * attach via `runtime.when(event, fn)` and fire on `runtime.emit(
73
89
  * event, payload)`. Foundation calls this out as the broadcast verb
74
90
  * distinct from telemetry-push (`runtime.pushTelemetry(record)`).
75
91
  */
@@ -94,6 +110,16 @@ export class Runtime {
94
110
  */
95
111
  sinkStages = [];
96
112
  terminalKinds = new Set();
113
+ /**
114
+ * Inbound source chain — populated by `runtime.source(stage)`, the mirror
115
+ * of the sink chain. `receive` runs them in position order (early → middle →
116
+ * terminal) and then the default `inboundRouter` lands the message on the
117
+ * one execution terminal. Terminal stages with a `kind` are exclusivity-
118
+ * checked, in a namespace independent of sink kinds.
119
+ */
120
+ sourceStages = [];
121
+ terminalSourceKinds = new Set();
122
+ inboundRouter = this.buildInboundRouter();
97
123
  constructor(options = {}) {
98
124
  this.container = options.container ?? createContainer();
99
125
  this._logger = options.logger ?? new NoopLogger();
@@ -180,22 +206,52 @@ export class Runtime {
180
206
  listCapabilities() {
181
207
  return this.capabilities.map((c) => c.name);
182
208
  }
209
+ /**
210
+ * Describe installed capabilities for the manifest/topology — name, the
211
+ * handler `kinds` it scopes to (undefined = universal), and the ctx keys it
212
+ * contributes (discovered by invoking `provideCtx` with a probe envelope).
213
+ * Read-only introspection; a `provideCtx` that throws on a bare probe yields
214
+ * `ctxKeys: []` rather than failing the scan.
215
+ */
216
+ describeCapabilities() {
217
+ const probe = seedEnvelope({});
218
+ const signal = new AbortController().signal;
219
+ return this.capabilities.map((c) => {
220
+ let ctxKeys = [];
221
+ if (c.provideCtx) {
222
+ try {
223
+ ctxKeys = Object.keys(c.provideCtx({ envelope: probe, container: this.container, runtime: this, signal }));
224
+ }
225
+ catch {
226
+ /* probe-time failure — leave ctxKeys empty */
227
+ }
228
+ }
229
+ return { name: c.name, kinds: c.kinds, ctxKeys };
230
+ });
231
+ }
183
232
  /**
184
233
  * Internal — called by `execute` to build the ctx contribution from every
185
234
  * installed capability for one dispatch. Public so subclass dispatchers
186
235
  * (forge) can compose; not part of the documented contract.
187
236
  */
188
- buildCapabilityCtx(envelope) {
237
+ buildCapabilityCtx(envelope, signal, kind) {
189
238
  if (this.capabilities.length === 0)
190
239
  return {};
191
240
  const merged = {};
192
241
  for (const cap of this.capabilities) {
193
242
  if (!cap.provideCtx)
194
243
  continue;
244
+ // Kind-scoped: a capability with `kinds` only contributes to dispatches
245
+ // of those kinds; one without `kinds` is universal. The workflow/actor
246
+ // routers call this with their kind too, so envelope-scoped verbs have a
247
+ // single source and no builder re-implements them.
248
+ if (cap.kinds && (kind === undefined || !cap.kinds.includes(kind)))
249
+ continue;
195
250
  const piece = cap.provideCtx({
196
251
  envelope,
197
252
  container: this.container,
198
253
  runtime: this,
254
+ signal,
199
255
  });
200
256
  Object.assign(merged, piece);
201
257
  }
@@ -236,7 +292,21 @@ export class Runtime {
236
292
  const ordered = this.orderedSinkStages();
237
293
  const ctx = { event, payload, envelope };
238
294
  for (const stage of ordered) {
295
+ const t0 = performance.now();
239
296
  const result = await stage.run(ctx);
297
+ this.pushTelemetry({
298
+ kind: "sink.stage",
299
+ stage: stage.name,
300
+ position: stage.position,
301
+ stageKind: stage.kind,
302
+ event: event.name,
303
+ correlationId: envelope.correlationId,
304
+ tenant: envelope.tenant,
305
+ shortCircuited: !!(result && result.continue === false),
306
+ durationMs: performance.now() - t0,
307
+ appName: this.appName,
308
+ ts: new Date().toISOString(),
309
+ });
240
310
  if (result && result.continue === false)
241
311
  return;
242
312
  }
@@ -252,6 +322,118 @@ export class Runtime {
252
322
  buckets[s.position].push(s);
253
323
  return [...buckets.early, ...buckets.middle, ...buckets.terminal];
254
324
  }
325
+ /**
326
+ * Install an inbound source stage — the mirror of `sink(stage)`. Position-
327
+ * ordered: early → middle → terminal; install order within a position.
328
+ * Terminal stages carrying a `kind` are deduplicated (one per kind), in a
329
+ * namespace independent of sink kinds.
330
+ */
331
+ source(stage) {
332
+ if (stage.position === "terminal" && stage.kind) {
333
+ if (this.terminalSourceKinds.has(stage.kind)) {
334
+ throw new Error(`Runtime.source: terminal stage of kind "${stage.kind}" already installed — only one terminal per kind allowed.`);
335
+ }
336
+ this.terminalSourceKinds.add(stage.kind);
337
+ }
338
+ this.sourceStages.push(stage);
339
+ }
340
+ /** All installed source stages plus the terminal router. Used by tests + Studio. */
341
+ listSourceStages() {
342
+ return [...this.sourceStages, this.inboundRouter];
343
+ }
344
+ /**
345
+ * Inbound entry point — the mirror of `sinkDrain`. A transport (HTTP, queue,
346
+ * broker) hands a raw message here; every source stage runs in position
347
+ * order (early → middle → terminal), then the default router lands it on the
348
+ * one execution terminal. A stage returning `{ continue: false }` short-
349
+ * circuits the rest (e.g. a dedup stage dropping a replay).
350
+ *
351
+ * `opts.envelope` carries the seed / overrides the transport resolved
352
+ * (tenant, user, correlation); `opts.extras` threads transport context
353
+ * (logger, koa) onto the handler ctx. Returns the router's `ctx.result` — a
354
+ * command's handler result; events return nothing.
355
+ */
356
+ async receive(message, opts) {
357
+ const ctx = {
358
+ message,
359
+ envelope: opts?.envelope ?? {},
360
+ extras: opts?.extras,
361
+ signal: opts?.signal,
362
+ parent: opts?.parent,
363
+ resolve: (name) => this.container.resolve(name),
364
+ };
365
+ for (const stage of this.orderedSourceStages()) {
366
+ // The terminal router lands the message on execute/emit, which emit
367
+ // their own records; recording it here would double-count. Installed
368
+ // stages get one `source.stage` record each.
369
+ if (stage === this.inboundRouter) {
370
+ const result = await stage.run(ctx);
371
+ if (result && result.continue === false)
372
+ return ctx.result;
373
+ continue;
374
+ }
375
+ const t0 = performance.now();
376
+ const result = await stage.run(ctx);
377
+ this.pushTelemetry({
378
+ kind: "source.stage",
379
+ stage: stage.name,
380
+ position: stage.position,
381
+ stageKind: stage.kind,
382
+ message: { name: ctx.message?.name, kind: ctx.message?.kind },
383
+ correlationId: ctx.envelope.correlationId,
384
+ tenant: ctx.envelope.tenant,
385
+ shortCircuited: !!(result && result.continue === false),
386
+ durationMs: performance.now() - t0,
387
+ appName: this.appName,
388
+ ts: new Date().toISOString(),
389
+ });
390
+ if (result && result.continue === false)
391
+ return ctx.result;
392
+ }
393
+ return ctx.result;
394
+ }
395
+ /** Stable inbound ordering: early → middle → terminal, router lands last. */
396
+ orderedSourceStages() {
397
+ const buckets = {
398
+ early: [],
399
+ middle: [],
400
+ terminal: [],
401
+ };
402
+ for (const s of this.sourceStages)
403
+ buckets[s.position].push(s);
404
+ return [...buckets.early, ...buckets.middle, ...buckets.terminal, this.inboundRouter];
405
+ }
406
+ /**
407
+ * The default terminal source stage — the router. A `command` lands on
408
+ * `execute` (by direct `target` reference, or by `name` through the
409
+ * registry); an `event` lands on `emit`. This is the one place inbound
410
+ * messages reach the execution terminal — no second dispatch path.
411
+ */
412
+ buildInboundRouter() {
413
+ return {
414
+ name: "router",
415
+ position: "terminal",
416
+ run: async (ctx) => {
417
+ const msg = ctx.message;
418
+ if (!msg)
419
+ return;
420
+ if (msg.kind === "command") {
421
+ const target = msg.target ?? msg.name;
422
+ ctx.result = await this.execute(target, msg.input, { ...ctx.envelope, parent: ctx.parent, signal: ctx.signal }, ctx.extras);
423
+ }
424
+ else {
425
+ if (!msg.event) {
426
+ throw new Error(`Runtime.receive: event "${msg.name}" arrived without its definition; ` +
427
+ "inbound events need `event` set so emit can validate the payload.");
428
+ }
429
+ ctx.result = await this.emit(msg.event, msg.input, {
430
+ ...ctx.envelope,
431
+ parent: ctx.parent,
432
+ });
433
+ }
434
+ },
435
+ };
436
+ }
255
437
  /**
256
438
  * Wire a hook's per-step tap into the canonical telemetry stream. After
257
439
  * this call, every `.use()` / `.on()` step on the hook emits a
@@ -299,8 +481,8 @@ export class Runtime {
299
481
  * subclass-widened records (CQRS kinds in forge) don't need cast
300
482
  * gymnastics — listeners narrow with `switch (rec.kind)`.
301
483
  *
302
- * Public so external dispatchers (forge's ForgeDispatcher composed
303
- * around a Runtime instance) can push records without subclassing.
484
+ * Public so forge's plugins (which ride this runtime rather than
485
+ * subclassing it) can push their own domain records.
304
486
  * Misuse risk is low: callers who own a Runtime are by definition
305
487
  * inside the trust boundary.
306
488
  *
@@ -351,6 +533,28 @@ export class Runtime {
351
533
  listHandlers() {
352
534
  return [...this.handlers.keys()];
353
535
  }
536
+ /**
537
+ * Mint the delivery envelope for a dispatch. With a `parent` it derives a
538
+ * child (correlationId carried, causationId = parent.messageId); otherwise
539
+ * it seeds a fresh chain from the supplied overrides. The single place the
540
+ * derive-vs-seed choice lives — `execute`, `emit`, and `enqueue` share it
541
+ * so a ref minted up-front matches the envelope the handler sees.
542
+ */
543
+ mintEnvelope(partial) {
544
+ return partial?.parent
545
+ ? deriveEnvelope(partial.parent, {
546
+ tenant: partial.tenant,
547
+ userId: partial.userId,
548
+ user: partial.user,
549
+ })
550
+ : seedEnvelope({
551
+ tenant: partial?.tenant,
552
+ userId: partial?.userId,
553
+ user: partial?.user,
554
+ causationId: partial?.causationId,
555
+ correlationId: partial?.correlationId,
556
+ });
557
+ }
354
558
  /**
355
559
  * Canonical sync dispatch verb. Validates input via the handler's input
356
560
  * schema, mints a child envelope (or seeds one when no parent is given),
@@ -370,28 +574,19 @@ export class Runtime {
370
574
  // parent envelope threads through so correlationId stays linked and
371
575
  // causationId records what triggered this dispatch. When called from
372
576
  // an entry point (HTTP adopter, queue subscriber) with no parent, the
373
- // envelope is seeded fresh either from explicit overrides or a fully
374
- // new chain.
375
- const envelope = envelopePartial?.parent
376
- ? deriveEnvelope(envelopePartial.parent, {
377
- tenant: envelopePartial.tenant,
378
- userId: envelopePartial.userId,
379
- user: envelopePartial.user,
380
- })
381
- : seedEnvelope({
382
- tenant: envelopePartial?.tenant,
383
- userId: envelopePartial?.userId,
384
- user: envelopePartial?.user,
385
- causationId: envelopePartial?.causationId,
386
- correlationId: envelopePartial?.correlationId,
387
- });
577
+ // envelope is seeded fresh. A pre-minted `envelope` (from `enqueue`) is
578
+ // used verbatim so the returned ref and the handler agree on identity.
579
+ const envelope = envelopePartial?.envelope ?? this.mintEnvelope(envelopePartial);
388
580
  const scope = this.container.createScope();
389
581
  const signal = envelopePartial?.signal ?? new AbortController().signal;
390
582
  // Capability ctx — every `runtime.add(cap)` contributes per-dispatch
391
- // ctx members via `provideCtx`. Spread FIRST so handler-provided extras
392
- // and the canonical runtime verbs (execute/enqueue/emit/resolve/scope)
393
- // can shadow if there's a name collision.
394
- const capCtx = this.buildCapabilityCtx(envelope);
583
+ // ctx members via `provideCtx`, scoped to the target's kind. Spread FIRST
584
+ // so handler-provided extras and the canonical runtime verbs (execute/
585
+ // enqueue/emit/resolve/scope) can shadow if there's a name collision.
586
+ const targetKind = target?.config?.kind ??
587
+ target?.$kind ??
588
+ "handler";
589
+ const capCtx = this.buildCapabilityCtx(envelope, signal, targetKind);
395
590
  // The handler chain expects a ctx with at least `input` on it. We also
396
591
  // expose envelope + the three verbs bound to this runtime, threaded
397
592
  // with this envelope as the parent so child dispatches inherit the
@@ -404,6 +599,10 @@ export class Runtime {
404
599
  ...(extras ?? {}),
405
600
  input,
406
601
  envelope,
602
+ // The per-dispatch cancellation signal, available to every handler
603
+ // shape (plain `(input, ctx)` and forge). Forge's capability sets the
604
+ // same value; exposing it here makes signal observation uniform.
605
+ signal,
407
606
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
408
607
  execute: (h, i, partial) => this.execute(h, i, { ...partial, parent: envelope }),
409
608
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -428,7 +627,10 @@ export class Runtime {
428
627
  if (this.userMiddlewareCount > 0) {
429
628
  this.ensureDispatchCorePin();
430
629
  const hctx = {
431
- action: def,
630
+ // Dispatch middleware (auth/rbac/tracing) reads the action contract
631
+ // (policy/retry/name). When the handler implements one — forge
632
+ // actions carry `.action` — expose that; otherwise the handler def.
633
+ action: def.action ?? def,
432
634
  input,
433
635
  ctx,
434
636
  coreFn: innerCore,
@@ -462,28 +664,37 @@ export class Runtime {
462
664
  this.dispatchCorePinned = true;
463
665
  }
464
666
  /**
465
- * Fire-and-forget dispatch. Default implementation: `setImmediate`
466
- * `execute`. Queue-adapter installation can override this to enqueue
467
- * onto an external queue (BullMQ, SQS, etc.). Errors are pushed onto
468
- * the telemetry stream as `kind: "enqueue.failed"` the caller has
469
- * already returned.
667
+ * Fire-and-forget dispatch. Mints the delivery envelope up-front, hands
668
+ * the deferred dispatch that exact envelope, and returns a {@link MessageRef}
669
+ * naming it so the caller can correlate / observe without blocking on the
670
+ * result. Default implementation: `setImmediate` `execute`. Queue-adapter
671
+ * installation can override this to enqueue onto an external queue (BullMQ,
672
+ * SQS, etc.) while keeping the same up-front envelope + ref contract. Errors
673
+ * surface on the telemetry stream as `kind: "enqueue.failed"` — the caller
674
+ * has already returned.
470
675
  */
471
676
  enqueue(
472
677
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
473
678
  handler, input, envelopePartial) {
474
679
  const name = typeof handler === "string" ? handler : handler.name;
680
+ const envelope = this.mintEnvelope(envelopePartial);
475
681
  setImmediate(() => {
476
- void this.execute(handler, input, envelopePartial).catch((err) => {
682
+ void this.execute(handler, input, {
683
+ envelope,
684
+ signal: envelopePartial?.signal,
685
+ }).catch((err) => {
477
686
  this.pushTelemetry({
478
687
  kind: "enqueue.failed",
479
688
  handler: name,
689
+ messageId: envelope.messageId,
690
+ correlationId: envelope.correlationId,
480
691
  error: serializeError(err),
481
692
  appName: this.appName,
482
693
  ts: new Date().toISOString(),
483
694
  });
484
695
  });
485
696
  });
486
- return Promise.resolve();
697
+ return Promise.resolve(messageRef(envelope, name, "command"));
487
698
  }
488
699
  /** Resolve a dependency from the runtime's container. */
489
700
  resolve(name) {
@@ -501,18 +712,15 @@ export class Runtime {
501
712
  set = new Set();
502
713
  this.eventListeners.set(event.name, set);
503
714
  }
504
- set.add(listener);
715
+ // A listener IS a handler — tag the fn `kind:"listener"` so it gets the
716
+ // kind-scoped ctx + per-run telemetry like every other unit of behavior.
717
+ // Store the tagged wrapper (not the raw fn) so unsubscribe stays exact.
718
+ const handler = asListenerHandler(listener);
719
+ set.add(handler);
505
720
  return () => {
506
- this.eventListeners.get(event.name)?.delete(listener);
721
+ this.eventListeners.get(event.name)?.delete(handler);
507
722
  };
508
723
  }
509
- /**
510
- * Alias for {@link when} — same behavior, same return. Preserved for
511
- * call sites that prefer the longer name.
512
- */
513
- subscribe(event, listener) {
514
- return this.when(event, listener);
515
- }
516
724
  /**
517
725
  * Broadcast an event to its subscribers. Validates payload against the
518
726
  * event's schema, mints a child envelope, fires every registered
@@ -520,42 +728,60 @@ export class Runtime {
520
728
  * "event.emitted"` telemetry record. Subscriber throws are captured
521
729
  * but do not propagate to the caller.
522
730
  */
523
- async emit(event, payload, envelopePartial) {
524
- const validated = event.schema.parse(payload);
525
- const envelope = envelopePartial?.parent
526
- ? deriveEnvelope(envelopePartial.parent, {
527
- tenant: envelopePartial.tenant,
528
- userId: envelopePartial.userId,
529
- user: envelopePartial.user,
530
- })
531
- : seedEnvelope({
532
- tenant: envelopePartial?.tenant,
533
- userId: envelopePartial?.userId,
534
- user: envelopePartial?.user,
535
- causationId: envelopePartial?.causationId,
536
- correlationId: envelopePartial?.correlationId,
537
- });
538
- const startedAt = performance.now();
539
- const subs = this.eventListeners.get(event.name);
731
+ /**
732
+ * The one incoming-to-all LOCAL delivery. Runs the ordered `LocalDelivery`
733
+ * chain (forge's idempotency → actors → projections → workflows attach
734
+ * here); if an upstream step vetoed (`deduped`), stops — otherwise fans
735
+ * out to `when` listeners. Both `emit` (the validated entry)
736
+ * and forge's publish ride this, so there is one local-delivery path and
737
+ * no second fan-out. `envelope` is the already-minted delivery envelope —
738
+ * callers derive/seed it; `deliver` does not. Returns whether the event
739
+ * was deduped so a caller can gate its outbound `publish`.
740
+ */
741
+ async deliver(eventName, payload, envelope, dedupKey) {
742
+ const dp = { eventName, payload, envelope, dedupKey, deduped: false };
743
+ await this.hooks.LocalDelivery.run(dp);
744
+ if (dp.deduped)
745
+ return { deduped: true };
746
+ const subs = this.eventListeners.get(eventName);
540
747
  if (subs && subs.size > 0) {
541
- // Listener ctx the documented subset of the workflow `when` ctx,
542
- // every verb threaded with this envelope as parent so follow-up
543
- // dispatches inherit the correlation chain.
748
+ // Listener ctx, kind-scoped: `buildCapabilityCtx("listener")` layers any
749
+ // listener-tagged plugin verbs on top of the core base verbs every
750
+ // listener has. The base verbs stay core (a forge-less app reacts too),
751
+ // each threaded with this envelope as parent so follow-ups inherit the
752
+ // correlation chain. Identical shape to the workflow `when` ctx subset.
753
+ const capCtx = this.buildCapabilityCtx(envelope, new AbortController().signal, "listener");
544
754
  const listenerCtx = {
755
+ ...capCtx,
545
756
  envelope,
546
757
  send: (h, i, partial) => this.execute(h, i, { ...partial, parent: envelope }),
547
758
  emit: (ev, p, partial) => this.emit(ev, p, { ...partial, parent: envelope }),
548
759
  resolve: (name) => this.container.resolve(name),
549
760
  logger: this._logger,
550
761
  };
551
- // Wrap in async so a SYNC throw inside fn() rejects the wrapper
552
- // rather than escaping the .map(). allSettled then captures it.
553
- const results = await Promise.allSettled([...subs].map(async (fn) => fn(validated, listenerCtx)));
762
+ const appName = this.appName;
763
+ // Each listener is a `kind:"listener"` handler fire in parallel, record
764
+ // a dispatch-style record per run so a reaction shows in the trace.
765
+ // SYNC throws reject the async wrapper; allSettled captures them so a
766
+ // failing listener never breaks siblings.
767
+ const results = await Promise.allSettled([...subs].map(async (fn) => {
768
+ const t0 = performance.now();
769
+ await fn(payload, listenerCtx);
770
+ this.pushTelemetry({
771
+ kind: "listener.fired",
772
+ event: eventName,
773
+ listener: fn.name || "listener",
774
+ durationMs: performance.now() - t0,
775
+ envelope,
776
+ appName,
777
+ ts: new Date().toISOString(),
778
+ });
779
+ }));
554
780
  for (const r of results) {
555
781
  if (r.status === "rejected") {
556
782
  this.pushTelemetry({
557
783
  kind: "event.listener.failed",
558
- event: event.name,
784
+ event: eventName,
559
785
  envelope,
560
786
  error: serializeError(r.reason),
561
787
  appName: this.appName,
@@ -564,18 +790,46 @@ export class Runtime {
564
790
  }
565
791
  }
566
792
  }
793
+ return { deduped: false };
794
+ }
795
+ async emit(event, payload, envelopePartial) {
796
+ const validated = event.schema.parse(payload);
797
+ const envelope = this.mintEnvelope(envelopePartial);
798
+ const startedAt = performance.now();
799
+ const subscriberCount = this.eventListeners.get(event.name)?.size ?? 0;
800
+ // One local delivery — runs the LocalDelivery chain (empty until forge
801
+ // attaches its fold steps) then fans out to listeners.
802
+ await this.deliver(event.name, validated, envelope, envelope.messageId);
567
803
  this.pushTelemetry({
568
804
  kind: "event.emitted",
569
805
  event: event.name,
570
806
  payload: validated,
571
807
  envelope,
572
808
  durationMs: performance.now() - startedAt,
573
- subscriberCount: subs?.size ?? 0,
809
+ subscriberCount,
574
810
  appName: this.appName,
575
811
  ts: new Date().toISOString(),
576
812
  });
577
813
  }
578
814
  }
815
+ /**
816
+ * Wrap a bare listener fn as a `kind:"listener"` handler. Wraps (never mutates)
817
+ * the user's fn so a shared closure isn't tagged in place; idempotent if the fn
818
+ * is already a listener handler. The name is taken from the fn for telemetry.
819
+ */
820
+ function asListenerHandler(fn) {
821
+ const tagged = fn;
822
+ if (tagged.$kind === "handler" && tagged.config?.kind === "listener") {
823
+ return fn;
824
+ }
825
+ const handler = ((payload, ctx) => fn(payload, ctx));
826
+ Object.defineProperties(handler, {
827
+ $kind: { value: "handler", enumerable: true },
828
+ config: { value: { kind: "listener" }, enumerable: true },
829
+ name: { value: fn.name || "listener", enumerable: true, configurable: true },
830
+ });
831
+ return handler;
832
+ }
579
833
  /**
580
834
  * Canonical factory for a Runtime. Matches the foundation-doc shape
581
835
  * (`createRuntime(opts)`) so consumer code stays uniform across