@lensmcp/cluster 1.16.16 → 1.16.18

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.
@@ -11,6 +11,10 @@ export interface LensChildren {
11
11
  * distinct port, append its `lensmcp.local/<key>` route to its fragment. Returns a reaper (kills the
12
12
  * child WITHOUT auto-heal respawn) for the daemon to call on unregister — or undefined if no bundle. */
13
13
  hostWorkspaceDashboard(fragment: WorkspaceFragment): (() => void) | undefined;
14
+ /** Host a REGISTERED workspace's `lens:true` FRONTEND apps (completes model-A "the daemon hosts
15
+ * everything"): spawn each app's vite (+ capture) in the workspace's OWN root + event bus, on a
16
+ * collision-safe port, and point its route target at that port. Returns a reaper for unregister. */
17
+ hostWorkspaceApps(fragment: WorkspaceFragment): () => void;
14
18
  /** Reap all managed children (called from `stop()`). */
15
19
  reapAll(): void;
16
20
  }
@@ -1 +1 @@
1
- {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AAQA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAgB,MAAM,SAAS,CAAC;AAQnF,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;iFAC6E;IAC7E,qBAAqB,IAAI,IAAI,CAAC;IAC9B;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAC9E,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,GAAG,YAAY,CAkNvH"}
1
+ {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AAQA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAuB,MAAM,SAAS,CAAC;AAQ1F,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;iFAC6E;IAC7E,qBAAqB,IAAI,IAAI,CAAC;IAC9B;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAC9E;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,IAAI,CAAC;IAC3D,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,GAAG,YAAY,CAySvH"}
@@ -38,6 +38,10 @@ function createLensChildren(rt, obs, options) {
38
38
  const labeledChildren = new Map();
39
39
  const removedLabels = new Set();
40
40
  const usedDashboardPorts = new Set();
41
+ // Lens-app vite ports already claimed (the daemon's own apps + every registered workspace's), so a
42
+ // registered workspace whose app declares a port already in use gets offset (vite's --strictPort would
43
+ // otherwise exit-loop). Keyed by the actual bound port.
44
+ const usedAppPorts = new Set();
41
45
  // A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
42
46
  // `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
43
47
  // `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
@@ -45,16 +49,19 @@ function createLensChildren(rt, obs, options) {
45
49
  // and a child that crashes-on-boot backs off to HEAL_MAX_MS rather than spinning. A child that ran
46
50
  // healthily (≥ HEAL_HEALTHY_MS) gets a fresh budget, so one that dies after an hour comes back fast. A
47
51
  // dead child still NEVER kills the cluster — it simply respawns in the background.
48
- const spawnManagedChild = (label, bin, args, env, optional = false, respawn) => {
52
+ const spawnManagedChild = (label, bin, args, env, optional = false, respawn,
53
+ // Per-child working dir. The daemon's OWN children run in `rt.root`; a REGISTERED workspace's vite must
54
+ // run in ITS root (so vite loads the right .env / resolves its own context). Threaded through auto-heal.
55
+ cwd) => {
49
56
  void optional;
50
57
  void respawn; // legacy knobs — auto-heal is now universal (signature kept for compat)
51
58
  const spawnedAt = Date.now();
52
- const child = (0, node_child_process_1.spawn)(bin, args, { cwd: rt.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
59
+ const child = (0, node_child_process_1.spawn)(bin, args, { cwd: cwd ?? rt.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
53
60
  lensChildren.push(child);
54
61
  labeledChildren.set(label, child); // the CURRENT handle for this label (a per-workspace dashboard reap targets it)
55
- // Track a `lens:true` app's vite dev server (label 'vite') for the zombie recycle. Its `LENSMCP_PROJECT`
56
- // (set by lens-children when it spawns the frontend) names the app; a fresh respawn re-adds via this path.
57
- if (label === 'vite')
62
+ // Track a `lens:true` app's vite dev server (label 'vite', or `<ws>:<proj>:vite` for a registered
63
+ // workspace) for the zombie recycle. Its `LENSMCP_PROJECT` names the app; a fresh respawn re-adds here.
64
+ if (label === 'vite' || label.endsWith(':vite'))
58
65
  viteChildren.set(child, { project: env?.['LENSMCP_PROJECT'] ?? 'app', spawnedAt });
59
66
  child.on('exit', (code, sig) => {
60
67
  const idx = lensChildren.indexOf(child);
@@ -71,7 +78,7 @@ function createLensChildren(rt, obs, options) {
71
78
  lensHeal.set(label, heal);
72
79
  console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); auto-healing in ${Math.round(delay / 1000)}s (attempt ${heal.attempts}).`);
73
80
  const t = setTimeout(() => { if (!rt.stopped())
74
- spawnManagedChild(label, bin, args, env, optional, respawn); }, delay);
81
+ spawnManagedChild(label, bin, args, env, optional, respawn, cwd); }, delay);
75
82
  t.unref?.();
76
83
  });
77
84
  return child;
@@ -165,6 +172,7 @@ function createLensChildren(rt, obs, options) {
165
172
  if (app.started)
166
173
  continue;
167
174
  app.started = true;
175
+ usedAppPorts.add(app.port); // claim the daemon's own lens-app ports so a registered workspace offsets off them
168
176
  try {
169
177
  (0, lens_frontend_1.spawnLensFrontend)({
170
178
  projectRoot: app.projectRoot,
@@ -227,6 +235,86 @@ function createLensChildren(rt, obs, options) {
227
235
  labeledChildren.delete(label);
228
236
  };
229
237
  };
238
+ // P3d — host a REGISTERED workspace's `lens:true` FRONTEND apps (the model-A completeness gap: the daemon
239
+ // spawned a registered workspace's pods on-demand + its dashboard, but NOT its lens frontends, so a route
240
+ // like app.<key>.local pointed at a port nothing bound → 502). For each lens app: spawn its vite (+ browser
241
+ // capture) in the registered workspace's OWN root + event bus, on a COLLISION-SAFE port (offset off the
242
+ // daemon's own + other workspaces', since vite's --strictPort exits on a clash), and update the route
243
+ // TARGET to that port so the merged table (rebuilt right after this in the control handler) upstreams
244
+ // correctly. Unique labels (`<ws>:<proj>:vite`) keep auto-heal + the reaper from touching the daemon's own
245
+ // identically-named vites. Returns a reaper the control endpoint calls on unregister.
246
+ const hostWorkspaceApps = (fragment) => {
247
+ // Group a project's (possibly several host) routes so one spawned vite serves them all.
248
+ const byProject = new Map();
249
+ for (const r of fragment.routes) {
250
+ if (!r.lens)
251
+ continue;
252
+ const list = byProject.get(r.lens.project) ?? [];
253
+ list.push(r);
254
+ byProject.set(r.lens.project, list);
255
+ }
256
+ if (byProject.size === 0)
257
+ return () => { };
258
+ const eventFile = path.join(fragment.root, '.lensmcp', 'events.jsonl'); // the registered workspace's OWN bus
259
+ const labels = [];
260
+ const ports = [];
261
+ for (const [project, routes] of byProject) {
262
+ const lens0 = routes[0].lens;
263
+ if (lens0.started)
264
+ continue;
265
+ // Collision-safe port: keep the declared port unless already claimed, else step up.
266
+ let port = lens0.port;
267
+ while (usedAppPorts.has(port))
268
+ port += 1;
269
+ usedAppPorts.add(port);
270
+ ports.push(port);
271
+ // Point every route of this project at the actual bound port (all share the one vite).
272
+ for (const r of routes) {
273
+ r.lens.started = true;
274
+ r.lens.port = port;
275
+ r.target = `http://localhost:${port}`;
276
+ }
277
+ const labelPrefix = `${fragment.wsKey}:${project}:`;
278
+ const viteLabel = `${labelPrefix}vite`;
279
+ const captureLabel = `${labelPrefix}browser-capture`;
280
+ labels.push(viteLabel, captureLabel);
281
+ removedLabels.delete(viteLabel); // a fresh registration after a prior unregister → allow auto-heal again
282
+ removedLabels.delete(captureLabel);
283
+ // A cwd-injecting spawn view: the registered workspace's children run in ITS root (not the daemon's).
284
+ const spawnInRoot = (label, bin, args, env, optional, respawn) => spawnManagedChild(label, bin, args, env, optional, respawn, fragment.root);
285
+ console.log(`[gateway] hosting lens app '${project}' of '${fragment.wsKey}' → vite :${port}`);
286
+ try {
287
+ (0, lens_frontend_1.spawnLensFrontend)({
288
+ projectRoot: lens0.projectRoot,
289
+ workspaceRoot: fragment.root,
290
+ project,
291
+ eventFile,
292
+ port,
293
+ chrome: true,
294
+ headless: true,
295
+ viteEnv: { LENSMCP_PROJECT: project },
296
+ labelPrefix,
297
+ log: (l) => console.log(l),
298
+ }, spawnInRoot);
299
+ emit('info', `lens app started: ${fragment.wsKey}/${project} (vite :${port})`, `cluster-lens-app:${fragment.wsKey}:${project}`, { kind: 'lens-app-up', project, port });
300
+ }
301
+ catch (e) {
302
+ console.error(`[gateway] lens app ${fragment.wsKey}/${project}: ${e.message}`);
303
+ }
304
+ }
305
+ return () => {
306
+ for (const label of labels) {
307
+ removedLabels.add(label); // stop auto-heal resurrecting it
308
+ try {
309
+ labeledChildren.get(label)?.kill('SIGTERM');
310
+ }
311
+ catch { /* already gone */ }
312
+ labeledChildren.delete(label);
313
+ }
314
+ for (const p of ports)
315
+ usedAppPorts.delete(p);
316
+ };
317
+ };
230
318
  const reapAll = () => {
231
319
  if (lensSweeper) {
232
320
  clearInterval(lensSweeper);
@@ -239,5 +327,5 @@ function createLensChildren(rt, obs, options) {
239
327
  catch { /* already gone */ }
240
328
  }
241
329
  };
242
- return { spawnManagedChild, bootSingletonsAndApps, hostWorkspaceDashboard, reapAll };
330
+ return { spawnManagedChild, bootSingletonsAndApps, hostWorkspaceDashboard, hostWorkspaceApps, reapAll };
243
331
  }
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAsB,MAAM,SAAS,CAAC;AAkDxH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAgPxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAsB,MAAM,SAAS,CAAC;AAkDxH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAkPxB"}
@@ -150,23 +150,24 @@ async function startGateway(options, context) {
150
150
  // The daemon CONTROL PLANE (loopback ~/.lensmcp/control.sock): a second workspace's `gateway start`
151
151
  // REGISTERS its fragment here instead of fighting for :443, and the daemon HOSTS its services (spawns
152
152
  // them, model A) + its dashboard (planning/multi-workspace-gateway.md P3). Harmless for a lone gateway.
153
- const dashboardReapers = new Map();
153
+ const workspaceReapers = new Map();
154
154
  let controlServer;
155
155
  try {
156
156
  controlServer = (0, control_1.startControlServer)({
157
157
  rt,
158
158
  killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
159
159
  onRegister: (fragment) => {
160
- // Host the registered workspace's dashboard (appends its lensmcp.local/<key> route to the fragment
161
- // BEFORE the control handler's rebuild) and remember its reaper. The cert re-mint happens in
160
+ // Host the registered workspace's dashboard AND its lens FRONTEND apps (both spawned in ITS root/bus;
161
+ // the dashboard appends its lensmcp.local/<key> route + the apps rewrite their route targets to the
162
+ // actual bound port — all BEFORE the control handler's rebuild). The cert re-mint happens in
162
163
  // rebuildRoutes (called by the control handler right after this), once the full table is live.
163
- const reap = lens.hostWorkspaceDashboard(fragment);
164
- if (reap)
165
- dashboardReapers.set(fragment.wsKey, reap);
164
+ const reapDash = lens.hostWorkspaceDashboard(fragment);
165
+ const reapApps = lens.hostWorkspaceApps(fragment);
166
+ workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
166
167
  },
167
168
  onUnregister: (fragment) => {
168
- dashboardReapers.get(fragment.wsKey)?.();
169
- dashboardReapers.delete(fragment.wsKey);
169
+ workspaceReapers.get(fragment.wsKey)?.();
170
+ workspaceReapers.delete(fragment.wsKey);
170
171
  },
171
172
  });
172
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.16.16",
3
+ "version": "1.16.18",
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",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.16.16",
69
- "@lensmcp/nx-plugin": "1.16.16",
68
+ "@lensmcp/node-instrumentation": "1.16.18",
69
+ "@lensmcp/nx-plugin": "1.16.18",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",