@nwire/app 0.10.1 → 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/README.md CHANGED
@@ -16,10 +16,9 @@ import { z } from "zod";
16
16
 
17
17
  const app = createApp({ appName: "api" });
18
18
 
19
- app.wire(
20
- post("/hello", { body: z.object({ name: z.string() }) }),
21
- async (input) => ({ message: `Hello, ${input.name}!` }),
22
- );
19
+ app.wire(post("/hello", { body: z.object({ name: z.string() }) }), async (input) => ({
20
+ message: `Hello, ${input.name}!`,
21
+ }));
23
22
  ```
24
23
 
25
24
  `createApp` builds an **App** — a bounded context with its container,
@@ -64,10 +63,7 @@ const ordersApp = createApp({ appName: "orders" /* ... */ });
64
63
  const inventoryApp = createApp({ appName: "inventory" /* ... */ });
65
64
 
66
65
  const monolith = appCompose(ordersApp, inventoryApp);
67
- await endpoint("monolith", { port: 3000 })
68
- .use(httpKoa())
69
- .mount(monolith)
70
- .run();
66
+ await endpoint("monolith", { port: 3000 }).use(httpKoa()).mount(monolith).run();
71
67
  ```
72
68
 
73
69
  ## Framework events
package/dist/app.d.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * `@nwire/app` — composition root. `createApp` constructs a Runtime, runs
3
- * plugin setup, and drives start/stop. `createRuntime` exposes the raw
4
- * substrate for advanced cases.
3
+ * plugin lifecycle, and exposes the fluent verbs that move the App's typed
4
+ * capability set. `createRuntime` exposes the raw substrate for cases that
5
+ * need the dispatch core without the App layer on top.
5
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
8
  export { definePlugin, isPlugin } from "./define-plugin.js";
8
9
  export { createApp, type App, type CreateAppOptions } from "./create-app.js";
9
10
  export { appCompose, containerOf, type AppComposeOptions, type CollisionPolicy, } from "./compose-app.js";
11
+ export { subscribe, isSubscription, type Subscription, type WhenFn } from "./subscribe.js";
12
+ export { publishCapability, publishPlugin, type PublishCaps } from "./publish.js";
13
+ export { defineCapability, type Capability, type CapabilityBase, type OutboundStage, type StagePosition, type StageContext, } from "@nwire/runtime";
10
14
  export type { HandlerBaseCtx, Ctx } from "@nwire/handler";
package/dist/app.js CHANGED
@@ -1,9 +1,20 @@
1
1
  /**
2
2
  * `@nwire/app` — composition root. `createApp` constructs a Runtime, runs
3
- * plugin setup, and drives start/stop. `createRuntime` exposes the raw
4
- * substrate for advanced cases.
3
+ * plugin lifecycle, and exposes the fluent verbs that move the App's typed
4
+ * capability set. `createRuntime` exposes the raw substrate for cases that
5
+ * need the dispatch core without the App layer on top.
5
6
  */
6
7
  export { Runtime, createRuntime, serializeError, } from "./runtime/index.js";
7
8
  export { definePlugin, isPlugin } from "./define-plugin.js";
8
9
  export { createApp } from "./create-app.js";
9
10
  export { appCompose, containerOf, } from "./compose-app.js";
11
+ // Listener primitive — `subscribe((when) => …)` creates a Subscription
12
+ // value the App registers through `app.subscribe(sub | subs)`.
13
+ export { subscribe, isSubscription } from "./subscribe.js";
14
+ // Publish capability — contributes `ctx.publish`. Composes
15
+ // `runtime.emit` (local fanout) with `runtime.sinkDrain` (outbound chain),
16
+ // gated on the event's `$public` marker.
17
+ export { publishCapability, publishPlugin } from "./publish.js";
18
+ // Capability + sink primitives — re-exported from @nwire/runtime so the
19
+ // App surface presents a single import surface.
20
+ export { defineCapability, } from "@nwire/runtime";
@@ -38,10 +38,7 @@ function bindingKey(wire) {
38
38
  return `${adapter}:${verb ? `${verb}:` : ""}${id}`;
39
39
  }
40
40
  function isComposeOptions(x) {
41
- return (typeof x === "object" &&
42
- x !== null &&
43
- !("interface" in x) &&
44
- "onCollision" in x);
41
+ return (typeof x === "object" && x !== null && !("interface" in x) && "onCollision" in x);
45
42
  }
46
43
  export function appCompose(...args) {
47
44
  // Trailing options arg?
@@ -79,8 +76,8 @@ export function appCompose(...args) {
79
76
  // The composite presents as an App. It re-uses the FIRST child's
80
77
  // runtime / container / start / stop / etc. — the composite is for
81
78
  // wire surface, not for runtime fan-out. Multi-runtime composition is
82
- // not supported in 0.10; if you want it, mount each app under its own
83
- // endpoint instead.
79
+ // out of scope; to run more than one runtime in the same process,
80
+ // mount each app under its own endpoint.
84
81
  const head = apps[0];
85
82
  const compositeName = apps.map((a) => a.appName).join("+");
86
83
  const composite = {
@@ -88,10 +85,28 @@ export function appCompose(...args) {
88
85
  $kind: "app",
89
86
  appName: compositeName,
90
87
  name: compositeName,
88
+ get state() {
89
+ return head.state;
90
+ },
91
91
  container: head.container,
92
92
  runtime: head.runtime,
93
93
  interface: mergedInterface,
94
94
  plugins: apps.flatMap((a) => a.plugins),
95
+ with(plugin) {
96
+ // Composites install through the head app — multi-runtime install is
97
+ // out of scope. The composite's runtime is the head's runtime, so
98
+ // anything added on the composite lands there.
99
+ head.with(plugin);
100
+ return composite;
101
+ },
102
+ subscribe(sub) {
103
+ head.subscribe(sub);
104
+ return composite;
105
+ },
106
+ when(event, fn) {
107
+ head.when(event, fn);
108
+ return composite;
109
+ },
95
110
  wire(binding, handler) {
96
111
  mergedInterface.wire(binding, handler);
97
112
  return composite;
@@ -1,21 +1,44 @@
1
1
  /**
2
- * `createApp` — composition root. Constructs a Runtime, registers
3
- * plugins, and exposes the lifecycle the runtime owns.
2
+ * `createApp` — composition root. Constructs a Runtime, registers plugins,
3
+ * and exposes the fluent verbs that move the App's typed capability set:
4
+ *
5
+ * - `App<TCaps>` is generic. Each `.with(plugin)` intersects the plugin's
6
+ * `__mark` into `TCaps`, so handler ctx narrows automatically.
7
+ *
8
+ * - `.with(plugin)` is the typed plugin-install verb. Equivalent to
9
+ * `runtime.registerPlugin(plugin)` at runtime but moves the type.
10
+ *
11
+ * - `.subscribe(sub | subs)` registers `Subscription` values — closures
12
+ * produced by the module-level `subscribe(fn)` primitive. The pattern
13
+ * for organised listener files under `app/listeners/`.
14
+ *
15
+ * - `.when(event, fn)` is the inline shortcut over `runtime.when`.
16
+ *
17
+ * Plugins passed via `createApp({ plugins })` install identically to those
18
+ * installed via `.with()`.
4
19
  */
5
20
  import { type Container } from "@nwire/container/awilix";
6
21
  import { type Logger } from "@nwire/logger";
7
22
  import { type Binding, type HandlerDef, type Interface, type WireCtxBuilder } from "@nwire/wires";
23
+ import type { EventDefinition } from "@nwire/messages";
24
+ import type { MessageEnvelope } from "@nwire/envelope";
8
25
  import { type PluginDefinition, type Runtime } from "./runtime/index.js";
26
+ import { type Subscription } from "./subscribe.js";
9
27
  export interface CreateAppOptions {
10
28
  /** App name — surfaces in framework-hook payloads + dev logger output. */
11
29
  readonly appName: string;
12
- /** Plugins. Setup runs synchronously at construction. */
30
+ /**
31
+ * Plugins to install at construction. Setup runs synchronously. Equivalent
32
+ * to chaining `.with(plugin)` after `createApp({...})`; the array form is
33
+ * untyped (TCaps stays `{}`), so prefer `.with(plugin)` when the App's
34
+ * capability set should narrow handler ctx.
35
+ */
13
36
  readonly plugins?: readonly PluginDefinition[];
14
37
  /**
15
- * Handlers registered on the runtime at construction so any plugin
16
- * (forge, queue, …) can pick them up during boot. Each handler must
17
- * satisfy the structural shape `runtime.registerHandler` accepts
18
- * (`name`, `input`, `run(ctx, opts?)` returning `{ result }`).
38
+ * Handlers registered on the runtime at construction so any plugin can
39
+ * pick them up during boot. Each handler must satisfy the structural
40
+ * shape `runtime.registerHandler` accepts: `name`, `input`, and
41
+ * `run(ctx, opts?)` returning `{ result }`.
19
42
  */
20
43
  readonly handlers?: readonly any[];
21
44
  /** Override the container — defaults to a fresh `createContainer()`. */
@@ -25,7 +48,11 @@ export interface CreateAppOptions {
25
48
  /** Logger override. */
26
49
  readonly logger?: Logger;
27
50
  }
28
- export interface App {
51
+ /**
52
+ * The App. `TCaps` is the phantom carrier of every capability contributed
53
+ * by plugins via `.with()`. Defaults to `{}`.
54
+ */
55
+ export interface App<TCaps = {}> {
29
56
  readonly $nwireApp: true;
30
57
  readonly $kind: "app";
31
58
  readonly appName: string;
@@ -40,15 +67,48 @@ export interface App {
40
67
  */
41
68
  readonly interface: Interface;
42
69
  readonly plugins: readonly PluginDefinition[];
70
+ /** Phantom marker — never read at runtime. */
71
+ readonly __caps?: TCaps;
72
+ /**
73
+ * Install a plugin and advance the App's type. Same runtime effect as
74
+ * passing the plugin in `createApp({plugins})`; the difference is the
75
+ * type-level move — `TCaps` intersects the plugin's __mark so handler
76
+ * ctx narrows to the new shape.
77
+ */
78
+ with<TMark>(plugin: PluginDefinition<void, TMark>): App<TCaps & TMark>;
79
+ with<TOpts, TMark>(plugin: PluginDefinition<TOpts, TMark>): App<TCaps & TMark>;
80
+ /**
81
+ * Register Subscription values. Each subscription's `install(when)` is
82
+ * called with the App's `runtime.when`. Pass a single sub or an array.
83
+ */
84
+ subscribe(sub: Subscription | readonly Subscription[]): this;
85
+ /**
86
+ * Inline listener shortcut over `runtime.when`. Use for one-offs where
87
+ * pulling a dedicated listener file feels heavy. For organised codebases,
88
+ * prefer `.subscribe([...])` from `app/listeners/`.
89
+ */
90
+ when<TPayload>(event: EventDefinition & {
91
+ readonly name: string;
92
+ readonly schema: {
93
+ parse(input: unknown): unknown;
94
+ };
95
+ }, fn: (payload: TPayload, envelope: MessageEnvelope) => Promise<void> | void): this;
43
96
  /** Sugar for `app.interface.wire(b, h)`. Returns the app for chaining. */
44
97
  wire(binding: Binding, handler: HandlerDef): this;
45
98
  /** Sugar for `app.interface.provide(builder)`. Returns the app for chaining. */
46
99
  provide<TExtras extends object>(builder: WireCtxBuilder<TExtras>): this;
47
- start(): Promise<void>;
100
+ /** Current lifecycle phase. */
101
+ readonly state: "idle" | "starting" | "running" | "stopping" | "stopped";
102
+ /**
103
+ * Boot the app. Optional `appConfig` is bound on the container as
104
+ * `"config"` so plugin boot callbacks can resolve it. Idempotent —
105
+ * concurrent / repeat calls return the same Promise.
106
+ */
107
+ start(appConfig?: unknown): Promise<void>;
48
108
  stop(reason?: string): Promise<void>;
49
- /** Endpoint alias. */
50
- boot(): Promise<void>;
51
- /** Endpoint alias. */
109
+ /** Endpoint alias for {@link start}. */
110
+ boot(appConfig?: unknown): Promise<void>;
111
+ /** Endpoint alias for {@link stop}. */
52
112
  shutdown(): Promise<void>;
53
113
  /**
54
114
  * Run a built-in framework hook by registry-slot name. Resolves `false`
@@ -56,4 +116,4 @@ export interface App {
56
116
  */
57
117
  dispatchFrameworkEvent(slot: string, payload: unknown): Promise<boolean>;
58
118
  }
59
- export declare function createApp(options: CreateAppOptions): App;
119
+ export declare function createApp(options: CreateAppOptions): App<{}>;
@@ -1,23 +1,46 @@
1
1
  /**
2
- * `createApp` — composition root. Constructs a Runtime, registers
3
- * plugins, and exposes the lifecycle the runtime owns.
2
+ * `createApp` — composition root. Constructs a Runtime, registers plugins,
3
+ * and exposes the fluent verbs that move the App's typed capability set:
4
+ *
5
+ * - `App<TCaps>` is generic. Each `.with(plugin)` intersects the plugin's
6
+ * `__mark` into `TCaps`, so handler ctx narrows automatically.
7
+ *
8
+ * - `.with(plugin)` is the typed plugin-install verb. Equivalent to
9
+ * `runtime.registerPlugin(plugin)` at runtime but moves the type.
10
+ *
11
+ * - `.subscribe(sub | subs)` registers `Subscription` values — closures
12
+ * produced by the module-level `subscribe(fn)` primitive. The pattern
13
+ * for organised listener files under `app/listeners/`.
14
+ *
15
+ * - `.when(event, fn)` is the inline shortcut over `runtime.when`.
16
+ *
17
+ * Plugins passed via `createApp({ plugins })` install identically to those
18
+ * installed via `.with()`.
4
19
  */
5
20
  import { createContainer } from "@nwire/container/awilix";
6
21
  import { NoopLogger } from "@nwire/logger";
7
22
  import { createInterface, } from "@nwire/wires";
8
23
  import { createRuntime } from "./runtime/index.js";
24
+ import { AppLifecycle } from "./lifecycle.js";
25
+ import { isSubscription } from "./subscribe.js";
9
26
  export function createApp(options) {
10
27
  const appName = options.appName;
11
28
  const container = options.container ?? createContainer();
12
29
  const logger = options.logger ?? new NoopLogger();
13
30
  const runtime = options.runtime ?? createRuntime({ container, logger, appName });
14
- const plugins = options.plugins ?? [];
31
+ const plugins = [...(options.plugins ?? [])];
15
32
  const iface = createInterface();
16
33
  for (const h of options.handlers ?? []) {
17
34
  runtime.registerHandler(h);
18
35
  }
36
+ // The orchestrator owns plugin queueing, register/setup phase
37
+ // execution, and the boot/shutdown state machine. Plugins passed in
38
+ // `options.plugins` enqueue immediately; `.with(plugin)` enqueues on
39
+ // top. Both fire at `app.start()`, in register-then-setup order across
40
+ // all plugins.
41
+ const lifecycle = new AppLifecycle({ appName, container, runtime });
19
42
  for (const p of plugins) {
20
- runtime.registerPlugin(p);
43
+ lifecycle.enqueue(p);
21
44
  }
22
45
  const app = {
23
46
  $nwireApp: true,
@@ -28,6 +51,30 @@ export function createApp(options) {
28
51
  runtime,
29
52
  interface: iface,
30
53
  plugins,
54
+ get state() {
55
+ return lifecycle.currentState;
56
+ },
57
+ with(plugin) {
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ lifecycle.enqueue(plugin);
60
+ plugins.push(plugin);
61
+ // Phantom type advance — runtime identity stays the same.
62
+ return app;
63
+ },
64
+ subscribe(sub) {
65
+ const subs = isSubscription(sub) ? [sub] : sub;
66
+ const whenFn = (event, fn) => {
67
+ runtime.when(event, fn);
68
+ };
69
+ for (const s of subs) {
70
+ s.install(whenFn);
71
+ }
72
+ return app;
73
+ },
74
+ when(event, fn) {
75
+ runtime.when(event, fn);
76
+ return app;
77
+ },
31
78
  wire(binding, handler) {
32
79
  iface.wire(binding, handler);
33
80
  // Tag the just-appended wire with its source app so adopters can
@@ -40,10 +87,17 @@ export function createApp(options) {
40
87
  iface.provide(builder);
41
88
  return app;
42
89
  },
43
- start: () => runtime.start(),
44
- stop: (reason) => runtime.stop(reason),
45
- boot: () => runtime.start(),
46
- shutdown: () => runtime.stop(),
90
+ start: (appConfig) => {
91
+ // Bind appConfig as "config" so plugin register, setup, and boot
92
+ // callbacks can resolve it. Skipped when no config is passed.
93
+ if (appConfig !== undefined && !container.has("config")) {
94
+ container.register("config", appConfig);
95
+ }
96
+ return lifecycle.start();
97
+ },
98
+ stop: (reason) => lifecycle.stop(reason),
99
+ boot: (appConfig) => app.start(appConfig),
100
+ shutdown: () => lifecycle.stop(),
47
101
  dispatchFrameworkEvent: async (slot, payload) => {
48
102
  const hooks = runtime.hooks;
49
103
  const h = hooks[slot];
@@ -1,14 +1,39 @@
1
1
  /**
2
2
  * Plugin factory — wraps the canonical `PluginDefinition` shape with
3
- * source-location capture so Studio + scan can render where a plugin
4
- * was declared. Setup runs synchronously at `runtime.registerPlugin`
5
- * time and contributes container bindings, hook subscriptions, and
6
- * boot/dispose queues.
3
+ * source-location capture so tooling (Studio, scan) can render where a
4
+ * plugin was declared.
5
+ *
6
+ * Two call forms produce the same returned shape:
7
+ *
8
+ * // Single-phase: pass a setup closure directly.
9
+ * definePlugin("name", (ctx) => { ... setup body ... });
10
+ *
11
+ * // Two-phase: pass a body object with options + register + setup.
12
+ * definePlugin("name", {
13
+ * options: opts,
14
+ * register: (ctx) => { ... bindings + hook slots ... },
15
+ * setup: (ctx) => { ... capabilities + listeners + boots ... },
16
+ * });
17
+ *
18
+ * Both produce a `PluginDefinition<TOptions, TMark>` that the Runtime's
19
+ * `registerPlugin` runs as register-then-setup.
7
20
  */
8
21
  import { type SourceLocation } from "@nwire/messages";
9
22
  import type { PluginContext, PluginDefinition } from "./runtime/index.js";
23
+ /** Body for the object call form. */
24
+ export interface DefinePluginBody<TOptions = void, TMark = unknown> {
25
+ readonly options?: TOptions;
26
+ register?(ctx: PluginContext<TOptions>): void;
27
+ setup(ctx: PluginContext<TOptions>): void;
28
+ readonly __mark?: TMark;
29
+ }
30
+ /** Single-phase form — receives a setup closure directly. */
10
31
  export declare function definePlugin(name: string, setup: (ctx: PluginContext) => void): PluginDefinition & {
11
32
  readonly $source?: SourceLocation;
12
33
  };
34
+ /** 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
+ };
13
38
  /** Discriminator for runtime-side checks. */
14
39
  export declare function isPlugin(x: unknown): x is PluginDefinition;
@@ -1,16 +1,36 @@
1
1
  /**
2
2
  * Plugin factory — wraps the canonical `PluginDefinition` shape with
3
- * source-location capture so Studio + scan can render where a plugin
4
- * was declared. Setup runs synchronously at `runtime.registerPlugin`
5
- * time and contributes container bindings, hook subscriptions, and
6
- * boot/dispose queues.
3
+ * source-location capture so tooling (Studio, scan) can render where a
4
+ * plugin was declared.
5
+ *
6
+ * Two call forms produce the same returned shape:
7
+ *
8
+ * // Single-phase: pass a setup closure directly.
9
+ * definePlugin("name", (ctx) => { ... setup body ... });
10
+ *
11
+ * // Two-phase: pass a body object with options + register + setup.
12
+ * definePlugin("name", {
13
+ * options: opts,
14
+ * register: (ctx) => { ... bindings + hook slots ... },
15
+ * setup: (ctx) => { ... capabilities + listeners + boots ... },
16
+ * });
17
+ *
18
+ * Both produce a `PluginDefinition<TOptions, TMark>` that the Runtime's
19
+ * `registerPlugin` runs as register-then-setup.
7
20
  */
8
21
  import { captureSourceLocation } from "@nwire/messages";
9
- export function definePlugin(name, setup) {
22
+ // ─── Implementation ───────────────────────────────────────────────────────
23
+ export function definePlugin(name, setupOrBody) {
10
24
  const $source = captureSourceLocation();
25
+ if (typeof setupOrBody === "function") {
26
+ return { name, setup: setupOrBody, $source };
27
+ }
11
28
  return {
12
29
  name,
13
- setup,
30
+ options: setupOrBody.options,
31
+ register: setupOrBody.register,
32
+ setup: setupOrBody.setup,
33
+ __mark: setupOrBody.__mark,
14
34
  $source,
15
35
  };
16
36
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * App lifecycle orchestrator — owns the plugin queue, register/setup phase
3
+ * execution, the boot/shutdown state machine, and the firing of
4
+ * `AppRegistering` / `AppBooting` / `AppReady` / `AppShutdown` hooks.
5
+ *
6
+ * The Runtime is the dispatch substrate (container, capabilities, sinks,
7
+ * events, middleware, telemetry). It does not know about plugins. The
8
+ * App composes the Runtime with this orchestrator to get plugin lifecycle.
9
+ */
10
+ import type { Container } from "@nwire/container";
11
+ import type { PluginDefinition, Runtime } from "@nwire/runtime";
12
+ /** Lifecycle state. */
13
+ export type LifecycleState = "idle" | "starting" | "running" | "stopping" | "stopped";
14
+ export interface AppLifecycleOptions {
15
+ readonly appName: string;
16
+ readonly container: Container;
17
+ readonly runtime: Runtime;
18
+ }
19
+ /**
20
+ * The orchestrator. Each App owns one of these.
21
+ */
22
+ export declare class AppLifecycle {
23
+ private readonly appName;
24
+ private readonly container;
25
+ private readonly runtime;
26
+ /** Plugins enqueued via `enqueue` whose phases haven't run yet. */
27
+ private readonly pendingPlugins;
28
+ /** Plugins whose register + setup have run. Ordered by registration. */
29
+ private readonly plugins;
30
+ private state;
31
+ private startPromise;
32
+ private stopPromise;
33
+ constructor(opts: AppLifecycleOptions);
34
+ /** Current lifecycle phase. */
35
+ get currentState(): LifecycleState;
36
+ /** Names of every plugin queued or registered, in registration order. */
37
+ pluginNames(): readonly string[];
38
+ /**
39
+ * Queue a plugin for the next `start()`. Throws if a plugin of the same
40
+ * name is already enqueued or registered, or if the lifecycle has
41
+ * advanced past `idle` (queue is closed once start begins).
42
+ */
43
+ enqueue<TOptions = void>(plugin: PluginDefinition<TOptions>): void;
44
+ /**
45
+ * Drain queued plugins: all `register`s first, then all `setup`s, then
46
+ * record their boot/dispose queues for the lifecycle to run. Synchronous;
47
+ * idempotent (no-op once the queue is empty).
48
+ */
49
+ runPluginPhases(): void;
50
+ /**
51
+ * Boot the app:
52
+ *
53
+ * 1. Drain queued plugins (registers, then setups).
54
+ * 2. Fire `PluginRegistered` for each registered plugin.
55
+ * 3. Fire `AppRegistering` chain (vetoable).
56
+ * 4. Fire `AppBooting` chain (vetoable).
57
+ * 5. For each plugin: `PluginBooting` chain → boot queue → `PluginBooted`.
58
+ * 6. Run container health checks; fail-fast if any throw.
59
+ * 7. Fire `AppBooted` + `AppReady`.
60
+ *
61
+ * Idempotent + in-flight-shared.
62
+ */
63
+ start(): Promise<void>;
64
+ /**
65
+ * Graceful shutdown:
66
+ *
67
+ * 1. Fire `AppShuttingDown` chain (vetoable).
68
+ * 2. For each plugin in reverse order: `PluginShuttingDown` chain →
69
+ * dispose queue (LIFO, errors isolated) → `PluginShutdown`.
70
+ * 3. `container.dispose()` — runs `bind({ dispose })` callbacks LIFO.
71
+ * 4. Fire `AppShutdown`.
72
+ *
73
+ * Idempotent + in-flight-shared. The first thrown error during plugin
74
+ * dispose or container dispose is rethrown after the sequence completes.
75
+ */
76
+ stop(reason?: string): Promise<void>;
77
+ /** Build the plugin context — same shape across register and setup. */
78
+ private buildPluginContext;
79
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * App lifecycle orchestrator — owns the plugin queue, register/setup phase
3
+ * execution, the boot/shutdown state machine, and the firing of
4
+ * `AppRegistering` / `AppBooting` / `AppReady` / `AppShutdown` hooks.
5
+ *
6
+ * The Runtime is the dispatch substrate (container, capabilities, sinks,
7
+ * events, middleware, telemetry). It does not know about plugins. The
8
+ * App composes the Runtime with this orchestrator to get plugin lifecycle.
9
+ */
10
+ /**
11
+ * The orchestrator. Each App owns one of these.
12
+ */
13
+ export class AppLifecycle {
14
+ appName;
15
+ container;
16
+ runtime;
17
+ /** Plugins enqueued via `enqueue` whose phases haven't run yet. */
18
+ pendingPlugins = [];
19
+ /** Plugins whose register + setup have run. Ordered by registration. */
20
+ plugins = [];
21
+ state = "idle";
22
+ startPromise;
23
+ stopPromise;
24
+ constructor(opts) {
25
+ this.appName = opts.appName;
26
+ this.container = opts.container;
27
+ this.runtime = opts.runtime;
28
+ }
29
+ /** Current lifecycle phase. */
30
+ get currentState() {
31
+ return this.state;
32
+ }
33
+ /** Names of every plugin queued or registered, in registration order. */
34
+ pluginNames() {
35
+ return [...this.plugins.map((p) => p.name), ...this.pendingPlugins.map((p) => p.def.name)];
36
+ }
37
+ /**
38
+ * Queue a plugin for the next `start()`. Throws if a plugin of the same
39
+ * name is already enqueued or registered, or if the lifecycle has
40
+ * advanced past `idle` (queue is closed once start begins).
41
+ */
42
+ enqueue(plugin) {
43
+ if (this.state !== "idle") {
44
+ throw new Error(`App.with: cannot enqueue "${plugin.name}" — app is "${this.state}".`);
45
+ }
46
+ if (this.plugins.some((p) => p.name === plugin.name)) {
47
+ throw new Error(`App.with: plugin "${plugin.name}" already registered.`);
48
+ }
49
+ if (this.pendingPlugins.some((p) => p.def.name === plugin.name)) {
50
+ throw new Error(`App.with: plugin "${plugin.name}" already enqueued.`);
51
+ }
52
+ this.pendingPlugins.push({ def: plugin });
53
+ }
54
+ /**
55
+ * Drain queued plugins: all `register`s first, then all `setup`s, then
56
+ * record their boot/dispose queues for the lifecycle to run. Synchronous;
57
+ * idempotent (no-op once the queue is empty).
58
+ */
59
+ runPluginPhases() {
60
+ if (this.pendingPlugins.length === 0)
61
+ return;
62
+ const drained = this.pendingPlugins.splice(0, this.pendingPlugins.length);
63
+ const prepared = drained.map((p) => {
64
+ const boots = [];
65
+ const disposes = [];
66
+ const ctx = this.buildPluginContext(p.def, boots, disposes);
67
+ return { def: p.def, ctx, boots, disposes };
68
+ });
69
+ for (const p of prepared) {
70
+ if (p.def.register)
71
+ p.def.register(p.ctx);
72
+ }
73
+ for (const p of prepared) {
74
+ p.def.setup(p.ctx);
75
+ }
76
+ for (const p of prepared) {
77
+ this.plugins.push({ name: p.def.name, boots: p.boots, disposes: p.disposes });
78
+ }
79
+ }
80
+ /**
81
+ * Boot the app:
82
+ *
83
+ * 1. Drain queued plugins (registers, then setups).
84
+ * 2. Fire `PluginRegistered` for each registered plugin.
85
+ * 3. Fire `AppRegistering` chain (vetoable).
86
+ * 4. Fire `AppBooting` chain (vetoable).
87
+ * 5. For each plugin: `PluginBooting` chain → boot queue → `PluginBooted`.
88
+ * 6. Run container health checks; fail-fast if any throw.
89
+ * 7. Fire `AppBooted` + `AppReady`.
90
+ *
91
+ * Idempotent + in-flight-shared.
92
+ */
93
+ start() {
94
+ if (this.startPromise)
95
+ return this.startPromise;
96
+ if (this.state === "running")
97
+ return Promise.resolve();
98
+ if (this.state !== "idle") {
99
+ return Promise.reject(new Error(`App.start: cannot start from lifecycle state "${this.state}".`));
100
+ }
101
+ this.state = "starting";
102
+ const appName = this.appName;
103
+ const hooks = this.runtime.hooks;
104
+ this.startPromise = (async () => {
105
+ try {
106
+ this.runPluginPhases();
107
+ for (const p of this.plugins) {
108
+ await hooks.PluginRegistered.run({ appName, pluginName: p.name, kind: "plugin" });
109
+ }
110
+ const registering = await hooks.AppRegistering.runDetailed({ appName });
111
+ if (registering.outcome === "failed")
112
+ throw registering.error;
113
+ if (registering.outcome !== "completed") {
114
+ throw new Error(`App.start("${appName}"): AppRegistering vetoed by chain step.`);
115
+ }
116
+ const booting = await hooks.AppBooting.runDetailed({ appName });
117
+ if (booting.outcome === "failed")
118
+ throw booting.error;
119
+ if (booting.outcome !== "completed") {
120
+ throw new Error(`App.start("${appName}"): AppBooting vetoed by chain step.`);
121
+ }
122
+ for (const p of this.plugins) {
123
+ const pre = await hooks.PluginBooting.runDetailed({
124
+ appName,
125
+ pluginName: p.name,
126
+ kind: "plugin",
127
+ });
128
+ if (pre.outcome === "failed")
129
+ throw pre.error;
130
+ if (pre.outcome !== "completed") {
131
+ throw new Error(`App.start("${appName}"): plugin "${p.name}" boot vetoed by chain step.`);
132
+ }
133
+ const startedAt = performance.now();
134
+ for (const fn of p.boots) {
135
+ await fn();
136
+ }
137
+ await hooks.PluginBooted.run({
138
+ appName,
139
+ pluginName: p.name,
140
+ durationMs: performance.now() - startedAt,
141
+ kind: "plugin",
142
+ });
143
+ }
144
+ const checks = await this.container.runChecks();
145
+ const failed = checks.filter((c) => !c.ok);
146
+ if (failed.length > 0) {
147
+ const summary = failed
148
+ .map((c) => `${c.name}: ${c.error?.message ?? "check failed"}`)
149
+ .join("; ");
150
+ throw new Error(`App.start("${appName}"): container check(s) failed — ${summary}`);
151
+ }
152
+ await hooks.AppBooted.run({ appName, bootedAt: new Date().toISOString() });
153
+ await hooks.AppReady.run({ appName, readyAt: new Date().toISOString() });
154
+ this.state = "running";
155
+ }
156
+ catch (err) {
157
+ this.state = "idle";
158
+ this.startPromise = undefined;
159
+ throw err;
160
+ }
161
+ })();
162
+ return this.startPromise;
163
+ }
164
+ /**
165
+ * Graceful shutdown:
166
+ *
167
+ * 1. Fire `AppShuttingDown` chain (vetoable).
168
+ * 2. For each plugin in reverse order: `PluginShuttingDown` chain →
169
+ * dispose queue (LIFO, errors isolated) → `PluginShutdown`.
170
+ * 3. `container.dispose()` — runs `bind({ dispose })` callbacks LIFO.
171
+ * 4. Fire `AppShutdown`.
172
+ *
173
+ * Idempotent + in-flight-shared. The first thrown error during plugin
174
+ * dispose or container dispose is rethrown after the sequence completes.
175
+ */
176
+ stop(reason) {
177
+ if (this.stopPromise)
178
+ return this.stopPromise;
179
+ if (this.state === "stopped")
180
+ return Promise.resolve();
181
+ if (this.state !== "running") {
182
+ return Promise.reject(new Error(`App.stop: cannot stop from lifecycle state "${this.state}".`));
183
+ }
184
+ this.state = "stopping";
185
+ const appName = this.appName;
186
+ const hooks = this.runtime.hooks;
187
+ this.stopPromise = (async () => {
188
+ const errors = [];
189
+ const shuttingDown = await hooks.AppShuttingDown.runDetailed({ appName, reason });
190
+ if (shuttingDown.outcome !== "completed") {
191
+ this.state = "running";
192
+ this.stopPromise = undefined;
193
+ if (shuttingDown.outcome === "failed")
194
+ throw shuttingDown.error;
195
+ throw new Error(`App.stop("${appName}"): AppShuttingDown vetoed by chain step.`);
196
+ }
197
+ for (let i = this.plugins.length - 1; i >= 0; i--) {
198
+ const p = this.plugins[i];
199
+ const pre = await hooks.PluginShuttingDown.runDetailed({
200
+ appName,
201
+ pluginName: p.name,
202
+ kind: "plugin",
203
+ });
204
+ if (pre.outcome !== "completed") {
205
+ // eslint-disable-next-line no-console
206
+ console.warn(`App.stop("${appName}"): PluginShuttingDown vetoed for "${p.name}" — skipping dispose.`);
207
+ continue;
208
+ }
209
+ const startedAt = performance.now();
210
+ for (let j = p.disposes.length - 1; j >= 0; j--) {
211
+ try {
212
+ await p.disposes[j]();
213
+ }
214
+ catch (err) {
215
+ errors.push(err);
216
+ // eslint-disable-next-line no-console
217
+ console.error(`App.stop("${appName}"): plugin "${p.name}" dispose threw:`, err);
218
+ }
219
+ }
220
+ await hooks.PluginShutdown.run({
221
+ appName,
222
+ pluginName: p.name,
223
+ durationMs: performance.now() - startedAt,
224
+ kind: "plugin",
225
+ });
226
+ }
227
+ try {
228
+ await this.container.dispose();
229
+ }
230
+ catch (err) {
231
+ errors.push(err);
232
+ // eslint-disable-next-line no-console
233
+ console.error(`App.stop("${appName}"): container.dispose threw:`, err);
234
+ }
235
+ await hooks.AppShutdown.run({ appName });
236
+ this.state = "stopped";
237
+ if (errors.length > 0) {
238
+ throw errors[0];
239
+ }
240
+ })();
241
+ return this.stopPromise;
242
+ }
243
+ /** Build the plugin context — same shape across register and setup. */
244
+ buildPluginContext(plugin, boots, disposes) {
245
+ return {
246
+ container: this.container,
247
+ runtime: this.runtime,
248
+ hooks: this.runtime.hooks,
249
+ options: plugin.options,
250
+ bind: (name, factory, opts) => {
251
+ this.container.register(name, factory, opts);
252
+ },
253
+ add: (cap) => {
254
+ this.runtime.add(cap);
255
+ },
256
+ sink: (stage) => {
257
+ this.runtime.sink(stage);
258
+ },
259
+ on: (name, fn) => {
260
+ const reg = this.runtime.hooks;
261
+ const h = reg[name];
262
+ 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.`);
264
+ }
265
+ h.on(fn);
266
+ },
267
+ boot: (fn) => {
268
+ boots.push(fn);
269
+ },
270
+ dispose: (fn) => {
271
+ disposes.push(fn);
272
+ },
273
+ shutdown: (fn) => {
274
+ disposes.push(fn);
275
+ },
276
+ defineHook: (name) => this.runtime.defineHook(name),
277
+ };
278
+ }
279
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `publishCapability` + `publishPlugin` — cross-process publish as a
3
+ * capability composition.
4
+ *
5
+ * import { createApp, publishPlugin } from "@nwire/app";
6
+ *
7
+ * const app = createApp({ appName: "shop" }).with(publishPlugin());
8
+ *
9
+ * // inside a handler:
10
+ * await ctx.publish(OrderShipped, { orderId, customerId });
11
+ *
12
+ * The capability composes `runtime.emit` (local fan-out to subscribers)
13
+ * with `runtime.sinkDrain` (outbound chain delivery). For events marked
14
+ * public via `defineEvent(...).public()` the call fires both; for
15
+ * private events only local subscribers fire and the sink is skipped.
16
+ *
17
+ * ## When to use this vs forge
18
+ *
19
+ * Forge ships its own bundled publish path inside `ForgeDispatcher` —
20
+ * projections fold, actors transition, workflows fire, and the bus
21
+ * publishes in one atomic sequence with defined order. That sequence
22
+ * cannot be decomposed without breaking saga ordering guarantees.
23
+ *
24
+ * `publishCapability` is the non-forge path: same emit + sink-drain
25
+ * shape, no projection/actor/workflow integration. Apps that aren't
26
+ * doing CQRS but still need cross-process broadcast install this; apps
27
+ * with forge use the forge path.
28
+ */
29
+ import { type Capability } from "@nwire/runtime";
30
+ import type { EventDefinition } from "@nwire/messages";
31
+ import type { PluginDefinition } from "./runtime/index.js";
32
+ /** Ctx shape contributed by `publishCapability`. */
33
+ export interface PublishCaps {
34
+ publish<TPayload>(event: EventDefinition & {
35
+ readonly name: string;
36
+ readonly schema: {
37
+ parse(input: unknown): unknown;
38
+ };
39
+ /** Set by `defineEvent(...).public()`. */
40
+ readonly $public?: boolean;
41
+ }, payload: TPayload): Promise<void>;
42
+ }
43
+ /**
44
+ * The capability. Provides `ctx.publish`; the runtime methods it composes
45
+ * (`emit`, `sinkDrain`) are already exposed.
46
+ *
47
+ * Sink drain fires only when `event.$public === true`. Private events
48
+ * stay intra-bounded-context — they fan out to local subscribers but
49
+ * never cross the process membrane.
50
+ */
51
+ export declare const publishCapability: Capability<PublishCaps>;
52
+ /**
53
+ * `app.with(publishPlugin())` installs `publishCapability`. Stateless;
54
+ * no options. The capability's `__mark` carries `PublishCaps` so the
55
+ * App's `<TCaps>` learns about `ctx.publish`.
56
+ */
57
+ export declare function publishPlugin(): PluginDefinition<void, PublishCaps>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `publishCapability` + `publishPlugin` — cross-process publish as a
3
+ * capability composition.
4
+ *
5
+ * import { createApp, publishPlugin } from "@nwire/app";
6
+ *
7
+ * const app = createApp({ appName: "shop" }).with(publishPlugin());
8
+ *
9
+ * // inside a handler:
10
+ * await ctx.publish(OrderShipped, { orderId, customerId });
11
+ *
12
+ * The capability composes `runtime.emit` (local fan-out to subscribers)
13
+ * with `runtime.sinkDrain` (outbound chain delivery). For events marked
14
+ * public via `defineEvent(...).public()` the call fires both; for
15
+ * private events only local subscribers fire and the sink is skipped.
16
+ *
17
+ * ## When to use this vs forge
18
+ *
19
+ * Forge ships its own bundled publish path inside `ForgeDispatcher` —
20
+ * projections fold, actors transition, workflows fire, and the bus
21
+ * publishes in one atomic sequence with defined order. That sequence
22
+ * cannot be decomposed without breaking saga ordering guarantees.
23
+ *
24
+ * `publishCapability` is the non-forge path: same emit + sink-drain
25
+ * shape, no projection/actor/workflow integration. Apps that aren't
26
+ * doing CQRS but still need cross-process broadcast install this; apps
27
+ * with forge use the forge path.
28
+ */
29
+ import { defineCapability } from "@nwire/runtime";
30
+ import { definePlugin } from "./define-plugin.js";
31
+ /**
32
+ * The capability. Provides `ctx.publish`; the runtime methods it composes
33
+ * (`emit`, `sinkDrain`) are already exposed.
34
+ *
35
+ * Sink drain fires only when `event.$public === true`. Private events
36
+ * stay intra-bounded-context — they fan out to local subscribers but
37
+ * never cross the process membrane.
38
+ */
39
+ export const publishCapability = defineCapability({
40
+ name: "publish",
41
+ provideCtx: ({ runtime, envelope }) => ({
42
+ publish: async (event, payload) => {
43
+ await runtime.emit(event, payload, { parent: envelope });
44
+ if (event.$public === true) {
45
+ await runtime.sinkDrain(event, payload, envelope);
46
+ }
47
+ },
48
+ }),
49
+ });
50
+ /**
51
+ * `app.with(publishPlugin())` installs `publishCapability`. Stateless;
52
+ * no options. The capability's `__mark` carries `PublishCaps` so the
53
+ * App's `<TCaps>` learns about `ctx.publish`.
54
+ */
55
+ export function publishPlugin() {
56
+ return definePlugin("publish", {
57
+ setup: ({ add }) => {
58
+ add(publishCapability);
59
+ },
60
+ });
61
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Runtime — Container + dispatch hook + per-runtime FrameworkHooks
3
3
  * registry + telemetry stream + plugin lifecycle.
4
+ *
5
+ * Re-exports the canonical Runtime surface from `@nwire/runtime` so
6
+ * `@nwire/app` consumers can import everything from a single package.
4
7
  */
5
- export { Runtime, createRuntime, isBuiltInHook, serializeError, type RuntimeOptions, type Telemetry, type TelemetryListener, type HookStepTelemetry, type DispatchHookCtx, type DispatchMiddleware, type SerializedError, type PluginDefinition, type PluginContext, } from "./runtime.js";
6
- export { createFrameworkHooks, type FrameworkHooks, type PluginKind, } from "./framework-hooks.js";
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";
9
+ export { createFrameworkHooks, type FrameworkHooks, type PluginKind } from "@nwire/runtime";
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Runtime — Container + dispatch hook + per-runtime FrameworkHooks
3
3
  * registry + telemetry stream + plugin lifecycle.
4
+ *
5
+ * Re-exports the canonical Runtime surface from `@nwire/runtime` so
6
+ * `@nwire/app` consumers can import everything from a single package.
4
7
  */
5
- export { Runtime, createRuntime, isBuiltInHook, serializeError, } from "./runtime.js";
6
- export { createFrameworkHooks, } from "./framework-hooks.js";
8
+ export { Runtime, createRuntime, isBuiltInHook, serializeError, } from "@nwire/runtime";
9
+ export { createFrameworkHooks } from "@nwire/runtime";
@@ -268,6 +268,9 @@ export declare class Runtime {
268
268
  readonly signal?: AbortSignal;
269
269
  readonly parent?: MessageEnvelope;
270
270
  }, extras?: Record<string, unknown>): Promise<TOutput>;
271
+ /** Innermost dispatch step that calls the actual handler. Pinned once. */
272
+ private dispatchCorePinned;
273
+ private ensureDispatchCorePin;
271
274
  /**
272
275
  * Fire-and-forget dispatch. Default implementation: `setImmediate` →
273
276
  * `execute`. Queue-adapter installation can override this to enqueue
@@ -516,25 +516,47 @@ export class Runtime {
516
516
  resolve: (name) => scope.resolve(name),
517
517
  scope,
518
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;
519
+ // Normalize the dispatch target to a HandlerDefinition. Plain
520
+ // `(input, ctx) => ...` functions get wrapped in a synthetic
521
+ // definition so the dispatch path has ONE shape downstream no
522
+ // typeof-function branch, no parallel call site.
523
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
+ const def = typeof target === "function" && !("$kind" in target)
525
+ ? wrapFunctionAsHandlerDef(target)
526
+ : target;
527
+ const innerCore = async () => {
531
528
  const out = await def.run(ctx, { signal });
532
529
  return out.result;
530
+ };
531
+ try {
532
+ if (this.userMiddlewareCount > 0) {
533
+ this.ensureDispatchCorePin();
534
+ const hctx = {
535
+ action: def,
536
+ input,
537
+ ctx,
538
+ coreFn: innerCore,
539
+ };
540
+ await this.dispatchHook.run(hctx);
541
+ return hctx.result;
542
+ }
543
+ return await innerCore();
533
544
  }
534
545
  finally {
535
546
  await scope.dispose();
536
547
  }
537
548
  }
549
+ /** Innermost dispatch step that calls the actual handler. Pinned once. */
550
+ dispatchCorePinned = false;
551
+ ensureDispatchCorePin() {
552
+ if (this.dispatchCorePinned)
553
+ return;
554
+ this.dispatchHook.use(async (hc, next) => {
555
+ hc.result = await hc.coreFn();
556
+ await next();
557
+ }, { name: "__nwire_runtime_core__", priority: Number.NEGATIVE_INFINITY });
558
+ this.dispatchCorePinned = true;
559
+ }
538
560
  /**
539
561
  * Fire-and-forget dispatch. Default implementation: `setImmediate` →
540
562
  * `execute`. Queue-adapter installation can override this to enqueue
@@ -639,4 +661,25 @@ export class Runtime {
639
661
  export function createRuntime(options = {}) {
640
662
  return new Runtime(options);
641
663
  }
664
+ /**
665
+ * Wrap a plain `(input, ctx) => …` function in a minimal HandlerDefinition
666
+ * so `runtime.execute` has one dispatch path. No hook chain, no validation
667
+ * — just enough shape for `def.run(ctx)` to call the function and surface
668
+ * its result on `ctx.result`. Allocated once per execute call; the cost
669
+ * is one object + two property reads relative to a direct function call.
670
+ */
671
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
672
+ function wrapFunctionAsHandlerDef(fn) {
673
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
674
+ return {
675
+ $kind: "handler",
676
+ name: fn.name || "anonymous",
677
+ config: { handler: fn },
678
+ async run(ctx) {
679
+ ctx.result = await fn(ctx.input, ctx);
680
+ return ctx;
681
+ },
682
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
683
+ };
684
+ }
642
685
  export { isBuiltInHook };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `subscribe((when) => …)` — the listener primitive.
3
+ *
4
+ * A `Subscription` is a plain value: a closure that receives a `when`
5
+ * function and registers listeners through it. The closure binds late —
6
+ * at registration the App passes its own runtime's `when`, so each
7
+ * subscription targets the right runtime. No ambient state, no
8
+ * side-effect imports, multi-app safe.
9
+ *
10
+ * // app/listeners/welcome-mail.ts
11
+ * import { subscribe } from "@nwire/app";
12
+ * import { UserRegistered } from "../events";
13
+ *
14
+ * export default subscribe((when) => {
15
+ * when(UserRegistered, async (payload, env) => {
16
+ * await sendWelcomeEmail(payload.email);
17
+ * });
18
+ * });
19
+ *
20
+ * Subscriptions are registered through `app.subscribe(sub | subs)`.
21
+ */
22
+ import type { EventDefinition } from "@nwire/messages";
23
+ import type { MessageEnvelope } from "@nwire/envelope";
24
+ /**
25
+ * Late-bound listener registration handed to a subscribe closure.
26
+ */
27
+ export type WhenFn = <TPayload>(event: EventDefinition & {
28
+ readonly name: string;
29
+ }, fn: (payload: TPayload, envelope: MessageEnvelope) => Promise<void> | void) => void;
30
+ /**
31
+ * A subscription is a closure waiting for an App's `when` to be handed in.
32
+ * `install(when)` is the contract: the App passes its runtime's `when` and
33
+ * the closure registers everything it knows.
34
+ */
35
+ export interface Subscription {
36
+ readonly $kind: "subscription";
37
+ install(when: WhenFn): void;
38
+ }
39
+ /**
40
+ * Wrap a registration closure as a Subscription value. The closure is
41
+ * held by reference — when an App later `subscribe(sub)`s it, the closure
42
+ * runs with the App's `when` injected.
43
+ */
44
+ export declare function subscribe(fn: (when: WhenFn) => void): Subscription;
45
+ /** True if `value` looks like a Subscription. */
46
+ export declare function isSubscription(value: unknown): value is Subscription;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `subscribe((when) => …)` — the listener primitive.
3
+ *
4
+ * A `Subscription` is a plain value: a closure that receives a `when`
5
+ * function and registers listeners through it. The closure binds late —
6
+ * at registration the App passes its own runtime's `when`, so each
7
+ * subscription targets the right runtime. No ambient state, no
8
+ * side-effect imports, multi-app safe.
9
+ *
10
+ * // app/listeners/welcome-mail.ts
11
+ * import { subscribe } from "@nwire/app";
12
+ * import { UserRegistered } from "../events";
13
+ *
14
+ * export default subscribe((when) => {
15
+ * when(UserRegistered, async (payload, env) => {
16
+ * await sendWelcomeEmail(payload.email);
17
+ * });
18
+ * });
19
+ *
20
+ * Subscriptions are registered through `app.subscribe(sub | subs)`.
21
+ */
22
+ /**
23
+ * Wrap a registration closure as a Subscription value. The closure is
24
+ * held by reference — when an App later `subscribe(sub)`s it, the closure
25
+ * runs with the App's `when` injected.
26
+ */
27
+ export function subscribe(fn) {
28
+ return {
29
+ $kind: "subscription",
30
+ install: fn,
31
+ };
32
+ }
33
+ /** True if `value` looks like a Subscription. */
34
+ export function isSubscription(value) {
35
+ return (typeof value === "object" &&
36
+ value !== null &&
37
+ value.$kind === "subscription" &&
38
+ typeof value.install === "function");
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/app",
3
- "version": "0.10.1",
3
+ "version": "0.11.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,13 +29,14 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@nwire/container": "0.10.1",
33
- "@nwire/envelope": "0.10.1",
34
- "@nwire/handler": "0.10.1",
35
- "@nwire/wires": "0.10.1",
36
- "@nwire/messages": "0.10.1",
37
- "@nwire/logger": "0.10.1",
38
- "@nwire/hooks": "0.10.1"
32
+ "@nwire/container": "0.11.0",
33
+ "@nwire/envelope": "0.11.0",
34
+ "@nwire/hooks": "0.11.0",
35
+ "@nwire/handler": "0.11.0",
36
+ "@nwire/logger": "0.11.0",
37
+ "@nwire/runtime": "0.11.0",
38
+ "@nwire/messages": "0.11.0",
39
+ "@nwire/wires": "0.11.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/node": "^22.19.9",