@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 +4 -8
- package/dist/app.d.ts +6 -2
- package/dist/app.js +13 -2
- package/dist/compose-app.js +21 -6
- package/dist/create-app.d.ts +73 -13
- package/dist/create-app.js +62 -8
- package/dist/define-plugin.d.ts +29 -4
- package/dist/define-plugin.js +26 -6
- package/dist/lifecycle.d.ts +79 -0
- package/dist/lifecycle.js +279 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +61 -0
- package/dist/runtime/index.d.ts +5 -2
- package/dist/runtime/index.js +5 -2
- package/dist/runtime/runtime.d.ts +3 -0
- package/dist/runtime/runtime.js +55 -12
- package/dist/subscribe.d.ts +46 -0
- package/dist/subscribe.js +39 -0
- package/package.json +9 -8
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
|
-
|
|
21
|
-
|
|
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
|
|
4
|
-
* substrate for
|
|
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
|
|
4
|
-
* substrate for
|
|
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";
|
package/dist/compose-app.js
CHANGED
|
@@ -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
|
-
//
|
|
83
|
-
// endpoint
|
|
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;
|
package/dist/create-app.d.ts
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createApp` — composition root. Constructs a Runtime, registers
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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<{}>;
|
package/dist/create-app.js
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createApp` — composition root. Constructs a Runtime, registers
|
|
3
|
-
*
|
|
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
|
-
|
|
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: () =>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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];
|
package/dist/define-plugin.d.ts
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin factory — wraps the canonical `PluginDefinition` shape with
|
|
3
|
-
* source-location capture so Studio
|
|
4
|
-
* was declared.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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;
|
package/dist/define-plugin.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin factory — wraps the canonical `PluginDefinition` shape with
|
|
3
|
-
* source-location capture so Studio
|
|
4
|
-
* was declared.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/publish.js
ADDED
|
@@ -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
|
+
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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 "
|
|
6
|
-
export { createFrameworkHooks, type FrameworkHooks, type PluginKind
|
|
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";
|
package/dist/runtime/index.js
CHANGED
|
@@ -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 "
|
|
6
|
-
export { createFrameworkHooks
|
|
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
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -516,25 +516,47 @@ export class Runtime {
|
|
|
516
516
|
resolve: (name) => scope.resolve(name),
|
|
517
517
|
scope,
|
|
518
518
|
};
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"@nwire/envelope": "0.
|
|
34
|
-
"@nwire/
|
|
35
|
-
"@nwire/
|
|
36
|
-
"@nwire/
|
|
37
|
-
"@nwire/
|
|
38
|
-
"@nwire/
|
|
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",
|