@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/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 +42 -56
- package/dist/endpoint.js +133 -177
- 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
|
@@ -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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
|
186
|
-
//
|
|
187
|
-
//
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
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
|
|
281
|
-
appName: this.apps[0]?.appName ?? this.name,
|
|
282
|
-
transport: s.transport,
|
|
283
|
-
});
|
|
290
|
+
await this.adapters[i].shutdown();
|
|
284
291
|
}
|
|
285
|
-
catch {
|
|
286
|
-
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
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
|
|
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
|
|
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
|
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"}
|