@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.
- package/executors/gateway/runtime/lens-children.d.ts +4 -0
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -1
- package/executors/gateway/runtime/lens-children.js +95 -7
- package/executors/gateway/runtime/server.d.ts.map +1 -1
- package/executors/gateway/runtime/server.js +22 -15
- package/package.json +3 -3
|
@@ -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,
|
|
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'
|
|
56
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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.
|
|
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.
|
|
69
|
-
"@lensmcp/nx-plugin": "1.16.
|
|
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",
|