@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":"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"}
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) => {
@@ -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.1.0",
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",