@lensmcp/cluster 1.12.0 → 1.13.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 +15 -167
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +24 -1041
- package/executors/gateway/main.prod-gateway.js +74 -2
- package/executors/gateway/prod-gateway.lib.d.ts +19 -151
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +16 -863
- package/executors/gateway/prod-runtime/access-log.d.ts +9 -0
- package/executors/gateway/prod-runtime/access-log.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/access-log.js +24 -0
- package/executors/gateway/prod-runtime/app.d.ts +23 -0
- package/executors/gateway/prod-runtime/app.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/app.js +123 -0
- package/executors/gateway/prod-runtime/auth.d.ts +30 -0
- package/executors/gateway/prod-runtime/auth.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/auth.js +51 -0
- package/executors/gateway/prod-runtime/cors.d.ts +19 -0
- package/executors/gateway/prod-runtime/cors.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/cors.js +40 -0
- package/executors/gateway/prod-runtime/edge.d.ts +16 -0
- package/executors/gateway/prod-runtime/edge.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/edge.js +65 -0
- package/executors/gateway/prod-runtime/handler.d.ts +32 -0
- package/executors/gateway/prod-runtime/handler.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/handler.js +226 -0
- package/executors/gateway/prod-runtime/hooks.d.ts +20 -0
- package/executors/gateway/prod-runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/hooks.js +42 -0
- package/executors/gateway/prod-runtime/observability.d.ts +23 -0
- package/executors/gateway/prod-runtime/observability.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/observability.js +125 -0
- package/executors/gateway/prod-runtime/rollout.d.ts +18 -0
- package/executors/gateway/prod-runtime/rollout.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/rollout.js +103 -0
- package/executors/gateway/prod-runtime/routing.d.ts +4 -0
- package/executors/gateway/prod-runtime/routing.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/routing.js +40 -0
- package/executors/gateway/prod-runtime/server.d.ts +3 -0
- package/executors/gateway/prod-runtime/server.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/server.js +79 -0
- package/executors/gateway/prod-runtime/trust.d.ts +12 -0
- package/executors/gateway/prod-runtime/trust.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/trust.js +32 -0
- package/executors/gateway/prod-runtime/types.d.ts +241 -0
- package/executors/gateway/prod-runtime/types.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/types.js +2 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts +21 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upgrade.js +116 -0
- package/executors/gateway/prod-runtime/upstream.d.ts +13 -0
- package/executors/gateway/prod-runtime/upstream.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upstream.js +21 -0
- package/executors/gateway/runtime/auth.d.ts +8 -0
- package/executors/gateway/runtime/auth.d.ts.map +1 -0
- package/executors/gateway/runtime/auth.js +60 -0
- package/executors/gateway/runtime/dev-auth.d.ts +9 -0
- package/executors/gateway/runtime/dev-auth.d.ts.map +1 -0
- package/executors/gateway/runtime/dev-auth.js +79 -0
- package/executors/gateway/runtime/discovery.d.ts +17 -0
- package/executors/gateway/runtime/discovery.d.ts.map +1 -0
- package/executors/gateway/runtime/discovery.js +111 -0
- package/executors/gateway/runtime/edge.d.ts +31 -0
- package/executors/gateway/runtime/edge.d.ts.map +1 -0
- package/executors/gateway/runtime/edge.js +47 -0
- package/executors/gateway/runtime/handler.d.ts +24 -0
- package/executors/gateway/runtime/handler.d.ts.map +1 -0
- package/executors/gateway/runtime/handler.js +165 -0
- package/executors/gateway/runtime/hooks.d.ts +23 -0
- package/executors/gateway/runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/runtime/hooks.js +55 -0
- package/executors/gateway/runtime/lens-children.d.ts +13 -0
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -0
- package/executors/gateway/runtime/lens-children.js +136 -0
- package/executors/gateway/runtime/lifecycle.d.ts +13 -0
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -0
- package/executors/gateway/runtime/lifecycle.js +179 -0
- package/executors/gateway/runtime/observability.d.ts +17 -0
- package/executors/gateway/runtime/observability.d.ts.map +1 -0
- package/executors/gateway/runtime/observability.js +107 -0
- package/executors/gateway/runtime/proxy.d.ts +20 -0
- package/executors/gateway/runtime/proxy.d.ts.map +1 -0
- package/executors/gateway/runtime/proxy.js +113 -0
- package/executors/gateway/runtime/scope.d.ts +16 -0
- package/executors/gateway/runtime/scope.d.ts.map +1 -0
- package/executors/gateway/runtime/scope.js +68 -0
- package/executors/gateway/runtime/server.d.ts +3 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -0
- package/executors/gateway/runtime/server.js +168 -0
- package/executors/gateway/runtime/service-keys.d.ts +11 -0
- package/executors/gateway/runtime/service-keys.d.ts.map +1 -0
- package/executors/gateway/runtime/service-keys.js +49 -0
- package/executors/gateway/runtime/types.d.ts +234 -0
- package/executors/gateway/runtime/types.d.ts.map +1 -0
- package/executors/gateway/runtime/types.js +2 -0
- package/executors/gateway/runtime/upgrade.d.ts +22 -0
- package/executors/gateway/runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/runtime/upgrade.js +63 -0
- package/package.json +3 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createLensChildren = createLensChildren;
|
|
4
|
+
/**
|
|
5
|
+
* Lens-mode managed children: the dashboard + MCP SINGLETONS (cluster-wide,
|
|
6
|
+
* owned by the gateway), and each `lens:true` app's Vite + browser capture.
|
|
7
|
+
* EVERY managed child AUTO-HEALS — restarted with exponential backoff when it
|
|
8
|
+
* exits while the gateway is up — and a dead child NEVER kills the cluster.
|
|
9
|
+
*/
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const manifest_1 = require("../manifest");
|
|
12
|
+
const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
|
|
13
|
+
const scope_1 = require("./scope");
|
|
14
|
+
const HEAL_BASE_MS = 1_500; // first restart delay
|
|
15
|
+
const HEAL_MAX_MS = 30_000; // backoff cap — a true boot-loop settles here, never hot-loops
|
|
16
|
+
const HEAL_HEALTHY_MS = 20_000; // ran at least this long ⇒ a FRESH crash ⇒ reset the backoff budget
|
|
17
|
+
function createLensChildren(rt, obs, options) {
|
|
18
|
+
const { emit } = obs;
|
|
19
|
+
const lensChildren = [];
|
|
20
|
+
// Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
|
|
21
|
+
// backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
|
|
22
|
+
const lensHeal = new Map();
|
|
23
|
+
// A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
|
|
24
|
+
// `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
|
|
25
|
+
// `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
|
|
26
|
+
// So a transient crash (a dev-server wedge, an OOM) recovers on its own — the user never hand-restarts —
|
|
27
|
+
// and a child that crashes-on-boot backs off to HEAL_MAX_MS rather than spinning. A child that ran
|
|
28
|
+
// healthily (≥ HEAL_HEALTHY_MS) gets a fresh budget, so one that dies after an hour comes back fast. A
|
|
29
|
+
// dead child still NEVER kills the cluster — it simply respawns in the background.
|
|
30
|
+
const spawnManagedChild = (label, bin, args, env, optional = false, respawn) => {
|
|
31
|
+
void optional;
|
|
32
|
+
void respawn; // legacy knobs — auto-heal is now universal (signature kept for compat)
|
|
33
|
+
const spawnedAt = Date.now();
|
|
34
|
+
const child = (0, node_child_process_1.spawn)(bin, args, { cwd: rt.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
|
|
35
|
+
lensChildren.push(child);
|
|
36
|
+
child.on('exit', (code, sig) => {
|
|
37
|
+
const idx = lensChildren.indexOf(child);
|
|
38
|
+
if (idx >= 0)
|
|
39
|
+
lensChildren.splice(idx, 1); // drop the dead handle so stop() won't signal it
|
|
40
|
+
if (rt.stopped())
|
|
41
|
+
return; // the gateway is shutting down — do NOT resurrect
|
|
42
|
+
const heal = lensHeal.get(label) ?? { attempts: 0 };
|
|
43
|
+
if (Date.now() - spawnedAt >= HEAL_HEALTHY_MS)
|
|
44
|
+
heal.attempts = 0; // it was healthy → fresh budget
|
|
45
|
+
const delay = Math.min(HEAL_MAX_MS, HEAL_BASE_MS * 2 ** heal.attempts);
|
|
46
|
+
heal.attempts += 1;
|
|
47
|
+
lensHeal.set(label, heal);
|
|
48
|
+
console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); auto-healing in ${Math.round(delay / 1000)}s (attempt ${heal.attempts}).`);
|
|
49
|
+
const t = setTimeout(() => { if (!rt.stopped())
|
|
50
|
+
spawnManagedChild(label, bin, args, env, optional, respawn); }, delay);
|
|
51
|
+
t.unref?.();
|
|
52
|
+
});
|
|
53
|
+
return child;
|
|
54
|
+
};
|
|
55
|
+
const bootSingletonsAndApps = () => {
|
|
56
|
+
const lensApps = [...new Map(rt.routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
|
|
57
|
+
const wantDashboard = options.dashboard !== false;
|
|
58
|
+
const wantMcp = options.mcp !== false && lensApps.length > 0;
|
|
59
|
+
if (wantDashboard) {
|
|
60
|
+
const dashboardBundle = (0, lens_frontend_1.findDashboardBundle)(rt.root);
|
|
61
|
+
if (!dashboardBundle) {
|
|
62
|
+
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.");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Per-project lens scope: the dashboard is mounted at lensmcp.local/<key>/ so
|
|
66
|
+
// MANY projects coexist (each at its own /<key> on its own port). The dashboard
|
|
67
|
+
// OWNS its base path (LENSMCP_DASHBOARD_BASE) and history-routes under it, so the
|
|
68
|
+
// gateway routes pass-through (matchRoute on the prefix; the proxy never strips).
|
|
69
|
+
const scope = (0, scope_1.readLensScope)(rt.root);
|
|
70
|
+
const dashboardPort = options.dashboardPort ?? scope.dashboardPort;
|
|
71
|
+
const lensHost = options.lensHost ?? 'lensmcp.local';
|
|
72
|
+
const lensBase = scope.basePath; // e.g. /tetros
|
|
73
|
+
// Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)), so lensmcp.local
|
|
74
|
+
// auto-joins the TLS SANs. Run `gateway:trust` to add it to /etc/hosts.
|
|
75
|
+
rt.routes.push({ host: lensHost, prefix: lensBase, project: '(lens-dashboard)', target: `http://localhost:${dashboardPort}` });
|
|
76
|
+
(0, manifest_1.sortBySpecificity)(rt.routes);
|
|
77
|
+
console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at https://${lensHost}${lensBase}/)`);
|
|
78
|
+
emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort, base: lensBase });
|
|
79
|
+
spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
|
|
80
|
+
LENSMCP_EVENT_FILE: rt.eventFile,
|
|
81
|
+
LENSMCP_DASHBOARD_PORT: String(dashboardPort),
|
|
82
|
+
LENSMCP_DASHBOARD_BASE: lensBase,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (wantMcp) {
|
|
87
|
+
const mcpBundle = (0, lens_frontend_1.findMcpBundle)(rt.root);
|
|
88
|
+
if (!mcpBundle) {
|
|
89
|
+
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`.");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(`[gateway] starting lens MCP server (${mcpBundle})`);
|
|
93
|
+
spawnManagedChild('lens-mcp', process.execPath, [mcpBundle], {
|
|
94
|
+
LENSMCP_EVENT_FILE: rt.eventFile,
|
|
95
|
+
LENSMCP_TRANSPORT: process.env['LENSMCP_TRANSPORT'] ?? 'http',
|
|
96
|
+
LENSMCP_PORT: process.env['LENSMCP_PORT'] ?? '3000',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Spawn each `lens:true` frontend's Vite + browser capture as the upstream,
|
|
101
|
+
// via the SAME shared helper `agent-dev` uses (singletons excluded — owned above).
|
|
102
|
+
for (const app of lensApps) {
|
|
103
|
+
if (app.started)
|
|
104
|
+
continue;
|
|
105
|
+
app.started = true;
|
|
106
|
+
try {
|
|
107
|
+
(0, lens_frontend_1.spawnLensFrontend)({
|
|
108
|
+
projectRoot: app.projectRoot,
|
|
109
|
+
workspaceRoot: rt.root,
|
|
110
|
+
project: app.project,
|
|
111
|
+
eventFile: rt.eventFile,
|
|
112
|
+
port: app.port,
|
|
113
|
+
chrome: true,
|
|
114
|
+
headless: true,
|
|
115
|
+
// Identify this app's events on the shared bus (mirrors the serve
|
|
116
|
+
// executor's LENSMCP_PROJECT stamp for pods).
|
|
117
|
+
viteEnv: { LENSMCP_PROJECT: app.project },
|
|
118
|
+
log: (l) => console.log(l),
|
|
119
|
+
}, spawnManagedChild);
|
|
120
|
+
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 });
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
console.error(`[gateway] lens app ${app.project}: ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const reapAll = () => {
|
|
128
|
+
for (const c of lensChildren) {
|
|
129
|
+
try {
|
|
130
|
+
c.kill('SIGTERM');
|
|
131
|
+
}
|
|
132
|
+
catch { /* already gone */ }
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return { spawnManagedChild, bootSingletonsAndApps, reapAll };
|
|
136
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GatewayRuntime, ServiceCtl, SockPool } from './types';
|
|
2
|
+
import type { Observability } from './observability';
|
|
3
|
+
export interface ServiceLayer {
|
|
4
|
+
scanNow(pool: SockPool): number;
|
|
5
|
+
spawnService(svc: ServiceCtl): void;
|
|
6
|
+
ensureUp(svc: ServiceCtl): Promise<boolean>;
|
|
7
|
+
scaleUp(svc: ServiceCtl): void;
|
|
8
|
+
killSpawned(svc: ServiceCtl, why: string): void;
|
|
9
|
+
/** Start the idle sweeper; returns the interval (already `.unref()`'d). */
|
|
10
|
+
startSweeper(): NodeJS.Timeout;
|
|
11
|
+
}
|
|
12
|
+
export declare function createServiceLayer(rt: GatewayRuntime, obs: Observability): ServiceLayer;
|
|
13
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIrD,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CAsIvF"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createServiceLayer = createServiceLayer;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* Service lifecycle: scale-from-zero, autoscale, idle kill. The gateway is the
|
|
7
|
+
* load balancer in front of each pod service's unix-socket pool and owns the
|
|
8
|
+
* pods' serverless lifecycle (eager/minPods/maxPods/idleKillSec).
|
|
9
|
+
*/
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const fs = tslib_1.__importStar(require("node:fs"));
|
|
12
|
+
const http = tslib_1.__importStar(require("node:http"));
|
|
13
|
+
const path = tslib_1.__importStar(require("node:path"));
|
|
14
|
+
const discovery_1 = require("./discovery");
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
function createServiceLayer(rt, obs) {
|
|
17
|
+
const { emit } = obs;
|
|
18
|
+
const scanNow = (pool) => { pool.scannedAt = 0; return (0, discovery_1.pickSock)(pool, rt.scanTtlMs) ? pool.socks.length : 0; };
|
|
19
|
+
const spawnService = (svc) => {
|
|
20
|
+
if (rt.stopped())
|
|
21
|
+
return; // never spawn a process after the gateway is told to stop
|
|
22
|
+
const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
|
|
23
|
+
const min = Math.max(1, svc.decl.minPods ?? 1);
|
|
24
|
+
console.log(`[gateway] starting ${target} (pods=${min})…`);
|
|
25
|
+
emit('info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
|
|
26
|
+
svc.state = 'starting';
|
|
27
|
+
svc.spawnAt = Date.now();
|
|
28
|
+
// childCount goes as a CLI option, NOT env: nx executes tasks via its
|
|
29
|
+
// daemon, whose environment is not the gateway's — env vars get lost.
|
|
30
|
+
const proc = (0, node_child_process_1.spawn)('yarn', ['nx', 'run', target, `--childCount=${min}`], {
|
|
31
|
+
cwd: rt.root,
|
|
32
|
+
detached: true,
|
|
33
|
+
// NX_DAEMON=false keeps the serve task IN this spawned process group.
|
|
34
|
+
// With the daemon on, Nx hands the long-running task to the persistent
|
|
35
|
+
// daemon (a DIFFERENT group), so the later process-group kill reaps only
|
|
36
|
+
// the `yarn nx` shim and orphans the devserver + pod tree — the classic
|
|
37
|
+
// "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
|
|
38
|
+
// avoids the env-loss the daemon was relied on for.)
|
|
39
|
+
env: { ...process.env, NX_DAEMON: 'false' },
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
svc.lastErrors = [];
|
|
43
|
+
svc.buildFailed = false;
|
|
44
|
+
const sniff = (d) => {
|
|
45
|
+
const text = d.toString();
|
|
46
|
+
for (const line of text.split('\n')) {
|
|
47
|
+
if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
|
|
48
|
+
// eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
|
|
49
|
+
svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
|
|
50
|
+
if (svc.lastErrors.length > 12)
|
|
51
|
+
svc.lastErrors.shift();
|
|
52
|
+
svc.buildFailed = true;
|
|
53
|
+
emit('error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: svc.lastErrors[svc.lastErrors.length - 1] });
|
|
54
|
+
}
|
|
55
|
+
if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
|
|
56
|
+
svc.buildFailed = false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
proc.stdout?.on('data', (d) => { if (rt.stopped())
|
|
60
|
+
return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
|
|
61
|
+
proc.stderr?.on('data', (d) => { if (rt.stopped())
|
|
62
|
+
return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
|
|
63
|
+
proc.on('exit', (code) => {
|
|
64
|
+
svc.proc = undefined;
|
|
65
|
+
svc.state = 'down';
|
|
66
|
+
if (rt.stopped())
|
|
67
|
+
return; // gateway is tearing down — its dying services' exit chatter is noise
|
|
68
|
+
console.log(`[gateway] ${target} exited (${code})`);
|
|
69
|
+
emit(code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
|
|
70
|
+
});
|
|
71
|
+
svc.proc = proc;
|
|
72
|
+
};
|
|
73
|
+
const ensureUp = async (svc) => {
|
|
74
|
+
if (scanNow(svc.pool) > 0) {
|
|
75
|
+
if (svc.state !== 'up') {
|
|
76
|
+
emit('info', `service up: ${svc.project} (${svc.pool.socks.length} pods, adopted)`, `cluster-service:${svc.project}`, { kind: 'service-up', project: svc.project, pods: svc.pool.socks.length, adopted: true });
|
|
77
|
+
}
|
|
78
|
+
svc.state = 'up';
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (rt.stopped())
|
|
82
|
+
return false;
|
|
83
|
+
if (svc.state !== 'starting' && !svc.proc)
|
|
84
|
+
spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
|
|
85
|
+
const deadline = Date.now() + rt.startupTimeoutMs;
|
|
86
|
+
const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
|
|
87
|
+
while (!rt.stopped() && Date.now() < deadline) {
|
|
88
|
+
await sleep(Math.min(700, rt.startupTimeoutMs / 4));
|
|
89
|
+
if (svc.buildFailed && Date.now() > failFastAt)
|
|
90
|
+
return false;
|
|
91
|
+
if (scanNow(svc.pool) > 0) {
|
|
92
|
+
svc.state = 'up';
|
|
93
|
+
svc.buildFailed = false;
|
|
94
|
+
const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
|
|
95
|
+
console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
|
|
96
|
+
emit('info', `cold start: ${svc.project} (${svc.pool.socks.length} pods, ${durationMs ?? '?'}ms)`, `cluster-service:${svc.project}`, { kind: 'cold-start', project: svc.project, pods: svc.pool.socks.length, durationMs });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (svc.state === 'down')
|
|
100
|
+
return false; // spawn died
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
104
|
+
const scaleUp = (svc) => {
|
|
105
|
+
const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
|
|
106
|
+
const cur = svc.pool.socks.length;
|
|
107
|
+
const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
|
|
108
|
+
if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
|
|
109
|
+
return;
|
|
110
|
+
if (Date.now() - svc.lastScaleAt < 5000)
|
|
111
|
+
return;
|
|
112
|
+
svc.lastScaleAt = Date.now();
|
|
113
|
+
const mod = admin.startsWith('https')
|
|
114
|
+
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
115
|
+
require('node:https')
|
|
116
|
+
: http;
|
|
117
|
+
const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
|
|
118
|
+
r.on('error', () => undefined);
|
|
119
|
+
r.end(JSON.stringify({ pods: cur + 1 }));
|
|
120
|
+
console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
|
|
121
|
+
emit('info', `autoscale: ${svc.project} ${cur} → ${cur + 1} pods`, `cluster-service:${svc.project}`, { kind: 'autoscale', project: svc.project, from: cur, to: cur + 1, inflight: svc.inflight });
|
|
122
|
+
};
|
|
123
|
+
// Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
|
|
124
|
+
const killSpawned = (svc, why) => {
|
|
125
|
+
if (!svc.proc)
|
|
126
|
+
return;
|
|
127
|
+
console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
|
|
128
|
+
emit('info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
|
|
129
|
+
const dying = svc.proc;
|
|
130
|
+
// Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
|
|
131
|
+
// buffered stdout/stderr as it exits, and those `sniff` callbacks would
|
|
132
|
+
// keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
|
|
133
|
+
// if a fresh request re-spawns this same svc while the corpse still flushes.
|
|
134
|
+
try {
|
|
135
|
+
dying.stdout?.removeAllListeners('data');
|
|
136
|
+
dying.stderr?.removeAllListeners('data');
|
|
137
|
+
dying.removeAllListeners('exit');
|
|
138
|
+
}
|
|
139
|
+
catch { /* ignore */ }
|
|
140
|
+
try {
|
|
141
|
+
process.kill(-dying.pid, 'SIGTERM');
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
try {
|
|
145
|
+
dying.kill('SIGTERM');
|
|
146
|
+
}
|
|
147
|
+
catch { /* gone */ }
|
|
148
|
+
}
|
|
149
|
+
svc.proc = undefined;
|
|
150
|
+
svc.state = 'down';
|
|
151
|
+
// children die with the tree but their sock FILES survive — remove them
|
|
152
|
+
// so the canvas and the proxy never see ghost pods.
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
try {
|
|
155
|
+
for (const f of fs.readdirSync(svc.pool.dir)) {
|
|
156
|
+
if (f.endsWith('.sock'))
|
|
157
|
+
fs.unlinkSync(path.join(svc.pool.dir, f));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* gone */ }
|
|
161
|
+
svc.pool.socks = [];
|
|
162
|
+
svc.pool.scannedAt = 0;
|
|
163
|
+
}, 1200).unref?.();
|
|
164
|
+
};
|
|
165
|
+
const startSweeper = () => {
|
|
166
|
+
const sweeper = setInterval(() => {
|
|
167
|
+
for (const svc of rt.services) {
|
|
168
|
+
const idle = svc.decl.idleKillSec;
|
|
169
|
+
if (!svc.proc || !idle || svc.inflight > 0)
|
|
170
|
+
continue;
|
|
171
|
+
if (Date.now() - svc.lastUsed > idle * 1000)
|
|
172
|
+
killSpawned(svc, `idle ${idle}s`);
|
|
173
|
+
}
|
|
174
|
+
}, rt.sweepMs);
|
|
175
|
+
sweeper.unref();
|
|
176
|
+
return sweeper;
|
|
177
|
+
};
|
|
178
|
+
return { scanNow, spawnService, ensureUp, scaleUp, killSpawned, startSweeper };
|
|
179
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import type { GwTrace, Route } from './types';
|
|
3
|
+
export type Severity = 'info' | 'warning' | 'error';
|
|
4
|
+
export interface Observability {
|
|
5
|
+
emit(severity: Severity, title: string, fingerprint: string, raw?: Record<string, unknown>, flowCtx?: Record<string, unknown>): void;
|
|
6
|
+
eid(): string;
|
|
7
|
+
beginTrace(req: http.IncomingMessage): GwTrace | undefined;
|
|
8
|
+
traceStep(t: GwTrace | undefined, stepName: string, info?: Record<string, unknown>): void;
|
|
9
|
+
finishTrace(t: GwTrace | undefined, project: string, status: number, extra?: Record<string, unknown>): void;
|
|
10
|
+
recordEdge(route: Route, ms: number, status: number, caller?: string): void;
|
|
11
|
+
/** Start the periodic traffic-edge flush; returns the interval (already `.unref()`'d). */
|
|
12
|
+
startEdgeFlusher(): NodeJS.Timeout;
|
|
13
|
+
}
|
|
14
|
+
export declare function createObservability(eventFile: string, opts: {
|
|
15
|
+
trafficFlushMs: number;
|
|
16
|
+
}): Observability;
|
|
17
|
+
//# sourceMappingURL=observability.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/observability.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpD,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACrI,GAAG,IAAI,MAAM,CAAC;IACd,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,OAAO,GAAG,SAAS,CAAC;IAC3D,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1F,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5G,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5E,0FAA0F;IAC1F,gBAAgB,IAAI,MAAM,CAAC,OAAO,CAAC;CACpC;AAKD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CA6FtG"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createObservability = createObservability;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* The observability layer: everything the gateway writes to the LensMCP bus.
|
|
7
|
+
* Bus (`emit`/`eid`), per-request mechanism trace (`beginTrace`/`traceStep`/
|
|
8
|
+
* `finishTrace`), and aggregated host→service traffic edges (`recordEdge` +
|
|
9
|
+
* `startEdgeFlusher`). Merged because they only ever vary together — trace and
|
|
10
|
+
* edges both emit; both need `eid`.
|
|
11
|
+
*
|
|
12
|
+
* Appends BaseEvent-shaped lines to the shared event file the MCP tails (same
|
|
13
|
+
* rendezvous the vite plugin and nest instrumentation use), so the lens sees
|
|
14
|
+
* the cluster itself: services up/down, cold starts, autoscale, idle kills, and
|
|
15
|
+
* aggregated traffic edges for the topology.
|
|
16
|
+
*/
|
|
17
|
+
const fs = tslib_1.__importStar(require("node:fs"));
|
|
18
|
+
const path = tslib_1.__importStar(require("node:path"));
|
|
19
|
+
const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
|
|
20
|
+
function createObservability(eventFile, opts) {
|
|
21
|
+
const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
|
|
22
|
+
const emit = (severity, title, fingerprint, raw, flowCtx) => {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(path.dirname(eventFile), { recursive: true });
|
|
25
|
+
fs.appendFileSync(eventFile, JSON.stringify({
|
|
26
|
+
id: eid(), sessionId: 'pending', timestamp: Date.now(),
|
|
27
|
+
source: 'gateway', category: 'cluster', severity,
|
|
28
|
+
context: { sessionId: 'pending', ...flowCtx }, fingerprint, title, raw,
|
|
29
|
+
}) + '\n');
|
|
30
|
+
}
|
|
31
|
+
catch { /* lens offline — never block traffic */ }
|
|
32
|
+
};
|
|
33
|
+
// --- per-request mechanism trace (flows) ----------------------------------
|
|
34
|
+
// When a request carries lens flow headers (stamped by the browser client
|
|
35
|
+
// runtime or an instrumented upstream), the gateway narrates its own steps —
|
|
36
|
+
// received → route-match → auth → pod-select (+ cold-start) → response — as
|
|
37
|
+
// ONE `gateway-request` event correlated by the same flowId/requestId.
|
|
38
|
+
// Header-less traffic (curl, health checks) stays un-narrated so the bus
|
|
39
|
+
// doesn't bloat.
|
|
40
|
+
const beginTrace = (req) => {
|
|
41
|
+
const flowId = hdr(req.headers['x-lensmcp-flow-id']);
|
|
42
|
+
let requestId = hdr(req.headers['x-request-id']);
|
|
43
|
+
if (!flowId && !requestId && !hdr(req.headers['traceparent']))
|
|
44
|
+
return undefined;
|
|
45
|
+
if (!requestId) {
|
|
46
|
+
// Mint the request id AT THE EDGE and inject it downstream, so the
|
|
47
|
+
// gateway trace and the pod's server-request/spans share one id —
|
|
48
|
+
// that's the join key that attributes pod events to the routed service.
|
|
49
|
+
requestId = eid();
|
|
50
|
+
req.headers['x-request-id'] = requestId;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
startedAt: Date.now(),
|
|
54
|
+
method: (req.method ?? 'GET').toUpperCase(),
|
|
55
|
+
host: (req.headers.host || '').split(':')[0],
|
|
56
|
+
url: req.url ?? '/',
|
|
57
|
+
steps: [],
|
|
58
|
+
...(flowId ? { flowId } : {}),
|
|
59
|
+
...(requestId ? { requestId } : {}),
|
|
60
|
+
...(hdr(req.headers['x-lensmcp-origin-node-id']) ? { originNodeId: hdr(req.headers['x-lensmcp-origin-node-id']) } : {}),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const traceStep = (t, stepName, info) => {
|
|
64
|
+
if (!t)
|
|
65
|
+
return;
|
|
66
|
+
t.steps.push({ step: stepName, atMs: Date.now() - t.startedAt, ...info });
|
|
67
|
+
};
|
|
68
|
+
const finishTrace = (t, project, status, extra) => {
|
|
69
|
+
if (!t || t.done)
|
|
70
|
+
return;
|
|
71
|
+
t.done = true;
|
|
72
|
+
const durationMs = Date.now() - t.startedAt;
|
|
73
|
+
emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${durationMs}ms)`, `gateway-request:${project}`, {
|
|
74
|
+
kind: 'gateway-request', project, host: t.host, method: t.method, url: t.url,
|
|
75
|
+
status, durationMs, startedAt: t.startedAt, steps: t.steps, ...extra,
|
|
76
|
+
}, {
|
|
77
|
+
...(t.flowId ? { flowId: t.flowId } : {}),
|
|
78
|
+
...(t.requestId ? { requestId: t.requestId } : {}),
|
|
79
|
+
...(t.originNodeId ? { originNodeId: t.originNodeId } : {}),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
// --- aggregated host→service traffic edges --------------------------------
|
|
83
|
+
// The request-derived topology (nodes/edges appear from observed traffic).
|
|
84
|
+
const edges = new Map();
|
|
85
|
+
const recordEdge = (route, ms, status, caller) => {
|
|
86
|
+
const host = route.host ?? '(default)';
|
|
87
|
+
const key = (caller ? caller + '⇒' : '') + host + '→' + route.project;
|
|
88
|
+
const e = edges.get(key) ?? { host, project: route.project, caller, count: 0, totalMs: 0, errors: 0 };
|
|
89
|
+
e.count += 1;
|
|
90
|
+
e.totalMs += ms;
|
|
91
|
+
if (status >= 500 || status === 0)
|
|
92
|
+
e.errors += 1;
|
|
93
|
+
edges.set(key, e);
|
|
94
|
+
};
|
|
95
|
+
const startEdgeFlusher = () => {
|
|
96
|
+
const t = setInterval(() => {
|
|
97
|
+
for (const e of edges.values()) {
|
|
98
|
+
const avgMs = Math.round(e.totalMs / e.count);
|
|
99
|
+
emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.project}: ${e.count} req, ~${avgMs}ms${e.errors ? `, ${e.errors} errors` : ''}`, `gateway-traffic:${e.host}->${e.project}`, { kind: 'gateway-traffic', host: e.host, project: e.project, caller: e.caller, count: e.count, avgMs, errors: e.errors });
|
|
100
|
+
}
|
|
101
|
+
edges.clear();
|
|
102
|
+
}, opts.trafficFlushMs);
|
|
103
|
+
t.unref();
|
|
104
|
+
return t;
|
|
105
|
+
};
|
|
106
|
+
return { emit, eid, beginTrace, traceStep, finishTrace, recordEdge, startEdgeFlusher };
|
|
107
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import type * as stream from 'node:stream';
|
|
3
|
+
import type { GatewayRuntime, Route } from './types';
|
|
4
|
+
import type { Observability } from './observability';
|
|
5
|
+
import type { ServiceLayer } from './lifecycle';
|
|
6
|
+
export interface ProxyServer {
|
|
7
|
+
web(req: http.IncomingMessage, res: http.ServerResponse, opts: Record<string, unknown>, cb: (err: Error) => void): void;
|
|
8
|
+
ws(req: http.IncomingMessage, socket: stream.Duplex, head: Buffer, opts: Record<string, unknown>, cb: (err?: Error) => void): void;
|
|
9
|
+
on(event: 'proxyRes', cb: (proxyRes: http.IncomingMessage, req: http.IncomingMessage) => void): void;
|
|
10
|
+
close?(): void;
|
|
11
|
+
}
|
|
12
|
+
export interface ProxyLayer {
|
|
13
|
+
proxy: ProxyServer;
|
|
14
|
+
forward(route: Route, req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
|
|
15
|
+
agentForTarget(target: unknown): http.Agent | import('node:https').Agent;
|
|
16
|
+
destroyAgents(): void;
|
|
17
|
+
closeProxy(): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function createProxy(rt: GatewayRuntime, obs: Observability, svcLayer: ServiceLayer): ProxyLayer;
|
|
20
|
+
//# sourceMappingURL=proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAKhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CA4FtG"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createProxy = createProxy;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* The forwarding layer: one pooled `http-proxy` instance (keep-alive agents) and
|
|
7
|
+
* the `forward` that drives it — pod round-robin with scale-from-zero/autoscale,
|
|
8
|
+
* or a TCP upstream. Owns the edge-response branding (`x-lensmcp-pod`, stripping
|
|
9
|
+
* `x-powered-by`, `server: LensMCP`).
|
|
10
|
+
*/
|
|
11
|
+
const fs = tslib_1.__importStar(require("node:fs"));
|
|
12
|
+
const http = tslib_1.__importStar(require("node:http"));
|
|
13
|
+
const path = tslib_1.__importStar(require("node:path"));
|
|
14
|
+
const discovery_1 = require("./discovery");
|
|
15
|
+
const edge_1 = require("./edge");
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
|
+
const httpProxy = require('http-proxy');
|
|
18
|
+
function createProxy(rt, obs, svcLayer) {
|
|
19
|
+
const { recordEdge, finishTrace, traceStep } = obs;
|
|
20
|
+
// Keep-alive to upstreams (pods over unix sockets AND TCP apps). Without an
|
|
21
|
+
// agent http-proxy opens a fresh connection per request — a connect per call
|
|
22
|
+
// that ~halves throughput and adds latency. One pooled agent reuses sockets
|
|
23
|
+
// (keyed by socketPath / host:port); https targets get the verify-skipping
|
|
24
|
+
// https agent. NB: this file uses `require('node:https')` to match the source
|
|
25
|
+
// file's pattern (the orchestrator shadows the module with a local TLS flag).
|
|
26
|
+
const agentOpts = { keepAlive: true, keepAliveMsecs: 30_000, maxSockets: 1024, maxFreeSockets: 256 };
|
|
27
|
+
const httpAgent = new http.Agent(agentOpts);
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
const httpsAgent = new (require('node:https').Agent)(agentOpts);
|
|
30
|
+
const agentForTarget = (target) => typeof target === 'string' && target.startsWith('https:') ? httpsAgent : httpAgent;
|
|
31
|
+
// secure:false — upstreams may terminate TLS with the same local CA.
|
|
32
|
+
// proxyTimeout — a pod that accepts the socket but never responds would
|
|
33
|
+
// otherwise pin svc.inflight forever (blocking idle-kill, skewing autoscale)
|
|
34
|
+
// and leave the gateway-request trace open; the timeout fires the proxy
|
|
35
|
+
// error path → 502 → res 'close' → inflight decrements + trace finishes.
|
|
36
|
+
const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
|
|
37
|
+
// Surface WHICH pod served the request (round-robin made visible).
|
|
38
|
+
proxy.on('proxyRes', (proxyRes, req) => {
|
|
39
|
+
const pod = req.__lensmcpPod;
|
|
40
|
+
if (pod)
|
|
41
|
+
proxyRes.headers['x-lensmcp-pod'] = pod;
|
|
42
|
+
// The gateway owns the edge response: drop the upstream framework leak
|
|
43
|
+
// (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
|
|
44
|
+
delete proxyRes.headers['x-powered-by'];
|
|
45
|
+
proxyRes.headers['server'] = 'LensMCP';
|
|
46
|
+
});
|
|
47
|
+
const forward = async (route, req, res) => {
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
const trace = req.__lensmcpTrace;
|
|
50
|
+
res.on('close', () => {
|
|
51
|
+
recordEdge(route, Date.now() - startedAt, res.statusCode ?? 0, req.__lensmcpCaller);
|
|
52
|
+
finishTrace(trace, route.project, res.statusCode ?? 0, {
|
|
53
|
+
...(req.__lensmcpPod ? { pod: req.__lensmcpPod } : {}),
|
|
54
|
+
...(req.__lensmcpCaller ? { caller: req.__lensmcpCaller } : {}),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
58
|
+
req.url = route.prependPrefix + (req.url ?? '/');
|
|
59
|
+
}
|
|
60
|
+
if (route.pool) {
|
|
61
|
+
const ctl = route.svc;
|
|
62
|
+
if (ctl) {
|
|
63
|
+
ctl.lastUsed = Date.now();
|
|
64
|
+
ctl.inflight += 1;
|
|
65
|
+
res.on('close', () => { ctl.inflight -= 1; ctl.lastUsed = Date.now(); });
|
|
66
|
+
}
|
|
67
|
+
let sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
|
|
68
|
+
if (!sock && ctl) {
|
|
69
|
+
// scale from zero: start the service, buffer this request until pods are up
|
|
70
|
+
traceStep(trace, 'cold-start', { project: route.project });
|
|
71
|
+
if (await svcLayer.ensureUp(ctl))
|
|
72
|
+
sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
|
|
73
|
+
}
|
|
74
|
+
if (ctl)
|
|
75
|
+
svcLayer.scaleUp(ctl);
|
|
76
|
+
if (!sock) {
|
|
77
|
+
const errs = ctl?.lastErrors?.length ? ` Service errors: ${ctl.lastErrors.join('; ')}` : '';
|
|
78
|
+
(0, edge_1.sendError)(res, req, 503, 'unavailable', `Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs} The watcher recovers and the next request retries automatically.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
req.__lensmcpPod = path.basename(sock, '.sock');
|
|
82
|
+
traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
|
|
83
|
+
// NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
|
|
84
|
+
// does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
|
|
85
|
+
// Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
|
|
86
|
+
// /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
|
|
87
|
+
// external redirect URLs, so no rewrite is needed.
|
|
88
|
+
proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
|
|
89
|
+
route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
|
|
90
|
+
route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(sock);
|
|
93
|
+
}
|
|
94
|
+
catch { /* already gone */ } // a refused sock is a GHOST file — remove it so rescans stay truthful
|
|
95
|
+
console.error(`[gateway] pod ${path.basename(sock)} of ${route.project}:`, err.message);
|
|
96
|
+
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// autoRewrite ONLY for a string (external URL) target — a non-string target crashes
|
|
101
|
+
// setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
|
|
102
|
+
proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
|
|
103
|
+
console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
|
|
104
|
+
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const destroyAgents = () => { httpAgent.destroy(); httpsAgent.destroy(); };
|
|
108
|
+
const closeProxy = () => { try {
|
|
109
|
+
proxy.close?.();
|
|
110
|
+
}
|
|
111
|
+
catch { /* ignore */ } };
|
|
112
|
+
return { proxy, forward, agentForTarget, destroyAgents, closeProxy };
|
|
113
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The base domain for the lens dashboard host — the longest common dotted
|
|
2
|
+
* suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
|
|
3
|
+
* 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
|
|
4
|
+
* to 'lens.local' if no host is declared. */
|
|
5
|
+
export declare function baseDomainOf(hosts: string[]): string;
|
|
6
|
+
/** Per-workspace lens scope (key + dashboard port + mount path). Prefers the
|
|
7
|
+
* CLI-written `.lensmcp/config.json` (so the gateway agrees with `lensmcp gateway`
|
|
8
|
+
* / `lensmcp dashboard`); falls back to deriving from the workspace. */
|
|
9
|
+
export declare function readLensScope(root: string): {
|
|
10
|
+
key: string;
|
|
11
|
+
dashboardPort: number;
|
|
12
|
+
basePath: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function lensKeyFrom(root: string): string;
|
|
15
|
+
export declare function lensSlug(input: string): string;
|
|
16
|
+
//# sourceMappingURL=scope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/scope.ts"],"names":[],"mappings":"AAOA;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;yEAEyE;AACzE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C"}
|