@lensmcp/cluster 1.1.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.
|
@@ -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":"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;AAQ5H,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAKzC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAiB3F;AAED,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAQ9F;AASD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;CACzE;AAED,2FAA2F;AAC3F,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC9B;;iGAE6F;IAC7F,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2FAA2F;IAC3F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AAE1F,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;gEAC4D;IAC5D,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED;;;yBAGyB;AACzB,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,kBAAkB,CAAC;IACzB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,2DAA2D;AAC3D,MAAM,WAAW,qBAAsB,SAAQ,qBAAqB;IAClE,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAWD;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAGD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,SAAO,GAAG,MAAM,GAAG,SAAS,CAa7E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAC1C,WAAW,SAAc,GACxB;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,UAAU,EAAE,CAAA;CAAE,CAyD7C;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAqsBxB"}
|
|
@@ -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) => {
|
|
@@ -786,6 +923,14 @@ async function startGateway(options, context) {
|
|
|
786
923
|
emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
|
|
787
924
|
for (const svc of services)
|
|
788
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
|
+
}
|
|
789
934
|
await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
|
|
790
935
|
try {
|
|
791
936
|
proxy.close?.();
|
|
@@ -7,4 +7,12 @@ export interface GatewayExecutorSchema {
|
|
|
7
7
|
trafficFlushMs?: number;
|
|
8
8
|
/** Workspace-relative path to a per-request middleware module (the JWT seam). */
|
|
9
9
|
middleware?: string;
|
|
10
|
+
/** Spawn the lens dashboard as a gateway-managed child + route lens.<baseDomain>. Default true. */
|
|
11
|
+
dashboard?: boolean;
|
|
12
|
+
/** Spawn the single MCP server as a gateway-managed child when a `lens:true` app exists. Default true. */
|
|
13
|
+
mcp?: boolean;
|
|
14
|
+
/** Port the managed dashboard listens on (proxied behind lens.<baseDomain>). Default 4321. */
|
|
15
|
+
dashboardPort?: number;
|
|
16
|
+
/** Override the dashboard hostname. Default lens.<baseDomain>. */
|
|
17
|
+
lensHost?: string;
|
|
10
18
|
}
|
|
@@ -24,6 +24,25 @@
|
|
|
24
24
|
"trafficFlushMs": {
|
|
25
25
|
"type": "number",
|
|
26
26
|
"description": "Traffic-edge aggregation window in ms before flushing to the LensMCP bus. Default 5000; lower (e.g. 1000) for near-realtime canvases."
|
|
27
|
+
},
|
|
28
|
+
"dashboard": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"default": true,
|
|
31
|
+
"description": "Spawn the LensMCP dashboard (the human web view of the lens) as a gateway-managed child and route it at lens.<baseDomain>. The gateway also owns the single MCP server, so `lens:true` frontend apps run only their Vite + instrumentation. Set false to skip (e.g. when a separate `lensmcp dashboard` is already running)."
|
|
32
|
+
},
|
|
33
|
+
"mcp": {
|
|
34
|
+
"type": "boolean",
|
|
35
|
+
"default": true,
|
|
36
|
+
"description": "Spawn the single LensMCP MCP server (the agent surface) as a gateway-managed child when any `lens:true` frontend app is declared. Set false if an external `lensmcp mcp` / agent-dev already owns it."
|
|
37
|
+
},
|
|
38
|
+
"dashboardPort": {
|
|
39
|
+
"type": "number",
|
|
40
|
+
"default": 4321,
|
|
41
|
+
"description": "Port the gateway-managed dashboard listens on (proxied behind lens.<baseDomain>). Default 4321."
|
|
42
|
+
},
|
|
43
|
+
"lensHost": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Override the dashboard's dev hostname. Default lens.<baseDomain>, where <baseDomain> is the longest common dotted suffix of the declared cluster hosts (e.g. lens.tetros.ai.local)."
|
|
27
46
|
}
|
|
28
47
|
},
|
|
29
48
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./index.d.ts",
|