@nwire/runtime 0.11.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/LICENSE +21 -0
- package/dist/capability.d.ts +68 -0
- package/dist/capability.js +31 -0
- package/dist/framework-hooks.d.ts +110 -0
- package/dist/framework-hooks.js +39 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/runtime.d.ts +402 -0
- package/dist/runtime.js +588 -0
- package/dist/sink.d.ts +54 -0
- package/dist/sink.js +13 -0
- package/package.json +47 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime — Container, dispatch hook, FrameworkHooks registry, telemetry
|
|
3
|
+
* stream, plugin lifecycle. The dispatch hook composes user middleware
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { createContainer } from "@nwire/container/awilix";
|
|
9
|
+
import { hook } from "@nwire/hooks";
|
|
10
|
+
import { NoopLogger } from "@nwire/logger";
|
|
11
|
+
import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
|
|
12
|
+
import { createFrameworkHooks, isBuiltInHook } from "./framework-hooks.js";
|
|
13
|
+
export function serializeError(err) {
|
|
14
|
+
if (err instanceof Error) {
|
|
15
|
+
const out = {
|
|
16
|
+
name: err.name,
|
|
17
|
+
message: err.message,
|
|
18
|
+
stack: err.stack,
|
|
19
|
+
};
|
|
20
|
+
for (const k of Object.keys(err)) {
|
|
21
|
+
if (k === "name" || k === "message" || k === "stack")
|
|
22
|
+
continue;
|
|
23
|
+
out[k] = err[k];
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
return { name: "NonError", message: String(err) };
|
|
28
|
+
}
|
|
29
|
+
export class Runtime {
|
|
30
|
+
container;
|
|
31
|
+
_logger;
|
|
32
|
+
appName;
|
|
33
|
+
/**
|
|
34
|
+
* Public accessor — external dispatchers compose runtimes and need the
|
|
35
|
+
* logger; test-kit harness extensions also swap it in for tap capture.
|
|
36
|
+
*/
|
|
37
|
+
get logger() {
|
|
38
|
+
return this._logger;
|
|
39
|
+
}
|
|
40
|
+
set logger(value) {
|
|
41
|
+
this._logger = value;
|
|
42
|
+
}
|
|
43
|
+
/** Public accessor — external dispatchers register middleware via this hook. */
|
|
44
|
+
get dispatchHook$() {
|
|
45
|
+
return this.dispatchHook;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Dispatch substrate. Subclasses register their pinned `handler` step at
|
|
49
|
+
* priority `-Infinity` and call `dispatchHook.run(ctx)` from their own
|
|
50
|
+
* `dispatch` method. The tap forwards every chain step to the canonical
|
|
51
|
+
* telemetry stream.
|
|
52
|
+
*/
|
|
53
|
+
dispatchHook;
|
|
54
|
+
userMiddlewareCount = 0;
|
|
55
|
+
/**
|
|
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)`.
|
|
59
|
+
*/
|
|
60
|
+
hooks;
|
|
61
|
+
telemetryListeners = [];
|
|
62
|
+
/**
|
|
63
|
+
* Handler registry — keyed by `handler.name`. Populated via
|
|
64
|
+
* `registerHandler(handler)` so consumers can dispatch by name:
|
|
65
|
+
* `runtime.execute("orders.place", input)`. Adapters (HTTP, queue, MCP)
|
|
66
|
+
* also iterate this for their own wire / tool tables.
|
|
67
|
+
*/
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
handlers = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* Per-event subscriber registry — keyed by `event.name`. Subscribers
|
|
72
|
+
* attach via `runtime.subscribe(event, fn)` and fire on `runtime.emit(
|
|
73
|
+
* event, payload)`. Foundation calls this out as the broadcast verb
|
|
74
|
+
* distinct from telemetry-push (`runtime.pushTelemetry(record)`).
|
|
75
|
+
*/
|
|
76
|
+
eventListeners = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* Installed capabilities — added via `runtime.add(cap)`. Each entry's
|
|
79
|
+
* `provideCtx` is invoked once per dispatch and its output spread onto
|
|
80
|
+
* the handler ctx; `provideRuntime` (if any) was already merged onto
|
|
81
|
+
* the runtime instance at install time.
|
|
82
|
+
*
|
|
83
|
+
* Stored as an array (not a map) — the contract is install-order; a
|
|
84
|
+
* later capability can shadow an earlier one's ctx key if needed.
|
|
85
|
+
* Duplicate `name` install throws to surface accidental double-installs.
|
|
86
|
+
*/
|
|
87
|
+
capabilities = [];
|
|
88
|
+
capabilityNames = new Set();
|
|
89
|
+
/**
|
|
90
|
+
* Outbound sink chain — populated by `runtime.sink(stage)`. Stored as one
|
|
91
|
+
* array; `sinkDrain` runs them in position order (early → middle → terminal),
|
|
92
|
+
* and within a position in install order. Terminal stages with a `kind` are
|
|
93
|
+
* exclusivity-checked at install time so two NATS terminals can't both win.
|
|
94
|
+
*/
|
|
95
|
+
sinkStages = [];
|
|
96
|
+
terminalKinds = new Set();
|
|
97
|
+
constructor(options = {}) {
|
|
98
|
+
this.container = options.container ?? createContainer();
|
|
99
|
+
this._logger = options.logger ?? new NoopLogger();
|
|
100
|
+
this.appName = options.appName ?? "app";
|
|
101
|
+
this.hooks = createFrameworkHooks();
|
|
102
|
+
for (const h of Object.values(this.hooks)) {
|
|
103
|
+
this.observe(h);
|
|
104
|
+
}
|
|
105
|
+
this.dispatchHook = hook("runtime.dispatch");
|
|
106
|
+
this.observe(this.dispatchHook);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Materialise a slot on `runtime.hooks` for a plugin-defined framework
|
|
110
|
+
* hook. Idempotent — calling twice with the same name returns the same
|
|
111
|
+
* Hook. The slot is automatically observed for telemetry.
|
|
112
|
+
*
|
|
113
|
+
* Pair this with TS module augmentation:
|
|
114
|
+
*
|
|
115
|
+
* declare module "@nwire/app" {
|
|
116
|
+
* interface FrameworkHooks {
|
|
117
|
+
* MyEvent: Hook<{ tenant: string }>;
|
|
118
|
+
* }
|
|
119
|
+
* }
|
|
120
|
+
*
|
|
121
|
+
* const h = runtime.defineHook<{ tenant: string }>("MyEvent");
|
|
122
|
+
*/
|
|
123
|
+
defineHook(name) {
|
|
124
|
+
const reg = this.hooks;
|
|
125
|
+
const existing = reg[name];
|
|
126
|
+
if (existing)
|
|
127
|
+
return existing;
|
|
128
|
+
const h = hook(`runtime.${name}`);
|
|
129
|
+
reg[name] = h;
|
|
130
|
+
this.observe(h);
|
|
131
|
+
return h;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Register a dispatch middleware. Outermost first — the order you call
|
|
135
|
+
* `use()` is the order layers wrap (first `use` is the outermost layer).
|
|
136
|
+
* Middlewares run once per dispatch, outside the retry loop.
|
|
137
|
+
*
|
|
138
|
+
* Each user middleware gets a distinct negative priority so the chain
|
|
139
|
+
* order matches registration order; the pinned subclass-owned "handler"
|
|
140
|
+
* step at `-Infinity` stays strictly innermost.
|
|
141
|
+
*/
|
|
142
|
+
use(middleware) {
|
|
143
|
+
const priority = -this.userMiddlewareCount;
|
|
144
|
+
this.userMiddlewareCount += 1;
|
|
145
|
+
this.dispatchHook.use(async (hctx, next) => {
|
|
146
|
+
hctx.result = await middleware(async () => {
|
|
147
|
+
await next();
|
|
148
|
+
return hctx.result;
|
|
149
|
+
}, hctx.action, hctx.input, hctx.ctx);
|
|
150
|
+
}, { name: middleware.name || `middleware#${this.userMiddlewareCount}`, priority });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Install a capability. Adds its `provideRuntime` members to this runtime
|
|
154
|
+
* instance once, and queues its `provideCtx` so every dispatch sees the
|
|
155
|
+
* contribution. Duplicate names throw — capability install is meant to be
|
|
156
|
+
* explicit, and accidental double-installs usually indicate a layering bug
|
|
157
|
+
* (e.g. two plugins installing the same publish capability).
|
|
158
|
+
*
|
|
159
|
+
* `add` is the runtime-layer install verb; `with` belongs to the App
|
|
160
|
+
* layer, where it also advances the `<TCaps>` phantom type.
|
|
161
|
+
*/
|
|
162
|
+
add(cap) {
|
|
163
|
+
if (this.capabilityNames.has(cap.name)) {
|
|
164
|
+
throw new Error(`Runtime.add: capability "${cap.name}" already installed.`);
|
|
165
|
+
}
|
|
166
|
+
this.capabilityNames.add(cap.name);
|
|
167
|
+
this.capabilities.push(cap);
|
|
168
|
+
if (cap.provideRuntime) {
|
|
169
|
+
const members = cap.provideRuntime(this);
|
|
170
|
+
for (const [key, fn] of Object.entries(members)) {
|
|
171
|
+
const target = this;
|
|
172
|
+
if (key in target) {
|
|
173
|
+
throw new Error(`Runtime.add: capability "${cap.name}" tried to install runtime member "${key}", but the runtime already has it. Pick a different name.`);
|
|
174
|
+
}
|
|
175
|
+
target[key] = fn;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** All installed capability names, in install order. Used by tests + Studio. */
|
|
180
|
+
listCapabilities() {
|
|
181
|
+
return this.capabilities.map((c) => c.name);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Internal — called by `execute` to build the ctx contribution from every
|
|
185
|
+
* installed capability for one dispatch. Public so subclass dispatchers
|
|
186
|
+
* (forge) can compose; not part of the documented contract.
|
|
187
|
+
*/
|
|
188
|
+
buildCapabilityCtx(envelope) {
|
|
189
|
+
if (this.capabilities.length === 0)
|
|
190
|
+
return {};
|
|
191
|
+
const merged = {};
|
|
192
|
+
for (const cap of this.capabilities) {
|
|
193
|
+
if (!cap.provideCtx)
|
|
194
|
+
continue;
|
|
195
|
+
const piece = cap.provideCtx({
|
|
196
|
+
envelope,
|
|
197
|
+
container: this.container,
|
|
198
|
+
runtime: this,
|
|
199
|
+
});
|
|
200
|
+
Object.assign(merged, piece);
|
|
201
|
+
}
|
|
202
|
+
return merged;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Install an outbound pipeline stage. Position-ordered: early → middle →
|
|
206
|
+
* terminal. Within a position, install order is run order. Terminal stages
|
|
207
|
+
* carrying a `kind` are deduplicated — installing a second NATS terminal
|
|
208
|
+
* throws so silent override never happens.
|
|
209
|
+
*/
|
|
210
|
+
sink(stage) {
|
|
211
|
+
if (stage.position === "terminal" && stage.kind) {
|
|
212
|
+
if (this.terminalKinds.has(stage.kind)) {
|
|
213
|
+
throw new Error(`Runtime.sink: terminal stage of kind "${stage.kind}" already installed — only one terminal per kind allowed.`);
|
|
214
|
+
}
|
|
215
|
+
this.terminalKinds.add(stage.kind);
|
|
216
|
+
}
|
|
217
|
+
this.sinkStages.push(stage);
|
|
218
|
+
}
|
|
219
|
+
/** All installed sink stages, in install order. Used by tests + Studio. */
|
|
220
|
+
listSinkStages() {
|
|
221
|
+
return this.sinkStages;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Drain the outbound chain for one event. Runs every stage in position
|
|
225
|
+
* order (early → middle → terminal); a stage returning `{ continue: false }`
|
|
226
|
+
* short-circuits the rest. Errors propagate (the publish capability that
|
|
227
|
+
* called this owns retry / dead-letter).
|
|
228
|
+
*
|
|
229
|
+
* This is the cross-process exit. Local fanout is `runtime.emit()`; the
|
|
230
|
+
* publish capability composes the two — `emit` for local subscribers,
|
|
231
|
+
* `sinkDrain` for cross-process delivery.
|
|
232
|
+
*/
|
|
233
|
+
async sinkDrain(event, payload, envelope) {
|
|
234
|
+
if (this.sinkStages.length === 0)
|
|
235
|
+
return;
|
|
236
|
+
const ordered = this.orderedSinkStages();
|
|
237
|
+
const ctx = { event, payload, envelope };
|
|
238
|
+
for (const stage of ordered) {
|
|
239
|
+
const result = await stage.run(ctx);
|
|
240
|
+
if (result && result.continue === false)
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** Stable position ordering: early → middle → terminal, install-order within. */
|
|
245
|
+
orderedSinkStages() {
|
|
246
|
+
const buckets = {
|
|
247
|
+
early: [],
|
|
248
|
+
middle: [],
|
|
249
|
+
terminal: [],
|
|
250
|
+
};
|
|
251
|
+
for (const s of this.sinkStages)
|
|
252
|
+
buckets[s.position].push(s);
|
|
253
|
+
return [...buckets.early, ...buckets.middle, ...buckets.terminal];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Wire a hook's per-step tap into the canonical telemetry stream. After
|
|
257
|
+
* this call, every `.use()` / `.on()` step on the hook emits a
|
|
258
|
+
* `kind: "hook.step"` record through `runtime.onTelemetry`, the same way
|
|
259
|
+
* the built-in `runtime.dispatch` hook does.
|
|
260
|
+
*
|
|
261
|
+
* The framework calls this for every framework hook in the registry;
|
|
262
|
+
* plugin authors can call it on their own hooks if they want their
|
|
263
|
+
* extension points surfaced in Studio / dev-logger / OTel for free.
|
|
264
|
+
*/
|
|
265
|
+
observe(hk) {
|
|
266
|
+
hk.tap((obs) => {
|
|
267
|
+
this.pushTelemetry({
|
|
268
|
+
kind: "hook.step",
|
|
269
|
+
hookName: obs.hookName,
|
|
270
|
+
hookId: obs.hookId,
|
|
271
|
+
runId: obs.runId,
|
|
272
|
+
parentRunId: obs.parentRunId,
|
|
273
|
+
stepId: obs.stepId,
|
|
274
|
+
stepKind: obs.stepKind,
|
|
275
|
+
stepName: obs.stepName,
|
|
276
|
+
phase: obs.phase,
|
|
277
|
+
durationMs: obs.durationMs,
|
|
278
|
+
error: obs.error ? serializeError(obs.error) : undefined,
|
|
279
|
+
appName: this.appName,
|
|
280
|
+
ts: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Subscribe to the canonical telemetry stream. Returns an unsubscribe.
|
|
286
|
+
* Throwing in a listener is caught and logged; never breaks dispatch.
|
|
287
|
+
*/
|
|
288
|
+
onTelemetry(listener) {
|
|
289
|
+
this.telemetryListeners.push(listener);
|
|
290
|
+
return () => this.offTelemetry(listener);
|
|
291
|
+
}
|
|
292
|
+
offTelemetry(listener) {
|
|
293
|
+
const i = this.telemetryListeners.indexOf(listener);
|
|
294
|
+
if (i >= 0)
|
|
295
|
+
this.telemetryListeners.splice(i, 1);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Push a record onto the telemetry stream. Accepts `unknown` so
|
|
299
|
+
* subclass-widened records (CQRS kinds in forge) don't need cast
|
|
300
|
+
* gymnastics — listeners narrow with `switch (rec.kind)`.
|
|
301
|
+
*
|
|
302
|
+
* Public so external dispatchers (forge's ForgeDispatcher composed
|
|
303
|
+
* around a Runtime instance) can push records without subclassing.
|
|
304
|
+
* Misuse risk is low: callers who own a Runtime are by definition
|
|
305
|
+
* inside the trust boundary.
|
|
306
|
+
*
|
|
307
|
+
* Renamed from `emit` to free that name for the canonical event-
|
|
308
|
+
* broadcast verb (see `Runtime.emit(event, payload, envelope?)`).
|
|
309
|
+
*/
|
|
310
|
+
pushTelemetry(record) {
|
|
311
|
+
if (this.telemetryListeners.length === 0)
|
|
312
|
+
return;
|
|
313
|
+
for (const listener of this.telemetryListeners) {
|
|
314
|
+
try {
|
|
315
|
+
listener(record);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
// Best-effort — don't let a bad listener break dispatch.
|
|
319
|
+
// eslint-disable-next-line no-console
|
|
320
|
+
console.error("Runtime.onTelemetry listener threw:", err);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
getContainer() {
|
|
325
|
+
return this.container;
|
|
326
|
+
}
|
|
327
|
+
// ─── Canonical dispatch verbs ──────────────────────────────────────
|
|
328
|
+
/**
|
|
329
|
+
* Register a handler so it can be executed by string name. Stamps the
|
|
330
|
+
* handler into the runtime's registry and into the container under the
|
|
331
|
+
* key `handler:<name>` so middleware that needs to resolve it can.
|
|
332
|
+
*/
|
|
333
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
334
|
+
registerHandler(handler) {
|
|
335
|
+
if (this.handlers.has(handler.name)) {
|
|
336
|
+
throw new Error(`Runtime.registerHandler: "${handler.name}" already registered.`);
|
|
337
|
+
}
|
|
338
|
+
this.handlers.set(handler.name, handler);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Look up a handler by name; throws if not registered.
|
|
342
|
+
*/
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
344
|
+
getHandler(name) {
|
|
345
|
+
const h = this.handlers.get(name);
|
|
346
|
+
if (!h)
|
|
347
|
+
throw new Error(`Runtime: no handler registered with name "${name}".`);
|
|
348
|
+
return h;
|
|
349
|
+
}
|
|
350
|
+
/** All registered handler names — used by adapters to build wire tables. */
|
|
351
|
+
listHandlers() {
|
|
352
|
+
return [...this.handlers.keys()];
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Canonical sync dispatch verb. Validates input via the handler's input
|
|
356
|
+
* schema, mints a child envelope (or seeds one when no parent is given),
|
|
357
|
+
* builds a per-request scope on the container, threads the result
|
|
358
|
+
* through the handler's `.use()` chain (and `.on()` listeners), then
|
|
359
|
+
* disposes the scope. Returns the handler's `ctx.result`.
|
|
360
|
+
*
|
|
361
|
+
* `handler` may be a `HandlerDefinition` reference (preferred) or a
|
|
362
|
+
* string name registered via `registerHandler()`.
|
|
363
|
+
*/
|
|
364
|
+
async execute(handler, input, envelopePartial, extras) {
|
|
365
|
+
// Resolve string names eagerly. HandlerDefinition references and plain
|
|
366
|
+
// functions both flow through unchanged — the dispatcher below picks
|
|
367
|
+
// the path based on shape.
|
|
368
|
+
const target = typeof handler === "string" ? this.getHandler(handler) : handler;
|
|
369
|
+
// Envelope inheritance: when called from inside a handler chain, the
|
|
370
|
+
// parent envelope threads through so correlationId stays linked and
|
|
371
|
+
// causationId records what triggered this dispatch. When called from
|
|
372
|
+
// 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
|
+
});
|
|
388
|
+
const scope = this.container.createScope();
|
|
389
|
+
const signal = envelopePartial?.signal ?? new AbortController().signal;
|
|
390
|
+
// 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);
|
|
395
|
+
// The handler chain expects a ctx with at least `input` on it. We also
|
|
396
|
+
// expose envelope + the three verbs bound to this runtime, threaded
|
|
397
|
+
// with this envelope as the parent so child dispatches inherit the
|
|
398
|
+
// correlation chain automatically. Transport-specific `extras` (e.g.
|
|
399
|
+
// koa kctx, logger) land on the same ctx so plain http handlers and
|
|
400
|
+
// forge handlers see one shape.
|
|
401
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
402
|
+
const ctx = {
|
|
403
|
+
...capCtx,
|
|
404
|
+
...(extras ?? {}),
|
|
405
|
+
input,
|
|
406
|
+
envelope,
|
|
407
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
408
|
+
execute: (h, i, partial) => this.execute(h, i, { ...partial, parent: envelope }),
|
|
409
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
410
|
+
enqueue: (h, i, partial) => this.enqueue(h, i, { ...partial, parent: envelope }),
|
|
411
|
+
emit: (event, payload, partial) => this.emit(event, payload, { ...partial, parent: envelope }),
|
|
412
|
+
resolve: (name) => scope.resolve(name),
|
|
413
|
+
scope,
|
|
414
|
+
};
|
|
415
|
+
// Normalize the dispatch target to a HandlerDefinition. Plain
|
|
416
|
+
// `(input, ctx) => ...` functions get wrapped in a synthetic
|
|
417
|
+
// definition so the dispatch path has ONE shape downstream — no
|
|
418
|
+
// typeof-function branch, no parallel call site.
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
420
|
+
const def = typeof target === "function" && !("$kind" in target)
|
|
421
|
+
? wrapFunctionAsHandlerDef(target)
|
|
422
|
+
: target;
|
|
423
|
+
const innerCore = async () => {
|
|
424
|
+
const out = await def.run(ctx, { signal });
|
|
425
|
+
return out.result;
|
|
426
|
+
};
|
|
427
|
+
try {
|
|
428
|
+
if (this.userMiddlewareCount > 0) {
|
|
429
|
+
this.ensureDispatchCorePin();
|
|
430
|
+
const hctx = {
|
|
431
|
+
action: def,
|
|
432
|
+
input,
|
|
433
|
+
ctx,
|
|
434
|
+
coreFn: innerCore,
|
|
435
|
+
};
|
|
436
|
+
await this.dispatchHook.run(hctx);
|
|
437
|
+
return hctx.result;
|
|
438
|
+
}
|
|
439
|
+
return await innerCore();
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
await scope.dispose();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Innermost dispatch step that calls the actual handler. Pinned once. */
|
|
446
|
+
dispatchCorePinned = false;
|
|
447
|
+
ensureDispatchCorePin() {
|
|
448
|
+
if (this.dispatchCorePinned)
|
|
449
|
+
return;
|
|
450
|
+
this.dispatchHook.use(async (hc, next) => {
|
|
451
|
+
hc.result = await hc.coreFn();
|
|
452
|
+
await next();
|
|
453
|
+
}, { name: "__nwire_runtime_core__", priority: Number.NEGATIVE_INFINITY });
|
|
454
|
+
this.dispatchCorePinned = true;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Fire-and-forget dispatch. Default implementation: `setImmediate` →
|
|
458
|
+
* `execute`. Queue-adapter installation can override this to enqueue
|
|
459
|
+
* onto an external queue (BullMQ, SQS, etc.). Errors are pushed onto
|
|
460
|
+
* the telemetry stream as `kind: "enqueue.failed"` — the caller has
|
|
461
|
+
* already returned.
|
|
462
|
+
*/
|
|
463
|
+
enqueue(
|
|
464
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
465
|
+
handler, input, envelopePartial) {
|
|
466
|
+
const name = typeof handler === "string" ? handler : handler.name;
|
|
467
|
+
setImmediate(() => {
|
|
468
|
+
void this.execute(handler, input, envelopePartial).catch((err) => {
|
|
469
|
+
this.pushTelemetry({
|
|
470
|
+
kind: "enqueue.failed",
|
|
471
|
+
handler: name,
|
|
472
|
+
error: serializeError(err),
|
|
473
|
+
appName: this.appName,
|
|
474
|
+
ts: new Date().toISOString(),
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
return Promise.resolve();
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Register a listener for an event. Returns an unsubscribe. Listeners are
|
|
482
|
+
* stored per `event.name` and fire in registration order via
|
|
483
|
+
* `Promise.allSettled` when `emit(event, ...)` is called. Throwing
|
|
484
|
+
* listeners are captured in telemetry but do not break sibling listeners.
|
|
485
|
+
*/
|
|
486
|
+
when(event, listener) {
|
|
487
|
+
let set = this.eventListeners.get(event.name);
|
|
488
|
+
if (!set) {
|
|
489
|
+
set = new Set();
|
|
490
|
+
this.eventListeners.set(event.name, set);
|
|
491
|
+
}
|
|
492
|
+
set.add(listener);
|
|
493
|
+
return () => {
|
|
494
|
+
this.eventListeners.get(event.name)?.delete(listener);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Alias for {@link when} — same behavior, same return. Preserved for
|
|
499
|
+
* call sites that prefer the longer name.
|
|
500
|
+
*/
|
|
501
|
+
subscribe(event, listener) {
|
|
502
|
+
return this.when(event, listener);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Broadcast an event to its subscribers. Validates payload against the
|
|
506
|
+
* event's schema, mints a child envelope, fires every registered
|
|
507
|
+
* subscriber in parallel (allSettled), and records a `kind:
|
|
508
|
+
* "event.emitted"` telemetry record. Subscriber throws are captured
|
|
509
|
+
* but do not propagate to the caller.
|
|
510
|
+
*/
|
|
511
|
+
async emit(event, payload, envelopePartial) {
|
|
512
|
+
const validated = event.schema.parse(payload);
|
|
513
|
+
const envelope = envelopePartial?.parent
|
|
514
|
+
? deriveEnvelope(envelopePartial.parent, {
|
|
515
|
+
tenant: envelopePartial.tenant,
|
|
516
|
+
userId: envelopePartial.userId,
|
|
517
|
+
user: envelopePartial.user,
|
|
518
|
+
})
|
|
519
|
+
: seedEnvelope({
|
|
520
|
+
tenant: envelopePartial?.tenant,
|
|
521
|
+
userId: envelopePartial?.userId,
|
|
522
|
+
user: envelopePartial?.user,
|
|
523
|
+
causationId: envelopePartial?.causationId,
|
|
524
|
+
correlationId: envelopePartial?.correlationId,
|
|
525
|
+
});
|
|
526
|
+
const startedAt = performance.now();
|
|
527
|
+
const subs = this.eventListeners.get(event.name);
|
|
528
|
+
if (subs && subs.size > 0) {
|
|
529
|
+
// Wrap in async so a SYNC throw inside fn() rejects the wrapper
|
|
530
|
+
// rather than escaping the .map(). allSettled then captures it.
|
|
531
|
+
const results = await Promise.allSettled([...subs].map(async (fn) => fn(validated, envelope)));
|
|
532
|
+
for (const r of results) {
|
|
533
|
+
if (r.status === "rejected") {
|
|
534
|
+
this.pushTelemetry({
|
|
535
|
+
kind: "event.listener.failed",
|
|
536
|
+
event: event.name,
|
|
537
|
+
envelope,
|
|
538
|
+
error: serializeError(r.reason),
|
|
539
|
+
appName: this.appName,
|
|
540
|
+
ts: new Date().toISOString(),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
this.pushTelemetry({
|
|
546
|
+
kind: "event.emitted",
|
|
547
|
+
event: event.name,
|
|
548
|
+
payload: validated,
|
|
549
|
+
envelope,
|
|
550
|
+
durationMs: performance.now() - startedAt,
|
|
551
|
+
subscriberCount: subs?.size ?? 0,
|
|
552
|
+
appName: this.appName,
|
|
553
|
+
ts: new Date().toISOString(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Canonical factory for a Runtime. Matches the foundation-doc shape
|
|
559
|
+
* (`createRuntime(opts)`) so consumer code stays uniform across
|
|
560
|
+
* primitives — `createContainer()`, `createInterface()`, `createApp()`,
|
|
561
|
+
* `createRuntime()`. The `new Runtime(opts)` form remains available for
|
|
562
|
+
* subclasses + tests that need to override protected members.
|
|
563
|
+
*/
|
|
564
|
+
export function createRuntime(options = {}) {
|
|
565
|
+
return new Runtime(options);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Wrap a plain `(input, ctx) => …` function in a minimal HandlerDefinition
|
|
569
|
+
* so `runtime.execute` has one dispatch path. No hook chain, no validation
|
|
570
|
+
* — just enough shape for `def.run(ctx)` to call the function and surface
|
|
571
|
+
* its result on `ctx.result`. Allocated once per execute call; the cost
|
|
572
|
+
* is one object + two property reads relative to a direct function call.
|
|
573
|
+
*/
|
|
574
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
575
|
+
function wrapFunctionAsHandlerDef(fn) {
|
|
576
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
577
|
+
return {
|
|
578
|
+
$kind: "handler",
|
|
579
|
+
name: fn.name || "anonymous",
|
|
580
|
+
config: { handler: fn },
|
|
581
|
+
async run(ctx) {
|
|
582
|
+
ctx.result = await fn(ctx.input, ctx);
|
|
583
|
+
return ctx;
|
|
584
|
+
},
|
|
585
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
export { isBuiltInHook };
|
package/dist/sink.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound sink chain — the seam between in-process event emission and
|
|
3
|
+
* cross-process delivery.
|
|
4
|
+
*
|
|
5
|
+
* Plugins and endpoint adapters install stages with `runtime.sink(stage)`.
|
|
6
|
+
* At drain time every stage runs in position order; a stage that returns
|
|
7
|
+
* `{ continue: false }` stops the chain. Empty chain is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* The runtime knows about the chain but nothing about transports — every
|
|
10
|
+
* terminal (NATS, webhook, OTLP, log file) is installed by an outbound
|
|
11
|
+
* adapter through `endpoint.use(...)` at boot.
|
|
12
|
+
*/
|
|
13
|
+
import type { MessageEnvelope } from "@nwire/envelope";
|
|
14
|
+
import type { EventDefinition } from "@nwire/messages";
|
|
15
|
+
/**
|
|
16
|
+
* Where in the pipeline a stage runs. Stages within the same position fire
|
|
17
|
+
* in install order; positions fire in this order:
|
|
18
|
+
*
|
|
19
|
+
* early — outbox / dedup / validate (runs before anything else)
|
|
20
|
+
* middle — filter / tee / transform (the bulk of the work)
|
|
21
|
+
* terminal — broker / webhook / sink-to-disk (one per kind, asserted at boot)
|
|
22
|
+
*/
|
|
23
|
+
export type StagePosition = "early" | "middle" | "terminal";
|
|
24
|
+
/**
|
|
25
|
+
* Per-stage context. The drain builder hands every stage the same shape;
|
|
26
|
+
* a stage may mutate `payload` (transform) or read `envelope` (route).
|
|
27
|
+
*/
|
|
28
|
+
export interface StageContext {
|
|
29
|
+
readonly event: EventDefinition & {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
};
|
|
32
|
+
payload: unknown;
|
|
33
|
+
readonly envelope: MessageEnvelope;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An outbound pipeline stage. Returns nothing to keep going, or
|
|
37
|
+
* `{ continue: false }` to short-circuit (e.g. visibility filter rejecting
|
|
38
|
+
* a private event before it hits the terminal broker).
|
|
39
|
+
*/
|
|
40
|
+
export interface OutboundStage {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly position: StagePosition;
|
|
43
|
+
/**
|
|
44
|
+
* Optional kind tag — `"nats" | "webhook" | "otlp" | ...`. For terminal
|
|
45
|
+
* stages it enforces uniqueness (one nats terminal per runtime). For
|
|
46
|
+
* non-terminal stages it's diagnostic only.
|
|
47
|
+
*/
|
|
48
|
+
readonly kind?: string;
|
|
49
|
+
run(ctx: StageContext): Promise<{
|
|
50
|
+
continue?: boolean;
|
|
51
|
+
} | void> | {
|
|
52
|
+
continue?: boolean;
|
|
53
|
+
} | void;
|
|
54
|
+
}
|
package/dist/sink.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound sink chain — the seam between in-process event emission and
|
|
3
|
+
* cross-process delivery.
|
|
4
|
+
*
|
|
5
|
+
* Plugins and endpoint adapters install stages with `runtime.sink(stage)`.
|
|
6
|
+
* At drain time every stage runs in position order; a stage that returns
|
|
7
|
+
* `{ continue: false }` stops the chain. Empty chain is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* The runtime knows about the chain but nothing about transports — every
|
|
10
|
+
* terminal (NATS, webhook, OTLP, log file) is installed by an outbound
|
|
11
|
+
* adapter through `endpoint.use(...)` at boot.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|