@lensmcp/cluster 1.16.15 → 1.16.17

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,CA0OxB"}
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"}
@@ -110,7 +110,18 @@ async function startGateway(options, context) {
110
110
  const registry = new route_registry_1.RouteRegistry();
111
111
  registry.register({ wsKey, root: context.root, routes: discovered, services });
112
112
  const routes = []; // the LIVE table the request pipeline reads (a stable ref, mutated in place)
113
- const rebuildRoutes = () => (0, route_registry_1.rebuildRoutesInPlace)(routes, registry);
113
+ // Assigned after the TLS servers are built (below) re-mints the gateway cert with the CURRENT route
114
+ // hosts and hot-swaps it. Declared HERE (above rebuildRoutes) so rebuildRoutes can trigger it AFTER every
115
+ // live route change; a no-op until the TLS servers exist (the boot mint happens separately, below).
116
+ let refreshCert = () => { };
117
+ const rebuildRoutes = () => {
118
+ (0, route_registry_1.rebuildRoutesInPlace)(routes, registry);
119
+ // The cert tracks the live route hosts BY CONSTRUCTION: any register/unregister rebuilds the table then
120
+ // re-mints, so a dynamically-registered workspace's hostnames always join the SANs (no ERR_CERT_COMMON_
121
+ // NAME_INVALID). Wiring it here (not in the control onRegister) avoids the ordering trap where the cert
122
+ // was minted from the STALE table because onRegister ran before the control handler's rebuild.
123
+ refreshCert();
124
+ };
114
125
  rebuildRoutes();
115
126
  // --- per-service api keys (the gateway is the trust boundary) ---------------
116
127
  const { serviceKeys, keyToProject } = (0, service_keys_1.loadServiceKeys)(context.root, services);
@@ -139,28 +150,24 @@ async function startGateway(options, context) {
139
150
  // The daemon CONTROL PLANE (loopback ~/.lensmcp/control.sock): a second workspace's `gateway start`
140
151
  // REGISTERS its fragment here instead of fighting for :443, and the daemon HOSTS its services (spawns
141
152
  // them, model A) + its dashboard (planning/multi-workspace-gateway.md P3). Harmless for a lone gateway.
142
- const dashboardReapers = new Map();
143
- // Assigned after the TLS servers are built (below) — re-mints the gateway cert with the CURRENT route
144
- // hosts and hot-swaps it, so a workspace that registers AFTER boot gets its hostnames into the cert SANs
145
- // (otherwise the browser sees ERR_CERT_COMMON_NAME_INVALID for the new workspace's hosts).
146
- let refreshCert = () => { };
153
+ const workspaceReapers = new Map();
147
154
  let controlServer;
148
155
  try {
149
156
  controlServer = (0, control_1.startControlServer)({
150
157
  rt,
151
158
  killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
152
159
  onRegister: (fragment) => {
153
- // Host the registered workspace's dashboard (appends its lensmcp.local/<key> route to the fragment
154
- // BEFORE the control handler's rebuild) and remember its reaper.
155
- const reap = lens.hostWorkspaceDashboard(fragment);
156
- if (reap)
157
- dashboardReapers.set(fragment.wsKey, reap);
158
- refreshCert(); // the new workspace's hosts must join the gateway cert SANs
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
163
+ // rebuildRoutes (called by the control handler right after this), once the full table is live.
164
+ const reapDash = lens.hostWorkspaceDashboard(fragment);
165
+ const reapApps = lens.hostWorkspaceApps(fragment);
166
+ workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
159
167
  },
160
168
  onUnregister: (fragment) => {
161
- dashboardReapers.get(fragment.wsKey)?.();
162
- dashboardReapers.delete(fragment.wsKey);
163
- refreshCert();
169
+ workspaceReapers.get(fragment.wsKey)?.();
170
+ workspaceReapers.delete(fragment.wsKey);
164
171
  },
165
172
  });
166
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.16.15",
3
+ "version": "1.16.17",
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.15",
69
- "@lensmcp/nx-plugin": "1.16.15",
68
+ "@lensmcp/node-instrumentation": "1.16.17",
69
+ "@lensmcp/nx-plugin": "1.16.17",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",