@nwire/endpoint 0.9.2 → 0.10.1

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
@@ -1,105 +1,112 @@
1
1
  # @nwire/endpoint
2
2
 
3
- > Process lifecycle for any Node server graceful shutdown, K8s probes, signal handling. The only Nwire layer that talks to the operating system.
4
-
5
- Wraps any Node server (Express, Fastify, Koa, Nest, Hono, raw `http`, or
6
- a Nwire interface) with `http-terminator` drain, `lightship` readiness /
7
- liveness probes on a dedicated port, signal handling, and ordered
8
- shutdown. Boots Nwire apps + mounts Nwire interfaces in the same managed
9
- process.
3
+ > Process lifecycle boot an app under one or more transports, drain
4
+ > in-flight work on SIGTERM, surface K8s probes. The only Nwire layer
5
+ > that talks to the operating system.
10
6
 
11
7
  ```bash
12
8
  pnpm add @nwire/endpoint
13
9
  ```
14
10
 
15
- ## Quick example — Nwire app + interface
11
+ ## Quick example
16
12
 
17
13
  ```ts
14
+ import { createApp } from "@nwire/app";
18
15
  import { endpoint } from "@nwire/endpoint";
19
- import { createApp } from "@nwire/forge";
20
- import { httpInterface, get } from "@nwire/http";
16
+ import { httpKoa } from "@nwire/koa";
21
17
 
22
- const app = createApp({ modules: [stations] });
23
- const api = httpInterface({ prefix: "/api" }).wire(get("/health"), async () => ({ ok: true }));
18
+ const app = createApp({ appName: "api" });
19
+ // ... app.wire(...) wires here ...
24
20
 
25
21
  await endpoint("api", { port: 3000 })
26
- .serve(app) // boots container first
27
- .serve(api) // attaches HTTP routes
22
+ .use(httpKoa())
23
+ .mount(app)
28
24
  .run();
29
25
  ```
30
26
 
31
- ## Quick exampleforeign framework
27
+ `.use(adopter)` installs a transport runtime `httpKoa`, `expressAdapter`,
28
+ `queueInMemory`, `bullmqAdapter`, `mcpAdapter`, `cronAdapter`. Each adopter
29
+ consumes the wires whose binding matches its kind. Stack as many as you
30
+ want on one endpoint; the same wires run under all of them.
31
+
32
+ ## Multi-transport example
32
33
 
33
34
  ```ts
34
- import express from "express";
35
+ import { createApp } from "@nwire/app";
35
36
  import { endpoint } from "@nwire/endpoint";
37
+ import { httpKoa } from "@nwire/koa";
38
+ import { queueInMemory } from "@nwire/queue";
39
+ import { mcpAdapter } from "@nwire/mcp";
40
+
41
+ await endpoint("svc", { port: 3000 })
42
+ .use(httpKoa({ prefix: "/api" }))
43
+ .use(queueInMemory())
44
+ .use(mcpAdapter())
45
+ .mount(app)
46
+ .run();
47
+ ```
36
48
 
37
- const app = express();
38
- app.get("/", (_req, res) => res.send("hi"));
49
+ ## Adopter contract
39
50
 
40
- await endpoint("legacy", { port: 3000 }).serve(app).run();
41
- // Now lives behind K8s probes + graceful shutdown.
51
+ ```ts
52
+ interface Adapter {
53
+ readonly $kind: "adapter";
54
+ readonly kind: string; // "http", "queue", "mcp", ...
55
+ boot(ctx: AdapterBootContext): Promise<void>;
56
+ shutdown(reason?: string): Promise<void>;
57
+ }
58
+
59
+ interface AdapterBootContext {
60
+ readonly wires: ReadonlyArray<Wire>;
61
+ containerOf(wire: Wire): Container | undefined;
62
+ readonly logger: Logger;
63
+ addCheck(check: HealthCheck): void;
64
+ }
42
65
  ```
43
66
 
44
- ## Surface
67
+ Each adopter receives the full wire collection at boot, picks the ones it
68
+ serves (`binding.$adapter === <its kind>`), and dispatches handlers
69
+ through the runtime when forge is in play, or directly otherwise.
70
+
71
+ ## Lifecycle
45
72
 
46
- | Export | Role |
47
- | --------------------------------------------------------- | ------------------------------------------------------------ |
48
- | `endpoint(name, config)` | Builder entry. Chain `.serve(target)`, `.run()`. |
49
- | `EndpointBuilder` | The chainable type returned by `endpoint()`. |
50
- | `attachLifecycle(opts)` | Low-level escape hatch when you already own a Node `Server`. |
51
- | `defineCheck(name, fn)` | Readiness probe contributor; aggregated into `/ready`. |
52
- | `isNwireServable` / `isAppServable` / `isForeignServable` | Type guards for `.serve()` discrimination. |
53
- | `RunningEndpoint` | Resolved value from `.run()` — exposes `shutdown(reason)`. |
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. |
54
79
 
55
- ### Config
80
+ `endpoint()` returns a `RunningEndpoint` from `.run()`. Tests call
81
+ `running.shutdown("test")` to skip the SIGTERM dance.
82
+
83
+ ## Config
56
84
 
57
85
  ```ts
58
86
  endpoint("api", {
59
- port: 3000, // omit for queue/cron-only endpoints
60
- host: "0.0.0.0",
61
- banner: true, // boot banner on stdout
62
- probes: { port: 9_400, checks: [redisProbe] },
63
- shutdown: {
64
- timeoutMs: 30_000,
65
- onShutdown: async () => {
66
- /* … */
67
- },
68
- },
69
- exitOnShutdown: true, // test seam — skip process.exit()
87
+ banner: true, // boot banner on stdout
88
+ probes: { port: 9_400, enabled: true },
89
+ shutdown: { drainTimeout: 30_000, hardTimeout: 5_000 },
90
+ exitOnShutdown: true, // tests pass false
70
91
  });
71
92
  ```
72
93
 
73
- ### What `.serve()` accepts
74
-
75
- | Target | Detection | Behavior |
76
- | ---------------------------------------- | ------------------------- | ----------------------------------------------- |
77
- | Nwire interface (e.g. `httpInterface()`) | `$nwireServable === true` | Attached to a host (HTTP / queue / …). |
78
- | Nwire app (`createApp`) | `$nwireApp === true` | Booted first; its container fulfils interfaces. |
79
- | Express / Fastify / Koa / Nest | Has `.listen(port, …)` | Wrapped with terminator + probes. |
80
-
81
- Multiple `.serve()` calls compose: apps boot in declaration order;
82
- interfaces attach after; shutdown runs in reverse.
94
+ ## Probes
83
95
 
84
- ### Lifecycle observation
96
+ When `probes.enabled` is true, a separate HTTP server on
97
+ `probes.port` (default 9400) serves:
85
98
 
86
- Each `endpoint(name, ...)` instance creates three named hooks
87
- (`endpoint.boot:<name>`, `endpoint.serve:<name>`, `endpoint.shutdown:<name>`)
88
- on construction so they appear in `listHooks()` and `.nwire/hooks.json`
89
- before any `.run()`. Plugins layer `.use()` / `.on()` to extend a phase
90
- without monkey-patching the builder. App runtimes that expose
91
- `adoptHook` get the taps routed into telemetry automatically.
99
+ - `GET /live` liveness; 200 if the process is up
100
+ - `GET /ready` — readiness; 200 once boot completes and all `addCheck()`
101
+ contributors pass
92
102
 
93
- The builder also fires `WireMounting` / `WireMounted` / `WireUnmounted`
94
- on every served app's `FrameworkEventBus`, plus `AppReady` once
95
- lightship flips `/ready` to 200.
103
+ Both are intentionally on a separate port from the app — Kubernetes can
104
+ poll them without touching the app's traffic.
96
105
 
97
106
  ## Related
98
107
 
99
- - `@nwire/http` — produces `NwireServable` instances via `httpInterface()`.
100
- - `@nwire/app` — declares the `WireMounting` / `AppReady` framework events fired here.
101
- - `@nwire/hooks` — the dispatch substrate behind the three per-endpoint hooks.
102
-
103
- ## Status
104
-
105
- v0.x — builder API + framework events + per-endpoint hooks are locked. Multi-foreign-framework on one port is intentionally unsupported.
108
+ - [`@nwire/app`](../core-app) — produces the App that adopters mount
109
+ - [`@nwire/wires`](../core-wires) — the binding kinds adopters consume
110
+ - [`@nwire/koa`](../nwire-koa)canonical HTTP adopter
111
+ - [`@nwire/queue`](../nwire-queue) — in-memory queue adopter (`@nwire/bullmq` for production)
112
+ - [`@nwire/mcp`](../nwire-mcp) — Model Context Protocol adopter
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `Adapter` contract — what every transport runtime (`@nwire/koa`,
3
+ * `@nwire/bullmq`, `@nwire/mcp`, `@nwire/cron`, …) implements.
4
+ *
5
+ * The endpoint hands each adopter its slice of wires + a `containerOf`
6
+ * resolver that maps each wire back to its source app's container.
7
+ * Adopter ownership is bounded: filter wires by `kind`, build the
8
+ * transport runtime, start serving, drain on shutdown.
9
+ *
10
+ * `endpoint("api").use(adopter).mount(source).run()` is the nwire path.
11
+ */
12
+ import type { Wire } from "@nwire/wires";
13
+ import type { Container } from "@nwire/container";
14
+ import type { Logger } from "@nwire/logger";
15
+ import type { HealthCheck } from "./lifecycle.js";
16
+ export interface AdapterBootContext {
17
+ /**
18
+ * Wires the adopter should consume — already filtered to the adopter's
19
+ * kind by the endpoint (the endpoint calls `interface.forAdapter(kind)`
20
+ * before invoking boot).
21
+ */
22
+ readonly wires: readonly Wire[];
23
+ /**
24
+ * Resolve a wire to its source app's container. Returns `undefined`
25
+ * for wires that came from a standalone interface (no app); adopters
26
+ * fall back to a `dummyContainer()` or skip resolution as appropriate.
27
+ */
28
+ readonly containerOf: (wire: Wire) => Container | undefined;
29
+ /** Logger handed in by the endpoint. */
30
+ readonly logger: Logger;
31
+ /** Register a health check with the endpoint's lifecycle manager. */
32
+ readonly addCheck: (check: HealthCheck) => void;
33
+ /**
34
+ * Endpoint-level port hint, when the host declared one
35
+ * (`endpoint("name", { port })`). HTTP-class adopters fall back to it
36
+ * when no transport-level port was passed to the adopter factory.
37
+ */
38
+ readonly port?: number;
39
+ /** Endpoint-level host hint, paired with `port`. */
40
+ readonly host?: string;
41
+ }
42
+ /**
43
+ * Structural shape every transport adopter satisfies. Adopters never
44
+ * extend a base class — the contract is duck-typed at the endpoint.
45
+ */
46
+ export interface Adapter {
47
+ readonly $kind: "adapter";
48
+ /** Adopter-kind tag — matches `binding.$adapter` on the wires it consumes. */
49
+ readonly kind: string;
50
+ boot(opts: AdapterBootContext): Promise<void>;
51
+ shutdown(reason?: string): Promise<void>;
52
+ /**
53
+ * Optional — port the adopter's listener bound to, once `boot()` ran.
54
+ * HTTP-class adopters surface this so the endpoint banner can report
55
+ * the real port (ephemeral binds with `port: 0` only become visible
56
+ * after `listen()` resolves). Returns undefined for non-listening
57
+ * adopters or before boot.
58
+ */
59
+ port?(): number | undefined;
60
+ }
61
+ /** Type narrow — is this an Adapter? */
62
+ export declare function isAdapter(x: unknown): x is Adapter;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `Adapter` contract — what every transport runtime (`@nwire/koa`,
3
+ * `@nwire/bullmq`, `@nwire/mcp`, `@nwire/cron`, …) implements.
4
+ *
5
+ * The endpoint hands each adopter its slice of wires + a `containerOf`
6
+ * resolver that maps each wire back to its source app's container.
7
+ * Adopter ownership is bounded: filter wires by `kind`, build the
8
+ * transport runtime, start serving, drain on shutdown.
9
+ *
10
+ * `endpoint("api").use(adopter).mount(source).run()` is the nwire path.
11
+ */
12
+ /** Type narrow — is this an Adapter? */
13
+ export function isAdapter(x) {
14
+ return (typeof x === "object" &&
15
+ x !== null &&
16
+ x.$kind === "adapter" &&
17
+ typeof x.kind === "string" &&
18
+ typeof x.boot === "function" &&
19
+ typeof x.shutdown === "function");
20
+ }
@@ -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, isNwireServable, type AppServable, type EndpointConfig, type ForeignServable, type HostBindings, type HostFactory, type NwireServable, type RunningEndpoint, type Servable, } from "./endpoint.js";
22
+ export { endpoint, EndpointBuilder, isAppServable, isForeignServable, type AppServable, type EndpointConfig, 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
- //# sourceMappingURL=endpoint-index.d.ts.map
24
+ export { isAdapter, type Adapter, type AdapterBootContext } from "./adapter.js";
@@ -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, isNwireServable, } from "./endpoint.js";
22
+ export { endpoint, EndpointBuilder, isAppServable, isForeignServable, } from "./endpoint.js";
23
23
  export { attachLifecycle, defineCheck, } from "./lifecycle.js";
24
- //# sourceMappingURL=endpoint-index.js.map
24
+ export { isAdapter } from "./adapter.js";
@@ -28,7 +28,7 @@
28
28
  * ## Why this exists
29
29
  *
30
30
  * Every framework eventually needs a process layer — something that knows
31
- * about SIGTERM, drains in-flight requests, exposes `/healthz`, and binds
31
+ * about SIGTERM, drains in-flight requests, exposes `/live`, and binds
32
32
  * to a port. Most frameworks bolt this on as a deployment afterthought.
33
33
  * `@nwire/endpoint` makes it the first-class entry point so you don't
34
34
  * write the same K8s-readiness code on every project. It works alone —
@@ -36,8 +36,9 @@
36
36
  */
37
37
  import { type Server } from "node:http";
38
38
  import type { Container } from "@nwire/container";
39
- import type { AttachBindings, NwireInterface } from "@nwire/interface";
40
- import { type HealthConfig, type HealthCheck, type LifecycleManager, type ShutdownConfig } from "./lifecycle.js";
39
+ import type { Interface } from "@nwire/wires";
40
+ import { type HealthConfig, type LifecycleManager, type ShutdownConfig } from "./lifecycle.js";
41
+ import { type Adapter } from "./adapter.js";
41
42
  /**
42
43
  * Per-endpoint lifecycle hook context. The same shape flows through all
43
44
  * three phases (boot / serve / shutdown) so observers can correlate.
@@ -56,33 +57,18 @@ export interface EndpointHookCtx {
56
57
  readonly startedAt: string;
57
58
  }
58
59
  /**
59
- * Anything that can be served by an endpoint. Three categories:
60
+ * Anything that can be served by an endpoint. Two categories:
60
61
  *
61
- * 1. **NwireServable** — a Nwire interface (http/queue/etc) that knows how
62
- * to produce a request handler and report what transport it needs.
63
- * 2. **ForeignServable** — an existing framework app (Express / Fastify /
62
+ * 1. **ForeignServable** — an existing framework app (Express / Fastify /
64
63
  * Koa / Nest / etc) that the endpoint will wrap.
65
- * 3. **AppServable** — a Container-with-lifecycle that gets booted first
66
- * so transports can use its bindings.
64
+ * 2. **AppServable** — a Container-with-lifecycle that gets booted first
65
+ * so transports can use its bindings. Wires are dispatched via
66
+ * `.use(adopter).mount(app)` separately from `.serve()`.
67
67
  *
68
68
  * Detection is structural: the endpoint inspects the shape at runtime to
69
- * decide how to serve it. No magic see {@link isNwireServable},
70
- * {@link isAppServable}, {@link isForeignServable}.
69
+ * decide how to serve it. See {@link isAppServable}, {@link isForeignServable}.
71
70
  */
72
- export type Servable = NwireServable | ForeignServable | AppServable;
73
- /**
74
- * A Nwire interface compiled for the endpoint. The interface exposes how
75
- * it wants to be mounted: which transport, what handler shape, what
76
- * health checks it contributes.
77
- */
78
- export interface NwireServable extends NwireInterface {
79
- /** Which transport this interface uses. Decides what listener spins up. */
80
- readonly transport: "http" | "graphql" | "queue" | "cron" | "ws" | "mcp" | "custom";
81
- /** Optional health checks contributed by the interface itself. */
82
- readonly checks?: readonly HealthCheck[];
83
- /** Run during shutdown — disconnect from broker, flush, etc. */
84
- shutdown?(): void | Promise<void>;
85
- }
71
+ export type Servable = ForeignServable | AppServable;
86
72
  /**
87
73
  * A foreign framework app — typically `express()`, `Fastify()`, `Koa()`,
88
74
  * or `NestFactory.create(AppModule)`. The endpoint will use the framework's
@@ -113,21 +99,12 @@ export interface AppServable {
113
99
  readonly appName?: string;
114
100
  /**
115
101
  * Optional — dispatch a framework lifecycle event on the app's bus.
116
- * The endpoint uses this to fire `nwire.wire.mounting` /
117
- * `nwire.wire.mounted` / `nwire.wire.unmounted` / `nwire.app.ready`
118
- * at the right points in the serve loop.
119
- *
120
- * The dispatcher is duck-typed by event NAME so endpoint stays
121
- * independent of `@nwire/app`. Apps that don't expose this method
122
- * still serve correctly — endpoint just skips dispatch silently.
123
- *
124
- * Returns `false` when a series-bail subscriber prevented; `true`
125
- * otherwise. Endpoint honors `false` only for the `*-ing` events.
102
+ * The endpoint uses this to fire `nwire.app.ready` once readiness
103
+ * flips. Duck-typed by event name so endpoint stays independent of
104
+ * `@nwire/app`; apps without the method still serve correctly.
126
105
  */
127
106
  dispatchFrameworkEvent?(eventName: string, payload: unknown): Promise<boolean>;
128
107
  }
129
- /** What the endpoint hands to each NwireServable.attach() call. */
130
- export type HostBindings = AttachBindings;
131
108
  export interface EndpointConfig {
132
109
  /** Port for HTTP-class transports. Omit for queue-only / cron-only endpoints. */
133
110
  readonly port?: number;
@@ -170,13 +147,21 @@ export interface RunningEndpoint {
170
147
  * ```
171
148
  */
172
149
  export declare function endpoint(name: string, config?: EndpointConfig): EndpointBuilder;
150
+ /**
151
+ * Type narrow — does this source carry a wire collection (an App or a
152
+ * standalone Interface)? Endpoint accepts both shapes via `.mount()`.
153
+ */
154
+ type WireSource = (AppServable & {
155
+ readonly interface: Interface;
156
+ }) | Interface;
173
157
  /** Internal builder; users get one via {@link endpoint}. */
174
158
  export declare class EndpointBuilder {
175
159
  private readonly name;
176
160
  private readonly config;
177
161
  private apps;
178
- private nwireServables;
179
162
  private foreignServables;
163
+ private adapters;
164
+ private mounted;
180
165
  /**
181
166
  * Per-phase lifecycle hooks. Created lazily at builder-construction so
182
167
  * they appear in `listHooks()` (and `.nwire/hooks.json` after scan)
@@ -192,11 +177,25 @@ export declare class EndpointBuilder {
192
177
  private readonly shutdownHook;
193
178
  constructor(name: string, config: EndpointConfig);
194
179
  /**
195
- * Add something to serve. Multiple calls compose. Apps are booted first
196
- * (any order between them is OK — boot order matters within an app, not
197
- * across them), then Nwire interfaces and foreign apps are attached.
180
+ * Add something to serve. Multiple calls compose an App boots its
181
+ * container/plugins; a framework app (Express/Fastify/Nest) attaches
182
+ * as a foreign listener so its routing system runs side by side. For
183
+ * Nwire wire-collections, use `.use(adopter).mount(app)`.
198
184
  */
199
185
  serve(target: Servable): EndpointBuilder;
186
+ /**
187
+ * Install a transport adopter — an `Adapter` value produced by an
188
+ * adopter package (`httpKoa()`, `queueBullmq()`, `mcp()`, …). Multiple
189
+ * `.use()` calls compose; the endpoint hands each adopter its slice of
190
+ * wires from the mounted source at `.run()` time.
191
+ */
192
+ use(adopter: Adapter): EndpointBuilder;
193
+ /**
194
+ * 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.
197
+ */
198
+ mount(source: WireSource): EndpointBuilder;
200
199
  /**
201
200
  * Boot all served apps in order, then attach all served interfaces +
202
201
  * foreign apps, then start listening. Returns a {@link RunningEndpoint}
@@ -209,8 +208,6 @@ export declare class EndpointBuilder {
209
208
  */
210
209
  run(): Promise<RunningEndpoint>;
211
210
  }
212
- /** True when the target is a Nwire interface (http/queue/etc) compiled for endpoint mount. */
213
- export declare function isNwireServable(x: unknown): x is NwireServable;
214
211
  /** True when the target is a Nwire app (container + lifecycle). */
215
212
  export declare function isAppServable(x: unknown): x is AppServable;
216
213
  /**
@@ -218,18 +215,7 @@ export declare function isAppServable(x: unknown): x is AppServable;
218
215
  *
219
216
  * Some hosts (Express, Connect) return a callable function as their app
220
217
  * value; others return an object (Fastify, Koa). We accept both — a
221
- * function with a `.listen` method counts. NwireServables are excluded
222
- * by the `$nwireServable` check at the call site.
218
+ * function with a `.listen` method counts.
223
219
  */
224
220
  export declare function isForeignServable(x: unknown): x is ForeignServable;
225
- /**
226
- * Per-transport host factory shape (provided by interface packages like
227
- * `@nwire/http`). The endpoint stays transport-agnostic by holding a
228
- * reference to this through `_buildHost` and asking the transport to
229
- * produce a Server when needed.
230
- */
231
- export interface HostFactory {
232
- addCheck(check: HealthCheck): void;
233
- listen(port: number, host: string): Promise<Server>;
234
- }
235
- //# sourceMappingURL=endpoint.d.ts.map
221
+ export {};