@nwire/endpoint 0.9.1 → 0.10.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/dist/endpoint.js CHANGED
@@ -34,8 +34,11 @@
34
34
  * write the same K8s-readiness code on every project. It works alone —
35
35
  * install only this package and wrap any Node server.
36
36
  */
37
+ import { createServer as createHttpServer } from "node:http";
37
38
  import { hook } from "@nwire/hooks";
39
+ import { ConsoleLogger } from "@nwire/logger";
38
40
  import { attachLifecycle, } from "./lifecycle.js";
41
+ import { isAdapter } from "./adapter.js";
39
42
  // ─── Builder API ──────────────────────────────────────────────────────────
40
43
  /**
41
44
  * Build an endpoint. The returned builder is chainable: `.serve()` adds
@@ -60,13 +63,24 @@ import { attachLifecycle, } from "./lifecycle.js";
60
63
  export function endpoint(name, config = {}) {
61
64
  return new EndpointBuilder(name, config);
62
65
  }
66
+ function isInterfaceShape(x) {
67
+ return (typeof x === "object" &&
68
+ x !== null &&
69
+ x.$kind === "interface" &&
70
+ Array.isArray(x.wires));
71
+ }
72
+ function isAppWithInterface(x) {
73
+ return isAppServable(x) && isInterfaceShape(x.interface);
74
+ }
63
75
  /** Internal builder; users get one via {@link endpoint}. */
64
76
  export class EndpointBuilder {
65
77
  name;
66
78
  config;
67
79
  apps = [];
68
- nwireServables = [];
69
80
  foreignServables = [];
81
+ // ─── New-shape state (.use / .mount) ─────────────────────────────────
82
+ adapters = [];
83
+ mounted;
70
84
  /**
71
85
  * Per-phase lifecycle hooks. Created lazily at builder-construction so
72
86
  * they appear in `listHooks()` (and `.nwire/hooks.json` after scan)
@@ -100,22 +114,53 @@ export class EndpointBuilder {
100
114
  }, { name: "shutdown" });
101
115
  }
102
116
  /**
103
- * Add something to serve. Multiple calls compose. Apps are booted first
104
- * (any order between them is OK — boot order matters within an app, not
105
- * across them), then Nwire interfaces and foreign apps are attached.
117
+ * Add something to serve. Multiple calls compose an App boots its
118
+ * container/plugins; a framework app (Express/Fastify/Nest) attaches
119
+ * as a foreign listener so its routing system runs side by side. For
120
+ * Nwire wire-collections, use `.use(adopter).mount(app)`.
106
121
  */
107
122
  serve(target) {
108
123
  if (isAppServable(target)) {
109
124
  this.apps.push(target);
110
125
  }
111
- else if (isNwireServable(target)) {
112
- this.nwireServables.push(target);
113
- }
114
126
  else if (isForeignServable(target)) {
115
127
  this.foreignServables.push(target);
116
128
  }
117
129
  else {
118
- throw new Error(`endpoint("${this.name}").serve(): target is not a Nwire app, Nwire interface, or framework app.`);
130
+ throw new Error(`endpoint("${this.name}").serve(): target is not a Nwire app or a framework app.`);
131
+ }
132
+ return this;
133
+ }
134
+ /**
135
+ * Install a transport adopter — an `Adapter` value produced by an
136
+ * adopter package (`httpKoa()`, `queueBullmq()`, `mcp()`, …). Multiple
137
+ * `.use()` calls compose; the endpoint hands each adopter its slice of
138
+ * wires from the mounted source at `.run()` time.
139
+ */
140
+ use(adopter) {
141
+ if (!isAdapter(adopter)) {
142
+ throw new Error(`endpoint("${this.name}").use(): expected an Adapter, got ${typeof adopter}.`);
143
+ }
144
+ this.adapters.push(adopter);
145
+ return this;
146
+ }
147
+ /**
148
+ * 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
+ */
152
+ mount(source) {
153
+ if (this.mounted) {
154
+ throw new Error(`endpoint("${this.name}").mount(): a source is already mounted ("${"appName" in this.mounted ? this.mounted.appName : "interface"}"). ` +
155
+ `One source per endpoint — use appCompose(...) from "@nwire/app" to compose multiple apps.`);
156
+ }
157
+ if (!isAppWithInterface(source) && !isInterfaceShape(source)) {
158
+ throw new Error(`endpoint("${this.name}").mount(): expected an App with .interface, or a standalone Interface.`);
159
+ }
160
+ this.mounted = source;
161
+ // If mounting an App, also boot it via the existing apps array.
162
+ if (isAppWithInterface(source) && !this.apps.includes(source)) {
163
+ this.apps.push(source);
119
164
  }
120
165
  return this;
121
166
  }
@@ -157,101 +202,69 @@ export class EndpointBuilder {
157
202
  for (const app of this.apps) {
158
203
  await app.boot();
159
204
  }
160
- // Helper to dispatch a lifecycle event on every served app's bus.
161
- // Apps that don't expose `dispatchFrameworkEvent` are silently
162
- // skipped (L2 standalone case has no apps; observers register on
163
- // the app's bus so there's nothing to dispatch to).
164
- const fireLifecycle = async (eventName, payload) => {
165
- let allowed = true;
166
- for (const app of this.apps) {
167
- if (app.dispatchFrameworkEvent) {
168
- const ok = await app.dispatchFrameworkEvent(eventName, payload);
169
- if (!ok)
170
- allowed = false;
171
- }
172
- }
173
- return allowed;
174
- };
175
- // The container the host hands to interfaces. If multiple apps were
176
- // served, we use the first app's container as primary — interfaces
177
- // can reach into others via `ctx.resolve` if those bindings exist.
178
- // Most apps have one container; this case-edge is for BFFs.
179
- //
180
- // L2 path: no app served (`.serve(api)` on a bare httpInterface). The
181
- // interface already has a container set via `.provide(...)`; we must
182
- // NOT clobber it. Pass `undefined` so the interface's attach() can
183
- // preserve its own provision.
205
+ // The container the host hands to each adopter the first served
206
+ // app's. Multi-app composites carry per-wire app references that
207
+ // `containerOf(wire)` resolves to per dispatch.
184
208
  const container = this.apps[0]?.container;
185
- // 2. Start the operating-system listener. For HTTP-class endpoints we
186
- // spin a single Node http.Server that hosts all NwireServables +
187
- // any ForeignServable. Workers-only endpoints skip the listener
188
- // entirely; the probes server is still available.
209
+ // 2. Start the foreign-framework listener when one was served, then
210
+ // boot every adopter. Workers-only endpoints with no foreign app
211
+ // skip the listener entirely; the probes server is still available.
189
212
  //
190
213
  // Fire the per-endpoint `serve` hook before any transport attaches —
191
214
  // tap observers see "serve phase starting" with the endpoint context.
192
- // The existing `nwire.wire.mounting` / `mounted` framework events
193
- // still fire below; the hook is an additive observation surface.
194
- //
195
- // Each `s.attach(...)` is wrapped with WireMounting (interceptable)
196
- // and WireMounted (observable) so dev logger + Studio Live show
197
- // every wire as it comes up.
198
215
  await this.serveHook.run({
199
216
  endpointName: this.name,
200
217
  phase: "serve",
201
218
  startedAt: new Date().toISOString(),
202
219
  });
203
220
  let server;
204
- if (this.config.port !== undefined &&
205
- (this.nwireServables.length || this.foreignServables.length)) {
206
- // Pre-attach hook: fire WireMounting for each interface BEFORE
207
- // startListener does the actual attach.
208
- for (const s of this.nwireServables) {
209
- const ok = await fireLifecycle("nwire.wire.mounting", {
210
- appName: this.apps[0]?.appName ?? this.name,
211
- transport: s.transport,
212
- manifest: undefined,
213
- });
214
- if (!ok) {
215
- throw new Error(`endpoint("${this.name}").serve(): wire "${s.transport}" mount prevented by a WireMounting subscriber.`);
216
- }
217
- }
221
+ if (this.config.port !== undefined && this.foreignServables.length) {
218
222
  server = await startListener({
219
223
  port: this.config.port,
220
224
  host: this.config.host,
221
- nwireServables: this.nwireServables,
222
225
  foreignServables: this.foreignServables,
226
+ container,
223
227
  });
224
- // Post-attach: every wire is now attached. Fire WireMounted.
225
- for (const s of this.nwireServables) {
226
- await fireLifecycle("nwire.wire.mounted", {
227
- appName: this.apps[0]?.appName ?? this.name,
228
- transport: s.transport,
229
- manifest: undefined,
230
- });
231
- }
232
228
  }
233
- else {
234
- // Workers / queue / cron attach NwireServables to a non-HTTP host.
235
- for (const s of this.nwireServables) {
236
- const ok = await fireLifecycle("nwire.wire.mounting", {
237
- appName: this.apps[0]?.appName ?? this.name,
238
- transport: s.transport,
239
- manifest: undefined,
240
- });
241
- if (!ok) {
242
- throw new Error(`endpoint("${this.name}").serve(): wire "${s.transport}" mount prevented by a WireMounting subscriber.`);
243
- }
244
- await s.attach({ addCheck: () => undefined });
245
- await fireLifecycle("nwire.wire.mounted", {
246
- appName: this.apps[0]?.appName ?? this.name,
247
- transport: s.transport,
248
- manifest: undefined,
249
- });
250
- }
229
+ // ─── New-shape adopter boot ────────────────────────────────────────
230
+ // For each registered adopter, hand it its slice of wires + a
231
+ // containerOf resolver that maps each wire back to its source app's
232
+ // container (falling back to the endpoint's primary container).
233
+ const accumulatedChecks = [];
234
+ const adapterLogger = new ConsoleLogger();
235
+ const mountedSource = this.mounted;
236
+ if (this.adapters.length > 0 && !mountedSource) {
237
+ throw new Error(`endpoint("${this.name}"): .use(adopter) was called but no source is mounted. ` +
238
+ `Call .mount(app) before .run().`);
239
+ }
240
+ const sourceInterface = mountedSource === undefined
241
+ ? undefined
242
+ : isInterfaceShape(mountedSource)
243
+ ? mountedSource
244
+ : mountedSource.interface;
245
+ 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.
250
+ const sourceApp = mountedSource && !isInterfaceShape(mountedSource) ? mountedSource : undefined;
251
+ const wireApp = _w.app;
252
+ return wireApp?.container ?? sourceApp?.container;
253
+ };
254
+ for (const adopter of this.adapters) {
255
+ const wires = sourceInterface?.forAdapter(adopter.kind) ?? [];
256
+ await adopter.boot({
257
+ wires,
258
+ containerOf,
259
+ logger: adapterLogger,
260
+ addCheck: (c) => accumulatedChecks.push(c),
261
+ port: this.config.port,
262
+ host: this.config.host,
263
+ });
251
264
  }
252
265
  // 3. Wrap the server (if any) with graceful shutdown + probes.
253
266
  const lifecycle = await attachLifecycle({
254
- server: server ?? makeProbeOnlyServer(),
267
+ server: server ?? (await makeProbeOnlyServer()),
255
268
  shutdown: {
256
269
  ...this.config.shutdown,
257
270
  onShutdown: async () => {
@@ -270,26 +283,17 @@ export class EndpointBuilder {
270
283
  catch {
271
284
  /* swallow — shutdown must proceed */
272
285
  }
273
- // Fire WireUnmounted for each interface BEFORE its shutdown
274
- // runs, so observers can record "wire stopping" with full
275
- // context. Errors during dispatch are swallowed shutdown
276
- // must complete regardless.
277
- for (let i = this.nwireServables.length - 1; i >= 0; i--) {
278
- const s = this.nwireServables[i];
286
+ // Adopter shutdowns in reverse use() order each adopter
287
+ // drains its in-flight work.
288
+ for (let i = this.adapters.length - 1; i >= 0; i--) {
279
289
  try {
280
- await fireLifecycle("nwire.wire.unmounted", {
281
- appName: this.apps[0]?.appName ?? this.name,
282
- transport: s.transport,
283
- });
290
+ await this.adapters[i].shutdown();
284
291
  }
285
- catch {
286
- /* swallow — shutdown must proceed */
292
+ catch (err) {
293
+ // eslint-disable-next-line no-console
294
+ console.error(`[endpoint:${this.name}] adopter shutdown failed:`, err);
287
295
  }
288
296
  }
289
- // Interface shutdowns — let them disconnect cleanly.
290
- for (let i = this.nwireServables.length - 1; i >= 0; i--) {
291
- await this.nwireServables[i].shutdown?.();
292
- }
293
297
  // Apps shutdown in reverse boot order.
294
298
  for (let i = this.apps.length - 1; i >= 0; i--) {
295
299
  await this.apps[i].shutdown();
@@ -300,19 +304,10 @@ export class EndpointBuilder {
300
304
  },
301
305
  health: {
302
306
  ...this.config.probes,
303
- checks: [
304
- ...(this.config.probes?.checks ?? []),
305
- ...this.nwireServables.flatMap((s) => s.checks ?? []),
306
- ],
307
+ checks: this.config.probes?.checks ?? [],
307
308
  },
308
309
  exitOnShutdown: this.config.exitOnShutdown,
309
310
  });
310
- // 4. Register interface checks dynamically too — they may have been
311
- // discovered during attach (e.g., queue probes once connected).
312
- for (const s of this.nwireServables) {
313
- for (const c of s.checks ?? [])
314
- lifecycle.addCheck(c);
315
- }
316
311
  // 5. Flip readiness and print the banner. AppReady fires AFTER
317
312
  // lightship marks /ready 200 — that's the moment the wire actually
318
313
  // accepts traffic.
@@ -326,13 +321,18 @@ export class EndpointBuilder {
326
321
  }
327
322
  }
328
323
  if (this.config.banner !== false) {
329
- // Only advertise probes if lightship actually bound. The bind
330
- // can be skipped (probes disabled) or fail silently on
331
- // EADDRINUSE (handled in attachLifecycle).
324
+ // Read the actual bound port from each adopter that exposes one —
325
+ // covers ephemeral binds (`port: 0`) and any case where the adopter
326
+ // chose its own port. Falls back to the endpoint-declared port.
327
+ const adopterPorts = this.adapters
328
+ .map((a) => a.port?.())
329
+ .filter((p) => typeof p === "number");
330
+ const dataPort = adopterPorts[0] ?? this.config.port;
332
331
  const probesBound = lifecycle.lightship !== undefined;
333
332
  printBanner({
334
333
  name: this.name,
335
- port: this.config.port,
334
+ host: this.config.host ?? "0.0.0.0",
335
+ port: dataPort,
336
336
  probePort: probesBound ? (this.config.probes?.port ?? 9_400) : undefined,
337
337
  });
338
338
  }
@@ -345,12 +345,6 @@ export class EndpointBuilder {
345
345
  }
346
346
  }
347
347
  // ─── Type guards ──────────────────────────────────────────────────────────
348
- /** True when the target is a Nwire interface (http/queue/etc) compiled for endpoint mount. */
349
- export function isNwireServable(x) {
350
- return (typeof x === "object" &&
351
- x !== null &&
352
- x.$nwireServable === true);
353
- }
354
348
  /** True when the target is a Nwire app (container + lifecycle). */
355
349
  export function isAppServable(x) {
356
350
  return typeof x === "object" && x !== null && x.$nwireApp === true;
@@ -360,8 +354,7 @@ export function isAppServable(x) {
360
354
  *
361
355
  * Some hosts (Express, Connect) return a callable function as their app
362
356
  * value; others return an object (Fastify, Koa). We accept both — a
363
- * function with a `.listen` method counts. NwireServables are excluded
364
- * by the `$nwireServable` check at the call site.
357
+ * function with a `.listen` method counts.
365
358
  */
366
359
  export function isForeignServable(x) {
367
360
  return (x !== null &&
@@ -370,69 +363,33 @@ export function isForeignServable(x) {
370
363
  }
371
364
  // ─── Internal helpers ─────────────────────────────────────────────────────
372
365
  /**
373
- * Start the operating-system listener. When multiple NwireServables share
374
- * a port, this composes them onto a single http.Server. ForeignServables
375
- * use their own `.listen()` and the resulting Server is what gets wrapped
376
- * with lifecyclemeaning today only ONE ForeignServable per endpoint is
377
- * supported (more would require an internal multiplexer). The 99% case
378
- * is fine; multi-foreign on one port is exotic and you'd build it yourself.
366
+ * Start the operating-system listener for a foreign framework app. The
367
+ * foreign app owns its own server; the endpoint just kicks `.listen()`
368
+ * and wraps the resulting Server with lifecycle. Only one ForeignServable
369
+ * per endpoint is supported multi-foreign on one port is exotic and
370
+ * the caller can compose inside the framework first.
379
371
  */
380
372
  async function startListener(opts) {
381
373
  if (opts.foreignServables.length > 1) {
382
374
  throw new Error(`endpoint: more than one foreign-framework target on one port is not supported; ` +
383
375
  `mount them separately or compose them inside the framework first.`);
384
376
  }
385
- // Case A — foreign app owns the server. Nwire interfaces attach onto it
386
- // via to* adapters (toExpress, toKoa) the user calls themselves before
387
- // serving the framework. The endpoint just wraps the resulting Server.
388
- if (opts.foreignServables.length === 1) {
389
- const foreign = opts.foreignServables[0];
390
- const server = await new Promise((resolve) => {
391
- const s = foreign.listen(opts.port, opts.host ?? "0.0.0.0", () => resolve(s));
392
- });
393
- // NwireServables attached to a foreign endpoint must register
394
- // themselves separately — there's no way for us to mount them onto
395
- // the foreign framework's router automatically without knowing the
396
- // framework. Future: detect Express/Fastify/Koa and auto-mount.
397
- for (const s of opts.nwireServables) {
398
- await s.attach({ addCheck: () => undefined });
399
- }
400
- return server;
401
- }
402
- // Case B — Nwire interfaces only. Build a host that they attach to.
403
- // For now we delegate to the first servable's transport-specific host
404
- // builder. Multi-transport on one port (REST + GraphQL + WS) requires
405
- // a more elaborate host; that's @nwire/http's job.
406
- if (opts.nwireServables.length === 0) {
407
- throw new Error("endpoint: nothing to listen on (no Nwire interfaces, no foreign app)");
408
- }
409
- // Lean fallback: assume the first servable carries an internal listener
410
- // factory. The richer multi-transport host lives in @nwire/http.
411
- const primary = opts.nwireServables[0];
412
- const buildHost = primary._buildHost;
413
- const hostFactory = buildHost ? buildHost.bind(primary) : undefined;
414
- if (!hostFactory) {
415
- throw new Error(`endpoint: interface "${primary.transport}" does not expose _buildHost(); ` +
416
- `it cannot be the primary listener. Install a transport package that supports it.`);
417
- }
418
- const built = hostFactory();
419
- for (const s of opts.nwireServables) {
420
- await s.attach({ addCheck: built.addCheck });
421
- }
422
- return await built.listen(opts.port, opts.host ?? "0.0.0.0");
377
+ const foreign = opts.foreignServables[0];
378
+ return await new Promise((resolve) => {
379
+ const s = foreign.listen(opts.port, opts.host ?? "0.0.0.0", () => resolve(s));
380
+ });
423
381
  }
424
382
  /** Probe-only server for workers/cron — nothing on the data port. */
425
- function makeProbeOnlyServer() {
426
- // Lazy import keeps the cold-path module load out of the hot start.
427
- // eslint-disable-next-line @typescript-eslint/no-require-imports
428
- const http = require("node:http");
429
- const srv = http.createServer((_req, res) => {
383
+ async function makeProbeOnlyServer() {
384
+ const srv = createHttpServer((_req, res) => {
430
385
  res.statusCode = 404;
431
386
  res.end();
432
387
  });
433
- // We don't actually .listen() this; attachLifecycle uses it only as
434
- // the http-terminator target. Workers have no data port to drain;
435
- // the probe port (lightship) is separate.
388
+ // Bind ephemerally so http-terminator has something to drain at
389
+ // shutdown without a live listener, terminator throws "Server is
390
+ // not running" and onShutdown never fires. The 127.0.0.1:0 bind is
391
+ // ignored functionally; we just need an active socket.
392
+ await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve));
436
393
  return srv;
437
394
  }
438
395
  // `emptyContainer()` was previously used when no app was served — but
@@ -444,17 +401,16 @@ function printBanner(opts) {
444
401
  const lines = [
445
402
  `nwire endpoint "${opts.name}" listening`,
446
403
  opts.port !== undefined
447
- ? ` data: http://0.0.0.0:${opts.port}`
404
+ ? ` data: http://${opts.host}:${opts.port}`
448
405
  : ` data: (no HTTP listener)`,
449
406
  ];
450
407
  if (opts.probePort !== undefined) {
451
408
  // lightship's actual paths — not /readyz + /healthz (which was a
452
409
  // historical misnaming in our docs).
453
- lines.push(` probes: http://0.0.0.0:${opts.probePort}/live · /ready`);
410
+ lines.push(` probes: http://${opts.host}:${opts.probePort}/live · /ready`);
454
411
  }
455
412
  else {
456
413
  lines.push(` probes: (disabled — port already in use or probes disabled)`);
457
414
  }
458
415
  console.log(lines.join("\n"));
459
416
  }
460
- //# sourceMappingURL=endpoint.js.map
@@ -133,4 +133,3 @@ export declare function attachLifecycle(opts: {
133
133
  /** Test seam — set to false to skip process.exit() at the end. */
134
134
  readonly exitOnShutdown?: boolean;
135
135
  }): Promise<LifecycleManager>;
136
- //# sourceMappingURL=lifecycle.d.ts.map
package/dist/lifecycle.js CHANGED
@@ -227,4 +227,3 @@ export async function attachLifecycle(opts) {
227
227
  },
228
228
  };
229
229
  }
230
- //# sourceMappingURL=lifecycle.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/endpoint",
3
- "version": "0.9.1",
3
+ "version": "0.10.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,9 +32,10 @@
32
32
  "dependencies": {
33
33
  "http-terminator": "^3.2.0",
34
34
  "lightship": "^9.0.4",
35
- "@nwire/hooks": "0.9.1",
36
- "@nwire/container": "0.9.1",
37
- "@nwire/interface": "0.9.1"
35
+ "@nwire/container": "0.10.0",
36
+ "@nwire/hooks": "0.10.0",
37
+ "@nwire/wires": "0.10.0",
38
+ "@nwire/logger": "0.10.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^22.19.9",
@@ -1,14 +0,0 @@
1
- /**
2
- * Smoke tests for the endpoint builder. Exercises:
3
- *
4
- * - Type guards on the three Servable shapes (Nwire interface / app / foreign)
5
- * - Builder accepts each shape via .serve()
6
- * - .serve() with an unknown shape throws a clear error
7
- *
8
- * Full lifecycle integration (boot → listen → SIGTERM → drain → exit) is
9
- * covered by the docker-compose integration suite, not this unit file.
10
- * That suite lives at `examples/integration-tests/` and runs against
11
- * real K8s probes + http-terminator timing.
12
- */
13
- export {};
14
- //# sourceMappingURL=endpoint.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"endpoint.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/endpoint.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -1,96 +0,0 @@
1
- /**
2
- * Smoke tests for the endpoint builder. Exercises:
3
- *
4
- * - Type guards on the three Servable shapes (Nwire interface / app / foreign)
5
- * - Builder accepts each shape via .serve()
6
- * - .serve() with an unknown shape throws a clear error
7
- *
8
- * Full lifecycle integration (boot → listen → SIGTERM → drain → exit) is
9
- * covered by the docker-compose integration suite, not this unit file.
10
- * That suite lives at `examples/integration-tests/` and runs against
11
- * real K8s probes + http-terminator timing.
12
- */
13
- import { describe, it, expect } from "vitest";
14
- import { createContainer } from "@nwire/container";
15
- import { endpoint, isAppServable, isForeignServable, isNwireServable, } from "../endpoint.js";
16
- describe("endpoint() — type guards", () => {
17
- it("identifies a NwireServable by the $nwireServable marker", () => {
18
- const ns = {
19
- $nwireServable: true,
20
- transport: "http",
21
- attach: () => undefined,
22
- };
23
- expect(isNwireServable(ns)).toBe(true);
24
- expect(isAppServable(ns)).toBe(false);
25
- expect(isForeignServable(ns)).toBe(false);
26
- });
27
- it("identifies an AppServable by the $nwireApp marker", () => {
28
- const app = {
29
- $nwireApp: true,
30
- container: createContainer(),
31
- boot: async () => undefined,
32
- shutdown: async () => undefined,
33
- };
34
- expect(isAppServable(app)).toBe(true);
35
- expect(isNwireServable(app)).toBe(false);
36
- });
37
- it("identifies a ForeignServable by .listen()", () => {
38
- /**
39
- * Anything with `.listen(port, host?, cb?): Server` is treated as a
40
- * foreign framework. The endpoint will wrap the resulting Server
41
- * with attachLifecycle().
42
- */
43
- const foreign = {
44
- listen: () => ({}),
45
- };
46
- expect(isForeignServable(foreign)).toBe(true);
47
- expect(isNwireServable(foreign)).toBe(false);
48
- expect(isAppServable(foreign)).toBe(false);
49
- });
50
- it("rejects plain objects that satisfy none of the contracts", () => {
51
- const garbage = { foo: 1, bar: () => undefined };
52
- expect(isNwireServable(garbage)).toBe(false);
53
- expect(isAppServable(garbage)).toBe(false);
54
- expect(isForeignServable(garbage)).toBe(false);
55
- });
56
- it("accepts a CALLABLE app value with .listen — Express 5's app is a function", () => {
57
- // Regression: Express 5 returns a callable (typeof === "function") with
58
- // a .listen property. A previous typeof === "object" check rejected it.
59
- const expressLikeApp = Object.assign(() => undefined, { listen: () => ({}) });
60
- expect(isForeignServable(expressLikeApp)).toBe(true);
61
- });
62
- it("rejects null + arrow fns without .listen", () => {
63
- expect(isForeignServable(null)).toBe(false);
64
- expect(isForeignServable(() => undefined)).toBe(false);
65
- });
66
- });
67
- describe("endpoint() — builder", () => {
68
- it("accepts each Servable shape via .serve() in any order", () => {
69
- /**
70
- * The builder doesn't run anything until .run() is called, so we can
71
- * verify ordering / acceptance without spinning up a server.
72
- */
73
- const app = {
74
- $nwireApp: true,
75
- container: createContainer(),
76
- boot: async () => undefined,
77
- shutdown: async () => undefined,
78
- };
79
- const iface = {
80
- $nwireServable: true,
81
- transport: "queue",
82
- attach: () => undefined,
83
- };
84
- expect(() => endpoint("api").serve(app).serve(iface)).not.toThrow();
85
- expect(() => endpoint("api").serve(iface).serve(app)).not.toThrow();
86
- });
87
- it("throws a clear error when .serve() gets an unknown shape", () => {
88
- /**
89
- * Catching the bad-input case at .serve() time (not .run() time) means
90
- * the developer sees the error at the call site, not buried in a
91
- * runtime stack trace from inside the boot sequence.
92
- */
93
- expect(() => endpoint("api").serve({ random: 42 })).toThrow(/not a Nwire app, Nwire interface, or framework app/);
94
- });
95
- });
96
- //# sourceMappingURL=endpoint.test.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"endpoint.test.js","sourceRoot":"","sources":["../../src/__tests__/endpoint.test.ts"],"names":[],"mappings":"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"}
@@ -1,13 +0,0 @@
1
- /**
2
- * Direct tests for `attachLifecycle()` — the graceful-shutdown wrapper
3
- * that sits between any Node http.Server and http-terminator + lightship.
4
- *
5
- * Specifically covers the edge cases that previously broke real boot
6
- * paths but were only surfaced by interop examples:
7
- *
8
- * - shutdown completes promptly when probes are disabled (no LB to wait for)
9
- * - shutdown waits drainDelay when probes are enabled (LB needs time)
10
- * - shutdown is idempotent
11
- */
12
- export {};
13
- //# sourceMappingURL=lifecycle.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"lifecycle.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/lifecycle.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}