@nwire/runtime 0.12.1 → 0.13.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/dist/capability.d.ts +17 -2
- package/dist/framework-hooks.d.ts +52 -83
- package/dist/framework-hooks.js +12 -19
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -2
- package/dist/runtime.d.ts +197 -41
- package/dist/runtime.js +327 -73
- package/dist/source.d.ts +104 -0
- package/dist/source.js +15 -0
- package/dist/telemetry-sink.d.ts +50 -0
- package/dist/telemetry-sink.js +54 -0
- package/package.json +8 -8
package/dist/runtime.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime — Container, dispatch hook,
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
* interface and materialise their slots via
|
|
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.
|
|
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
|
|
303
|
-
*
|
|
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
|
|
374
|
-
//
|
|
375
|
-
const envelope = envelopePartial?.
|
|
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
|
|
392
|
-
// and the canonical runtime verbs (execute/
|
|
393
|
-
// can shadow if there's a name collision.
|
|
394
|
-
const
|
|
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
|
|
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.
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
const
|
|
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
|
|
542
|
-
//
|
|
543
|
-
//
|
|
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
|
-
|
|
552
|
-
//
|
|
553
|
-
|
|
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:
|
|
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
|
|
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
|