@nwire/endpoint 0.9.2 → 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
@@ -36,7 +36,9 @@
36
36
  */
37
37
  import { createServer as createHttpServer } from "node:http";
38
38
  import { hook } from "@nwire/hooks";
39
+ import { ConsoleLogger } from "@nwire/logger";
39
40
  import { attachLifecycle, } from "./lifecycle.js";
41
+ import { isAdapter } from "./adapter.js";
40
42
  // ─── Builder API ──────────────────────────────────────────────────────────
41
43
  /**
42
44
  * Build an endpoint. The returned builder is chainable: `.serve()` adds
@@ -61,13 +63,24 @@ import { attachLifecycle, } from "./lifecycle.js";
61
63
  export function endpoint(name, config = {}) {
62
64
  return new EndpointBuilder(name, config);
63
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
+ }
64
75
  /** Internal builder; users get one via {@link endpoint}. */
65
76
  export class EndpointBuilder {
66
77
  name;
67
78
  config;
68
79
  apps = [];
69
- nwireServables = [];
70
80
  foreignServables = [];
81
+ // ─── New-shape state (.use / .mount) ─────────────────────────────────
82
+ adapters = [];
83
+ mounted;
71
84
  /**
72
85
  * Per-phase lifecycle hooks. Created lazily at builder-construction so
73
86
  * they appear in `listHooks()` (and `.nwire/hooks.json` after scan)
@@ -101,22 +114,53 @@ export class EndpointBuilder {
101
114
  }, { name: "shutdown" });
102
115
  }
103
116
  /**
104
- * Add something to serve. Multiple calls compose. Apps are booted first
105
- * (any order between them is OK — boot order matters within an app, not
106
- * 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)`.
107
121
  */
108
122
  serve(target) {
109
123
  if (isAppServable(target)) {
110
124
  this.apps.push(target);
111
125
  }
112
- else if (isNwireServable(target)) {
113
- this.nwireServables.push(target);
114
- }
115
126
  else if (isForeignServable(target)) {
116
127
  this.foreignServables.push(target);
117
128
  }
118
129
  else {
119
- 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);
120
164
  }
121
165
  return this;
122
166
  }
@@ -158,102 +202,69 @@ export class EndpointBuilder {
158
202
  for (const app of this.apps) {
159
203
  await app.boot();
160
204
  }
161
- // Helper to dispatch a lifecycle event on every served app's bus.
162
- // Apps that don't expose `dispatchFrameworkEvent` are silently
163
- // skipped (L2 standalone case has no apps; observers register on
164
- // the app's bus so there's nothing to dispatch to).
165
- const fireLifecycle = async (eventName, payload) => {
166
- let allowed = true;
167
- for (const app of this.apps) {
168
- if (app.dispatchFrameworkEvent) {
169
- const ok = await app.dispatchFrameworkEvent(eventName, payload);
170
- if (!ok)
171
- allowed = false;
172
- }
173
- }
174
- return allowed;
175
- };
176
- // The container the host hands to interfaces. If multiple apps were
177
- // served, we use the first app's container as primary — interfaces
178
- // can reach into others via `ctx.resolve` if those bindings exist.
179
- // Most apps have one container; this case-edge is for BFFs.
180
- //
181
- // L2 path: no app served (`.serve(api)` on a bare httpInterface). The
182
- // interface already has a container set via `.provide(...)`; we must
183
- // NOT clobber it. Pass `undefined` so the interface's attach() can
184
- // 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.
185
208
  const container = this.apps[0]?.container;
186
- // 2. Start the operating-system listener. For HTTP-class endpoints we
187
- // spin a single Node http.Server that hosts all NwireServables +
188
- // any ForeignServable. Workers-only endpoints skip the listener
189
- // 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.
190
212
  //
191
213
  // Fire the per-endpoint `serve` hook before any transport attaches —
192
214
  // tap observers see "serve phase starting" with the endpoint context.
193
- // The existing `nwire.wire.mounting` / `mounted` framework events
194
- // still fire below; the hook is an additive observation surface.
195
- //
196
- // Each `s.attach(...)` is wrapped with WireMounting (interceptable)
197
- // and WireMounted (observable) so dev logger + Studio Live show
198
- // every wire as it comes up.
199
215
  await this.serveHook.run({
200
216
  endpointName: this.name,
201
217
  phase: "serve",
202
218
  startedAt: new Date().toISOString(),
203
219
  });
204
220
  let server;
205
- if (this.config.port !== undefined &&
206
- (this.nwireServables.length || this.foreignServables.length)) {
207
- // Pre-attach hook: fire WireMounting for each interface BEFORE
208
- // startListener does the actual attach.
209
- for (const s of this.nwireServables) {
210
- const ok = await fireLifecycle("nwire.wire.mounting", {
211
- appName: this.apps[0]?.appName ?? this.name,
212
- transport: s.transport,
213
- manifest: undefined,
214
- });
215
- if (!ok) {
216
- throw new Error(`endpoint("${this.name}").serve(): wire "${s.transport}" mount prevented by a WireMounting subscriber.`);
217
- }
218
- }
221
+ if (this.config.port !== undefined && this.foreignServables.length) {
219
222
  server = await startListener({
220
223
  port: this.config.port,
221
224
  host: this.config.host,
222
- nwireServables: this.nwireServables,
223
225
  foreignServables: this.foreignServables,
224
226
  container,
225
227
  });
226
- // Post-attach: every wire is now attached. Fire WireMounted.
227
- for (const s of this.nwireServables) {
228
- await fireLifecycle("nwire.wire.mounted", {
229
- appName: this.apps[0]?.appName ?? this.name,
230
- transport: s.transport,
231
- manifest: undefined,
232
- });
233
- }
234
228
  }
235
- else {
236
- // Workers / queue / cron attach NwireServables to a non-HTTP host.
237
- for (const s of this.nwireServables) {
238
- const ok = await fireLifecycle("nwire.wire.mounting", {
239
- appName: this.apps[0]?.appName ?? this.name,
240
- transport: s.transport,
241
- manifest: undefined,
242
- });
243
- if (!ok) {
244
- throw new Error(`endpoint("${this.name}").serve(): wire "${s.transport}" mount prevented by a WireMounting subscriber.`);
245
- }
246
- await s.attach({ addCheck: () => undefined, container });
247
- await fireLifecycle("nwire.wire.mounted", {
248
- appName: this.apps[0]?.appName ?? this.name,
249
- transport: s.transport,
250
- manifest: undefined,
251
- });
252
- }
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
+ });
253
264
  }
254
265
  // 3. Wrap the server (if any) with graceful shutdown + probes.
255
266
  const lifecycle = await attachLifecycle({
256
- server: server ?? makeProbeOnlyServer(),
267
+ server: server ?? (await makeProbeOnlyServer()),
257
268
  shutdown: {
258
269
  ...this.config.shutdown,
259
270
  onShutdown: async () => {
@@ -272,26 +283,17 @@ export class EndpointBuilder {
272
283
  catch {
273
284
  /* swallow — shutdown must proceed */
274
285
  }
275
- // Fire WireUnmounted for each interface BEFORE its shutdown
276
- // runs, so observers can record "wire stopping" with full
277
- // context. Errors during dispatch are swallowed shutdown
278
- // must complete regardless.
279
- for (let i = this.nwireServables.length - 1; i >= 0; i--) {
280
- 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--) {
281
289
  try {
282
- await fireLifecycle("nwire.wire.unmounted", {
283
- appName: this.apps[0]?.appName ?? this.name,
284
- transport: s.transport,
285
- });
290
+ await this.adapters[i].shutdown();
286
291
  }
287
- catch {
288
- /* swallow — shutdown must proceed */
292
+ catch (err) {
293
+ // eslint-disable-next-line no-console
294
+ console.error(`[endpoint:${this.name}] adopter shutdown failed:`, err);
289
295
  }
290
296
  }
291
- // Interface shutdowns — let them disconnect cleanly.
292
- for (let i = this.nwireServables.length - 1; i >= 0; i--) {
293
- await this.nwireServables[i].shutdown?.();
294
- }
295
297
  // Apps shutdown in reverse boot order.
296
298
  for (let i = this.apps.length - 1; i >= 0; i--) {
297
299
  await this.apps[i].shutdown();
@@ -302,19 +304,10 @@ export class EndpointBuilder {
302
304
  },
303
305
  health: {
304
306
  ...this.config.probes,
305
- checks: [
306
- ...(this.config.probes?.checks ?? []),
307
- ...this.nwireServables.flatMap((s) => s.checks ?? []),
308
- ],
307
+ checks: this.config.probes?.checks ?? [],
309
308
  },
310
309
  exitOnShutdown: this.config.exitOnShutdown,
311
310
  });
312
- // 4. Register interface checks dynamically too — they may have been
313
- // discovered during attach (e.g., queue probes once connected).
314
- for (const s of this.nwireServables) {
315
- for (const c of s.checks ?? [])
316
- lifecycle.addCheck(c);
317
- }
318
311
  // 5. Flip readiness and print the banner. AppReady fires AFTER
319
312
  // lightship marks /ready 200 — that's the moment the wire actually
320
313
  // accepts traffic.
@@ -328,13 +321,18 @@ export class EndpointBuilder {
328
321
  }
329
322
  }
330
323
  if (this.config.banner !== false) {
331
- // Only advertise probes if lightship actually bound. The bind
332
- // can be skipped (probes disabled) or fail silently on
333
- // 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;
334
331
  const probesBound = lifecycle.lightship !== undefined;
335
332
  printBanner({
336
333
  name: this.name,
337
- port: this.config.port,
334
+ host: this.config.host ?? "0.0.0.0",
335
+ port: dataPort,
338
336
  probePort: probesBound ? (this.config.probes?.port ?? 9_400) : undefined,
339
337
  });
340
338
  }
@@ -347,12 +345,6 @@ export class EndpointBuilder {
347
345
  }
348
346
  }
349
347
  // ─── Type guards ──────────────────────────────────────────────────────────
350
- /** True when the target is a Nwire interface (http/queue/etc) compiled for endpoint mount. */
351
- export function isNwireServable(x) {
352
- return (typeof x === "object" &&
353
- x !== null &&
354
- x.$nwireServable === true);
355
- }
356
348
  /** True when the target is a Nwire app (container + lifecycle). */
357
349
  export function isAppServable(x) {
358
350
  return typeof x === "object" && x !== null && x.$nwireApp === true;
@@ -362,8 +354,7 @@ export function isAppServable(x) {
362
354
  *
363
355
  * Some hosts (Express, Connect) return a callable function as their app
364
356
  * value; others return an object (Fastify, Koa). We accept both — a
365
- * function with a `.listen` method counts. NwireServables are excluded
366
- * by the `$nwireServable` check at the call site.
357
+ * function with a `.listen` method counts.
367
358
  */
368
359
  export function isForeignServable(x) {
369
360
  return (x !== null &&
@@ -372,66 +363,33 @@ export function isForeignServable(x) {
372
363
  }
373
364
  // ─── Internal helpers ─────────────────────────────────────────────────────
374
365
  /**
375
- * Start the operating-system listener. When multiple NwireServables share
376
- * a port, this composes them onto a single http.Server. ForeignServables
377
- * use their own `.listen()` and the resulting Server is what gets wrapped
378
- * with lifecyclemeaning today only ONE ForeignServable per endpoint is
379
- * supported (more would require an internal multiplexer). The 99% case
380
- * 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.
381
371
  */
382
372
  async function startListener(opts) {
383
373
  if (opts.foreignServables.length > 1) {
384
374
  throw new Error(`endpoint: more than one foreign-framework target on one port is not supported; ` +
385
375
  `mount them separately or compose them inside the framework first.`);
386
376
  }
387
- // Case A — foreign app owns the server. Nwire interfaces attach onto it
388
- // via to* adapters (toExpress, toKoa) the user calls themselves before
389
- // serving the framework. The endpoint just wraps the resulting Server.
390
- if (opts.foreignServables.length === 1) {
391
- const foreign = opts.foreignServables[0];
392
- const server = await new Promise((resolve) => {
393
- const s = foreign.listen(opts.port, opts.host ?? "0.0.0.0", () => resolve(s));
394
- });
395
- // NwireServables attached to a foreign endpoint must register
396
- // themselves separately — there's no way for us to mount them onto
397
- // the foreign framework's router automatically without knowing the
398
- // framework. Future: detect Express/Fastify/Koa and auto-mount.
399
- for (const s of opts.nwireServables) {
400
- await s.attach({ addCheck: () => undefined, container: opts.container });
401
- }
402
- return server;
403
- }
404
- // Case B — Nwire interfaces only. Build a host that they attach to.
405
- // For now we delegate to the first servable's transport-specific host
406
- // builder. Multi-transport on one port (REST + GraphQL + WS) requires
407
- // a more elaborate host; that's @nwire/http's job.
408
- if (opts.nwireServables.length === 0) {
409
- throw new Error("endpoint: nothing to listen on (no Nwire interfaces, no foreign app)");
410
- }
411
- // Lean fallback: assume the first servable carries an internal listener
412
- // factory. The richer multi-transport host lives in @nwire/http.
413
- const primary = opts.nwireServables[0];
414
- const buildHost = primary._buildHost;
415
- const hostFactory = buildHost ? buildHost.bind(primary) : undefined;
416
- if (!hostFactory) {
417
- throw new Error(`endpoint: interface "${primary.transport}" does not expose _buildHost(); ` +
418
- `it cannot be the primary listener. Install a transport package that supports it.`);
419
- }
420
- const built = hostFactory();
421
- for (const s of opts.nwireServables) {
422
- await s.attach({ addCheck: built.addCheck, container: opts.container });
423
- }
424
- 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
+ });
425
381
  }
426
382
  /** Probe-only server for workers/cron — nothing on the data port. */
427
- function makeProbeOnlyServer() {
383
+ async function makeProbeOnlyServer() {
428
384
  const srv = createHttpServer((_req, res) => {
429
385
  res.statusCode = 404;
430
386
  res.end();
431
387
  });
432
- // We don't actually .listen() this; attachLifecycle uses it only as
433
- // the http-terminator target. Workers have no data port to drain;
434
- // 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));
435
393
  return srv;
436
394
  }
437
395
  // `emptyContainer()` was previously used when no app was served — but
@@ -443,17 +401,16 @@ function printBanner(opts) {
443
401
  const lines = [
444
402
  `nwire endpoint "${opts.name}" listening`,
445
403
  opts.port !== undefined
446
- ? ` data: http://0.0.0.0:${opts.port}`
404
+ ? ` data: http://${opts.host}:${opts.port}`
447
405
  : ` data: (no HTTP listener)`,
448
406
  ];
449
407
  if (opts.probePort !== undefined) {
450
408
  // lightship's actual paths — not /readyz + /healthz (which was a
451
409
  // historical misnaming in our docs).
452
- lines.push(` probes: http://0.0.0.0:${opts.probePort}/live · /ready`);
410
+ lines.push(` probes: http://${opts.host}:${opts.probePort}/live · /ready`);
453
411
  }
454
412
  else {
455
413
  lines.push(` probes: (disabled — port already in use or probes disabled)`);
456
414
  }
457
415
  console.log(lines.join("\n"));
458
416
  }
459
- //# 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.2",
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.2",
36
- "@nwire/container": "0.9.2",
37
- "@nwire/interface": "0.9.2"
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"}