@nwire/app 0.9.2 → 0.10.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/README.md +81 -95
- package/dist/app.d.ts +7 -24
- package/dist/app.js +6 -24
- package/dist/compose-app.d.ts +35 -0
- package/dist/compose-app.js +138 -0
- package/dist/create-app.d.ts +38 -66
- package/dist/create-app.js +42 -216
- package/dist/define-plugin.d.ts +10 -149
- package/dist/define-plugin.js +11 -62
- package/dist/runtime/framework-hooks.d.ts +110 -0
- package/dist/runtime/framework-hooks.js +39 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +6 -0
- package/dist/runtime/runtime.d.ts +349 -0
- package/dist/runtime/runtime.js +642 -0
- package/package.json +8 -5
- package/dist/__tests__/create-app.test.d.ts +0 -6
- package/dist/__tests__/create-app.test.d.ts.map +0 -1
- package/dist/__tests__/create-app.test.js +0 -126
- package/dist/__tests__/create-app.test.js.map +0 -1
- package/dist/__tests__/define-plugin.test.d.ts +0 -16
- package/dist/__tests__/define-plugin.test.d.ts.map +0 -1
- package/dist/__tests__/define-plugin.test.js +0 -269
- package/dist/__tests__/define-plugin.test.js.map +0 -1
- package/dist/__tests__/framework-events.test.d.ts +0 -18
- package/dist/__tests__/framework-events.test.d.ts.map +0 -1
- package/dist/__tests__/framework-events.test.js +0 -156
- package/dist/__tests__/framework-events.test.js.map +0 -1
- package/dist/app.d.ts.map +0 -1
- package/dist/app.js.map +0 -1
- package/dist/create-app.d.ts.map +0 -1
- package/dist/create-app.js.map +0 -1
- package/dist/define-plugin.d.ts.map +0 -1
- package/dist/define-plugin.js.map +0 -1
- package/dist/framework-event-bus.d.ts +0 -129
- package/dist/framework-event-bus.d.ts.map +0 -1
- package/dist/framework-event-bus.js +0 -188
- package/dist/framework-event-bus.js.map +0 -1
- package/dist/framework-events.d.ts +0 -233
- package/dist/framework-events.d.ts.map +0 -1
- package/dist/framework-events.js +0 -136
- package/dist/framework-events.js.map +0 -1
- package/dist/runtime.d.ts +0 -185
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js +0 -197
- package/dist/runtime.js.map +0 -1
|
@@ -0,0 +1,642 @@
|
|
|
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
|
+
* Registered plugins (one entry per `registerPlugin(def)` call). Each
|
|
79
|
+
* carries the captured boot/dispose closures the setup contributed.
|
|
80
|
+
* Boot fires in FIFO order at `start()`; dispose fires in LIFO order
|
|
81
|
+
* at `stop()`.
|
|
82
|
+
*/
|
|
83
|
+
plugins = [];
|
|
84
|
+
/**
|
|
85
|
+
* Lifecycle state machine. Both `start()` and `stop()` are idempotent
|
|
86
|
+
* and in-flight-shared: a second call while the first is still pending
|
|
87
|
+
* returns the same Promise; a second call after completion is a no-op.
|
|
88
|
+
*/
|
|
89
|
+
lifecycle = "idle";
|
|
90
|
+
startPromise;
|
|
91
|
+
stopPromise;
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
this.container = options.container ?? createContainer();
|
|
94
|
+
this._logger = options.logger ?? new NoopLogger();
|
|
95
|
+
this.appName = options.appName ?? "app";
|
|
96
|
+
this.hooks = createFrameworkHooks();
|
|
97
|
+
for (const h of Object.values(this.hooks)) {
|
|
98
|
+
this.observe(h);
|
|
99
|
+
}
|
|
100
|
+
this.dispatchHook = hook("runtime.dispatch");
|
|
101
|
+
this.observe(this.dispatchHook);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Materialise a slot on `runtime.hooks` for a plugin-defined framework
|
|
105
|
+
* hook. Idempotent — calling twice with the same name returns the same
|
|
106
|
+
* Hook. The slot is automatically observed for telemetry.
|
|
107
|
+
*
|
|
108
|
+
* Pair this with TS module augmentation:
|
|
109
|
+
*
|
|
110
|
+
* declare module "@nwire/app" {
|
|
111
|
+
* interface FrameworkHooks {
|
|
112
|
+
* MyEvent: Hook<{ tenant: string }>;
|
|
113
|
+
* }
|
|
114
|
+
* }
|
|
115
|
+
*
|
|
116
|
+
* const h = runtime.defineHook<{ tenant: string }>("MyEvent");
|
|
117
|
+
*/
|
|
118
|
+
defineHook(name) {
|
|
119
|
+
const reg = this.hooks;
|
|
120
|
+
const existing = reg[name];
|
|
121
|
+
if (existing)
|
|
122
|
+
return existing;
|
|
123
|
+
const h = hook(`runtime.${name}`);
|
|
124
|
+
reg[name] = h;
|
|
125
|
+
this.observe(h);
|
|
126
|
+
return h;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Register a dispatch middleware. Outermost first — the order you call
|
|
130
|
+
* `use()` is the order layers wrap (first `use` is the outermost layer).
|
|
131
|
+
* Middlewares run once per dispatch, outside the retry loop.
|
|
132
|
+
*
|
|
133
|
+
* Each user middleware gets a distinct negative priority so the chain
|
|
134
|
+
* order matches registration order; the pinned subclass-owned "handler"
|
|
135
|
+
* step at `-Infinity` stays strictly innermost.
|
|
136
|
+
*/
|
|
137
|
+
use(middleware) {
|
|
138
|
+
const priority = -this.userMiddlewareCount;
|
|
139
|
+
this.userMiddlewareCount += 1;
|
|
140
|
+
this.dispatchHook.use(async (hctx, next) => {
|
|
141
|
+
hctx.result = await middleware(async () => {
|
|
142
|
+
await next();
|
|
143
|
+
return hctx.result;
|
|
144
|
+
}, hctx.action, hctx.input, hctx.ctx);
|
|
145
|
+
}, { name: middleware.name || `middleware#${this.userMiddlewareCount}`, priority });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Wire a hook's per-step tap into the canonical telemetry stream. After
|
|
149
|
+
* this call, every `.use()` / `.on()` step on the hook emits a
|
|
150
|
+
* `kind: "hook.step"` record through `runtime.onTelemetry`, the same way
|
|
151
|
+
* the built-in `runtime.dispatch` hook does.
|
|
152
|
+
*
|
|
153
|
+
* The framework calls this for every framework hook in the registry;
|
|
154
|
+
* plugin authors can call it on their own hooks if they want their
|
|
155
|
+
* extension points surfaced in Studio / dev-logger / OTel for free.
|
|
156
|
+
*/
|
|
157
|
+
observe(hk) {
|
|
158
|
+
hk.tap((obs) => {
|
|
159
|
+
this.pushTelemetry({
|
|
160
|
+
kind: "hook.step",
|
|
161
|
+
hookName: obs.hookName,
|
|
162
|
+
hookId: obs.hookId,
|
|
163
|
+
runId: obs.runId,
|
|
164
|
+
parentRunId: obs.parentRunId,
|
|
165
|
+
stepId: obs.stepId,
|
|
166
|
+
stepKind: obs.stepKind,
|
|
167
|
+
stepName: obs.stepName,
|
|
168
|
+
phase: obs.phase,
|
|
169
|
+
durationMs: obs.durationMs,
|
|
170
|
+
error: obs.error ? serializeError(obs.error) : undefined,
|
|
171
|
+
appName: this.appName,
|
|
172
|
+
ts: new Date().toISOString(),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Subscribe to the canonical telemetry stream. Returns an unsubscribe.
|
|
178
|
+
* Throwing in a listener is caught and logged; never breaks dispatch.
|
|
179
|
+
*/
|
|
180
|
+
onTelemetry(listener) {
|
|
181
|
+
this.telemetryListeners.push(listener);
|
|
182
|
+
return () => this.offTelemetry(listener);
|
|
183
|
+
}
|
|
184
|
+
offTelemetry(listener) {
|
|
185
|
+
const i = this.telemetryListeners.indexOf(listener);
|
|
186
|
+
if (i >= 0)
|
|
187
|
+
this.telemetryListeners.splice(i, 1);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Push a record onto the telemetry stream. Accepts `unknown` so
|
|
191
|
+
* subclass-widened records (CQRS kinds in forge) don't need cast
|
|
192
|
+
* gymnastics — listeners narrow with `switch (rec.kind)`.
|
|
193
|
+
*
|
|
194
|
+
* Public so external dispatchers (forge's ForgeDispatcher composed
|
|
195
|
+
* around a Runtime instance) can push records without subclassing.
|
|
196
|
+
* Misuse risk is low: callers who own a Runtime are by definition
|
|
197
|
+
* inside the trust boundary.
|
|
198
|
+
*
|
|
199
|
+
* Renamed from `emit` to free that name for the canonical event-
|
|
200
|
+
* broadcast verb (see `Runtime.emit(event, payload, envelope?)`).
|
|
201
|
+
*/
|
|
202
|
+
pushTelemetry(record) {
|
|
203
|
+
if (this.telemetryListeners.length === 0)
|
|
204
|
+
return;
|
|
205
|
+
for (const listener of this.telemetryListeners) {
|
|
206
|
+
try {
|
|
207
|
+
listener(record);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
// Best-effort — don't let a bad listener break dispatch.
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.error("Runtime.onTelemetry listener threw:", err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
getContainer() {
|
|
217
|
+
return this.container;
|
|
218
|
+
}
|
|
219
|
+
/** Current lifecycle phase. */
|
|
220
|
+
get state() {
|
|
221
|
+
return this.lifecycle;
|
|
222
|
+
}
|
|
223
|
+
// ─── Plugin registration + lifecycle ───────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Register a plugin. The setup closure runs synchronously now —
|
|
226
|
+
* `bind()` calls land on the container immediately; `boot()` /
|
|
227
|
+
* `dispose()` calls accumulate queues that fire at `start()` /
|
|
228
|
+
* `stop()`. Returns the registered entry for callers that want it
|
|
229
|
+
* (rare — most consumers just discard).
|
|
230
|
+
*
|
|
231
|
+
* Plugin re-registration with the same `name` throws. Plugin setup
|
|
232
|
+
* may NOT await — async work belongs in `boot()`.
|
|
233
|
+
*/
|
|
234
|
+
registerPlugin(plugin) {
|
|
235
|
+
if (this.lifecycle !== "idle") {
|
|
236
|
+
throw new Error(`Runtime.registerPlugin: cannot register "${plugin.name}" — runtime is "${this.lifecycle}".`);
|
|
237
|
+
}
|
|
238
|
+
if (this.plugins.some((p) => p.name === plugin.name)) {
|
|
239
|
+
throw new Error(`Runtime.registerPlugin: plugin "${plugin.name}" already registered.`);
|
|
240
|
+
}
|
|
241
|
+
const boots = [];
|
|
242
|
+
const disposes = [];
|
|
243
|
+
const runtime = this;
|
|
244
|
+
const ctx = {
|
|
245
|
+
container: this.container,
|
|
246
|
+
runtime,
|
|
247
|
+
hooks: this.hooks,
|
|
248
|
+
bind: (name, factory, opts) => {
|
|
249
|
+
this.container.register(name, factory, opts);
|
|
250
|
+
},
|
|
251
|
+
boot: (fn) => {
|
|
252
|
+
boots.push(fn);
|
|
253
|
+
},
|
|
254
|
+
dispose: (fn) => {
|
|
255
|
+
disposes.push(fn);
|
|
256
|
+
},
|
|
257
|
+
defineHook: (name) => this.defineHook(name),
|
|
258
|
+
};
|
|
259
|
+
// The setup closure must be synchronous; we deliberately ignore any
|
|
260
|
+
// returned Promise — plugin authors put async work in boot().
|
|
261
|
+
plugin.setup(ctx);
|
|
262
|
+
const entry = {
|
|
263
|
+
name: plugin.name,
|
|
264
|
+
boots,
|
|
265
|
+
disposes,
|
|
266
|
+
};
|
|
267
|
+
this.plugins.push(entry);
|
|
268
|
+
return entry;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Two-phase boot. Idempotent + in-flight-shared.
|
|
272
|
+
*
|
|
273
|
+
* 1. Fire `PluginRegistered` for each registered plugin (observable).
|
|
274
|
+
* 2. Fire `AppRegistering` chain (vetoable via skip-next).
|
|
275
|
+
* 3. Fire `AppBooting` chain (vetoable via skip-next).
|
|
276
|
+
* 4. For each plugin in registration order:
|
|
277
|
+
* - fire `PluginBooting` chain (vetoable)
|
|
278
|
+
* - await every queued `boot()` fn for that plugin
|
|
279
|
+
* - fire `PluginBooted` (observable)
|
|
280
|
+
* 5. Run container health checks; fail-fast if any throw.
|
|
281
|
+
* 6. Fire `AppBooted` + `AppReady` (observable).
|
|
282
|
+
*/
|
|
283
|
+
start() {
|
|
284
|
+
if (this.startPromise)
|
|
285
|
+
return this.startPromise;
|
|
286
|
+
if (this.lifecycle === "running")
|
|
287
|
+
return Promise.resolve();
|
|
288
|
+
if (this.lifecycle !== "idle") {
|
|
289
|
+
return Promise.reject(new Error(`Runtime.start: cannot start from lifecycle state "${this.lifecycle}".`));
|
|
290
|
+
}
|
|
291
|
+
this.lifecycle = "starting";
|
|
292
|
+
const appName = this.appName;
|
|
293
|
+
const hooks = this.hooks;
|
|
294
|
+
this.startPromise = (async () => {
|
|
295
|
+
try {
|
|
296
|
+
for (const p of this.plugins) {
|
|
297
|
+
await hooks.PluginRegistered.run({ appName, pluginName: p.name, kind: "plugin" });
|
|
298
|
+
}
|
|
299
|
+
const registering = await hooks.AppRegistering.runDetailed({ appName });
|
|
300
|
+
if (registering.outcome === "failed")
|
|
301
|
+
throw registering.error;
|
|
302
|
+
if (registering.outcome !== "completed") {
|
|
303
|
+
throw new Error(`Runtime.start("${appName}"): AppRegistering vetoed by chain step.`);
|
|
304
|
+
}
|
|
305
|
+
const booting = await hooks.AppBooting.runDetailed({ appName });
|
|
306
|
+
if (booting.outcome === "failed")
|
|
307
|
+
throw booting.error;
|
|
308
|
+
if (booting.outcome !== "completed") {
|
|
309
|
+
throw new Error(`Runtime.start("${appName}"): AppBooting vetoed by chain step.`);
|
|
310
|
+
}
|
|
311
|
+
for (const p of this.plugins) {
|
|
312
|
+
const pre = await hooks.PluginBooting.runDetailed({
|
|
313
|
+
appName,
|
|
314
|
+
pluginName: p.name,
|
|
315
|
+
kind: "plugin",
|
|
316
|
+
});
|
|
317
|
+
if (pre.outcome === "failed")
|
|
318
|
+
throw pre.error;
|
|
319
|
+
if (pre.outcome !== "completed") {
|
|
320
|
+
throw new Error(`Runtime.start("${appName}"): plugin "${p.name}" boot vetoed by chain step.`);
|
|
321
|
+
}
|
|
322
|
+
const startedAt = performance.now();
|
|
323
|
+
for (const fn of p.boots) {
|
|
324
|
+
await fn();
|
|
325
|
+
}
|
|
326
|
+
await hooks.PluginBooted.run({
|
|
327
|
+
appName,
|
|
328
|
+
pluginName: p.name,
|
|
329
|
+
durationMs: performance.now() - startedAt,
|
|
330
|
+
kind: "plugin",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Fail-fast container health gate — every `bind(name, _, { check })`
|
|
334
|
+
// registered a probe; runChecks returns the failing ones.
|
|
335
|
+
const checks = await this.container.runChecks();
|
|
336
|
+
const failed = checks.filter((c) => !c.ok);
|
|
337
|
+
if (failed.length > 0) {
|
|
338
|
+
const summary = failed
|
|
339
|
+
.map((c) => `${c.name}: ${c.error?.message ?? "check failed"}`)
|
|
340
|
+
.join("; ");
|
|
341
|
+
throw new Error(`Runtime.start("${appName}"): container check(s) failed — ${summary}`);
|
|
342
|
+
}
|
|
343
|
+
await hooks.AppBooted.run({ appName, bootedAt: new Date().toISOString() });
|
|
344
|
+
await hooks.AppReady.run({ appName, readyAt: new Date().toISOString() });
|
|
345
|
+
this.lifecycle = "running";
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
this.lifecycle = "idle";
|
|
349
|
+
this.startPromise = undefined;
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
})();
|
|
353
|
+
return this.startPromise;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Graceful shutdown. Idempotent + in-flight-shared.
|
|
357
|
+
*
|
|
358
|
+
* 1. Fire `AppShuttingDown` chain (vetoable via skip-next).
|
|
359
|
+
* 2. For each plugin in REVERSE registration order:
|
|
360
|
+
* - fire `PluginShuttingDown` (chain — refusal skips that
|
|
361
|
+
* plugin's dispose so downstream plugins still clean up)
|
|
362
|
+
* - run every queued `dispose()` fn in reverse, errors isolated
|
|
363
|
+
* - fire `PluginShutdown` (observable)
|
|
364
|
+
* 3. `container.dispose()` — runs every bind({ dispose }) in LIFO.
|
|
365
|
+
* 4. Fire `AppShutdown` (observable).
|
|
366
|
+
*/
|
|
367
|
+
stop(reason) {
|
|
368
|
+
if (this.stopPromise)
|
|
369
|
+
return this.stopPromise;
|
|
370
|
+
if (this.lifecycle === "stopped")
|
|
371
|
+
return Promise.resolve();
|
|
372
|
+
if (this.lifecycle !== "running") {
|
|
373
|
+
return Promise.reject(new Error(`Runtime.stop: cannot stop from lifecycle state "${this.lifecycle}".`));
|
|
374
|
+
}
|
|
375
|
+
this.lifecycle = "stopping";
|
|
376
|
+
const appName = this.appName;
|
|
377
|
+
const hooks = this.hooks;
|
|
378
|
+
this.stopPromise = (async () => {
|
|
379
|
+
const errors = [];
|
|
380
|
+
const shuttingDown = await hooks.AppShuttingDown.runDetailed({ appName, reason });
|
|
381
|
+
if (shuttingDown.outcome !== "completed") {
|
|
382
|
+
// Either a veto (skip-next) or a chain step threw. In both cases
|
|
383
|
+
// the caller (endpoint) escalates with a hard-timeout SIGKILL.
|
|
384
|
+
this.lifecycle = "running";
|
|
385
|
+
this.stopPromise = undefined;
|
|
386
|
+
if (shuttingDown.outcome === "failed")
|
|
387
|
+
throw shuttingDown.error;
|
|
388
|
+
throw new Error(`Runtime.stop("${appName}"): AppShuttingDown vetoed by chain step.`);
|
|
389
|
+
}
|
|
390
|
+
for (let i = this.plugins.length - 1; i >= 0; i--) {
|
|
391
|
+
const p = this.plugins[i];
|
|
392
|
+
const pre = await hooks.PluginShuttingDown.runDetailed({
|
|
393
|
+
appName,
|
|
394
|
+
pluginName: p.name,
|
|
395
|
+
kind: "plugin",
|
|
396
|
+
});
|
|
397
|
+
if (pre.outcome !== "completed") {
|
|
398
|
+
// Skip this plugin's dispose; siblings still run.
|
|
399
|
+
// eslint-disable-next-line no-console
|
|
400
|
+
console.warn(`Runtime.stop("${appName}"): PluginShuttingDown vetoed for "${p.name}" — skipping dispose.`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const startedAt = performance.now();
|
|
404
|
+
for (let j = p.disposes.length - 1; j >= 0; j--) {
|
|
405
|
+
try {
|
|
406
|
+
await p.disposes[j]();
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
errors.push(err);
|
|
410
|
+
// eslint-disable-next-line no-console
|
|
411
|
+
console.error(`Runtime.stop("${appName}"): plugin "${p.name}" dispose threw:`, err);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await hooks.PluginShutdown.run({
|
|
415
|
+
appName,
|
|
416
|
+
pluginName: p.name,
|
|
417
|
+
durationMs: performance.now() - startedAt,
|
|
418
|
+
kind: "plugin",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
await this.container.dispose();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
errors.push(err);
|
|
426
|
+
// eslint-disable-next-line no-console
|
|
427
|
+
console.error(`Runtime.stop("${appName}"): container.dispose threw:`, err);
|
|
428
|
+
}
|
|
429
|
+
await hooks.AppShutdown.run({ appName });
|
|
430
|
+
this.lifecycle = "stopped";
|
|
431
|
+
if (errors.length > 0) {
|
|
432
|
+
throw errors[0];
|
|
433
|
+
}
|
|
434
|
+
})();
|
|
435
|
+
return this.stopPromise;
|
|
436
|
+
}
|
|
437
|
+
// ─── Canonical dispatch verbs ──────────────────────────────────────
|
|
438
|
+
/**
|
|
439
|
+
* Register a handler so it can be executed by string name. Stamps the
|
|
440
|
+
* handler into the runtime's registry and into the container under the
|
|
441
|
+
* key `handler:<name>` so middleware that needs to resolve it can.
|
|
442
|
+
*/
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
444
|
+
registerHandler(handler) {
|
|
445
|
+
if (this.handlers.has(handler.name)) {
|
|
446
|
+
throw new Error(`Runtime.registerHandler: "${handler.name}" already registered.`);
|
|
447
|
+
}
|
|
448
|
+
this.handlers.set(handler.name, handler);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Look up a handler by name; throws if not registered.
|
|
452
|
+
*/
|
|
453
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
454
|
+
getHandler(name) {
|
|
455
|
+
const h = this.handlers.get(name);
|
|
456
|
+
if (!h)
|
|
457
|
+
throw new Error(`Runtime: no handler registered with name "${name}".`);
|
|
458
|
+
return h;
|
|
459
|
+
}
|
|
460
|
+
/** All registered handler names — used by adapters to build wire tables. */
|
|
461
|
+
listHandlers() {
|
|
462
|
+
return [...this.handlers.keys()];
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Canonical sync dispatch verb. Validates input via the handler's input
|
|
466
|
+
* schema, mints a child envelope (or seeds one when no parent is given),
|
|
467
|
+
* builds a per-request scope on the container, threads the result
|
|
468
|
+
* through the handler's `.use()` chain (and `.on()` listeners), then
|
|
469
|
+
* disposes the scope. Returns the handler's `ctx.result`.
|
|
470
|
+
*
|
|
471
|
+
* `handler` may be a `HandlerDefinition` reference (preferred) or a
|
|
472
|
+
* string name registered via `registerHandler()`.
|
|
473
|
+
*/
|
|
474
|
+
async execute(handler, input, envelopePartial, extras) {
|
|
475
|
+
// Resolve string names eagerly. HandlerDefinition references and plain
|
|
476
|
+
// functions both flow through unchanged — the dispatcher below picks
|
|
477
|
+
// the path based on shape.
|
|
478
|
+
const target = typeof handler === "string" ? this.getHandler(handler) : handler;
|
|
479
|
+
// Envelope inheritance: when called from inside a handler chain, the
|
|
480
|
+
// parent envelope threads through so correlationId stays linked and
|
|
481
|
+
// causationId records what triggered this dispatch. When called from
|
|
482
|
+
// an entry point (HTTP adopter, queue subscriber) with no parent, the
|
|
483
|
+
// envelope is seeded fresh — either from explicit overrides or a fully
|
|
484
|
+
// new chain.
|
|
485
|
+
const envelope = envelopePartial?.parent
|
|
486
|
+
? deriveEnvelope(envelopePartial.parent, {
|
|
487
|
+
tenant: envelopePartial.tenant,
|
|
488
|
+
userId: envelopePartial.userId,
|
|
489
|
+
user: envelopePartial.user,
|
|
490
|
+
})
|
|
491
|
+
: seedEnvelope({
|
|
492
|
+
tenant: envelopePartial?.tenant,
|
|
493
|
+
userId: envelopePartial?.userId,
|
|
494
|
+
user: envelopePartial?.user,
|
|
495
|
+
causationId: envelopePartial?.causationId,
|
|
496
|
+
correlationId: envelopePartial?.correlationId,
|
|
497
|
+
});
|
|
498
|
+
const scope = this.container.createScope();
|
|
499
|
+
const signal = envelopePartial?.signal ?? new AbortController().signal;
|
|
500
|
+
// The handler chain expects a ctx with at least `input` on it. We also
|
|
501
|
+
// expose envelope + the three verbs bound to this runtime, threaded
|
|
502
|
+
// with this envelope as the parent so child dispatches inherit the
|
|
503
|
+
// correlation chain automatically. Transport-specific `extras` (e.g.
|
|
504
|
+
// koa kctx, logger) land on the same ctx so plain http handlers and
|
|
505
|
+
// forge handlers see one shape.
|
|
506
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
507
|
+
const ctx = {
|
|
508
|
+
...(extras ?? {}),
|
|
509
|
+
input,
|
|
510
|
+
envelope,
|
|
511
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
512
|
+
execute: (h, i, partial) => this.execute(h, i, { ...partial, parent: envelope }),
|
|
513
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
514
|
+
enqueue: (h, i, partial) => this.enqueue(h, i, { ...partial, parent: envelope }),
|
|
515
|
+
emit: (event, payload, partial) => this.emit(event, payload, { ...partial, parent: envelope }),
|
|
516
|
+
resolve: (name) => scope.resolve(name),
|
|
517
|
+
scope,
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
if (typeof target === "function" && !("$kind" in target)) {
|
|
521
|
+
// Plain function path — the wire holds a bare `(input, ctx) => ...`
|
|
522
|
+
// value. No hook chain to run; call directly with the unified ctx so
|
|
523
|
+
// plain handlers see the same surface as defineHandler ones (the
|
|
524
|
+
// three verbs, envelope, scope, transport extras).
|
|
525
|
+
return (await target(input, ctx));
|
|
526
|
+
}
|
|
527
|
+
// HandlerDefinition path — runs the hook chain (telemetry, .use()
|
|
528
|
+
// middleware, terminal step that validates + calls the handler body).
|
|
529
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
530
|
+
const def = target;
|
|
531
|
+
const out = await def.run(ctx, { signal });
|
|
532
|
+
return out.result;
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
await scope.dispose();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Fire-and-forget dispatch. Default implementation: `setImmediate` →
|
|
540
|
+
* `execute`. Queue-adapter installation can override this to enqueue
|
|
541
|
+
* onto an external queue (BullMQ, SQS, etc.). Errors are pushed onto
|
|
542
|
+
* the telemetry stream as `kind: "enqueue.failed"` — the caller has
|
|
543
|
+
* already returned.
|
|
544
|
+
*/
|
|
545
|
+
enqueue(
|
|
546
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
547
|
+
handler, input, envelopePartial) {
|
|
548
|
+
const name = typeof handler === "string" ? handler : handler.name;
|
|
549
|
+
setImmediate(() => {
|
|
550
|
+
void this.execute(handler, input, envelopePartial).catch((err) => {
|
|
551
|
+
this.pushTelemetry({
|
|
552
|
+
kind: "enqueue.failed",
|
|
553
|
+
handler: name,
|
|
554
|
+
error: serializeError(err),
|
|
555
|
+
appName: this.appName,
|
|
556
|
+
ts: new Date().toISOString(),
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
return Promise.resolve();
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Subscribe to an event. Returns an unsubscribe. Listeners are stored
|
|
564
|
+
* per `event.name` and fire in registration order via `Promise.allSettled`
|
|
565
|
+
* when `emit(event, ...)` is called. Throwing listeners are captured in
|
|
566
|
+
* telemetry but do not break sibling listeners.
|
|
567
|
+
*/
|
|
568
|
+
subscribe(event, listener) {
|
|
569
|
+
let set = this.eventListeners.get(event.name);
|
|
570
|
+
if (!set) {
|
|
571
|
+
set = new Set();
|
|
572
|
+
this.eventListeners.set(event.name, set);
|
|
573
|
+
}
|
|
574
|
+
set.add(listener);
|
|
575
|
+
return () => {
|
|
576
|
+
this.eventListeners.get(event.name)?.delete(listener);
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Broadcast an event to its subscribers. Validates payload against the
|
|
581
|
+
* event's schema, mints a child envelope, fires every registered
|
|
582
|
+
* subscriber in parallel (allSettled), and records a `kind:
|
|
583
|
+
* "event.emitted"` telemetry record. Subscriber throws are captured
|
|
584
|
+
* but do not propagate to the caller.
|
|
585
|
+
*/
|
|
586
|
+
async emit(event, payload, envelopePartial) {
|
|
587
|
+
const validated = event.schema.parse(payload);
|
|
588
|
+
const envelope = envelopePartial?.parent
|
|
589
|
+
? deriveEnvelope(envelopePartial.parent, {
|
|
590
|
+
tenant: envelopePartial.tenant,
|
|
591
|
+
userId: envelopePartial.userId,
|
|
592
|
+
user: envelopePartial.user,
|
|
593
|
+
})
|
|
594
|
+
: seedEnvelope({
|
|
595
|
+
tenant: envelopePartial?.tenant,
|
|
596
|
+
userId: envelopePartial?.userId,
|
|
597
|
+
user: envelopePartial?.user,
|
|
598
|
+
causationId: envelopePartial?.causationId,
|
|
599
|
+
correlationId: envelopePartial?.correlationId,
|
|
600
|
+
});
|
|
601
|
+
const startedAt = performance.now();
|
|
602
|
+
const subs = this.eventListeners.get(event.name);
|
|
603
|
+
if (subs && subs.size > 0) {
|
|
604
|
+
// Wrap in async so a SYNC throw inside fn() rejects the wrapper
|
|
605
|
+
// rather than escaping the .map(). allSettled then captures it.
|
|
606
|
+
const results = await Promise.allSettled([...subs].map(async (fn) => fn(validated, envelope)));
|
|
607
|
+
for (const r of results) {
|
|
608
|
+
if (r.status === "rejected") {
|
|
609
|
+
this.pushTelemetry({
|
|
610
|
+
kind: "event.listener.failed",
|
|
611
|
+
event: event.name,
|
|
612
|
+
envelope,
|
|
613
|
+
error: serializeError(r.reason),
|
|
614
|
+
appName: this.appName,
|
|
615
|
+
ts: new Date().toISOString(),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
this.pushTelemetry({
|
|
621
|
+
kind: "event.emitted",
|
|
622
|
+
event: event.name,
|
|
623
|
+
payload: validated,
|
|
624
|
+
envelope,
|
|
625
|
+
durationMs: performance.now() - startedAt,
|
|
626
|
+
subscriberCount: subs?.size ?? 0,
|
|
627
|
+
appName: this.appName,
|
|
628
|
+
ts: new Date().toISOString(),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Canonical factory for a Runtime. Matches the foundation-doc shape
|
|
634
|
+
* (`createRuntime(opts)`) so consumer code stays uniform across
|
|
635
|
+
* primitives — `createContainer()`, `createInterface()`, `createApp()`,
|
|
636
|
+
* `createRuntime()`. The `new Runtime(opts)` form remains available for
|
|
637
|
+
* subclasses + tests that need to override protected members.
|
|
638
|
+
*/
|
|
639
|
+
export function createRuntime(options = {}) {
|
|
640
|
+
return new Runtime(options);
|
|
641
|
+
}
|
|
642
|
+
export { isBuiltInHook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nwire/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Nwire — managed Container with plugin lifecycle, framework events, and DI hooks. Composes modules + plugins, boots in order, exposes a Container, fires framework events at every lifecycle transition.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -29,10 +29,13 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@nwire/container": "0.
|
|
33
|
-
"@nwire/
|
|
34
|
-
"@nwire/
|
|
35
|
-
"@nwire/
|
|
32
|
+
"@nwire/container": "0.10.0",
|
|
33
|
+
"@nwire/envelope": "0.10.0",
|
|
34
|
+
"@nwire/handler": "0.10.0",
|
|
35
|
+
"@nwire/hooks": "0.10.0",
|
|
36
|
+
"@nwire/wires": "0.10.0",
|
|
37
|
+
"@nwire/logger": "0.10.0",
|
|
38
|
+
"@nwire/messages": "0.10.0"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@types/node": "^22.19.9",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"create-app.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/create-app.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|