@nwire/app 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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * App / plugin LIFECYCLE hook registry — an App concern.
3
+ *
4
+ * The Runtime is the dispatch substrate; it does not know about plugins or a
5
+ * boot/shutdown lifecycle. The App owns that, so the lifecycle slots
6
+ * (`App*` / `Plugin*`) live here, on a per-App `AppHooks` registry that
7
+ * `AppLifecycle` fires from. Each slot is a `Hook<TPayload>` accessed as a
8
+ * typed property on the registry.
9
+ *
10
+ * The slots are declared by augmenting the `AppHooks` interface exported from
11
+ * `@nwire/runtime` (the same forward-declaration pattern forge uses for the
12
+ * runtime's `FrameworkHooks`), so `PluginContext.on` / `PluginContext.defineHook`
13
+ * stay typed against them.
14
+ *
15
+ * Lifecycle hooks still emit `hook.step` telemetry: `AppLifecycle` wires every
16
+ * slot through `runtime.observe(...)`, the same tap the runtime's own hooks use,
17
+ * so Studio's plugin live-tap (`hook.step`) keeps working.
18
+ */
19
+ import { type Hook } from "@nwire/hooks";
20
+ import type { PluginKind } from "@nwire/runtime";
21
+ declare module "@nwire/runtime" {
22
+ interface AppHooks {
23
+ AppRegistering: Hook<{
24
+ readonly appName: string;
25
+ }>;
26
+ AppBooting: Hook<{
27
+ readonly appName: string;
28
+ }>;
29
+ AppBooted: Hook<{
30
+ readonly appName: string;
31
+ readonly bootedAt: string;
32
+ }>;
33
+ AppReady: Hook<{
34
+ readonly appName: string;
35
+ readonly readyAt: string;
36
+ }>;
37
+ AppShuttingDown: Hook<{
38
+ readonly appName: string;
39
+ readonly reason?: string;
40
+ }>;
41
+ AppShutdown: Hook<{
42
+ readonly appName: string;
43
+ }>;
44
+ PluginRegistered: Hook<{
45
+ readonly appName: string;
46
+ readonly pluginName: string;
47
+ readonly kind?: PluginKind;
48
+ }>;
49
+ PluginBooting: Hook<{
50
+ readonly appName: string;
51
+ readonly pluginName: string;
52
+ readonly kind?: PluginKind;
53
+ }>;
54
+ PluginBooted: Hook<{
55
+ readonly appName: string;
56
+ readonly pluginName: string;
57
+ readonly durationMs: number;
58
+ readonly kind?: PluginKind;
59
+ }>;
60
+ PluginShuttingDown: Hook<{
61
+ readonly appName: string;
62
+ readonly pluginName: string;
63
+ readonly kind?: PluginKind;
64
+ }>;
65
+ PluginShutdown: Hook<{
66
+ readonly appName: string;
67
+ readonly pluginName: string;
68
+ readonly durationMs: number;
69
+ readonly kind?: PluginKind;
70
+ }>;
71
+ }
72
+ }
73
+ export type { AppHooks } from "@nwire/runtime";
74
+ /** Canonical names for the built-in lifecycle slots — labels the underlying Hooks. */
75
+ declare const BUILT_IN_APP_HOOK_NAMES: {
76
+ readonly AppRegistering: "nwire.app.registering";
77
+ readonly AppBooting: "nwire.app.booting";
78
+ readonly AppBooted: "nwire.app.booted";
79
+ readonly AppReady: "nwire.app.ready";
80
+ readonly AppShuttingDown: "nwire.app.shutting-down";
81
+ readonly AppShutdown: "nwire.app.shutdown";
82
+ readonly PluginRegistered: "nwire.plugin.registered";
83
+ readonly PluginBooting: "nwire.plugin.booting";
84
+ readonly PluginBooted: "nwire.plugin.booted";
85
+ readonly PluginShuttingDown: "nwire.plugin.shutting-down";
86
+ readonly PluginShutdown: "nwire.plugin.shutdown";
87
+ };
88
+ export type AppHookSlot = keyof typeof BUILT_IN_APP_HOOK_NAMES;
89
+ /**
90
+ * Construct a per-App lifecycle registry with every built-in slot
91
+ * pre-instantiated. Plugin-defined lifecycle slots added via
92
+ * `ctx.defineHook(name)` land on the same registry instance.
93
+ */
94
+ export declare function createAppHooks(): import("@nwire/runtime").AppHooks;
95
+ /** True if `slot` is one of the built-in app lifecycle hook keys. */
96
+ export declare function isAppHook(slot: string): slot is AppHookSlot;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * App / plugin LIFECYCLE hook registry — an App concern.
3
+ *
4
+ * The Runtime is the dispatch substrate; it does not know about plugins or a
5
+ * boot/shutdown lifecycle. The App owns that, so the lifecycle slots
6
+ * (`App*` / `Plugin*`) live here, on a per-App `AppHooks` registry that
7
+ * `AppLifecycle` fires from. Each slot is a `Hook<TPayload>` accessed as a
8
+ * typed property on the registry.
9
+ *
10
+ * The slots are declared by augmenting the `AppHooks` interface exported from
11
+ * `@nwire/runtime` (the same forward-declaration pattern forge uses for the
12
+ * runtime's `FrameworkHooks`), so `PluginContext.on` / `PluginContext.defineHook`
13
+ * stay typed against them.
14
+ *
15
+ * Lifecycle hooks still emit `hook.step` telemetry: `AppLifecycle` wires every
16
+ * slot through `runtime.observe(...)`, the same tap the runtime's own hooks use,
17
+ * so Studio's plugin live-tap (`hook.step`) keeps working.
18
+ */
19
+ import { hook } from "@nwire/hooks";
20
+ /** Canonical names for the built-in lifecycle slots — labels the underlying Hooks. */
21
+ const BUILT_IN_APP_HOOK_NAMES = {
22
+ AppRegistering: "nwire.app.registering",
23
+ AppBooting: "nwire.app.booting",
24
+ AppBooted: "nwire.app.booted",
25
+ AppReady: "nwire.app.ready",
26
+ AppShuttingDown: "nwire.app.shutting-down",
27
+ AppShutdown: "nwire.app.shutdown",
28
+ PluginRegistered: "nwire.plugin.registered",
29
+ PluginBooting: "nwire.plugin.booting",
30
+ PluginBooted: "nwire.plugin.booted",
31
+ PluginShuttingDown: "nwire.plugin.shutting-down",
32
+ PluginShutdown: "nwire.plugin.shutdown",
33
+ };
34
+ /**
35
+ * Construct a per-App lifecycle registry with every built-in slot
36
+ * pre-instantiated. Plugin-defined lifecycle slots added via
37
+ * `ctx.defineHook(name)` land on the same registry instance.
38
+ */
39
+ export function createAppHooks() {
40
+ const registry = {};
41
+ for (const [slot, name] of Object.entries(BUILT_IN_APP_HOOK_NAMES)) {
42
+ registry[slot] = hook(name);
43
+ }
44
+ return registry;
45
+ }
46
+ /** True if `slot` is one of the built-in app lifecycle hook keys. */
47
+ export function isAppHook(slot) {
48
+ return slot in BUILT_IN_APP_HOOK_NAMES;
49
+ }
package/dist/app.d.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  * capability set. `createRuntime` exposes the raw substrate for cases that
5
5
  * need the dispatch core without the App layer on top.
6
6
  */
7
- export { Runtime, createRuntime, serializeError, type DispatchHookCtx, type DispatchMiddleware, type HookStepTelemetry, type RuntimeOptions, type SerializedError, type Telemetry, type TelemetryListener, type FrameworkHooks, type PluginKind, type PluginDefinition, type PluginContext, } from "./runtime/index.js";
7
+ export { Runtime, createRuntime, serializeError, messageRef, type MessageRef, type DispatchHookCtx, type DispatchMiddleware, type HookStepTelemetry, type RuntimeOptions, type SerializedError, type Telemetry, type TelemetryListener, type FrameworkHooks, type PluginKind, type PluginDefinition, type PluginContext, } from "./runtime/index.js";
8
+ export { createAppHooks, isAppHook, type AppHooks, type AppHookSlot } from "./app-hooks.js";
8
9
  export { definePlugin, isPlugin } from "./define-plugin.js";
9
10
  export { createApp, type App, type CreateAppOptions } from "./create-app.js";
11
+ export type { PluginContributions } from "./lifecycle.js";
10
12
  export { appCompose, containerOf, type AppComposeOptions, type CollisionPolicy, } from "./compose-app.js";
11
13
  export { subscribe, isSubscription, type Subscription, type WhenFn } from "./subscribe.js";
12
14
  export { publishCapability, publishPlugin, type PublishCaps } from "./publish.js";
13
- export { defineCapability, type Capability, type CapabilityBase, type OutboundStage, type StagePosition, type StageContext, } from "@nwire/runtime";
15
+ export { defineCapability, type Capability, type CapabilityBase, type OutboundStage, type StagePosition, type StageContext, installTelemetryReporter, type TelemetryReporter, } from "@nwire/runtime";
14
16
  export type { HandlerBaseCtx, Ctx } from "@nwire/handler";
package/dist/app.js CHANGED
@@ -4,7 +4,11 @@
4
4
  * capability set. `createRuntime` exposes the raw substrate for cases that
5
5
  * need the dispatch core without the App layer on top.
6
6
  */
7
- export { Runtime, createRuntime, serializeError, } from "./runtime/index.js";
7
+ export { Runtime, createRuntime, serializeError, messageRef, } from "./runtime/index.js";
8
+ // App / plugin lifecycle hook registry — App-owned (the runtime has no
9
+ // lifecycle). Importing this module also applies the `AppHooks` augmentation
10
+ // that declares the App* / Plugin* slots.
11
+ export { createAppHooks, isAppHook } from "./app-hooks.js";
8
12
  export { definePlugin, isPlugin } from "./define-plugin.js";
9
13
  export { createApp } from "./create-app.js";
10
14
  export { appCompose, containerOf, } from "./compose-app.js";
@@ -17,4 +21,4 @@ export { subscribe, isSubscription } from "./subscribe.js";
17
21
  export { publishCapability, publishPlugin } from "./publish.js";
18
22
  // Capability + sink primitives — re-exported from @nwire/runtime so the
19
23
  // App surface presents a single import surface.
20
- export { defineCapability, } from "@nwire/runtime";
24
+ export { defineCapability, installTelemetryReporter, } from "@nwire/runtime";
@@ -90,8 +90,10 @@ export function appCompose(...args) {
90
90
  },
91
91
  container: head.container,
92
92
  runtime: head.runtime,
93
+ appHooks: head.appHooks,
93
94
  interface: mergedInterface,
94
95
  plugins: apps.flatMap((a) => a.plugins),
96
+ pluginContributions: () => apps.flatMap((a) => a.pluginContributions()),
95
97
  with(plugin) {
96
98
  // Composites install through the head app — multi-runtime install is
97
99
  // out of scope. The composite's runtime is the head's runtime, so
@@ -21,8 +21,9 @@ import { type Container } from "@nwire/container/awilix";
21
21
  import { type Logger } from "@nwire/logger";
22
22
  import { type Binding, type HandlerDef, type Interface, type WireCtxBuilder } from "@nwire/wires";
23
23
  import type { EventDefinition, EventPayload } from "@nwire/messages";
24
- import type { ListenerContext } from "@nwire/runtime";
24
+ import type { AppHooks, ListenerContext } from "@nwire/runtime";
25
25
  import { type PluginDefinition, type Runtime } from "./runtime/index.js";
26
+ import { type PluginContributions } from "./lifecycle.js";
26
27
  import { type Subscription } from "./subscribe.js";
27
28
  export interface CreateAppOptions {
28
29
  /** App name — surfaces in framework-hook payloads + dev logger output. */
@@ -60,6 +61,13 @@ export interface App<TCaps = {}> {
60
61
  readonly name: string;
61
62
  readonly container: Container;
62
63
  readonly runtime: Runtime;
64
+ /**
65
+ * App / plugin lifecycle hook registry (`App*` / `Plugin*` slots). Owned by
66
+ * the App, not the runtime — the runtime is a stateless dispatch substrate.
67
+ * Plugins observe lifecycle via `ctx.on(...)`; advanced callers can `.use()`
68
+ * a chain step (e.g. veto shutdown) directly on these slots.
69
+ */
70
+ readonly appHooks: AppHooks;
63
71
  /**
64
72
  * Wire collection — the same shape as `createInterface()` standalone.
65
73
  * Adopters consume `app.interface.forAdapter(kind)`; foundation-form
@@ -67,6 +75,13 @@ export interface App<TCaps = {}> {
67
75
  */
68
76
  readonly interface: Interface;
69
77
  readonly plugins: readonly PluginDefinition[];
78
+ /**
79
+ * Per-plugin contribution attribution captured at boot — which DI bindings,
80
+ * capabilities, stages, handlers, and hooks each plugin added. Empty until
81
+ * the plugin phases have run. Read by `captureTopology` to emit the
82
+ * `contributes` graph edge (Studio's Plugins page).
83
+ */
84
+ pluginContributions(): readonly PluginContributions[];
70
85
  /** Phantom marker — never read at runtime. */
71
86
  readonly __caps?: TCaps;
72
87
  /**
@@ -20,6 +20,7 @@
20
20
  import { createContainer } from "@nwire/container/awilix";
21
21
  import { NoopLogger } from "@nwire/logger";
22
22
  import { createInterface, } from "@nwire/wires";
23
+ import { autoInstallFileReporter, installTopologyEmit } from "@nwire/telemetry";
23
24
  import { createRuntime } from "./runtime/index.js";
24
25
  import { AppLifecycle } from "./lifecycle.js";
25
26
  import { isSubscription } from "./subscribe.js";
@@ -42,6 +43,15 @@ export function createApp(options) {
42
43
  for (const p of plugins) {
43
44
  lifecycle.enqueue(p);
44
45
  }
46
+ // Dev telemetry persistence — userland-unaware. When the process runs inside
47
+ // a project (a `.nwire` dir or `NWIRE_CWD` set), install the local file
48
+ // reporter so every run lands in `.nwire/telemetry/*.jsonl` for Studio
49
+ // history. No-op in unit tests / library imports (no project cwd). The
50
+ // detach runs at shutdown so the stream closes cleanly.
51
+ const detachTelemetry = autoInstallFileReporter(runtime);
52
+ if (detachTelemetry) {
53
+ lifecycle.appHooks.AppShutdown.on(() => detachTelemetry());
54
+ }
45
55
  const app = {
46
56
  $nwireApp: true,
47
57
  $kind: "app",
@@ -49,8 +59,10 @@ export function createApp(options) {
49
59
  name: appName,
50
60
  container,
51
61
  runtime,
62
+ appHooks: lifecycle.appHooks,
52
63
  interface: iface,
53
64
  plugins,
65
+ pluginContributions: () => lifecycle.pluginContributions(),
54
66
  get state() {
55
67
  return lifecycle.currentState;
56
68
  },
@@ -99,13 +111,20 @@ export function createApp(options) {
99
111
  boot: (appConfig) => app.start(appConfig),
100
112
  shutdown: () => lifecycle.stop(),
101
113
  dispatchFrameworkEvent: async (slot, payload) => {
102
- const hooks = runtime.hooks;
103
- const h = hooks[slot];
114
+ const appReg = lifecycle.appHooks;
115
+ const rtReg = runtime.hooks;
116
+ const h = appReg[slot] ?? rtReg[slot];
104
117
  if (!h)
105
118
  return true;
106
119
  const result = await h.runDetailed(payload);
107
120
  return result.outcome === "completed";
108
121
  },
109
122
  };
123
+ // Topology self-emit — userland-unaware, same project-cwd gate as the
124
+ // telemetry auto-install. When the app boots inside a project, it captures its
125
+ // own runtime wiring on `AppReady` and writes `.nwire/topology.json` so the
126
+ // static scanner can fold it in without booting the app itself. The file is
127
+ // removed on shutdown. No-op in unit tests / library imports (no project cwd).
128
+ installTopologyEmit(app);
110
129
  return app;
111
130
  }
@@ -18,7 +18,6 @@
18
18
  * Both produce a `PluginDefinition<TOptions, TMark>` that the Runtime's
19
19
  * `registerPlugin` runs as register-then-setup.
20
20
  */
21
- import { type SourceLocation } from "@nwire/messages";
22
21
  import type { PluginContext, PluginDefinition } from "./runtime/index.js";
23
22
  /** Body for the object call form. */
24
23
  export interface DefinePluginBody<TOptions = void, TMark = unknown> {
@@ -28,12 +27,8 @@ export interface DefinePluginBody<TOptions = void, TMark = unknown> {
28
27
  readonly __mark?: TMark;
29
28
  }
30
29
  /** Single-phase form — receives a setup closure directly. */
31
- export declare function definePlugin(name: string, setup: (ctx: PluginContext) => void): PluginDefinition & {
32
- readonly $source?: SourceLocation;
33
- };
30
+ export declare function definePlugin(name: string, setup: (ctx: PluginContext) => void): PluginDefinition;
34
31
  /** Two-phase form — accepts options + register + setup as a body object. */
35
- export declare function definePlugin<TMark = unknown, TOptions = void>(name: string, body: DefinePluginBody<TOptions, TMark>): PluginDefinition<TOptions, TMark> & {
36
- readonly $source?: SourceLocation;
37
- };
32
+ export declare function definePlugin<TMark = unknown, TOptions = void>(name: string, body: DefinePluginBody<TOptions, TMark>): PluginDefinition<TOptions, TMark>;
38
33
  /** Discriminator for runtime-side checks. */
39
34
  export declare function isPlugin(x: unknown): x is PluginDefinition;
@@ -18,12 +18,10 @@
18
18
  * Both produce a `PluginDefinition<TOptions, TMark>` that the Runtime's
19
19
  * `registerPlugin` runs as register-then-setup.
20
20
  */
21
- import { captureSourceLocation } from "@nwire/messages";
22
21
  // ─── Implementation ───────────────────────────────────────────────────────
23
22
  export function definePlugin(name, setupOrBody) {
24
- const $source = captureSourceLocation();
25
23
  if (typeof setupOrBody === "function") {
26
- return { name, setup: setupOrBody, $source };
24
+ return { name, setup: setupOrBody };
27
25
  }
28
26
  return {
29
27
  name,
@@ -31,7 +29,6 @@ export function definePlugin(name, setupOrBody) {
31
29
  register: setupOrBody.register,
32
30
  setup: setupOrBody.setup,
33
31
  __mark: setupOrBody.__mark,
34
- $source,
35
32
  };
36
33
  }
37
34
  /** Discriminator for runtime-side checks. */
@@ -8,7 +8,23 @@
8
8
  * App composes the Runtime with this orchestrator to get plugin lifecycle.
9
9
  */
10
10
  import type { Container } from "@nwire/container";
11
- import type { PluginDefinition, Runtime } from "@nwire/runtime";
11
+ import type { AppHooks, PluginDefinition, Runtime } from "@nwire/runtime";
12
+ /**
13
+ * What a plugin contributed to the app during its register + setup phases:
14
+ * DI bindings, capabilities, source/sink stages, registered handlers, and
15
+ * lifecycle hooks. Captured by diffing the runtime + global hook registry
16
+ * around each plugin's phase, so Studio can attribute every topology entry to
17
+ * the plugin that added it (the `contributes` graph edge).
18
+ */
19
+ export interface PluginContributions {
20
+ readonly plugin: string;
21
+ readonly bindings: readonly string[];
22
+ readonly capabilities: readonly string[];
23
+ readonly sourceStages: readonly string[];
24
+ readonly sinkStages: readonly string[];
25
+ readonly handlers: readonly string[];
26
+ readonly hooks: readonly string[];
27
+ }
12
28
  /** Lifecycle state. */
13
29
  export type LifecycleState = "idle" | "starting" | "running" | "stopping" | "stopped";
14
30
  export interface AppLifecycleOptions {
@@ -23,16 +39,27 @@ export declare class AppLifecycle {
23
39
  private readonly appName;
24
40
  private readonly container;
25
41
  private readonly runtime;
42
+ /**
43
+ * App / plugin lifecycle hook registry. Owned by the App, not the runtime —
44
+ * the runtime has no lifecycle. Wired through `runtime.observe(...)` so each
45
+ * slot emits `hook.step` telemetry exactly like the runtime's own hooks
46
+ * (Studio's plugin live-tap consumes these).
47
+ */
48
+ private readonly hooks;
26
49
  /** Plugins enqueued via `enqueue` whose phases haven't run yet. */
27
50
  private readonly pendingPlugins;
28
51
  /** Plugins whose register + setup have run. Ordered by registration. */
29
52
  private readonly plugins;
53
+ /** What each plugin contributed, captured during `runPluginPhases`. */
54
+ private readonly contributions;
30
55
  private state;
31
56
  private startPromise;
32
57
  private stopPromise;
33
58
  constructor(opts: AppLifecycleOptions);
34
59
  /** Current lifecycle phase. */
35
60
  get currentState(): LifecycleState;
61
+ /** The app's lifecycle hook registry (App* / Plugin* slots). */
62
+ get appHooks(): AppHooks;
36
63
  /** Names of every plugin queued or registered, in registration order. */
37
64
  pluginNames(): readonly string[];
38
65
  /**
@@ -47,6 +74,17 @@ export declare class AppLifecycle {
47
74
  * idempotent (no-op once the queue is empty).
48
75
  */
49
76
  runPluginPhases(): void;
77
+ /** A snapshot of the app's current registered surface — for contribution diffs. */
78
+ private snapshot;
79
+ /** Global hook registry names — best-effort (tolerate an absent registry). */
80
+ private hookNames;
81
+ /**
82
+ * Fold the surface added since `before` into the named plugin's contribution
83
+ * record, merging across the register + setup phases.
84
+ */
85
+ private recordContribution;
86
+ /** Per-plugin contribution attribution — read by `captureTopology`. */
87
+ pluginContributions(): readonly PluginContributions[];
50
88
  /**
51
89
  * Boot the app:
52
90
  *
@@ -76,4 +114,10 @@ export declare class AppLifecycle {
76
114
  stop(reason?: string): Promise<void>;
77
115
  /** Build the plugin context — same shape across register and setup. */
78
116
  private buildPluginContext;
117
+ /**
118
+ * Materialise a plugin-defined slot on the app lifecycle registry.
119
+ * Idempotent; the new slot is observed for `hook.step` telemetry like the
120
+ * built-in ones. Mirrors `Runtime.defineHook` but for app hooks.
121
+ */
122
+ private defineAppHook;
79
123
  }
package/dist/lifecycle.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * events, middleware, telemetry). It does not know about plugins. The
8
8
  * App composes the Runtime with this orchestrator to get plugin lifecycle.
9
9
  */
10
+ import { hook as hookFactory, listHooks } from "@nwire/hooks";
11
+ import { createAppHooks } from "./app-hooks.js";
10
12
  /**
11
13
  * The orchestrator. Each App owns one of these.
12
14
  */
@@ -14,10 +16,19 @@ export class AppLifecycle {
14
16
  appName;
15
17
  container;
16
18
  runtime;
19
+ /**
20
+ * App / plugin lifecycle hook registry. Owned by the App, not the runtime —
21
+ * the runtime has no lifecycle. Wired through `runtime.observe(...)` so each
22
+ * slot emits `hook.step` telemetry exactly like the runtime's own hooks
23
+ * (Studio's plugin live-tap consumes these).
24
+ */
25
+ hooks = createAppHooks();
17
26
  /** Plugins enqueued via `enqueue` whose phases haven't run yet. */
18
27
  pendingPlugins = [];
19
28
  /** Plugins whose register + setup have run. Ordered by registration. */
20
29
  plugins = [];
30
+ /** What each plugin contributed, captured during `runPluginPhases`. */
31
+ contributions = [];
21
32
  state = "idle";
22
33
  startPromise;
23
34
  stopPromise;
@@ -25,11 +36,21 @@ export class AppLifecycle {
25
36
  this.appName = opts.appName;
26
37
  this.container = opts.container;
27
38
  this.runtime = opts.runtime;
39
+ // Route every lifecycle hook's step telemetry through the runtime's
40
+ // canonical stream — same tap the runtime's own hooks use — so the
41
+ // `hook.step` records Studio's plugin live-tap relies on keep flowing.
42
+ for (const h of Object.values(this.hooks)) {
43
+ this.runtime.observe(h);
44
+ }
28
45
  }
29
46
  /** Current lifecycle phase. */
30
47
  get currentState() {
31
48
  return this.state;
32
49
  }
50
+ /** The app's lifecycle hook registry (App* / Plugin* slots). */
51
+ get appHooks() {
52
+ return this.hooks;
53
+ }
33
54
  /** Names of every plugin queued or registered, in registration order. */
34
55
  pluginNames() {
35
56
  return [...this.plugins.map((p) => p.name), ...this.pendingPlugins.map((p) => p.def.name)];
@@ -66,17 +87,85 @@ export class AppLifecycle {
66
87
  const ctx = this.buildPluginContext(p.def, boots, disposes);
67
88
  return { def: p.def, ctx, boots, disposes };
68
89
  });
90
+ // Attribute every binding/capability/stage/handler/hook a plugin adds by
91
+ // diffing the runtime + hook registry around each of its phases. Snapshot
92
+ // immediately before each call so the diff is that one plugin's delta —
93
+ // even though all registers run before all setups.
69
94
  for (const p of prepared) {
70
- if (p.def.register)
95
+ if (p.def.register) {
96
+ const before = this.snapshot();
71
97
  p.def.register(p.ctx);
98
+ this.recordContribution(p.def.name, before);
99
+ }
72
100
  }
73
101
  for (const p of prepared) {
102
+ const before = this.snapshot();
74
103
  p.def.setup(p.ctx);
104
+ this.recordContribution(p.def.name, before);
75
105
  }
76
106
  for (const p of prepared) {
77
107
  this.plugins.push({ name: p.def.name, boots: p.boots, disposes: p.disposes });
78
108
  }
79
109
  }
110
+ /** A snapshot of the app's current registered surface — for contribution diffs. */
111
+ snapshot() {
112
+ const rt = this.runtime;
113
+ const names = (xs, key) => new Set((xs ?? []).map(key));
114
+ return {
115
+ bindings: names(this.container.list?.(), (b) => b.name),
116
+ capabilities: new Set(rt.listCapabilities?.() ?? []),
117
+ sourceStages: names(rt.listSourceStages?.(), (s) => s.name),
118
+ sinkStages: names(rt.listSinkStages?.(), (s) => s.name),
119
+ handlers: new Set(rt.listHandlers?.() ?? []),
120
+ hooks: this.hookNames(),
121
+ };
122
+ }
123
+ /** Global hook registry names — best-effort (tolerate an absent registry). */
124
+ hookNames() {
125
+ try {
126
+ return new Set(listHooks().map((h) => h.name));
127
+ }
128
+ catch {
129
+ return new Set();
130
+ }
131
+ }
132
+ /**
133
+ * Fold the surface added since `before` into the named plugin's contribution
134
+ * record, merging across the register + setup phases.
135
+ */
136
+ recordContribution(plugin, before) {
137
+ const after = this.snapshot();
138
+ const added = (a, b) => [...b].filter((x) => !a.has(x));
139
+ const delta = {
140
+ plugin,
141
+ bindings: added(before.bindings, after.bindings),
142
+ capabilities: added(before.capabilities, after.capabilities),
143
+ sourceStages: added(before.sourceStages, after.sourceStages),
144
+ sinkStages: added(before.sinkStages, after.sinkStages),
145
+ handlers: added(before.handlers, after.handlers),
146
+ hooks: added(before.hooks, after.hooks),
147
+ };
148
+ const existing = this.contributions.find((c) => c.plugin === plugin);
149
+ if (!existing) {
150
+ this.contributions.push(delta);
151
+ return;
152
+ }
153
+ const merge = (a, b) => [
154
+ ...new Set([...a, ...b]),
155
+ ];
156
+ Object.assign(existing, {
157
+ bindings: merge(existing.bindings, delta.bindings),
158
+ capabilities: merge(existing.capabilities, delta.capabilities),
159
+ sourceStages: merge(existing.sourceStages, delta.sourceStages),
160
+ sinkStages: merge(existing.sinkStages, delta.sinkStages),
161
+ handlers: merge(existing.handlers, delta.handlers),
162
+ hooks: merge(existing.hooks, delta.hooks),
163
+ });
164
+ }
165
+ /** Per-plugin contribution attribution — read by `captureTopology`. */
166
+ pluginContributions() {
167
+ return this.contributions;
168
+ }
80
169
  /**
81
170
  * Boot the app:
82
171
  *
@@ -100,7 +189,7 @@ export class AppLifecycle {
100
189
  }
101
190
  this.state = "starting";
102
191
  const appName = this.appName;
103
- const hooks = this.runtime.hooks;
192
+ const hooks = this.hooks;
104
193
  this.startPromise = (async () => {
105
194
  try {
106
195
  this.runPluginPhases();
@@ -183,7 +272,7 @@ export class AppLifecycle {
183
272
  }
184
273
  this.state = "stopping";
185
274
  const appName = this.appName;
186
- const hooks = this.runtime.hooks;
275
+ const hooks = this.hooks;
187
276
  this.stopPromise = (async () => {
188
277
  const errors = [];
189
278
  const shuttingDown = await hooks.AppShuttingDown.runDetailed({ appName, reason });
@@ -250,17 +339,11 @@ export class AppLifecycle {
250
339
  bind: (name, factory, opts) => {
251
340
  this.container.register(name, factory, opts);
252
341
  },
253
- add: (cap) => {
254
- this.runtime.add(cap);
255
- },
256
- sink: (stage) => {
257
- this.runtime.sink(stage);
258
- },
259
342
  on: (name, fn) => {
260
- const reg = this.runtime.hooks;
343
+ const reg = this.hooks;
261
344
  const h = reg[name];
262
345
  if (!h) {
263
- throw new Error(`PluginContext.on: framework hook "${String(name)}" does not exist — call defineHook first if it's a plugin-defined slot.`);
346
+ throw new Error(`PluginContext.on: app lifecycle hook "${String(name)}" does not exist — call defineHook first if it's a plugin-defined slot.`);
264
347
  }
265
348
  h.on(fn);
266
349
  },
@@ -273,7 +356,22 @@ export class AppLifecycle {
273
356
  shutdown: (fn) => {
274
357
  disposes.push(fn);
275
358
  },
276
- defineHook: (name) => this.runtime.defineHook(name),
359
+ defineHook: (name) => this.defineAppHook(name),
277
360
  };
278
361
  }
362
+ /**
363
+ * Materialise a plugin-defined slot on the app lifecycle registry.
364
+ * Idempotent; the new slot is observed for `hook.step` telemetry like the
365
+ * built-in ones. Mirrors `Runtime.defineHook` but for app hooks.
366
+ */
367
+ defineAppHook(name) {
368
+ const reg = this.hooks;
369
+ const existing = reg[name];
370
+ if (existing)
371
+ return existing;
372
+ const h = hookFactory(`nwire.${name}`);
373
+ reg[name] = h;
374
+ this.runtime.observe(h);
375
+ return h;
376
+ }
279
377
  }
package/dist/publish.js CHANGED
@@ -54,8 +54,8 @@ export const publishCapability = defineCapability({
54
54
  */
55
55
  export function publishPlugin() {
56
56
  return definePlugin("publish", {
57
- setup: ({ add }) => {
58
- add(publishCapability);
57
+ setup: ({ runtime }) => {
58
+ runtime.add(publishCapability);
59
59
  },
60
60
  });
61
61
  }
@@ -5,5 +5,5 @@
5
5
  * Re-exports the canonical Runtime surface from `@nwire/runtime` so
6
6
  * `@nwire/app` consumers can import everything from a single package.
7
7
  */
8
- export { Runtime, createRuntime, isBuiltInHook, serializeError, type RuntimeOptions, type Telemetry, type TelemetryListener, type HookStepTelemetry, type DispatchHookCtx, type DispatchMiddleware, type SerializedError, type PluginDefinition, type PluginContext, } from "@nwire/runtime";
8
+ export { Runtime, createRuntime, isBuiltInHook, serializeError, messageRef, type MessageRef, type RuntimeOptions, type Telemetry, type TelemetryListener, type HookStepTelemetry, type DispatchHookCtx, type DispatchMiddleware, type SerializedError, type PluginDefinition, type PluginContext, } from "@nwire/runtime";
9
9
  export { createFrameworkHooks, type FrameworkHooks, type PluginKind } from "@nwire/runtime";
@@ -5,5 +5,5 @@
5
5
  * Re-exports the canonical Runtime surface from `@nwire/runtime` so
6
6
  * `@nwire/app` consumers can import everything from a single package.
7
7
  */
8
- export { Runtime, createRuntime, isBuiltInHook, serializeError, } from "@nwire/runtime";
8
+ export { Runtime, createRuntime, isBuiltInHook, serializeError, messageRef, } from "@nwire/runtime";
9
9
  export { createFrameworkHooks } from "@nwire/runtime";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nwire/app",
3
- "version": "0.12.1",
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.",
3
+ "version": "0.13.0",
4
+ "description": "Nwire — managed Container with plugin lifecycle, framework hooks, and DI. Composes apps + plugins, boots in order, exposes a Container, fires framework hooks at every lifecycle transition.",
5
5
  "keywords": [
6
6
  "app",
7
7
  "container",
@@ -29,14 +29,15 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@nwire/handler": "0.12.1",
33
- "@nwire/logger": "0.12.1",
34
- "@nwire/hooks": "0.12.1",
35
- "@nwire/messages": "0.12.1",
36
- "@nwire/runtime": "0.12.1",
37
- "@nwire/envelope": "0.12.1",
38
- "@nwire/wires": "0.12.1",
39
- "@nwire/container": "0.12.1"
32
+ "@nwire/container": "0.13.0",
33
+ "@nwire/handler": "0.13.0",
34
+ "@nwire/logger": "0.13.0",
35
+ "@nwire/hooks": "0.13.0",
36
+ "@nwire/telemetry": "0.13.0",
37
+ "@nwire/wires": "0.13.0",
38
+ "@nwire/runtime": "0.13.0",
39
+ "@nwire/messages": "0.13.0",
40
+ "@nwire/envelope": "0.13.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/node": "^22.19.9",