@nwire/endpoint 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
@@ -18,10 +18,7 @@ import { httpKoa } from "@nwire/koa";
18
18
  const app = createApp({ appName: "api" });
19
19
  // ... app.wire(...) wires here ...
20
20
 
21
- await endpoint("api", { port: 3000 })
22
- .use(httpKoa())
23
- .mount(app)
24
- .run();
21
+ await endpoint("api", { port: 3000 }).use(httpKoa()).mount(app).run();
25
22
  ```
26
23
 
27
24
  `.use(adopter)` installs a transport runtime — `httpKoa`, `expressAdapter`,
@@ -70,12 +67,12 @@ through the runtime when forge is in play, or directly otherwise.
70
67
 
71
68
  ## Lifecycle
72
69
 
73
- | Stage | What runs |
74
- | --------- | ------------------------------------------------------ |
75
- | `boot` | App plugins boot; adopters consume wires; probes open. |
76
- | `serve` | Adopters listen / subscribe; readiness flips green. |
77
- | `drain` | SIGTERM/SIGINT: stop accepting new work, finish open. |
78
- | `close` | Adopters shut down in reverse order; probes close. |
70
+ | Stage | What runs |
71
+ | ------- | ------------------------------------------------------ |
72
+ | `boot` | App plugins boot; adopters consume wires; probes open. |
73
+ | `serve` | Adopters listen / subscribe; readiness flips green. |
74
+ | `drain` | SIGTERM/SIGINT: stop accepting new work, finish open. |
75
+ | `close` | Adopters shut down in reverse order; probes close. |
79
76
 
80
77
  `endpoint()` returns a `RunningEndpoint` from `.run()`. Tests call
81
78
  `running.shutdown("test")` to skip the SIGTERM dance.
@@ -84,10 +81,10 @@ through the runtime when forge is in play, or directly otherwise.
84
81
 
85
82
  ```ts
86
83
  endpoint("api", {
87
- banner: true, // boot banner on stdout
84
+ banner: true, // boot banner on stdout
88
85
  probes: { port: 9_400, enabled: true },
89
86
  shutdown: { drainTimeout: 30_000, hardTimeout: 5_000 },
90
- exitOnShutdown: true, // tests pass false
87
+ exitOnShutdown: true, // tests pass false
91
88
  });
92
89
  ```
93
90
 
package/dist/adapter.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { Wire } from "@nwire/wires";
13
13
  import type { Container } from "@nwire/container";
14
14
  import type { Logger } from "@nwire/logger";
15
+ import type { OutboundStage } from "@nwire/runtime";
15
16
  import type { HealthCheck } from "./lifecycle.js";
16
17
  export interface AdapterBootContext {
17
18
  /**
@@ -38,15 +39,39 @@ export interface AdapterBootContext {
38
39
  readonly port?: number;
39
40
  /** Endpoint-level host hint, paired with `port`. */
40
41
  readonly host?: string;
42
+ /**
43
+ * Install an outbound pipeline stage on the mounted App's runtime.
44
+ * Outbound adapters (queuePublisher, natsPublisher, webhookSink, …) call
45
+ * this at boot to install their terminal stage; inbound adapters ignore
46
+ * it. Undefined when no App is mounted — the adapter is free to skip
47
+ * or fall back to a foreign-host integration.
48
+ */
49
+ readonly installSinkStage?: (stage: OutboundStage) => void;
41
50
  }
42
51
  /**
43
52
  * Structural shape every transport adopter satisfies. Adopters never
44
53
  * extend a base class — the contract is duck-typed at the endpoint.
54
+ *
55
+ * `direction` distinguishes the two membrane roles:
56
+ *
57
+ * - "inbound" — transport → execute. HTTP server, queue listener, cron.
58
+ * These consume wires and translate inbound traffic to
59
+ * `app.execute(handler, input)`.
60
+ *
61
+ * - "outbound" — sink → transport. Queue publisher, NATS publisher,
62
+ * webhook delivery, OTLP exporter. These install a
63
+ * terminal sink stage at boot and translate published
64
+ * events into transport messages.
65
+ *
66
+ * Optional, defaulting to "inbound" so adapters that haven't declared
67
+ * direction continue to behave as inbound transports.
45
68
  */
46
69
  export interface Adapter {
47
70
  readonly $kind: "adapter";
48
71
  /** Adopter-kind tag — matches `binding.$adapter` on the wires it consumes. */
49
72
  readonly kind: string;
73
+ /** Membrane direction. Defaults to "inbound" if absent. */
74
+ readonly direction?: "inbound" | "outbound";
50
75
  boot(opts: AdapterBootContext): Promise<void>;
51
76
  shutdown(reason?: string): Promise<void>;
52
77
  /**
@@ -19,6 +19,6 @@
19
19
  * (`http-terminator` + `lightship`). The high-level builder is what most
20
20
  * projects reach for; the low-level form is for adapters and tests.
21
21
  */
22
- export { endpoint, EndpointBuilder, isAppServable, isForeignServable, type AppServable, type EndpointConfig, type ForeignServable, type RunningEndpoint, type Servable, } from "./endpoint.js";
22
+ export { endpoint, EndpointBuilder, isAppServable, isForeignServable, type AppServable, type EndpointConfig, type ProcessConfig, type ForeignServable, type RunningEndpoint, type Servable, } from "./endpoint.js";
23
23
  export { attachLifecycle, defineCheck, type HealthCheck, type HealthConfig, type LifecycleManager, type ShutdownConfig, } from "./lifecycle.js";
24
24
  export { isAdapter, type Adapter, type AdapterBootContext } from "./adapter.js";
@@ -90,7 +90,12 @@ export interface ForeignServable {
90
90
  export interface AppServable {
91
91
  readonly $nwireApp: true;
92
92
  readonly container: Container;
93
- boot(): Promise<void>;
93
+ /**
94
+ * Boot the app. Optional `appConfig` — when present, bound on the app's
95
+ * container as "config" so plugin boot callbacks can read it (foundation
96
+ * §19). Endpoint threads what was passed to `.mount(app, appConfig)`.
97
+ */
98
+ boot(appConfig?: unknown): Promise<void>;
94
99
  shutdown(): Promise<void>;
95
100
  /**
96
101
  * Optional — app name used in lifecycle event payloads. Defaults to
@@ -105,6 +110,12 @@ export interface AppServable {
105
110
  */
106
111
  dispatchFrameworkEvent?(eventName: string, payload: unknown): Promise<boolean>;
107
112
  }
113
+ /**
114
+ * Process-level configuration for an endpoint. Owns OS-shaped concerns
115
+ * only: port, bind address, signal/drain budgets, probe port, banner
116
+ * toggle. App-level configuration (database URLs, API keys, feature
117
+ * flags) goes through `.mount(app, appConfig)`.
118
+ */
108
119
  export interface EndpointConfig {
109
120
  /** Port for HTTP-class transports. Omit for queue-only / cron-only endpoints. */
110
121
  readonly port?: number;
@@ -119,6 +130,8 @@ export interface EndpointConfig {
119
130
  /** Test seam — skip process.exit() during shutdown. Default unset. */
120
131
  readonly exitOnShutdown?: boolean;
121
132
  }
133
+ /** Foundation §19 alias — `endpoint("name", processConfig)` reads as intent. */
134
+ export type ProcessConfig = EndpointConfig;
122
135
  export interface RunningEndpoint {
123
136
  readonly name: string;
124
137
  readonly server?: Server;
@@ -162,6 +175,8 @@ export declare class EndpointBuilder {
162
175
  private foreignServables;
163
176
  private adapters;
164
177
  private mounted;
178
+ /** App config threaded into `app.boot(...)` at `run()`. */
179
+ private mountedAppConfig;
165
180
  /**
166
181
  * Per-phase lifecycle hooks. Created lazily at builder-construction so
167
182
  * they appear in `listHooks()` (and `.nwire/hooks.json` after scan)
@@ -192,10 +207,17 @@ export declare class EndpointBuilder {
192
207
  use(adopter: Adapter): EndpointBuilder;
193
208
  /**
194
209
  * Mount the source whose wires the adopters consume. One source per
195
- * endpoint — App or standalone Interface. For multi-app topologies,
196
- * compose via `appCompose(...)` from `@nwire/app` and mount the result.
210
+ * endpoint — an App or a standalone Interface. For multi-app
211
+ * topologies, compose via `appCompose(...)` from `@nwire/app` and
212
+ * mount the result.
213
+ *
214
+ * `appConfig` (when mounting an App) is threaded to `app.boot(appConfig)`
215
+ * at `.run()` time. Plugin boot callbacks read it via
216
+ * `container.resolve("config")`. The process/app config split:
217
+ * process config goes to `endpoint(name, processConfig)`, app config
218
+ * goes here.
197
219
  */
198
- mount(source: WireSource): EndpointBuilder;
220
+ mount(source: WireSource, appConfig?: unknown): EndpointBuilder;
199
221
  /**
200
222
  * Boot all served apps in order, then attach all served interfaces +
201
223
  * foreign apps, then start listening. Returns a {@link RunningEndpoint}
package/dist/endpoint.js CHANGED
@@ -81,6 +81,8 @@ export class EndpointBuilder {
81
81
  // ─── New-shape state (.use / .mount) ─────────────────────────────────
82
82
  adapters = [];
83
83
  mounted;
84
+ /** App config threaded into `app.boot(...)` at `run()`. */
85
+ mountedAppConfig = undefined;
84
86
  /**
85
87
  * Per-phase lifecycle hooks. Created lazily at builder-construction so
86
88
  * they appear in `listHooks()` (and `.nwire/hooks.json` after scan)
@@ -146,10 +148,17 @@ export class EndpointBuilder {
146
148
  }
147
149
  /**
148
150
  * Mount the source whose wires the adopters consume. One source per
149
- * endpoint — App or standalone Interface. For multi-app topologies,
150
- * compose via `appCompose(...)` from `@nwire/app` and mount the result.
151
+ * endpoint — an App or a standalone Interface. For multi-app
152
+ * topologies, compose via `appCompose(...)` from `@nwire/app` and
153
+ * mount the result.
154
+ *
155
+ * `appConfig` (when mounting an App) is threaded to `app.boot(appConfig)`
156
+ * at `.run()` time. Plugin boot callbacks read it via
157
+ * `container.resolve("config")`. The process/app config split:
158
+ * process config goes to `endpoint(name, processConfig)`, app config
159
+ * goes here.
151
160
  */
152
- mount(source) {
161
+ mount(source, appConfig) {
153
162
  if (this.mounted) {
154
163
  throw new Error(`endpoint("${this.name}").mount(): a source is already mounted ("${"appName" in this.mounted ? this.mounted.appName : "interface"}"). ` +
155
164
  `One source per endpoint — use appCompose(...) from "@nwire/app" to compose multiple apps.`);
@@ -158,6 +167,7 @@ export class EndpointBuilder {
158
167
  throw new Error(`endpoint("${this.name}").mount(): expected an App with .interface, or a standalone Interface.`);
159
168
  }
160
169
  this.mounted = source;
170
+ this.mountedAppConfig = appConfig;
161
171
  // If mounting an App, also boot it via the existing apps array.
162
172
  if (isAppWithInterface(source) && !this.apps.includes(source)) {
163
173
  this.apps.push(source);
@@ -200,7 +210,15 @@ export class EndpointBuilder {
200
210
  startedAt: new Date().toISOString(),
201
211
  });
202
212
  for (const app of this.apps) {
203
- await app.boot();
213
+ // Pass appConfig only to the mounted app — appCompose sub-apps don't
214
+ // carry their own per-app config in this path.
215
+ const appConfig = this.mounted && app === this.mounted ? this.mountedAppConfig : undefined;
216
+ if (appConfig !== undefined) {
217
+ await app.boot(appConfig);
218
+ }
219
+ else {
220
+ await app.boot();
221
+ }
204
222
  }
205
223
  // The container the host hands to each adopter — the first served
206
224
  // app's. Multi-app composites carry per-wire app references that
@@ -243,14 +261,24 @@ export class EndpointBuilder {
243
261
  ? mountedSource
244
262
  : mountedSource.interface;
245
263
  const containerOf = (_w) => {
246
- // For 0.10 first pass, route every wire to the mounted source's
247
- // container (if it's an App). Multi-app composites carry per-wire
248
- // app references on the wire (.app); future work uses that for
249
- // per-wire container routing.
264
+ // Wires tagged with a source app (via `appCompose`) route to that
265
+ // app's container; un-tagged wires fall back to the mounted
266
+ // source's container.
250
267
  const sourceApp = mountedSource && !isInterfaceShape(mountedSource) ? mountedSource : undefined;
251
268
  const wireApp = _w.app;
252
269
  return wireApp?.container ?? sourceApp?.container;
253
270
  };
271
+ // Outbound adapters install sink terminals on the mounted App's
272
+ // runtime at boot. Available only when an App with a Runtime is
273
+ // mounted.
274
+ const mountedAppRuntime = mountedSource && isAppWithInterface(mountedSource)
275
+ ? mountedSource.runtime
276
+ : undefined;
277
+ const installSinkStage = mountedAppRuntime?.sink
278
+ ? (stage) => {
279
+ mountedAppRuntime.sink(stage);
280
+ }
281
+ : undefined;
254
282
  for (const adopter of this.adapters) {
255
283
  const wires = sourceInterface?.forAdapter(adopter.kind) ?? [];
256
284
  await adopter.boot({
@@ -260,6 +288,8 @@ export class EndpointBuilder {
260
288
  addCheck: (c) => accumulatedChecks.push(c),
261
289
  port: this.config.port,
262
290
  host: this.config.host,
291
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
292
+ installSinkStage: installSinkStage,
263
293
  });
264
294
  }
265
295
  // 3. Wrap the server (if any) with graceful shutdown + probes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/endpoint",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Nwire — production process lifecycle. Wraps any Node server (Express, Fastify, Koa, Nest, Nwire interfaces) with K8s-grade graceful shutdown, http-terminator drain, and lightship readiness/liveness probes. Standalone — no framework dependency beyond @nwire/container.",
5
5
  "keywords": [
6
6
  "endpoint",
@@ -32,10 +32,11 @@
32
32
  "dependencies": {
33
33
  "http-terminator": "^3.2.0",
34
34
  "lightship": "^9.0.4",
35
- "@nwire/hooks": "0.10.1",
36
- "@nwire/container": "0.10.1",
37
- "@nwire/logger": "0.10.1",
38
- "@nwire/wires": "0.10.1"
35
+ "@nwire/hooks": "0.11.0",
36
+ "@nwire/runtime": "0.11.0",
37
+ "@nwire/container": "0.11.0",
38
+ "@nwire/wires": "0.11.0",
39
+ "@nwire/logger": "0.11.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/node": "^22.19.9",