@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.
@@ -0,0 +1,402 @@
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 { type Container } from "@nwire/container/awilix";
9
+ import { type Hook } from "@nwire/hooks";
10
+ import { type Logger } from "@nwire/logger";
11
+ import { type MessageEnvelope } from "@nwire/envelope";
12
+ import type { HandlerDefinition } from "@nwire/handler";
13
+ import type { EventDefinition } from "@nwire/messages";
14
+ import { isBuiltInHook, type FrameworkHooks } from "./framework-hooks.js";
15
+ import type { Capability } from "./capability.js";
16
+ import type { OutboundStage } from "./sink.js";
17
+ /**
18
+ * Serialized form of any thrown value — `Error` instances keep `name` /
19
+ * `message` / `stack` plus any own enumerable properties; non-Error throws
20
+ * round-trip as `{ name: "NonError", message: String(err) }`. Used by every
21
+ * telemetry record that carries an error payload.
22
+ */
23
+ export type SerializedError = {
24
+ readonly name: string;
25
+ readonly message: string;
26
+ readonly stack?: string;
27
+ readonly [k: string]: unknown;
28
+ };
29
+ export declare function serializeError(err: unknown): SerializedError;
30
+ /**
31
+ * `hook.step` — one record per chain/listener phase on any adopted hook.
32
+ * `runId` / `parentRunId` link nested hook invocations into a causal tree —
33
+ * that's what Studio's Trace page uses.
34
+ */
35
+ export interface HookStepTelemetry {
36
+ readonly kind: "hook.step";
37
+ readonly hookName: string;
38
+ readonly hookId: string;
39
+ readonly runId: string;
40
+ readonly parentRunId?: string;
41
+ readonly stepId: number;
42
+ readonly stepKind: "chain" | "listener";
43
+ readonly stepName?: string;
44
+ readonly phase: "start" | "end" | "error";
45
+ readonly durationMs?: number;
46
+ readonly error?: SerializedError;
47
+ readonly appName: string;
48
+ readonly ts: string;
49
+ }
50
+ /**
51
+ * Base `Telemetry` union — generic kinds only. Subclasses (forge) WIDEN
52
+ * this with their own domain kinds and re-export the wider union as their
53
+ * own `Telemetry` alias. Consumers that listen on the base accept
54
+ * everything; consumers that care about discriminants narrow with
55
+ * `switch (rec.kind)`.
56
+ */
57
+ export type Telemetry = HookStepTelemetry;
58
+ export type TelemetryListener<T = Telemetry> = (record: T) => void;
59
+ /**
60
+ * Per-dispatch context passed through the `runtime.dispatch` hook. Subclass
61
+ * dispatchers populate `coreFn` with their retry-loop + handler-invocation
62
+ * closure; the pinned innermost chain step calls it and writes `result`.
63
+ * User middlewares attached via `runtime.use()` see this ctx via adapters
64
+ * that re-expose the legacy `(next, action, input, ctx)` shape.
65
+ *
66
+ * Types here are deliberately structural / unknown — the base runtime
67
+ * doesn't know what an "action" or "context" looks like; forge tightens
68
+ * the generics in its own re-export.
69
+ */
70
+ export interface DispatchHookCtx {
71
+ readonly action: unknown;
72
+ readonly input: unknown;
73
+ readonly ctx: unknown;
74
+ readonly coreFn: () => Promise<unknown>;
75
+ result?: unknown;
76
+ }
77
+ /**
78
+ * Onion-style extension point around action dispatch. Outermost first;
79
+ * registration order is execution order (matches Koa/express semantics).
80
+ * Middlewares run OUTSIDE any retry loop — one pass per dispatch.
81
+ */
82
+ export type DispatchMiddleware = (next: () => Promise<unknown>, action: unknown, input: unknown, ctx: unknown) => Promise<unknown>;
83
+ export interface RuntimeOptions {
84
+ readonly container?: Container;
85
+ readonly logger?: Logger;
86
+ /**
87
+ * App / service name. Stamped on every telemetry record so a single
88
+ * multi-tenant observer can demux records by origin.
89
+ */
90
+ readonly appName?: string;
91
+ }
92
+ export declare class Runtime {
93
+ protected readonly container: Container;
94
+ protected readonly _logger: Logger;
95
+ readonly appName: string;
96
+ /**
97
+ * Public accessor — external dispatchers compose runtimes and need the
98
+ * logger; test-kit harness extensions also swap it in for tap capture.
99
+ */
100
+ get logger(): Logger;
101
+ set logger(value: Logger);
102
+ /** Public accessor — external dispatchers register middleware via this hook. */
103
+ get dispatchHook$(): Hook<DispatchHookCtx>;
104
+ /**
105
+ * Dispatch substrate. Subclasses register their pinned `handler` step at
106
+ * priority `-Infinity` and call `dispatchHook.run(ctx)` from their own
107
+ * `dispatch` method. The tap forwards every chain step to the canonical
108
+ * telemetry stream.
109
+ */
110
+ protected readonly dispatchHook: Hook<DispatchHookCtx>;
111
+ private userMiddlewareCount;
112
+ /**
113
+ * Per-runtime framework-hook registry. Built-in slots (App*, Plugin*,
114
+ * Wire*) are pre-instantiated; plugins augment the `FrameworkHooks`
115
+ * interface and materialise their slots via `defineHook(name)`.
116
+ */
117
+ readonly hooks: FrameworkHooks;
118
+ private readonly telemetryListeners;
119
+ /**
120
+ * Handler registry — keyed by `handler.name`. Populated via
121
+ * `registerHandler(handler)` so consumers can dispatch by name:
122
+ * `runtime.execute("orders.place", input)`. Adapters (HTTP, queue, MCP)
123
+ * also iterate this for their own wire / tool tables.
124
+ */
125
+ private readonly handlers;
126
+ /**
127
+ * Per-event subscriber registry — keyed by `event.name`. Subscribers
128
+ * attach via `runtime.subscribe(event, fn)` and fire on `runtime.emit(
129
+ * event, payload)`. Foundation calls this out as the broadcast verb
130
+ * distinct from telemetry-push (`runtime.pushTelemetry(record)`).
131
+ */
132
+ private readonly eventListeners;
133
+ /**
134
+ * Installed capabilities — added via `runtime.add(cap)`. Each entry's
135
+ * `provideCtx` is invoked once per dispatch and its output spread onto
136
+ * the handler ctx; `provideRuntime` (if any) was already merged onto
137
+ * the runtime instance at install time.
138
+ *
139
+ * Stored as an array (not a map) — the contract is install-order; a
140
+ * later capability can shadow an earlier one's ctx key if needed.
141
+ * Duplicate `name` install throws to surface accidental double-installs.
142
+ */
143
+ private readonly capabilities;
144
+ private readonly capabilityNames;
145
+ /**
146
+ * Outbound sink chain — populated by `runtime.sink(stage)`. Stored as one
147
+ * array; `sinkDrain` runs them in position order (early → middle → terminal),
148
+ * and within a position in install order. Terminal stages with a `kind` are
149
+ * exclusivity-checked at install time so two NATS terminals can't both win.
150
+ */
151
+ private readonly sinkStages;
152
+ private readonly terminalKinds;
153
+ constructor(options?: RuntimeOptions);
154
+ /**
155
+ * Materialise a slot on `runtime.hooks` for a plugin-defined framework
156
+ * hook. Idempotent — calling twice with the same name returns the same
157
+ * Hook. The slot is automatically observed for telemetry.
158
+ *
159
+ * Pair this with TS module augmentation:
160
+ *
161
+ * declare module "@nwire/app" {
162
+ * interface FrameworkHooks {
163
+ * MyEvent: Hook<{ tenant: string }>;
164
+ * }
165
+ * }
166
+ *
167
+ * const h = runtime.defineHook<{ tenant: string }>("MyEvent");
168
+ */
169
+ defineHook<TPayload>(name: keyof FrameworkHooks): Hook<TPayload>;
170
+ /**
171
+ * Register a dispatch middleware. Outermost first — the order you call
172
+ * `use()` is the order layers wrap (first `use` is the outermost layer).
173
+ * Middlewares run once per dispatch, outside the retry loop.
174
+ *
175
+ * Each user middleware gets a distinct negative priority so the chain
176
+ * order matches registration order; the pinned subclass-owned "handler"
177
+ * step at `-Infinity` stays strictly innermost.
178
+ */
179
+ use(middleware: DispatchMiddleware): void;
180
+ /**
181
+ * Install a capability. Adds its `provideRuntime` members to this runtime
182
+ * instance once, and queues its `provideCtx` so every dispatch sees the
183
+ * contribution. Duplicate names throw — capability install is meant to be
184
+ * explicit, and accidental double-installs usually indicate a layering bug
185
+ * (e.g. two plugins installing the same publish capability).
186
+ *
187
+ * `add` is the runtime-layer install verb; `with` belongs to the App
188
+ * layer, where it also advances the `<TCaps>` phantom type.
189
+ */
190
+ add(cap: Capability<unknown>): void;
191
+ /** All installed capability names, in install order. Used by tests + Studio. */
192
+ listCapabilities(): readonly string[];
193
+ /**
194
+ * Internal — called by `execute` to build the ctx contribution from every
195
+ * installed capability for one dispatch. Public so subclass dispatchers
196
+ * (forge) can compose; not part of the documented contract.
197
+ */
198
+ buildCapabilityCtx(envelope: MessageEnvelope): Record<string, unknown>;
199
+ /**
200
+ * Install an outbound pipeline stage. Position-ordered: early → middle →
201
+ * terminal. Within a position, install order is run order. Terminal stages
202
+ * carrying a `kind` are deduplicated — installing a second NATS terminal
203
+ * throws so silent override never happens.
204
+ */
205
+ sink(stage: OutboundStage): void;
206
+ /** All installed sink stages, in install order. Used by tests + Studio. */
207
+ listSinkStages(): readonly OutboundStage[];
208
+ /**
209
+ * Drain the outbound chain for one event. Runs every stage in position
210
+ * order (early → middle → terminal); a stage returning `{ continue: false }`
211
+ * short-circuits the rest. Errors propagate (the publish capability that
212
+ * called this owns retry / dead-letter).
213
+ *
214
+ * This is the cross-process exit. Local fanout is `runtime.emit()`; the
215
+ * publish capability composes the two — `emit` for local subscribers,
216
+ * `sinkDrain` for cross-process delivery.
217
+ */
218
+ sinkDrain(event: EventDefinition & {
219
+ readonly name: string;
220
+ }, payload: unknown, envelope: MessageEnvelope): Promise<void>;
221
+ /** Stable position ordering: early → middle → terminal, install-order within. */
222
+ private orderedSinkStages;
223
+ /**
224
+ * Wire a hook's per-step tap into the canonical telemetry stream. After
225
+ * this call, every `.use()` / `.on()` step on the hook emits a
226
+ * `kind: "hook.step"` record through `runtime.onTelemetry`, the same way
227
+ * the built-in `runtime.dispatch` hook does.
228
+ *
229
+ * The framework calls this for every framework hook in the registry;
230
+ * plugin authors can call it on their own hooks if they want their
231
+ * extension points surfaced in Studio / dev-logger / OTel for free.
232
+ */
233
+ observe<TCtx>(hk: Hook<TCtx>): void;
234
+ /**
235
+ * Subscribe to the canonical telemetry stream. Returns an unsubscribe.
236
+ * Throwing in a listener is caught and logged; never breaks dispatch.
237
+ */
238
+ onTelemetry<T = Telemetry>(listener: TelemetryListener<T>): () => void;
239
+ offTelemetry<T = Telemetry>(listener: TelemetryListener<T>): void;
240
+ /**
241
+ * Push a record onto the telemetry stream. Accepts `unknown` so
242
+ * subclass-widened records (CQRS kinds in forge) don't need cast
243
+ * gymnastics — listeners narrow with `switch (rec.kind)`.
244
+ *
245
+ * Public so external dispatchers (forge's ForgeDispatcher composed
246
+ * around a Runtime instance) can push records without subclassing.
247
+ * Misuse risk is low: callers who own a Runtime are by definition
248
+ * inside the trust boundary.
249
+ *
250
+ * Renamed from `emit` to free that name for the canonical event-
251
+ * broadcast verb (see `Runtime.emit(event, payload, envelope?)`).
252
+ */
253
+ pushTelemetry(record: unknown): void;
254
+ getContainer(): Container;
255
+ /**
256
+ * Register a handler so it can be executed by string name. Stamps the
257
+ * handler into the runtime's registry and into the container under the
258
+ * key `handler:<name>` so middleware that needs to resolve it can.
259
+ */
260
+ registerHandler(handler: HandlerDefinition<any, any, any>): void;
261
+ /**
262
+ * Look up a handler by name; throws if not registered.
263
+ */
264
+ getHandler(name: string): HandlerDefinition<any, any, any>;
265
+ /** All registered handler names — used by adapters to build wire tables. */
266
+ listHandlers(): readonly string[];
267
+ /**
268
+ * Canonical sync dispatch verb. Validates input via the handler's input
269
+ * schema, mints a child envelope (or seeds one when no parent is given),
270
+ * builds a per-request scope on the container, threads the result
271
+ * through the handler's `.use()` chain (and `.on()` listeners), then
272
+ * disposes the scope. Returns the handler's `ctx.result`.
273
+ *
274
+ * `handler` may be a `HandlerDefinition` reference (preferred) or a
275
+ * string name registered via `registerHandler()`.
276
+ */
277
+ execute<TInput = unknown, TOutput = unknown>(handler: HandlerDefinition<any, TOutput, any> | string | ((input: TInput, ctx: any) => TOutput | Promise<TOutput>), input: TInput, envelopePartial?: Partial<MessageEnvelope> & {
278
+ readonly signal?: AbortSignal;
279
+ readonly parent?: MessageEnvelope;
280
+ }, extras?: Record<string, unknown>): Promise<TOutput>;
281
+ /** Innermost dispatch step that calls the actual handler. Pinned once. */
282
+ private dispatchCorePinned;
283
+ private ensureDispatchCorePin;
284
+ /**
285
+ * Fire-and-forget dispatch. Default implementation: `setImmediate` →
286
+ * `execute`. Queue-adapter installation can override this to enqueue
287
+ * onto an external queue (BullMQ, SQS, etc.). Errors are pushed onto
288
+ * the telemetry stream as `kind: "enqueue.failed"` — the caller has
289
+ * already returned.
290
+ */
291
+ enqueue<TInput = unknown>(handler: HandlerDefinition<any, any, any> | string, input: TInput, envelopePartial?: Partial<MessageEnvelope> & {
292
+ readonly signal?: AbortSignal;
293
+ readonly parent?: MessageEnvelope;
294
+ }): Promise<void>;
295
+ /**
296
+ * Register a listener for an event. Returns an unsubscribe. Listeners are
297
+ * stored per `event.name` and fire in registration order via
298
+ * `Promise.allSettled` when `emit(event, ...)` is called. Throwing
299
+ * listeners are captured in telemetry but do not break sibling listeners.
300
+ */
301
+ when<TPayload>(event: EventDefinition & {
302
+ readonly name: string;
303
+ }, listener: (payload: TPayload, envelope: MessageEnvelope) => void | Promise<void>): () => void;
304
+ /**
305
+ * Alias for {@link when} — same behavior, same return. Preserved for
306
+ * call sites that prefer the longer name.
307
+ */
308
+ subscribe<TPayload>(event: EventDefinition & {
309
+ readonly name: string;
310
+ }, listener: (payload: TPayload, envelope: MessageEnvelope) => void | Promise<void>): () => void;
311
+ /**
312
+ * Broadcast an event to its subscribers. Validates payload against the
313
+ * event's schema, mints a child envelope, fires every registered
314
+ * subscriber in parallel (allSettled), and records a `kind:
315
+ * "event.emitted"` telemetry record. Subscriber throws are captured
316
+ * but do not propagate to the caller.
317
+ */
318
+ emit<TPayload>(event: EventDefinition & {
319
+ readonly name: string;
320
+ readonly schema: {
321
+ parse(input: unknown): unknown;
322
+ };
323
+ }, payload: TPayload, envelopePartial?: Partial<MessageEnvelope> & {
324
+ readonly parent?: MessageEnvelope;
325
+ }): Promise<void>;
326
+ }
327
+ /**
328
+ * Canonical factory for a Runtime. Matches the foundation-doc shape
329
+ * (`createRuntime(opts)`) so consumer code stays uniform across
330
+ * primitives — `createContainer()`, `createInterface()`, `createApp()`,
331
+ * `createRuntime()`. The `new Runtime(opts)` form remains available for
332
+ * subclasses + tests that need to override protected members.
333
+ */
334
+ export declare function createRuntime(options?: RuntimeOptions): Runtime;
335
+ /**
336
+ * Plugin definition. Two synchronous phases:
337
+ *
338
+ * - `register?` runs first — bindings declared here are guaranteed
339
+ * available when any plugin's setup runs.
340
+ * - `setup` runs second — capabilities, middleware, sinks, hook
341
+ * observers, and boot/shutdown queues land here.
342
+ *
343
+ * Plugins that only need wiring satisfy the interface with `setup` alone;
344
+ * `register` is optional.
345
+ *
346
+ * `TOptions` is the construction option type that plugin factories close
347
+ * over (e.g. `queuePlugin({ connection })`). The same value is surfaced
348
+ * on the context as `ctx.options` so plugin bodies can be written without
349
+ * relying on JS closure capture.
350
+ *
351
+ * `TMark` is the phantom carrier that the App's `<TCaps>` accumulates
352
+ * across `.with(plugin)`. Never read at runtime.
353
+ */
354
+ export interface PluginDefinition<TOptions = void, TMark = unknown> {
355
+ readonly name: string;
356
+ /** Options closed at construction. Surfaced as `ctx.options`. */
357
+ readonly options?: TOptions;
358
+ /** Declaration phase — bindings + hook materialisation. */
359
+ register?(ctx: PluginContext<TOptions>): void;
360
+ /** Wiring phase — capabilities, middleware, listeners, boot/shutdown queues. */
361
+ setup(ctx: PluginContext<TOptions>): void;
362
+ /** Phantom marker for App<TCaps> accumulation. Pure type-level. */
363
+ readonly __mark?: TMark;
364
+ }
365
+ /**
366
+ * What register/setup closures receive. One context type is used by both
367
+ * phases. Members:
368
+ *
369
+ * - `container` / `runtime` / `hooks` — the layer references
370
+ * - `options` — the typed value the plugin was constructed with
371
+ * - `bind` — write a binding into the container
372
+ * - `add(cap)` — install a capability (proxies to `runtime.add`)
373
+ * - `sink(stage)` — install an outbound pipeline stage
374
+ * - `use(mw)` — wrap dispatch in middleware (via runtime)
375
+ * - `on(hook, fn)` — observe a framework hook
376
+ * - `boot(fn)` / `shutdown(fn)` — async lifecycle queues
377
+ * - `defineHook(name)` — materialise a plugin-defined framework hook slot
378
+ */
379
+ export interface PluginContext<TOptions = void> {
380
+ readonly container: Container;
381
+ readonly runtime: Runtime;
382
+ readonly hooks: FrameworkHooks;
383
+ /** Options the plugin was constructed with. `undefined` for option-less plugins. */
384
+ readonly options: TOptions;
385
+ bind<T>(name: string, factory: T | (() => T) | (new (cradle: unknown) => T), opts?: {
386
+ dispose?: (v: T) => void | Promise<void>;
387
+ check?: (v: T) => void | Promise<void>;
388
+ }): void;
389
+ /** Install a capability — same as `runtime.add(cap)` but routed through this plugin. */
390
+ add(cap: Capability<unknown>): void;
391
+ /** Install an outbound pipeline stage. */
392
+ sink(stage: OutboundStage): void;
393
+ /** Observe a framework hook — calls `runtime.hooks[name].on(fn)` under the hood. */
394
+ on<K extends keyof FrameworkHooks>(name: K, fn: (payload: unknown) => void): void;
395
+ boot(fn: () => Promise<void> | void): void;
396
+ /** Async cleanup work to run at `runtime.stop()`. */
397
+ dispose(fn: () => Promise<void> | void): void;
398
+ /** Alias for {@link dispose}. */
399
+ shutdown(fn: () => Promise<void> | void): void;
400
+ defineHook<TCtx>(name: keyof FrameworkHooks): Hook<TCtx>;
401
+ }
402
+ export { isBuiltInHook };