@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/README.md +75 -68
- package/dist/adapter.d.ts +62 -0
- package/dist/adapter.js +20 -0
- package/dist/endpoint-index.d.ts +2 -2
- package/dist/endpoint-index.js +2 -2
- package/dist/endpoint.d.ts +41 -55
- package/dist/endpoint.js +130 -173
- package/dist/lifecycle.d.ts +0 -1
- package/dist/lifecycle.js +0 -1
- package/package.json +5 -4
- package/dist/__tests__/endpoint.test.d.ts +0 -14
- package/dist/__tests__/endpoint.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint.test.js +0 -96
- package/dist/__tests__/endpoint.test.js.map +0 -1
- package/dist/__tests__/lifecycle.test.d.ts +0 -13
- package/dist/__tests__/lifecycle.test.d.ts.map +0 -1
- package/dist/__tests__/lifecycle.test.js +0 -124
- package/dist/__tests__/lifecycle.test.js.map +0 -1
- package/dist/endpoint-index.d.ts.map +0 -1
- package/dist/endpoint-index.js.map +0 -1
- package/dist/endpoint.d.ts.map +0 -1
- package/dist/endpoint.js.map +0 -1
- package/dist/lifecycle.d.ts.map +0 -1
- package/dist/lifecycle.js.map +0 -1
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
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
|
|
283
|
-
appName: this.apps[0]?.appName ?? this.name,
|
|
284
|
-
transport: s.transport,
|
|
285
|
-
});
|
|
290
|
+
await this.adapters[i].shutdown();
|
|
286
291
|
}
|
|
287
|
-
catch {
|
|
288
|
-
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
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
|
|
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
|
|
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
|
package/dist/lifecycle.d.ts
CHANGED
package/dist/lifecycle.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nwire/endpoint",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
36
|
-
"@nwire/
|
|
37
|
-
"@nwire/
|
|
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"}
|