@lensmcp/cluster 1.0.0 → 1.2.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/executors/gateway/gateway.lib.d.ts +32 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +147 -1
- package/executors/gateway/health-check.d.ts +53 -0
- package/executors/gateway/health-check.d.ts.map +1 -0
- package/executors/gateway/health-check.js +66 -0
- package/executors/gateway/main.prod-gateway.js +219 -8
- package/executors/gateway/manifest.d.ts +7 -2
- package/executors/gateway/manifest.d.ts.map +1 -1
- package/executors/gateway/manifest.js +22 -9
- package/executors/gateway/metrics.d.ts +37 -0
- package/executors/gateway/metrics.d.ts.map +1 -0
- package/executors/gateway/metrics.js +56 -0
- package/executors/gateway/otel-tracing.d.ts +47 -0
- package/executors/gateway/otel-tracing.d.ts.map +1 -0
- package/executors/gateway/otel-tracing.js +73 -0
- package/executors/gateway/prod-gateway.lib.d.ts +87 -1
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +309 -31
- package/executors/gateway/providers-prod.d.ts.map +1 -1
- package/executors/gateway/providers-prod.js +46 -13
- package/executors/gateway/rate-limit.d.ts +66 -0
- package/executors/gateway/rate-limit.d.ts.map +1 -0
- package/executors/gateway/rate-limit.js +91 -0
- package/executors/gateway/schema.d.ts +8 -0
- package/executors/gateway/schema.json +19 -0
- package/executors.json +8 -8
- package/main.devserver.js +5 -4
- package/package.json +23 -2
|
@@ -64,6 +64,17 @@ export interface ClusterProjectDecl {
|
|
|
64
64
|
serveTarget?: string;
|
|
65
65
|
/** Devserver admin endpoint for scaling (default from port: http(s)://localhost:<port>). */
|
|
66
66
|
adminUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* FRONTEND apps only: run this app UNDER the lens. When true, the gateway
|
|
69
|
+
* SPAWNS the app's own Vite dev server with `@lensmcp/vite-plugin`
|
|
70
|
+
* instrumentation + a browser-capture sidecar (via the shared
|
|
71
|
+
* `spawnLensFrontend` helper) and routes THAT as the upstream — instead of
|
|
72
|
+
* assuming an external Vite already runs on `port`/`upstream`. The
|
|
73
|
+
* cluster-wide singletons (dashboard + MCP) are owned by the gateway, so a
|
|
74
|
+
* `lens:true` app never spawns its own. Absent/false ⇒ today's behavior
|
|
75
|
+
* (plain TCP upstream; bring-your-own Vite). Ignored for pod (`service`) decls.
|
|
76
|
+
*/
|
|
77
|
+
lens?: boolean;
|
|
67
78
|
}
|
|
68
79
|
export interface SockPool {
|
|
69
80
|
dir: string;
|
|
@@ -81,6 +92,22 @@ export interface Route {
|
|
|
81
92
|
svc?: ServiceCtl;
|
|
82
93
|
internal?: boolean;
|
|
83
94
|
auth?: AuthPolicy;
|
|
95
|
+
/** Frontend `lens:true` route: the gateway spawns this app's Vite + lens
|
|
96
|
+
* instrumentation as the upstream (see `LensFrontend`). */
|
|
97
|
+
lens?: LensFrontend;
|
|
98
|
+
}
|
|
99
|
+
/** A frontend app the gateway runs UNDER the lens (`cluster.lens: true`). The
|
|
100
|
+
* gateway spawns its Vite + browser capture as the upstream, deterministically
|
|
101
|
+
* on `port`, and never spawns the per-app dashboard/MCP (those are singletons
|
|
102
|
+
* the gateway owns). */
|
|
103
|
+
export interface LensFrontend {
|
|
104
|
+
project: string;
|
|
105
|
+
/** Absolute project root (holds vite.config.*). */
|
|
106
|
+
projectRoot: string;
|
|
107
|
+
/** The pinned Vite dev port (also the upstream target's port). */
|
|
108
|
+
port: number;
|
|
109
|
+
/** Set once the Vite child has been spawned (idempotency guard). */
|
|
110
|
+
started?: boolean;
|
|
84
111
|
}
|
|
85
112
|
export interface ServiceCtl {
|
|
86
113
|
project: string;
|
|
@@ -113,6 +140,11 @@ export interface GatewayHandle {
|
|
|
113
140
|
services: ServiceCtl[];
|
|
114
141
|
stop: () => Promise<void>;
|
|
115
142
|
}
|
|
143
|
+
/** The base domain for the lens dashboard host — the longest common dotted
|
|
144
|
+
* suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
|
|
145
|
+
* 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
|
|
146
|
+
* to 'lens.local' if no host is declared. */
|
|
147
|
+
export declare function baseDomainOf(hosts: string[]): string;
|
|
116
148
|
/** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
|
|
117
149
|
export declare function pickSock(pool: SockPool, scanTtlMs?: number): string | undefined;
|
|
118
150
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG9D,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAGlC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAqE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.hostMatches = void 0;
|
|
4
4
|
exports.verifyDevJwt = verifyDevJwt;
|
|
5
5
|
exports.stampIdentity = stampIdentity;
|
|
6
|
+
exports.baseDomainOf = baseDomainOf;
|
|
6
7
|
exports.pickSock = pickSock;
|
|
7
8
|
exports.discoverRoutes = discoverRoutes;
|
|
8
9
|
exports.startGateway = startGateway;
|
|
@@ -19,6 +20,7 @@ const http = tslib_1.__importStar(require("node:http"));
|
|
|
19
20
|
const os = tslib_1.__importStar(require("node:os"));
|
|
20
21
|
const path = tslib_1.__importStar(require("node:path"));
|
|
21
22
|
const manifest_1 = require("./manifest");
|
|
23
|
+
const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
|
|
22
24
|
var manifest_2 = require("./manifest"); // back-compat re-export
|
|
23
25
|
Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
|
|
24
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -71,6 +73,30 @@ const devAttrs = (req, claims) => ({
|
|
|
71
73
|
path: (req.url ?? '/').split('?')[0],
|
|
72
74
|
});
|
|
73
75
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
76
|
+
/** Extract the port from an `upstream` URL like `http://localhost:5173`. */
|
|
77
|
+
function lensPortFromUpstream(upstream) {
|
|
78
|
+
if (!upstream)
|
|
79
|
+
return undefined;
|
|
80
|
+
const m = /:(\d+)/.exec(upstream);
|
|
81
|
+
return m ? Number(m[1]) : undefined;
|
|
82
|
+
}
|
|
83
|
+
/** The base domain for the lens dashboard host — the longest common dotted
|
|
84
|
+
* suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
|
|
85
|
+
* 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
|
|
86
|
+
* to 'lens.local' if no host is declared. */
|
|
87
|
+
function baseDomainOf(hosts) {
|
|
88
|
+
const clean = hosts.filter((h) => !!h && !h.startsWith('*.'));
|
|
89
|
+
if (clean.length === 0)
|
|
90
|
+
return 'local';
|
|
91
|
+
// Use the SHORTEST host as the candidate base (most likely the apex), then
|
|
92
|
+
// require every other host to end with it.
|
|
93
|
+
const shortest = clean.reduce((a, b) => (b.length < a.length ? b : a));
|
|
94
|
+
if (clean.every((h) => h === shortest || h.endsWith('.' + shortest)))
|
|
95
|
+
return shortest;
|
|
96
|
+
// No clean common apex — derive the trailing 2-3 labels of the shortest host.
|
|
97
|
+
const parts = shortest.split('.');
|
|
98
|
+
return parts.slice(-Math.min(parts.length, 3)).join('.');
|
|
99
|
+
}
|
|
74
100
|
/** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
|
|
75
101
|
function pickSock(pool, scanTtlMs = 1500) {
|
|
76
102
|
const now = Date.now();
|
|
@@ -113,11 +139,24 @@ function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
|
|
|
113
139
|
let pool;
|
|
114
140
|
let svc;
|
|
115
141
|
let target;
|
|
142
|
+
let lens;
|
|
116
143
|
if (decl.service) {
|
|
144
|
+
// `lens` applies to FRONTEND apps only; a pod service is already
|
|
145
|
+
// instrumented by the serve executor's node-instrumentation graft.
|
|
146
|
+
if (decl.lens)
|
|
147
|
+
console.warn(`[gateway] ${name}: cluster.lens is ignored for pod (service) decls — backends are instrumented by serve-hmr.`);
|
|
117
148
|
pool = { dir: path.join(sockBaseDir, `${decl.service}-devserver`), socks: [], idx: -1, scannedAt: 0 };
|
|
118
149
|
svc = { project: name, decl, pool, state: 'down', lastUsed: Date.now(), inflight: 0, lastScaleAt: 0 };
|
|
119
150
|
services.push(svc);
|
|
120
151
|
}
|
|
152
|
+
else if (decl.lens) {
|
|
153
|
+
// Lens-mode frontend: the gateway SPAWNS this app's Vite + instrumentation
|
|
154
|
+
// (deterministically on `port`) and upstreams to it. One LensFrontend per
|
|
155
|
+
// project is SHARED across its host/default routes (so the spawn fires once).
|
|
156
|
+
const port = decl.port ?? lensPortFromUpstream(decl.upstream) ?? 5173;
|
|
157
|
+
target = `http://localhost:${port}`;
|
|
158
|
+
lens = { project: name, projectRoot: path.join(rootDir, p.root), port };
|
|
159
|
+
}
|
|
121
160
|
else {
|
|
122
161
|
target = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
|
|
123
162
|
if (!target) {
|
|
@@ -125,7 +164,7 @@ function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
|
|
|
125
164
|
continue;
|
|
126
165
|
}
|
|
127
166
|
}
|
|
128
|
-
const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth };
|
|
167
|
+
const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth, lens };
|
|
129
168
|
if (decl.host) {
|
|
130
169
|
routes.push({ ...base, host: decl.host });
|
|
131
170
|
// internal.<host>: east-west traffic, same pods, NO middleware.
|
|
@@ -222,6 +261,104 @@ async function startGateway(options, context) {
|
|
|
222
261
|
if (routes.length === 0) {
|
|
223
262
|
throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
|
|
224
263
|
}
|
|
264
|
+
// --- lens-mode children: the dashboard + MCP SINGLETONS, and `lens:true` apps ---
|
|
265
|
+
// The gateway owns ONE dashboard and ONE MCP server cluster-wide (each
|
|
266
|
+
// `lens:true` app runs only its Vite + instrumentation, publishing to the
|
|
267
|
+
// SHARED event bus — never its own dashboard/MCP, which would collide on
|
|
268
|
+
// :4321 / :3000). These children share the pods' lifecycle: best-effort
|
|
269
|
+
// (a crash doesn't tear the gateway down) and reaped on stop().
|
|
270
|
+
const lensChildren = [];
|
|
271
|
+
const lensRespawn = new Map();
|
|
272
|
+
// A `SpawnChild` over the gateway's managed-children array — the SAME shape
|
|
273
|
+
// `agent-dev` passes to `spawnLensFrontend`, so the helper behaves identically.
|
|
274
|
+
const spawnManagedChild = (label, bin, args, env, optional = false, respawn) => {
|
|
275
|
+
const child = (0, node_child_process_1.spawn)(bin, args, { cwd: context.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
|
|
276
|
+
lensChildren.push(child);
|
|
277
|
+
child.on('exit', (code, sig) => {
|
|
278
|
+
if (stopped)
|
|
279
|
+
return;
|
|
280
|
+
if (optional && respawn) {
|
|
281
|
+
const used = lensRespawn.get(label) ?? 0;
|
|
282
|
+
if (used < respawn.max) {
|
|
283
|
+
lensRespawn.set(label, used + 1);
|
|
284
|
+
console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); respawning in ${Math.round(respawn.delayMs / 1000)}s (${used + 1}/${respawn.max}).`);
|
|
285
|
+
const t = setTimeout(() => { if (!stopped)
|
|
286
|
+
spawnManagedChild(label, bin, args, env, optional, respawn); }, respawn.delayMs);
|
|
287
|
+
t.unref?.();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Singletons (dashboard/MCP) and exhausted-respawn children just log —
|
|
292
|
+
// a dead dashboard must never kill the cluster.
|
|
293
|
+
console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); continuing.`);
|
|
294
|
+
});
|
|
295
|
+
return child;
|
|
296
|
+
};
|
|
297
|
+
const lensApps = [...new Map(routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
|
|
298
|
+
const wantDashboard = options.dashboard !== false;
|
|
299
|
+
const wantMcp = options.mcp !== false && lensApps.length > 0;
|
|
300
|
+
if (wantDashboard) {
|
|
301
|
+
const dashboardBundle = (0, lens_frontend_1.findDashboardBundle)(context.root);
|
|
302
|
+
if (!dashboardBundle) {
|
|
303
|
+
console.warn("[gateway] lens dashboard bundle not found — skipping. Install the self-contained CLI: `npm i -D lensmcp` (it ships `bundled/dashboard.js`), or `yarn nx build @lensmcp/lensmcp-mcp` in-repo.");
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const dashboardPort = options.dashboardPort ?? 4321;
|
|
307
|
+
const declaredHosts = routes.map((r) => r.host).filter((h) => !!h);
|
|
308
|
+
const lensHost = options.lensHost ?? `lens.${baseDomainOf(declaredHosts)}`;
|
|
309
|
+
// Route the dashboard at lens.<baseDomain> (plain TCP upstream — the
|
|
310
|
+
// dashboard serves HTTP same-origin JSON; the gateway terminates TLS).
|
|
311
|
+
// Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)).
|
|
312
|
+
routes.push({ host: lensHost, project: '(lens-dashboard)', target: `http://localhost:${dashboardPort}` });
|
|
313
|
+
(0, manifest_1.sortBySpecificity)(routes);
|
|
314
|
+
console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at ${lensHost})`);
|
|
315
|
+
emit('info', `lens dashboard up: ${lensHost} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort });
|
|
316
|
+
spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
|
|
317
|
+
LENSMCP_EVENT_FILE: eventFile,
|
|
318
|
+
LENSMCP_DASHBOARD_PORT: String(dashboardPort),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (wantMcp) {
|
|
323
|
+
const mcpBundle = (0, lens_frontend_1.findMcpBundle)(context.root);
|
|
324
|
+
if (!mcpBundle) {
|
|
325
|
+
console.warn("[gateway] MCP server bundle not found — skipping (lens apps will still publish to the bus; an external `lensmcp mcp` can read it). Install: `npm i -D lensmcp`.");
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
console.log(`[gateway] starting lens MCP server (${mcpBundle})`);
|
|
329
|
+
spawnManagedChild('lens-mcp', process.execPath, [mcpBundle], {
|
|
330
|
+
LENSMCP_EVENT_FILE: eventFile,
|
|
331
|
+
LENSMCP_TRANSPORT: process.env['LENSMCP_TRANSPORT'] ?? 'http',
|
|
332
|
+
LENSMCP_PORT: process.env['LENSMCP_PORT'] ?? '3000',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Spawn each `lens:true` frontend's Vite + browser capture as the upstream,
|
|
337
|
+
// via the SAME shared helper `agent-dev` uses (singletons excluded — owned above).
|
|
338
|
+
for (const app of lensApps) {
|
|
339
|
+
if (app.started)
|
|
340
|
+
continue;
|
|
341
|
+
app.started = true;
|
|
342
|
+
try {
|
|
343
|
+
(0, lens_frontend_1.spawnLensFrontend)({
|
|
344
|
+
projectRoot: app.projectRoot,
|
|
345
|
+
workspaceRoot: context.root,
|
|
346
|
+
project: app.project,
|
|
347
|
+
eventFile,
|
|
348
|
+
port: app.port,
|
|
349
|
+
chrome: true,
|
|
350
|
+
headless: true,
|
|
351
|
+
// Identify this app's events on the shared bus (mirrors the serve
|
|
352
|
+
// executor's LENSMCP_PROJECT stamp for pods).
|
|
353
|
+
viteEnv: { LENSMCP_PROJECT: app.project },
|
|
354
|
+
log: (l) => console.log(l),
|
|
355
|
+
}, spawnManagedChild);
|
|
356
|
+
emit('info', `lens app started: ${app.project} (vite :${app.port})`, `cluster-lens-app:${app.project}`, { kind: 'lens-app-up', project: app.project, port: app.port });
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
console.error(`[gateway] lens app ${app.project}: ${e.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
225
362
|
// --- service lifecycle: scale-from-zero, autoscale, idle kill ----------------
|
|
226
363
|
const scanNow = (pool) => { pool.scannedAt = 0; return pickSock(pool, scanTtlMs) ? pool.socks.length : 0; };
|
|
227
364
|
const spawnService = (svc) => {
|
|
@@ -253,6 +390,7 @@ async function startGateway(options, context) {
|
|
|
253
390
|
const text = d.toString();
|
|
254
391
|
for (const line of text.split('\n')) {
|
|
255
392
|
if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
|
|
393
|
+
// eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
|
|
256
394
|
svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
|
|
257
395
|
if (svc.lastErrors.length > 12)
|
|
258
396
|
svc.lastErrors.shift();
|
|
@@ -785,6 +923,14 @@ async function startGateway(options, context) {
|
|
|
785
923
|
emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
|
|
786
924
|
for (const svc of services)
|
|
787
925
|
killSpawned(svc, 'gateway shutdown');
|
|
926
|
+
// Reap the lens-mode managed children (dashboard + MCP singletons + each
|
|
927
|
+
// `lens:true` app's Vite/capture) — same teardown as the pod services.
|
|
928
|
+
for (const c of lensChildren) {
|
|
929
|
+
try {
|
|
930
|
+
c.kill('SIGTERM');
|
|
931
|
+
}
|
|
932
|
+
catch { /* already gone */ }
|
|
933
|
+
}
|
|
788
934
|
await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
|
|
789
935
|
try {
|
|
790
936
|
proxy.close?.();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active upstream health-checking — opt-in. Periodically probes every known
|
|
3
|
+
* upstream URL and tracks healthy/unhealthy per endpoint, so the gateway routes
|
|
4
|
+
* a pooled service AROUND dead endpoints (instead of only discovering death
|
|
5
|
+
* passively, one failed request at a time) and the /statusz rollup reflects true
|
|
6
|
+
* liveness even with zero traffic.
|
|
7
|
+
*
|
|
8
|
+
* Liveness, not readiness: a probe is "healthy" when the endpoint ANSWERS with
|
|
9
|
+
* status < 500 — so a service without the health path still reads up on a 404;
|
|
10
|
+
* only a 5xx or a connection failure/timeout is down. New endpoints start
|
|
11
|
+
* optimistically healthy, so a not-yet-probed endpoint is never black-holed.
|
|
12
|
+
*
|
|
13
|
+
* Fail-open by design (see providers-prod `pick`): only multi-endpoint POOLS are
|
|
14
|
+
* health-filtered, and even then the gateway falls back to an unhealthy endpoint
|
|
15
|
+
* rather than 503 if a whole pool looks down — a checker false-positive must
|
|
16
|
+
* never take a service fully offline.
|
|
17
|
+
*/
|
|
18
|
+
export interface HealthCheckerOptions {
|
|
19
|
+
/** Probe path appended to each upstream URL. Default `/healthz`. */
|
|
20
|
+
path?: string;
|
|
21
|
+
/** Probe period (ms). Default 5000. */
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
/** Per-probe timeout (ms). Default 2000. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
/** Consecutive failures before an endpoint is marked unhealthy. Default 2. */
|
|
26
|
+
unhealthyThreshold?: number;
|
|
27
|
+
/** Consecutive successes before a down endpoint is marked healthy again. Default 1. */
|
|
28
|
+
healthyThreshold?: number;
|
|
29
|
+
/** Injectable fetch (tests). Defaults to global fetch. */
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
/** Notified when an endpoint flips health. */
|
|
32
|
+
onChange?: (url: string, healthy: boolean) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface EndpointHealth {
|
|
35
|
+
url: string;
|
|
36
|
+
healthy: boolean;
|
|
37
|
+
fails: number;
|
|
38
|
+
lastCheckedAt: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface HealthChecker {
|
|
41
|
+
/** Set the current URL set to monitor (called on every route rebuild). */
|
|
42
|
+
track(urls: string[]): void;
|
|
43
|
+
/** Current health (default true for an untracked / not-yet-probed URL). */
|
|
44
|
+
isHealthy(url: string): boolean;
|
|
45
|
+
/** Per-endpoint health for the status page. */
|
|
46
|
+
snapshot(): EndpointHealth[];
|
|
47
|
+
/** Probe every tracked URL once (used by start()'s loop and by tests). */
|
|
48
|
+
probeOnce(): Promise<void>;
|
|
49
|
+
start(): void;
|
|
50
|
+
stop(): void;
|
|
51
|
+
}
|
|
52
|
+
export declare function createHealthChecker(opts?: HealthCheckerOptions): HealthChecker;
|
|
53
|
+
//# sourceMappingURL=health-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health-check.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/health-check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAE9G,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC5B,2EAA2E;IAC3E,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,+CAA+C;IAC/C,QAAQ,IAAI,cAAc,EAAE,CAAC;IAC7B,0EAA0E;IAC1E,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;CACd;AAID,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CAyClF"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createHealthChecker = createHealthChecker;
|
|
4
|
+
function createHealthChecker(opts = {}) {
|
|
5
|
+
const path = opts.path ?? '/healthz';
|
|
6
|
+
const intervalMs = opts.intervalMs ?? 5000;
|
|
7
|
+
const timeoutMs = opts.timeoutMs ?? 2000;
|
|
8
|
+
const downAt = Math.max(1, opts.unhealthyThreshold ?? 2);
|
|
9
|
+
const upAt = Math.max(1, opts.healthyThreshold ?? 1);
|
|
10
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
11
|
+
const state = new Map();
|
|
12
|
+
let timer;
|
|
13
|
+
const probeUrl = (url) => url.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
|
|
14
|
+
const probe = async (url) => {
|
|
15
|
+
const st = state.get(url);
|
|
16
|
+
if (!st)
|
|
17
|
+
return;
|
|
18
|
+
let ok = false;
|
|
19
|
+
const ctl = new AbortController();
|
|
20
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const res = await doFetch(probeUrl(url), { method: 'GET', signal: ctl.signal, redirect: 'manual' });
|
|
23
|
+
ok = res.status < 500; // answered → up (even 404); only 5xx counts as down
|
|
24
|
+
}
|
|
25
|
+
catch { /* network error / timeout → unhealthy (ok stays false) */ }
|
|
26
|
+
finally {
|
|
27
|
+
clearTimeout(t);
|
|
28
|
+
}
|
|
29
|
+
st.lastCheckedAt = Date.now();
|
|
30
|
+
if (ok) {
|
|
31
|
+
st.oks += 1;
|
|
32
|
+
st.fails = 0;
|
|
33
|
+
if (!st.healthy && st.oks >= upAt) {
|
|
34
|
+
st.healthy = true;
|
|
35
|
+
opts.onChange?.(url, true);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
st.fails += 1;
|
|
40
|
+
st.oks = 0;
|
|
41
|
+
if (st.healthy && st.fails >= downAt) {
|
|
42
|
+
st.healthy = false;
|
|
43
|
+
opts.onChange?.(url, false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const probeOnce = () => Promise.allSettled([...state.keys()].map(probe)).then(() => undefined);
|
|
48
|
+
return {
|
|
49
|
+
track(urls) {
|
|
50
|
+
const next = new Set(urls);
|
|
51
|
+
for (const u of urls)
|
|
52
|
+
if (!state.has(u))
|
|
53
|
+
state.set(u, { healthy: true, fails: 0, oks: 0, lastCheckedAt: null });
|
|
54
|
+
for (const u of [...state.keys()])
|
|
55
|
+
if (!next.has(u))
|
|
56
|
+
state.delete(u);
|
|
57
|
+
},
|
|
58
|
+
isHealthy: (url) => state.get(url)?.healthy ?? true,
|
|
59
|
+
snapshot: () => [...state.entries()].map(([url, s]) => ({ url, healthy: s.healthy, fails: s.fails, lastCheckedAt: s.lastCheckedAt })),
|
|
60
|
+
probeOnce,
|
|
61
|
+
start() { if (timer)
|
|
62
|
+
return; timer = setInterval(() => void probeOnce(), intervalMs); timer.unref?.(); void probeOnce(); },
|
|
63
|
+
stop() { if (timer)
|
|
64
|
+
clearInterval(timer); timer = undefined; },
|
|
65
|
+
};
|
|
66
|
+
}
|