@lensmcp/cluster 1.16.23 → 1.16.25
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/basic-ssl.d.ts +9 -4
- package/basic-ssl.d.ts.map +1 -1
- package/basic-ssl.js +107 -42
- package/executors/gateway/runtime/control.d.ts +3 -2
- package/executors/gateway/runtime/control.d.ts.map +1 -1
- package/executors/gateway/runtime/control.js +1 -1
- package/executors/gateway/runtime/lens-children.d.ts +14 -5
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -1
- package/executors/gateway/runtime/lens-children.js +66 -17
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -1
- package/executors/gateway/runtime/lifecycle.js +5 -4
- package/executors/gateway/runtime/proxy.d.ts.map +1 -1
- package/executors/gateway/runtime/proxy.js +5 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -1
- package/executors/gateway/runtime/server.js +42 -8
- package/executors/gateway/schema.d.ts +2 -1
- package/executors/gateway/schema.json +1 -2
- package/executors/serve/schema.d.ts +4 -0
- package/executors/serve/schema.json +8 -0
- package/executors/serve/serve.impl.d.ts.map +1 -1
- package/executors/serve/serve.impl.js +26 -0
- package/executors/trust/trust.impl.d.ts +6 -4
- package/executors/trust/trust.impl.d.ts.map +1 -1
- package/executors/trust/trust.impl.js +28 -7
- package/main.devserver.d.ts.map +1 -1
- package/main.devserver.js +202 -5
- package/package.json +3 -3
package/basic-ssl.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export declare const CA_CERT_FILE = "lensmcp-local-ca.crt";
|
|
2
|
+
/** The machine-level CA directory: `~/.lensmcp/ca` (override home via `LENSMCP_HOME` — tests). */
|
|
3
|
+
export declare function caDir(): string;
|
|
4
|
+
/** Absolute path of the trust-once, machine-level CA certificate. */
|
|
5
|
+
export declare function caCertPath(): string;
|
|
2
6
|
export declare function getCertificateSync(cacheDir: string, name?: string, domains?: string[]): string;
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Ensure the machine-level CA exists (promoting `legacyCacheDir`'s per-workspace
|
|
9
|
+
* CA when the machine store is still empty); returns the machine CA cert path.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureCaSync(legacyCacheDir?: string): string;
|
|
7
12
|
//# sourceMappingURL=basic-ssl.d.ts.map
|
package/basic-ssl.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"basic-ssl.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/basic-ssl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"basic-ssl.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/basic-ssl.ts"],"names":[],"mappings":"AA4CA,eAAO,MAAM,YAAY,yBAAyB,CAAC;AAKnD,kGAAkG;AAClG,wBAAgB,KAAK,IAAI,MAAM,CAG9B;AAED,qEAAqE;AACrE,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,SAAgB,EAAE,OAAO,GAAE,MAAM,EAAO,GAAG,MAAM,CA8CzG;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,CAI5D"}
|
package/basic-ssl.js
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CA_CERT_FILE = void 0;
|
|
4
|
-
exports.
|
|
4
|
+
exports.caDir = caDir;
|
|
5
5
|
exports.caCertPath = caCertPath;
|
|
6
|
+
exports.getCertificateSync = getCertificateSync;
|
|
6
7
|
exports.ensureCaSync = ensureCaSync;
|
|
7
8
|
const tslib_1 = require("tslib");
|
|
8
9
|
/**
|
|
9
10
|
* Dev HTTPS certificates, mkcert-style: a long-lived LOCAL CA (10 years,
|
|
10
|
-
* minted once per
|
|
11
|
+
* minted once per MACHINE, trusted once in the OS keychain) signs short-lived
|
|
11
12
|
* leaf certs (30 days, re-minted per SAN set). Because the browser chains the
|
|
12
13
|
* leaf to the trusted CA, cert rotation and new gateway hostnames never show
|
|
13
14
|
* an interstitial again — unlike a bare self-signed cert, which would need
|
|
14
15
|
* re-trusting on every change.
|
|
15
16
|
*
|
|
17
|
+
* The CA is MACHINE-LEVEL — `~/.lensmcp/ca/` (override the home via
|
|
18
|
+
* `LENSMCP_HOME`, same convention as the workspace registry) — so every
|
|
19
|
+
* workspace/daemon on the machine serves chains anchored to ONE CA:
|
|
20
|
+
* - the OS keychain holds exactly one "lensmcp local dev CA" (no same-CN
|
|
21
|
+
* collisions between per-workspace CAs shadowing each other in `trust`),
|
|
22
|
+
* - a pod's `NODE_EXTRA_CA_CERTS` stays valid across daemon handovers
|
|
23
|
+
* (guest workspace → own daemon), because every daemon signs with the
|
|
24
|
+
* same anchor.
|
|
25
|
+
* The first machine-CA resolution PROMOTES a pre-existing per-workspace CA
|
|
26
|
+
* (the pre-machine-CA layout) when one exists, so keychain trust already
|
|
27
|
+
* granted to it carries over without another sudo prompt. LEAF certs stay
|
|
28
|
+
* per-workspace in `<root>/node_modules/.cache/davnx-webpack` and are reused
|
|
29
|
+
* only when they chain to the CURRENT machine CA (the issuer pin below).
|
|
30
|
+
*
|
|
16
31
|
* `getCertificateSync` returns ONE concatenated PEM (leaf key + leaf cert +
|
|
17
32
|
* CA cert). Node's `https.createServer({ key: pem, cert: pem })` accepts it:
|
|
18
33
|
* `key` takes the first private-key block, `cert` takes every certificate
|
|
@@ -20,10 +35,11 @@ const tslib_1 = require("tslib");
|
|
|
20
35
|
*
|
|
21
36
|
* Trust once (macOS):
|
|
22
37
|
* sudo security add-trusted-cert -d -r trustRoot \
|
|
23
|
-
* -k /Library/Keychains/System.keychain
|
|
38
|
+
* -k /Library/Keychains/System.keychain ~/.lensmcp/ca/lensmcp-local-ca.crt
|
|
24
39
|
*/
|
|
25
40
|
const crypto = tslib_1.__importStar(require("node:crypto"));
|
|
26
41
|
const fs = tslib_1.__importStar(require("node:fs"));
|
|
42
|
+
const os = tslib_1.__importStar(require("node:os"));
|
|
27
43
|
const path = tslib_1.__importStar(require("node:path"));
|
|
28
44
|
/** Re-mint the leaf when the cached one is older than this (TTL is 30 days). */
|
|
29
45
|
const LEAF_MAX_AGE_MS = 25 * 24 * 60 * 60 * 1000;
|
|
@@ -34,16 +50,22 @@ exports.CA_CERT_FILE = 'lensmcp-local-ca.crt';
|
|
|
34
50
|
/** The legacy (pre-rebrand) CA filename — swept on mint so a stale `davnx-local-ca.crt` never lingers. */
|
|
35
51
|
const LEGACY_CA_CERT_FILE = 'davnx-local-ca.crt';
|
|
36
52
|
const CA_KEY_FILE = '_ca-key.pem';
|
|
53
|
+
/** The machine-level CA directory: `~/.lensmcp/ca` (override home via `LENSMCP_HOME` — tests). */
|
|
54
|
+
function caDir() {
|
|
55
|
+
const home = process.env['LENSMCP_HOME'] || os.homedir();
|
|
56
|
+
return path.join(home, '.lensmcp', 'ca');
|
|
57
|
+
}
|
|
58
|
+
/** Absolute path of the trust-once, machine-level CA certificate. */
|
|
59
|
+
function caCertPath() {
|
|
60
|
+
return path.join(caDir(), exports.CA_CERT_FILE);
|
|
61
|
+
}
|
|
37
62
|
function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
|
|
38
|
-
// Resolve the CA FIRST
|
|
39
|
-
//
|
|
40
|
-
// is what makes a CA change actually take effect: otherwise a stale, old-CA-signed leaf short-circuits the
|
|
41
|
-
// mint and the rebrand never applies (the bug that needed a manual cache clear). On a normal run the CA is
|
|
42
|
-
// unchanged, nothing is swept, and the SAN-keyed leaf cache below still fast-paths.
|
|
43
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
63
|
+
// Resolve the CA FIRST (machine-level; promotes this workspace's legacy CA on first touch). The current
|
|
64
|
+
// CA pem then gates the leaf cache below — an issuer pin, see there.
|
|
44
65
|
const forge = require('node-forge');
|
|
45
66
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
46
|
-
const ca = loadOrCreateCa(
|
|
67
|
+
const ca = loadOrCreateCa(forge, cacheDir);
|
|
68
|
+
const caPem = forge.pki.certificateToPem(ca.cert);
|
|
47
69
|
// The SAN set is part of the cache key: adding a gateway hostname must
|
|
48
70
|
// mint a fresh leaf, not serve a cached one that lacks the new SAN.
|
|
49
71
|
const sanKey = crypto
|
|
@@ -55,7 +77,13 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
|
|
|
55
77
|
try {
|
|
56
78
|
const stat = fs.statSync(leafPath);
|
|
57
79
|
if (Date.now() - stat.ctimeMs < LEAF_MAX_AGE_MS) {
|
|
58
|
-
|
|
80
|
+
const cached = fs.readFileSync(leafPath, 'utf8');
|
|
81
|
+
// ISSUER PIN: the cached pem embeds the CA it chained to — serve it only when that is the CURRENT
|
|
82
|
+
// machine CA. With the CA machine-level, a stale leaf in THIS workspace can't be swept by a CA mint
|
|
83
|
+
// that happened elsewhere (another workspace, or pre-machine-CA code), so the old "a mint sweeps
|
|
84
|
+
// this dir's leaves" guarantee no longer covers every path; the pin does.
|
|
85
|
+
if (cached.includes(caPem))
|
|
86
|
+
return cached;
|
|
59
87
|
}
|
|
60
88
|
}
|
|
61
89
|
catch {
|
|
@@ -64,11 +92,13 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
|
|
|
64
92
|
const pem = createLeafCertificate(forge, ca, name, domains);
|
|
65
93
|
fs.writeFileSync(leafPath, pem);
|
|
66
94
|
// Stale leaves and the pre-1.3.4 self-signed cache just rot here — sweep
|
|
67
|
-
// them so the dir holds only the active leaf +
|
|
95
|
+
// them so the dir holds only the active leaf (+ any legacy CA pair, kept
|
|
96
|
+
// readable for older-code daemons that still anchor to it).
|
|
68
97
|
for (const f of fs.readdirSync(cacheDir)) {
|
|
69
98
|
const stale = (/^_leaf-.*\.pem$/.test(f) && f !== `_leaf-${sanKey}.pem`) ||
|
|
70
99
|
/^_cert.*\.pem$/.test(f) ||
|
|
71
|
-
/-dev\.crt$/.test(f)
|
|
100
|
+
/-dev\.crt$/.test(f) ||
|
|
101
|
+
f === LEGACY_CA_CERT_FILE;
|
|
72
102
|
if (stale) {
|
|
73
103
|
try {
|
|
74
104
|
fs.unlinkSync(path.join(cacheDir, f));
|
|
@@ -78,31 +108,79 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
|
|
|
78
108
|
}
|
|
79
109
|
return pem;
|
|
80
110
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
function ensureCaSync(cacheDir) {
|
|
87
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
111
|
+
/**
|
|
112
|
+
* Ensure the machine-level CA exists (promoting `legacyCacheDir`'s per-workspace
|
|
113
|
+
* CA when the machine store is still empty); returns the machine CA cert path.
|
|
114
|
+
*/
|
|
115
|
+
function ensureCaSync(legacyCacheDir) {
|
|
88
116
|
const forge = require('node-forge');
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return caCertPath(cacheDir);
|
|
117
|
+
loadOrCreateCa(forge, legacyCacheDir);
|
|
118
|
+
return caCertPath();
|
|
92
119
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const keyPath = path.join(cacheDir, CA_KEY_FILE);
|
|
120
|
+
/** Read a CA pair from `dir`; undefined when absent, unreadable, or too close to expiry to reuse. */
|
|
121
|
+
function readCaPair(dir, forge) {
|
|
96
122
|
try {
|
|
97
|
-
const cert = forge.pki.certificateFromPem(fs.readFileSync(
|
|
98
|
-
const key = forge.pki.privateKeyFromPem(fs.readFileSync(
|
|
123
|
+
const cert = forge.pki.certificateFromPem(fs.readFileSync(path.join(dir, exports.CA_CERT_FILE), 'utf8'));
|
|
124
|
+
const key = forge.pki.privateKeyFromPem(fs.readFileSync(path.join(dir, CA_KEY_FILE), 'utf8'));
|
|
99
125
|
if (cert.validity.notAfter.getTime() - Date.now() > CA_MIN_REMAINING_MS) {
|
|
100
126
|
return { cert, key };
|
|
101
127
|
}
|
|
102
128
|
}
|
|
103
129
|
catch {
|
|
104
|
-
/*
|
|
130
|
+
/* absent or unreadable */
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
function loadOrCreateCa(forge, promoteFromDir) {
|
|
135
|
+
const dir = caDir();
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
const existing = readCaPair(dir, forge);
|
|
138
|
+
if (existing)
|
|
139
|
+
return existing;
|
|
140
|
+
// Files present but unusable (expired / half-written) — clear them so the `wx` claim below can win.
|
|
141
|
+
// Rotation this races is a once-per-decade event; last write wins is fine at dev grade.
|
|
142
|
+
for (const f of [CA_KEY_FILE, exports.CA_CERT_FILE]) {
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(path.join(dir, f));
|
|
145
|
+
}
|
|
146
|
+
catch { /* absent */ }
|
|
147
|
+
}
|
|
148
|
+
// Candidate pair: PROMOTE the pre-machine-CA workspace pair when one exists — the exact cert the user
|
|
149
|
+
// already trusted in the keychain simply becomes the machine CA (no new sudo prompt). The workspace copy
|
|
150
|
+
// stays in place for any older-code daemon still anchoring to it. Otherwise mint fresh.
|
|
151
|
+
const promoted = promoteFromDir && promoteFromDir !== dir ? readCaPair(promoteFromDir, forge) : undefined;
|
|
152
|
+
const pair = promoted ?? mintCa(forge);
|
|
153
|
+
// Atomic claim on the KEY file (`wx`): concurrent daemons can race the first-ever machine resolution;
|
|
154
|
+
// exactly one pair may land on disk. The winner writes the cert right after; a loser adopts the winner's
|
|
155
|
+
// pair (bounded wait for that cert write).
|
|
156
|
+
try {
|
|
157
|
+
fs.writeFileSync(path.join(dir, CA_KEY_FILE), forge.pki.privateKeyToPem(pair.key), { mode: 0o600, flag: 'wx' });
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
if (e.code !== 'EEXIST')
|
|
161
|
+
throw e;
|
|
162
|
+
const winner = spinReadCaPair(dir, forge);
|
|
163
|
+
if (winner)
|
|
164
|
+
return winner;
|
|
165
|
+
throw new Error(`machine dev CA at ${dir} is claimed but unreadable — remove the directory and retry`, { cause: e });
|
|
105
166
|
}
|
|
167
|
+
fs.writeFileSync(path.join(dir, exports.CA_CERT_FILE), forge.pki.certificateToPem(pair.cert));
|
|
168
|
+
if (promoted) {
|
|
169
|
+
console.log(`[ssl] promoted the existing workspace dev CA to the machine store (${dir}) — keychain trust carries over.`);
|
|
170
|
+
}
|
|
171
|
+
return pair;
|
|
172
|
+
}
|
|
173
|
+
/** Bounded sync wait for a concurrent claimer's cert write to land (claim→cert is two writes apart). */
|
|
174
|
+
function spinReadCaPair(dir, forge) {
|
|
175
|
+
const deadline = Date.now() + 2000;
|
|
176
|
+
for (;;) {
|
|
177
|
+
const pair = readCaPair(dir, forge);
|
|
178
|
+
if (pair || Date.now() >= deadline)
|
|
179
|
+
return pair;
|
|
180
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); // sync 25ms sleep
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function mintCa(forge) {
|
|
106
184
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
107
185
|
const cert = forge.pki.createCertificate();
|
|
108
186
|
cert.publicKey = keys.publicKey;
|
|
@@ -123,19 +201,6 @@ function loadOrCreateCa(cacheDir, forge) {
|
|
|
123
201
|
{ name: 'subjectKeyIdentifier' },
|
|
124
202
|
]);
|
|
125
203
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
126
|
-
fs.writeFileSync(certPath, forge.pki.certificateToPem(cert));
|
|
127
|
-
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(keys.privateKey), { mode: 0o600 });
|
|
128
|
-
// A freshly-minted CA invalidates every cached LEAF (they were signed by the OLD CA) — sweep them so the
|
|
129
|
-
// next getCertificateSync re-mints against THIS CA (otherwise the SAN-keyed leaf cache serves a stale,
|
|
130
|
-
// old-CA-signed cert). Also drop the legacy `davnx-local-ca.crt` so the lensmcp rebrand leaves nothing behind.
|
|
131
|
-
for (const f of fs.readdirSync(cacheDir)) {
|
|
132
|
-
if (/^_leaf-.*\.pem$/.test(f) || f === LEGACY_CA_CERT_FILE) {
|
|
133
|
-
try {
|
|
134
|
-
fs.unlinkSync(path.join(cacheDir, f));
|
|
135
|
-
}
|
|
136
|
-
catch { /* best effort */ }
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
204
|
return { cert, key: keys.privateKey };
|
|
140
205
|
}
|
|
141
206
|
function createLeafCertificate(forge, ca, name, domains, ttlDays = 30) {
|
|
@@ -6,8 +6,9 @@ export interface ControlServerDeps {
|
|
|
6
6
|
rt: GatewayRuntime;
|
|
7
7
|
/** Reap a fragment's spawned devservers when it unregisters (the svcLayer's `killSpawned`). */
|
|
8
8
|
killService: (svc: WorkspaceFragment['services'][number], reason: string) => void;
|
|
9
|
-
/** Hooks so the daemon can host/tear-down a workspace's dashboard around the route merge (P3d).
|
|
10
|
-
|
|
9
|
+
/** Hooks so the daemon can host/tear-down a workspace's dashboard around the route merge (P3d).
|
|
10
|
+
* `onRegister` may be async (port probing) — the register handler AWAITS it before the rebuild. */
|
|
11
|
+
onRegister?: (fragment: WorkspaceFragment) => void | Promise<void>;
|
|
11
12
|
onUnregister?: (fragment: WorkspaceFragment) => void;
|
|
12
13
|
}
|
|
13
14
|
export interface ControlServer {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/control.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,+GAA+G;AAC/G,wBAAgB,iBAAiB,IAAI,MAAM,CAG1C;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAC;IACnB,+FAA+F;IAC/F,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClF
|
|
1
|
+
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/control.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,+GAA+G;AAC/G,wBAAgB,iBAAiB,IAAI,MAAM,CAG1C;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAC;IACnB,+FAA+F;IAC/F,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClF;wGACoG;IACpG,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAsBD;qFACqF;AACrF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAwEzE"}
|
|
@@ -90,7 +90,7 @@ function startControlServer(deps) {
|
|
|
90
90
|
const { routes, services } = (0, discovery_1.discoverRoutes)(body.root, body.projects ?? {}, undefined, body.wsKey);
|
|
91
91
|
const fragment = { wsKey: body.wsKey, root: body.root, routes, services };
|
|
92
92
|
rt.registry.register(fragment);
|
|
93
|
-
deps.onRegister?.(fragment); // hosts the dashboard + APPENDS its route to the fragment BEFORE the rebuild
|
|
93
|
+
await deps.onRegister?.(fragment); // hosts the dashboard + APPENDS its route to the fragment BEFORE the rebuild
|
|
94
94
|
rt.rebuildRoutes();
|
|
95
95
|
return send(res, 200, { ok: true, wsKey: body.wsKey, routes: routes.length, services: services.length, workspaces: rt.registry.keys() });
|
|
96
96
|
}
|
|
@@ -2,22 +2,31 @@ import { type SpawnChild } from '@lensmcp/nx-plugin/lens-frontend';
|
|
|
2
2
|
import type { WorkspaceFragment } from './route-registry';
|
|
3
3
|
import type { GatewayRuntime, GatewayRuntimeOptions } from './types';
|
|
4
4
|
import { type Observability } from './observability';
|
|
5
|
+
/** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
|
|
6
|
+
* know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
|
|
7
|
+
* holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
|
|
8
|
+
* we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
|
|
9
|
+
* dashboard redirects to foodguard" bug). */
|
|
10
|
+
export declare function probePortFree(port: number): Promise<boolean>;
|
|
5
11
|
export interface LensChildren {
|
|
6
12
|
spawnManagedChild: SpawnChild;
|
|
7
13
|
/** Boot the dashboard + MCP singletons and every `lens:true` app. Pushes the
|
|
8
|
-
* dashboard route onto `rt.routes` — MUST run before TLS SAN derivation.
|
|
9
|
-
|
|
14
|
+
* dashboard route onto `rt.routes` — MUST run before TLS SAN derivation.
|
|
15
|
+
* Async: every claimed port is OS-probed first (an orphan may hold it). */
|
|
16
|
+
bootSingletonsAndApps(): Promise<void>;
|
|
10
17
|
/** Host a REGISTERED workspace's dashboard (P3d): spawn its dashboard child on its OWN events file + a
|
|
11
18
|
* distinct port, append its `lensmcp.local/<key>` route to its fragment. Returns a reaper (kills the
|
|
12
19
|
* child WITHOUT auto-heal respawn) for the daemon to call on unregister — or undefined if no bundle. */
|
|
13
|
-
hostWorkspaceDashboard(fragment: WorkspaceFragment): (() => void) | undefined
|
|
20
|
+
hostWorkspaceDashboard(fragment: WorkspaceFragment): Promise<(() => void) | undefined>;
|
|
14
21
|
/** Host a REGISTERED workspace's `lens:true` FRONTEND apps (completes model-A "the daemon hosts
|
|
15
22
|
* everything"): spawn each app's vite (+ capture) in the workspace's OWN root + event bus, on a
|
|
16
23
|
* collision-safe port, and point its route target at that port. Returns a reaper for unregister. */
|
|
17
|
-
hostWorkspaceApps(fragment: WorkspaceFragment): () => void
|
|
24
|
+
hostWorkspaceApps(fragment: WorkspaceFragment): Promise<() => void>;
|
|
18
25
|
/** Reap all managed children (called from `stop()`): SIGTERM, a grace window, then SIGKILL any survivor
|
|
19
26
|
* (vite ignores SIGTERM → would orphan when the daemon exits). Async so `stop()` awaits the escalation. */
|
|
20
27
|
reapAll(): Promise<void>;
|
|
21
28
|
}
|
|
22
|
-
export declare function createLensChildren(rt: GatewayRuntime, obs: Observability, options: GatewayRuntimeOptions
|
|
29
|
+
export declare function createLensChildren(rt: GatewayRuntime, obs: Observability, options: GatewayRuntimeOptions, deps?: {
|
|
30
|
+
isPortFree?: (port: number) => Promise<boolean>;
|
|
31
|
+
}): LensChildren;
|
|
23
32
|
//# sourceMappingURL=lens-children.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AASA,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;;;;8CAI8C;AAC9C,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO5D;AAED,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;;gFAE4E;IAC5E,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;IACvF;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IACpE;gHAC4G;IAC5G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,cAAc,EAClB,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,qBAAqB,EAE9B,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GACzD,YAAY,CAgWd"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.probePortFree = probePortFree;
|
|
3
4
|
exports.createLensChildren = createLensChildren;
|
|
4
5
|
const tslib_1 = require("tslib");
|
|
5
6
|
/**
|
|
@@ -8,6 +9,7 @@ const tslib_1 = require("tslib");
|
|
|
8
9
|
* EVERY managed child AUTO-HEALS — restarted with exponential backoff when it
|
|
9
10
|
* exits while the gateway is up — and a dead child NEVER kills the cluster.
|
|
10
11
|
*/
|
|
12
|
+
const net = tslib_1.__importStar(require("node:net"));
|
|
11
13
|
const path = tslib_1.__importStar(require("node:path"));
|
|
12
14
|
const node_child_process_1 = require("node:child_process");
|
|
13
15
|
const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
|
|
@@ -19,8 +21,24 @@ const observability_1 = require("./observability");
|
|
|
19
21
|
const HEAL_BASE_MS = 1_500; // first restart delay
|
|
20
22
|
const HEAL_MAX_MS = 30_000; // backoff cap — a true boot-loop settles here, never hot-loops
|
|
21
23
|
const HEAL_HEALTHY_MS = 20_000; // ran at least this long ⇒ a FRESH crash ⇒ reset the backoff budget
|
|
22
|
-
|
|
24
|
+
/** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
|
|
25
|
+
* know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
|
|
26
|
+
* holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
|
|
27
|
+
* we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
|
|
28
|
+
* dashboard redirects to foodguard" bug). */
|
|
29
|
+
function probePortFree(port) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const probe = net.createServer();
|
|
32
|
+
probe.unref();
|
|
33
|
+
probe.once('error', () => resolve(false));
|
|
34
|
+
probe.listen(port, () => probe.close(() => resolve(true)));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function createLensChildren(rt, obs, options,
|
|
38
|
+
// Injectable OS-level port probe (unit tests stub it; real ports are machine-dependent).
|
|
39
|
+
deps) {
|
|
23
40
|
const { emit } = obs;
|
|
41
|
+
const isPortFree = deps?.isPortFree ?? probePortFree;
|
|
24
42
|
const lensChildren = [];
|
|
25
43
|
// Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
|
|
26
44
|
// backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
|
|
@@ -42,6 +60,18 @@ function createLensChildren(rt, obs, options) {
|
|
|
42
60
|
// registered workspace whose app declares a port already in use gets offset (vite's --strictPort would
|
|
43
61
|
// otherwise exit-loop). Keyed by the actual bound port.
|
|
44
62
|
const usedAppPorts = new Set();
|
|
63
|
+
// The FIRST port at/above `desired` that is neither claimed in-process nor held at the OS level (an
|
|
64
|
+
// orphaned child of a crashed daemon squats its old port — see probePortFree). Claims it in `claimed`.
|
|
65
|
+
const claimFreePort = async (desired, claimed) => {
|
|
66
|
+
let port = desired;
|
|
67
|
+
while (claimed.has(port) || !(await isPortFree(port)))
|
|
68
|
+
port += 1;
|
|
69
|
+
claimed.add(port);
|
|
70
|
+
if (port !== desired) {
|
|
71
|
+
console.warn(`[gateway] port ${desired} is unavailable (another process holds it — an orphan from a crashed gateway?) — using ${port} instead.`);
|
|
72
|
+
}
|
|
73
|
+
return port;
|
|
74
|
+
};
|
|
45
75
|
// A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
|
|
46
76
|
// `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
|
|
47
77
|
// `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
|
|
@@ -56,7 +86,14 @@ function createLensChildren(rt, obs, options) {
|
|
|
56
86
|
void optional;
|
|
57
87
|
void respawn; // legacy knobs — auto-heal is now universal (signature kept for compat)
|
|
58
88
|
const spawnedAt = Date.now();
|
|
59
|
-
|
|
89
|
+
// LENSMCP_PARENT_PID: our own bundles (dashboard/MCP) run a ppid watchdog against it and SELF-EXIT when
|
|
90
|
+
// this gateway dies ungracefully (crash/SIGKILL — reapAll never ran), so they don't orphan and squat
|
|
91
|
+
// their ports for the next gateway to route into.
|
|
92
|
+
const child = (0, node_child_process_1.spawn)(bin, args, {
|
|
93
|
+
cwd: cwd ?? rt.root,
|
|
94
|
+
env: { ...process.env, ...(env ?? {}), LENSMCP_PARENT_PID: String(process.pid) },
|
|
95
|
+
stdio: 'inherit',
|
|
96
|
+
});
|
|
60
97
|
lensChildren.push(child);
|
|
61
98
|
labeledChildren.set(label, child); // the CURRENT handle for this label (a per-workspace dashboard reap targets it)
|
|
62
99
|
// Track a `lens:true` app's vite dev server (label 'vite', or `<ws>:<proj>:vite` for a registered
|
|
@@ -115,7 +152,7 @@ function createLensChildren(rt, obs, options) {
|
|
|
115
152
|
}, Math.min(types_1.POD_STALE_SCAN_MS, 15_000));
|
|
116
153
|
lensSweeper.unref?.();
|
|
117
154
|
};
|
|
118
|
-
const bootSingletonsAndApps = () => {
|
|
155
|
+
const bootSingletonsAndApps = async () => {
|
|
119
156
|
const lensApps = [...new Map(rt.routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
|
|
120
157
|
const wantDashboard = options.dashboard !== false;
|
|
121
158
|
const wantMcp = options.mcp !== false && lensApps.length > 0;
|
|
@@ -130,8 +167,11 @@ function createLensChildren(rt, obs, options) {
|
|
|
130
167
|
// OWNS its base path (LENSMCP_DASHBOARD_BASE) and history-routes under it, so the
|
|
131
168
|
// gateway routes pass-through (matchRoute on the prefix; the proxy never strips).
|
|
132
169
|
const scope = (0, scope_1.readLensScope)(rt.root);
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
// The per-workspace scoped port (`.lensmcp/config.json` ports.dashboard) unless explicitly
|
|
171
|
+
// overridden — then OS-probed: an orphaned dashboard from a crashed gateway may still hold it, and
|
|
172
|
+
// routing there serves ANOTHER workspace's dashboard. Claiming also reserves it in-process so a
|
|
173
|
+
// registered workspace's dashboard differs.
|
|
174
|
+
const dashboardPort = await claimFreePort(options.dashboardPort ?? scope.dashboardPort, usedDashboardPorts);
|
|
135
175
|
const lensHost = options.lensHost ?? 'lensmcp.local';
|
|
136
176
|
const lensBase = scope.basePath; // e.g. /tetros
|
|
137
177
|
// Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)), so lensmcp.local
|
|
@@ -172,7 +212,20 @@ function createLensChildren(rt, obs, options) {
|
|
|
172
212
|
if (app.started)
|
|
173
213
|
continue;
|
|
174
214
|
app.started = true;
|
|
175
|
-
|
|
215
|
+
// Claim the app's port OS-probed (an orphaned vite from a crashed daemon may hold it — vite's
|
|
216
|
+
// --strictPort would exit-loop while the route proxies into the stale one). On a step-up, point every
|
|
217
|
+
// route of this project at the ACTUAL port (route objects are shared with the registry fragment, so
|
|
218
|
+
// the rewrite survives rebuilds — mirrors hostWorkspaceApps).
|
|
219
|
+
const appPort = await claimFreePort(app.port, usedAppPorts);
|
|
220
|
+
if (appPort !== app.port) {
|
|
221
|
+
for (const r of rt.routes) {
|
|
222
|
+
if (r.lens?.project === app.project) {
|
|
223
|
+
r.lens.port = appPort;
|
|
224
|
+
r.target = `http://localhost:${appPort}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
app.port = appPort;
|
|
228
|
+
}
|
|
176
229
|
try {
|
|
177
230
|
(0, lens_frontend_1.spawnLensFrontend)({
|
|
178
231
|
projectRoot: app.projectRoot,
|
|
@@ -199,17 +252,15 @@ function createLensChildren(rt, obs, options) {
|
|
|
199
252
|
// `lensmcp.local/<key>` route on its fragment. The dashboard BUNDLE is the daemon's (rt.root); only the
|
|
200
253
|
// events file + base + port are the registered workspace's. Returns a reaper the control endpoint calls
|
|
201
254
|
// on unregister (marks the label removed so auto-heal won't respawn it, then kills the child).
|
|
202
|
-
const hostWorkspaceDashboard = (fragment) => {
|
|
255
|
+
const hostWorkspaceDashboard = async (fragment) => {
|
|
203
256
|
const bundle = (0, lens_frontend_1.findDashboardBundle)(rt.root);
|
|
204
257
|
if (!bundle)
|
|
205
258
|
return undefined;
|
|
206
259
|
const scope = (0, scope_1.readLensScope)(fragment.root);
|
|
207
260
|
const lensHost = options.lensHost ?? 'lensmcp.local';
|
|
208
261
|
const lensBase = scope.basePath; // /<registered key>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
port += 1; // distinct from every other workspace's dashboard
|
|
212
|
-
usedDashboardPorts.add(port);
|
|
262
|
+
// Distinct from every other workspace's dashboard AND actually bindable (OS-probed for orphans).
|
|
263
|
+
const port = await claimFreePort(scope.dashboardPort, usedDashboardPorts);
|
|
213
264
|
const label = `lens-dashboard:${fragment.wsKey}`;
|
|
214
265
|
const eventFile = path.join(fragment.root, '.lensmcp', 'events.jsonl'); // the registered workspace's OWN bus
|
|
215
266
|
removedLabels.delete(label); // a fresh registration after a prior unregister → allow auto-heal again
|
|
@@ -250,7 +301,7 @@ function createLensChildren(rt, obs, options) {
|
|
|
250
301
|
// TARGET to that port so the merged table (rebuilt right after this in the control handler) upstreams
|
|
251
302
|
// correctly. Unique labels (`<ws>:<proj>:vite`) keep auto-heal + the reaper from touching the daemon's own
|
|
252
303
|
// identically-named vites. Returns a reaper the control endpoint calls on unregister.
|
|
253
|
-
const hostWorkspaceApps = (fragment) => {
|
|
304
|
+
const hostWorkspaceApps = async (fragment) => {
|
|
254
305
|
// Group a project's (possibly several host) routes so one spawned vite serves them all.
|
|
255
306
|
const byProject = new Map();
|
|
256
307
|
for (const r of fragment.routes) {
|
|
@@ -269,11 +320,9 @@ function createLensChildren(rt, obs, options) {
|
|
|
269
320
|
const lens0 = routes[0].lens;
|
|
270
321
|
if (lens0.started)
|
|
271
322
|
continue;
|
|
272
|
-
// Collision-safe port: keep the declared port unless already claimed
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
port += 1;
|
|
276
|
-
usedAppPorts.add(port);
|
|
323
|
+
// Collision-safe port: keep the declared port unless already claimed in-process OR held at the OS
|
|
324
|
+
// level (an orphaned vite from a crashed daemon), else step up.
|
|
325
|
+
const port = await claimFreePort(lens0.port, usedAppPorts);
|
|
277
326
|
ports.push(port);
|
|
278
327
|
// Point every route of this project at the actual bound port (all share the one vite).
|
|
279
328
|
for (const r of routes) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,iBAAiB,CAAC;AA8C/D;;sGAEsG;AACtG,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CAkRvF"}
|
|
@@ -15,13 +15,14 @@ const fs = tslib_1.__importStar(require("node:fs"));
|
|
|
15
15
|
const http = tslib_1.__importStar(require("node:http"));
|
|
16
16
|
const path = tslib_1.__importStar(require("node:path"));
|
|
17
17
|
const discovery_1 = require("./discovery");
|
|
18
|
-
const service_keys_1 = require("./service-keys");
|
|
19
18
|
const types_1 = require("./types");
|
|
20
19
|
/**
|
|
21
20
|
* The east-west env a pod needs to call ANOTHER service's `internal.<host>` route (memberships, settings,
|
|
22
21
|
* plan): (1) its OWN per-service key — presented as `x-api-key`; the gateway maps it back to the caller and
|
|
23
|
-
* stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the
|
|
24
|
-
*
|
|
22
|
+
* stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the
|
|
23
|
+
* MACHINE-LEVEL dev CA (`~/.lensmcp/ca`), so the pod's OUTBOUND `fetch('https://internal.<host>')` clears
|
|
24
|
+
* TLS — and keeps clearing it across daemon handovers, since every daemon on the machine signs with that
|
|
25
|
+
* same anchor (a per-workspace path here once broke pods that outlived a guest→own-daemon switch). Without
|
|
25
26
|
* these the call fails (connect/TLS or a gateway 401) and tenant-token minting can't resolve the role.
|
|
26
27
|
* NOT `INTERNAL_API_KEY`/`LENSMCP_INTERNAL_TOKEN` (those would flip ON the inbound gateway-trust guard) —
|
|
27
28
|
* a DEDICATED name a service opts into for OUTBOUND only, leaving inbound dev-standalone behavior unchanged.
|
|
@@ -34,7 +35,7 @@ function eastWestEnv(rt, project) {
|
|
|
34
35
|
if (rt.https) {
|
|
35
36
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy like server.ts (cert deps)
|
|
36
37
|
const basicSsl = require('../../../basic-ssl');
|
|
37
|
-
const caPath = basicSsl.caCertPath(
|
|
38
|
+
const caPath = basicSsl.caCertPath();
|
|
38
39
|
if (fs.existsSync(caPath))
|
|
39
40
|
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
40
41
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAsTtG"}
|
|
@@ -110,6 +110,11 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
110
110
|
agent: agentForTarget(target),
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
// An h2 ServerResponse whose stream faults (a RST from the client, a
|
|
114
|
+
// `res.destroy(err)` below) emits 'error'; UNHANDLED, that throws and can
|
|
115
|
+
// escalate to tear the whole h2 session down — the very "burst" symptom.
|
|
116
|
+
// A no-op listener keeps the fault local to THIS stream. (Harmless on h1.)
|
|
117
|
+
res.on('error', () => { });
|
|
113
118
|
const upstreamReq = mod.request(opts, (upstreamRes) => {
|
|
114
119
|
cbs.onResponse();
|
|
115
120
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA0BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED,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":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA0BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAmRxB"}
|
|
@@ -189,13 +189,13 @@ async function startGateway(options, context) {
|
|
|
189
189
|
controlServer = (0, control_1.startControlServer)({
|
|
190
190
|
rt,
|
|
191
191
|
killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
|
|
192
|
-
onRegister: (fragment) => {
|
|
192
|
+
onRegister: async (fragment) => {
|
|
193
193
|
// Host the registered workspace's dashboard AND its lens FRONTEND apps (both spawned in ITS root/bus;
|
|
194
194
|
// the dashboard appends its lensmcp.local/<key> route + the apps rewrite their route targets to the
|
|
195
|
-
// actual bound port — all BEFORE the control handler's rebuild). The cert re-mint
|
|
196
|
-
// rebuildRoutes (called by the control handler right after this), once the full table is live.
|
|
197
|
-
const reapDash = lens.hostWorkspaceDashboard(fragment);
|
|
198
|
-
const reapApps = lens.hostWorkspaceApps(fragment);
|
|
195
|
+
// actual bound port — all BEFORE the control handler's rebuild, which AWAITS this). The cert re-mint
|
|
196
|
+
// happens in rebuildRoutes (called by the control handler right after this), once the full table is live.
|
|
197
|
+
const reapDash = await lens.hostWorkspaceDashboard(fragment);
|
|
198
|
+
const reapApps = await lens.hostWorkspaceApps(fragment);
|
|
199
199
|
workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
|
|
200
200
|
},
|
|
201
201
|
onUnregister: (fragment) => {
|
|
@@ -249,7 +249,7 @@ async function startGateway(options, context) {
|
|
|
249
249
|
const sweeper = svcLayer.startSweeper();
|
|
250
250
|
// --- lens-mode children: dashboard + MCP singletons + `lens:true` apps ------
|
|
251
251
|
// MUST run before TLS — it pushes the dashboard route, which joins the cert SANs.
|
|
252
|
-
lens.bootSingletonsAndApps();
|
|
252
|
+
await lens.bootSingletonsAndApps();
|
|
253
253
|
// Eager services boot with the gateway.
|
|
254
254
|
for (const svc of services) {
|
|
255
255
|
if (svc.decl.eager)
|
|
@@ -267,7 +267,7 @@ async function startGateway(options, context) {
|
|
|
267
267
|
// below) automatically includes a newly-registered workspace's hostnames.
|
|
268
268
|
mintCert = () => basicSsl.getCertificateSync(cacheDir, 'gateway', rt.routes.map((r) => r.host).filter((h) => !!h));
|
|
269
269
|
pem = mintCert();
|
|
270
|
-
caPath = basicSsl.caCertPath(
|
|
270
|
+
caPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca) — one anchor for every daemon
|
|
271
271
|
}
|
|
272
272
|
const servers = [];
|
|
273
273
|
const boundPorts = [];
|
|
@@ -286,10 +286,44 @@ async function startGateway(options, context) {
|
|
|
286
286
|
// because node-http-proxy emits connection-specific headers that are illegal in h2.
|
|
287
287
|
const server = pem
|
|
288
288
|
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
289
|
-
require('node:http2').createSecureServer({
|
|
289
|
+
require('node:http2').createSecureServer({
|
|
290
|
+
key: pem,
|
|
291
|
+
cert: pem,
|
|
292
|
+
allowHTTP1: true,
|
|
293
|
+
// A full Vite dev-app reload streams TENS of MB of transformed ESM
|
|
294
|
+
// over ONE h2 session. Node's default `maxSessionMemory` is 10 MB —
|
|
295
|
+
// cross it and the session RSTs its streams / sends GOAWAY, which the
|
|
296
|
+
// browser reports as a BURST of ERR_HTTP2_PROTOCOL_ERROR (every
|
|
297
|
+
// in-flight module fails at the same instant). Raise the ceiling and
|
|
298
|
+
// the per-stream/peer limits to dev-front-door proportions.
|
|
299
|
+
maxSessionMemory: 512,
|
|
300
|
+
settings: { maxConcurrentStreams: 512 },
|
|
301
|
+
peerMaxConcurrentStreams: 512,
|
|
302
|
+
}, (req, res) => handler(req, res))
|
|
290
303
|
: http.createServer(handler);
|
|
291
304
|
server.on('upgrade', upgrade);
|
|
292
305
|
server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
|
|
306
|
+
// h2 SESSION resilience: without a `sessionError` handler ANY session-level
|
|
307
|
+
// fault (a client GOAWAY mid-burst, a flood-protection trip, a decode error)
|
|
308
|
+
// is unhandled → Node tears the session down, failing EVERY sibling stream at
|
|
309
|
+
// once. Handle it so ONE bad session logs + dies alone, never throwing and
|
|
310
|
+
// never taking the process (or other clients' sessions) with it. `session`
|
|
311
|
+
// errors are surfaced BOTH per-session and (unhandled) as `sessionError`.
|
|
312
|
+
const isH2 = pem != null;
|
|
313
|
+
if (isH2) {
|
|
314
|
+
const h2 = server;
|
|
315
|
+
h2.on('session', (session) => {
|
|
316
|
+
session.on('error', (err) => {
|
|
317
|
+
const code = err.code ?? err.message;
|
|
318
|
+
console.warn(`[gateway] h2 session error (${code}) — dropping this session only`);
|
|
319
|
+
if (!session.destroyed)
|
|
320
|
+
session.destroy();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
h2.on('sessionError', (err) => {
|
|
324
|
+
console.warn(`[gateway] h2 sessionError: ${err.code ?? err.message}`);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
293
327
|
await new Promise((resolve) => {
|
|
294
328
|
server.listen(port, () => {
|
|
295
329
|
const actual = server.address()?.port ?? port;
|
|
@@ -11,7 +11,8 @@ export interface GatewayExecutorSchema {
|
|
|
11
11
|
dashboard?: boolean;
|
|
12
12
|
/** Spawn the single MCP server as a gateway-managed child when a `lens:true` app exists. Default true. */
|
|
13
13
|
mcp?: boolean;
|
|
14
|
-
/** Port the managed dashboard listens on (proxied behind lens.<baseDomain>).
|
|
14
|
+
/** Port the managed dashboard listens on (proxied behind lens.<baseDomain>).
|
|
15
|
+
* Default: the workspace scope's dashboard port (`.lensmcp/config.json` `ports.dashboard`), else 4321. */
|
|
15
16
|
dashboardPort?: number;
|
|
16
17
|
/** Override the dashboard hostname. Default lens.<baseDomain>. */
|
|
17
18
|
lensHost?: string;
|
|
@@ -37,8 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dashboardPort": {
|
|
39
39
|
"type": "number",
|
|
40
|
-
"
|
|
41
|
-
"description": "Port the gateway-managed dashboard listens on (proxied behind lens.<baseDomain>). Default 4321."
|
|
40
|
+
"description": "Port the gateway-managed dashboard listens on (proxied behind lens.<baseDomain>). When omitted, the workspace's `.lensmcp/config.json` `ports.dashboard` wins (falling back to 4321) — a schema default here would OVERRIDE that per-workspace port for every workspace, colliding multi-workspace dashboards onto one port."
|
|
42
41
|
},
|
|
43
42
|
"lensHost": {
|
|
44
43
|
"type": "string",
|
|
@@ -50,4 +50,8 @@ export interface ServeExecutorSchema {
|
|
|
50
50
|
gateway?: GatewayConfig;
|
|
51
51
|
/** Plain-http listener straight to the children - service-to-service calls, no gateway middleware. */
|
|
52
52
|
internalPort?: number;
|
|
53
|
+
/** Inspector base port: pod slot N -> debugPort+N, workers -> debugPort+20+i. Env fallback: LENSMCP_DEBUG_PORT. */
|
|
54
|
+
debugPort?: number;
|
|
55
|
+
/** Tee pod/worker logs (ANSI-stripped) to this file for IDE tailing (WebStorm Logs tab). Env fallback: LENSMCP_LOG_FILE. */
|
|
56
|
+
logFile?: string;
|
|
53
57
|
}
|
|
@@ -186,6 +186,14 @@
|
|
|
186
186
|
"internalPort": {
|
|
187
187
|
"type": "number",
|
|
188
188
|
"description": "Plain-http listener that proxies straight to this service children — no gateway routes, no gateway middleware (JWT). For service-to-service calls, mirroring cluster-internal networking."
|
|
189
|
+
},
|
|
190
|
+
"debugPort": {
|
|
191
|
+
"type": "number",
|
|
192
|
+
"description": "Open a Node inspector in every pod (127.0.0.1 only): pod at slot N listens on debugPort+N, workers on debugPort+20+i. The printed 'Debugger listening on ws://…' line is click-to-attach in WebStorm/VS Code run consoles; ports are stable across crash-respawns, and hot reloads swap in-process so an attached debug session survives rebuilds. Also settable via the LENSMCP_DEBUG_PORT env var (this option wins). Give each service a distinct base. Without it, POST /webpack/debug on the running devserver opens inspectors on demand — no restart."
|
|
193
|
+
},
|
|
194
|
+
"logFile": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"description": "Tee every pod/worker log line (ANSI-stripped) to this file, relative to workspace root (e.g. '.lensmcp/logs/my-service.log'). Point an IDE at it — WebStorm: Run/Debug configuration → Logs tab — to see live service logs next to the debugger. Truncated once per serve run. Also settable via the LENSMCP_LOG_FILE env var (this option wins)."
|
|
189
197
|
}
|
|
190
198
|
},
|
|
191
199
|
"required": [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/serve/serve.impl.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAKpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAC5C,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,iBAAgB,aAAa,CAC3B,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,cAAc,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"serve.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/serve/serve.impl.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAKpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAC5C,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,iBAAgB,aAAa,CAC3B,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,cAAc,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA6RxD;AAED,eAAe,aAAa,CAAC"}
|
|
@@ -51,6 +51,21 @@ async function* serveExecutor(options, context) {
|
|
|
51
51
|
serviceName = options.serviceName;
|
|
52
52
|
}
|
|
53
53
|
const servePrefix = options.servePrefix ?? '';
|
|
54
|
+
// Debug base port: pod #slot opens its inspector on debugPort+slot (workers on
|
|
55
|
+
// debugPort+20+i). Option wins over the LENSMCP_DEBUG_PORT env var so per-project
|
|
56
|
+
// config can give each service a distinct base in cluster mode. Even without it,
|
|
57
|
+
// POST /webpack/debug on the running devserver opens inspectors on demand.
|
|
58
|
+
const debugPort = options.debugPort ?? (Number(process.env.LENSMCP_DEBUG_PORT) || undefined);
|
|
59
|
+
// Log tee: every pod/worker line is also appended (ANSI-stripped) to this file so
|
|
60
|
+
// an IDE can tail it — WebStorm: Run/Debug configuration → Logs tab. Truncated
|
|
61
|
+
// here once per `nx serve` run; devserver crash-respawns keep appending.
|
|
62
|
+
const logFile = options.logFile
|
|
63
|
+
? path.resolve(workspaceRoot, options.logFile)
|
|
64
|
+
: process.env.LENSMCP_LOG_FILE || undefined;
|
|
65
|
+
if (logFile) {
|
|
66
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
67
|
+
fs.writeFileSync(logFile, '');
|
|
68
|
+
}
|
|
54
69
|
// Resolve gateway middleware
|
|
55
70
|
let gatewayMiddlewarePath;
|
|
56
71
|
let gatewayConfigJson;
|
|
@@ -144,6 +159,8 @@ async function* serveExecutor(options, context) {
|
|
|
144
159
|
...(options.gateway?.https && { GATEWAY_HTTPS: "1" }),
|
|
145
160
|
...(options.gateway?.extraPorts?.length && { GATEWAY_EXTRA_PORTS: JSON.stringify(options.gateway.extraPorts) }),
|
|
146
161
|
...(options.internalPort && { INTERNAL_PORT: String(options.internalPort) }),
|
|
162
|
+
...(debugPort && { LENSMCP_DEBUG_PORT: String(debugPort) }),
|
|
163
|
+
...(logFile && { LENSMCP_LOG_FILE: logFile }),
|
|
147
164
|
...((options.workers?.length) && { WORKERS: JSON.stringify(options.workers.map(w => w.name)) }),
|
|
148
165
|
},
|
|
149
166
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
@@ -162,6 +179,15 @@ async function* serveExecutor(options, context) {
|
|
|
162
179
|
}, 1500).unref?.();
|
|
163
180
|
});
|
|
164
181
|
console.log(`[serve] Devserver started on port ${port} (service: ${serviceName}${servePrefix ? `, prefix: /${servePrefix}` : ''})`);
|
|
182
|
+
if (debugPort) {
|
|
183
|
+
console.log(`[serve] Debug: pod inspectors on 127.0.0.1:${debugPort}+ — click the "Debugger listening" link in the console, or attach WebStorm (Attach to Node.js/Chrome) to port ${debugPort}`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(`[serve] Debug on demand: POST http://localhost:${port}/webpack/debug (optionally ?port=<base>) opens pod inspectors without a restart`);
|
|
187
|
+
}
|
|
188
|
+
if (logFile) {
|
|
189
|
+
console.log(`[serve] Logs teed to ${logFile} — tail it in WebStorm via Run/Debug configuration → Logs tab`);
|
|
190
|
+
}
|
|
165
191
|
}
|
|
166
192
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
167
193
|
function _notifyDevServerReload() {
|
|
@@ -21,10 +21,12 @@ interface ExecutorContext {
|
|
|
21
21
|
* `trust` executor — one idempotent command that makes the HTTPS dev gateway
|
|
22
22
|
* green in real browsers:
|
|
23
23
|
*
|
|
24
|
-
* 1.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* 1. resolves the MACHINE-LEVEL dev CA (`~/.lensmcp/ca`) — minting it if
|
|
25
|
+
* missing, or promoting this workspace's legacy per-workspace CA so
|
|
26
|
+
* existing keychain trust carries over,
|
|
27
|
+
* 2. adds it ONCE PER MACHINE to the macOS System keychain (`sudo security
|
|
28
|
+
* add-trusted-cert`) — every leaf rotation / new hostname / new workspace
|
|
29
|
+
* then chains to it with no interstitial,
|
|
28
30
|
* 3. writes BOTH `/etc/hosts` families (`127.0.0.1` + `::1`) for every
|
|
29
31
|
* gateway route hostname — the missing `::1` line is what sends `.local`
|
|
30
32
|
* AAAA lookups to Bonjour/mDNS and costs ~5s per resolution,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trust.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/trust/trust.impl.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CACd,MAAM,EACN;YACE,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;gBAAE,OAAO,CAAC,EAAE;oBAAE,OAAO,CAAC,EAAE;wBAAE,MAAM,CAAC,EAAE,KAAK,CAAC;4BAAE,IAAI,CAAC,EAAE,MAAM,CAAA;yBAAE,CAAC,CAAA;qBAAE,CAAA;iBAAE,CAAA;aAAE,CAAC,CAAC;SAC7F,CACF,CAAC;KACH,CAAC;CACH;AAED
|
|
1
|
+
{"version":3,"file":"trust.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/trust/trust.impl.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CACd,MAAM,EACN;YACE,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;gBAAE,OAAO,CAAC,EAAE;oBAAE,OAAO,CAAC,EAAE;wBAAE,MAAM,CAAC,EAAE,KAAK,CAAC;4BAAE,IAAI,CAAC,EAAE,MAAM,CAAA;yBAAE,CAAC,CAAA;qBAAE,CAAA;iBAAE,CAAA;aAAE,CAAC,CAAC;SAC7F,CACF,CAAC;KACH,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAA8B,aAAa,CACzC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CA+F/B;AAeD,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,EAAE,CAmC7F"}
|
|
@@ -11,10 +11,12 @@ const basic_ssl_1 = require("../../basic-ssl");
|
|
|
11
11
|
* `trust` executor — one idempotent command that makes the HTTPS dev gateway
|
|
12
12
|
* green in real browsers:
|
|
13
13
|
*
|
|
14
|
-
* 1.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* 1. resolves the MACHINE-LEVEL dev CA (`~/.lensmcp/ca`) — minting it if
|
|
15
|
+
* missing, or promoting this workspace's legacy per-workspace CA so
|
|
16
|
+
* existing keychain trust carries over,
|
|
17
|
+
* 2. adds it ONCE PER MACHINE to the macOS System keychain (`sudo security
|
|
18
|
+
* add-trusted-cert`) — every leaf rotation / new hostname / new workspace
|
|
19
|
+
* then chains to it with no interstitial,
|
|
18
20
|
* 3. writes BOTH `/etc/hosts` families (`127.0.0.1` + `::1`) for every
|
|
19
21
|
* gateway route hostname — the missing `::1` line is what sends `.local`
|
|
20
22
|
* AAAA lookups to Bonjour/mDNS and costs ~5s per resolution,
|
|
@@ -25,6 +27,8 @@ const basic_ssl_1 = require("../../basic-ssl");
|
|
|
25
27
|
* Run it from a terminal: sudo prompts for your password inline.
|
|
26
28
|
*/
|
|
27
29
|
async function trustExecutor(options, context) {
|
|
30
|
+
// The workspace cert-cache dir is only the PROMOTION SOURCE now: ensureCaSync resolves the machine-level
|
|
31
|
+
// CA (~/.lensmcp/ca), adopting a pre-existing per-workspace CA from here so its keychain trust survives.
|
|
28
32
|
const cacheDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
|
|
29
33
|
const caPath = (0, basic_ssl_1.ensureCaSync)(cacheDir);
|
|
30
34
|
// HOSTS-ONLY mode (set by the CLI when this workspace is a GUEST of a shared daemon): the daemon serves
|
|
@@ -44,10 +48,14 @@ async function trustExecutor(options, context) {
|
|
|
44
48
|
return { success: true };
|
|
45
49
|
}
|
|
46
50
|
// --- 1) CA into the System keychain (skip when already there, or in hosts-only/guest mode) -----------
|
|
51
|
+
// "Already there" must match by FINGERPRINT, not CN: every workspace mints its own CA under the same
|
|
52
|
+
// "lensmcp local dev CA" name, so a same-named cert from another workspace (or a pre-rotation stale one)
|
|
53
|
+
// would otherwise satisfy a name lookup while the CA this gateway actually serves stays untrusted —
|
|
54
|
+
// the browser interstitial with a green-looking trust log.
|
|
55
|
+
const fingerprint = caSha256Fingerprint(caPath);
|
|
47
56
|
const present = hostsOnly ||
|
|
48
|
-
(
|
|
49
|
-
|
|
50
|
-
}).status === 0;
|
|
57
|
+
(fingerprint !== undefined &&
|
|
58
|
+
(0, node_child_process_1.spawnSync)('security', ['find-certificate', '-a', '-c', 'lensmcp local dev CA', '-Z', '/Library/Keychains/System.keychain'], { encoding: 'utf8' }).stdout?.includes(fingerprint) === true);
|
|
51
59
|
if (hostsOnly) {
|
|
52
60
|
console.log('[trust] hosts-only: a shared daemon serves TLS with its own CA — skipping the keychain step.');
|
|
53
61
|
}
|
|
@@ -105,6 +113,19 @@ async function trustExecutor(options, context) {
|
|
|
105
113
|
}
|
|
106
114
|
return { success: true };
|
|
107
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* SHA-256 of the CA file as uppercase hex without separators — the exact format
|
|
118
|
+
* `security find-certificate -Z` prints ("SHA-256 hash: <hex>"), so the caller
|
|
119
|
+
* can substring-match the keychain listing. `undefined` when openssl can't read
|
|
120
|
+
* the file (caller then falls through to add-trusted-cert, which is idempotent).
|
|
121
|
+
*/
|
|
122
|
+
function caSha256Fingerprint(caPath) {
|
|
123
|
+
const r = (0, node_child_process_1.spawnSync)('openssl', ['x509', '-in', caPath, '-noout', '-fingerprint', '-sha256'], { encoding: 'utf8' });
|
|
124
|
+
if (r.status !== 0 || !r.stdout)
|
|
125
|
+
return undefined;
|
|
126
|
+
const hex = r.stdout.split('=')[1]?.replaceAll(':', '').trim().toUpperCase();
|
|
127
|
+
return hex && /^[0-9A-F]{64}$/.test(hex) ? hex : undefined;
|
|
128
|
+
}
|
|
108
129
|
function collectHosts(options, context) {
|
|
109
130
|
const found = [];
|
|
110
131
|
// 1) This project's embedded gateway routes (single-service setups).
|
package/main.devserver.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.devserver.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/main.devserver.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"main.devserver.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/main.devserver.ts"],"names":[],"mappings":"AA+EA,MAAM,WAAW,YAAY;IAC3B,6FAA6F;IAC7F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB"}
|
package/main.devserver.js
CHANGED
|
@@ -25,6 +25,7 @@ const node_child_process_1 = require("node:child_process");
|
|
|
25
25
|
const http = tslib_1.__importStar(require("node:http"));
|
|
26
26
|
const readline = tslib_1.__importStar(require("node:readline"));
|
|
27
27
|
const inspector = tslib_1.__importStar(require("node:inspector"));
|
|
28
|
+
const util = tslib_1.__importStar(require("node:util"));
|
|
28
29
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
30
|
const httpProxy = require('http-proxy');
|
|
30
31
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -150,6 +151,31 @@ function debounce(fn, ms) {
|
|
|
150
151
|
// CHILD MODE (APP_RUNNER=1) — loads bundle, bootstraps NestJS app, listens on socket
|
|
151
152
|
// ===================================================================================
|
|
152
153
|
if (process.env.APP_RUNNER === '1') {
|
|
154
|
+
// Debug: open the inspector on the port the parent allocated (serve `debugPort`
|
|
155
|
+
// option / LENSMCP_DEBUG_PORT base + pod slot) and report the ws URL up via IPC.
|
|
156
|
+
// inspector.open() itself prints the native "Debugger listening on ws://…" line,
|
|
157
|
+
// which WebStorm/VS Code run consoles turn into a one-click attach link. The
|
|
158
|
+
// matching inspector.close() on shutdown already lives below.
|
|
159
|
+
const openInspector = (port) => {
|
|
160
|
+
if (!inspector.url()) {
|
|
161
|
+
try {
|
|
162
|
+
inspector.open(port, '127.0.0.1', false);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.warn(`[child] inspector.open(${port}) failed (port taken by another devserver?) — falling back to a random port:`, err.message);
|
|
166
|
+
try {
|
|
167
|
+
inspector.open(0, '127.0.0.1', false);
|
|
168
|
+
}
|
|
169
|
+
catch { /* no inspector at all — pod still serves, just undebuggable */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const url = inspector.url();
|
|
173
|
+
if (url && process.send)
|
|
174
|
+
process.send({ type: 'inspector-url', url });
|
|
175
|
+
};
|
|
176
|
+
const CHILD_DEBUG_PORT = Number(process.env.CHILD_DEBUG_PORT || '');
|
|
177
|
+
if (Number.isInteger(CHILD_DEBUG_PORT) && CHILD_DEBUG_PORT > 1024)
|
|
178
|
+
openInspector(CHILD_DEBUG_PORT);
|
|
153
179
|
// Zero-touch instrumentation: load BEFORE the app bundle so the require
|
|
154
180
|
// hook sees pg/ioredis/bullmq/@nestjs/core on their first load, builtins
|
|
155
181
|
// (fs/net/exec) are tapped, NestFactory.create grafts the lens module, and
|
|
@@ -273,6 +299,13 @@ if (process.env.APP_RUNNER === '1') {
|
|
|
273
299
|
process.on('message', async (msg) => {
|
|
274
300
|
if (!msg || typeof msg !== 'object')
|
|
275
301
|
return;
|
|
302
|
+
// On-demand debugging (POST /webpack/debug): open the inspector NOW, in the
|
|
303
|
+
// running pod — no restart, app state intact. Already open → just re-report.
|
|
304
|
+
if (msg.type === 'debug-open') {
|
|
305
|
+
const port = Number(msg.port);
|
|
306
|
+
openInspector(Number.isInteger(port) && port > 1024 ? port : 0);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
276
309
|
if (msg.type === 'reload') {
|
|
277
310
|
try {
|
|
278
311
|
await swapNow();
|
|
@@ -323,6 +356,67 @@ else {
|
|
|
323
356
|
];
|
|
324
357
|
const RESET = '\x1b[0m';
|
|
325
358
|
const CHILD_COUNT = Math.max(1, Number(process.env.CHILD_COUNT || 1));
|
|
359
|
+
// Debug base port (serve `debugPort` option / LENSMCP_DEBUG_PORT env): pod at
|
|
360
|
+
// slot S opens its inspector on base+S, workers on base+WORKER_DEBUG_PORT_OFFSET+i.
|
|
361
|
+
// Unset/0 → no inspectors at boot; POST /webpack/debug can still turn debugging on
|
|
362
|
+
// at runtime (mutating this base — sticky, so respawned pods stay debuggable).
|
|
363
|
+
// Each service needs its OWN base — two services sharing one base would race for
|
|
364
|
+
// the same ports (loser warns and runs undebuggable).
|
|
365
|
+
let debugBasePort = (() => {
|
|
366
|
+
const n = Number(process.env.LENSMCP_DEBUG_PORT || '');
|
|
367
|
+
return Number.isInteger(n) && n > 1024 ? n : 0;
|
|
368
|
+
})();
|
|
369
|
+
const WORKER_DEBUG_PORT_OFFSET = 20;
|
|
370
|
+
// Log tee (serve `logFile` option / LENSMCP_LOG_FILE env): every pod/worker line
|
|
371
|
+
// is ALSO appended, ANSI-stripped, to this file — so an IDE can tail it (WebStorm:
|
|
372
|
+
// run config → Logs tab) while the service itself runs anywhere (terminal, gateway).
|
|
373
|
+
const LOG_FILE = process.env.LENSMCP_LOG_FILE || '';
|
|
374
|
+
const logFileStream = LOG_FILE
|
|
375
|
+
? (() => {
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
378
|
+
return fs.createWriteStream(LOG_FILE, { flags: 'a' });
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.warn(`[parent] cannot open log file ${LOG_FILE}:`, err.message);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
})()
|
|
385
|
+
: null;
|
|
386
|
+
// eslint-disable-next-line no-control-regex
|
|
387
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
388
|
+
// ---------- Log hub ----------
|
|
389
|
+
// Every pod/worker line (and the parent's own lifecycle lines) fans out to:
|
|
390
|
+
// • the terminal (unchanged),
|
|
391
|
+
// • a ring buffer + live HTTP subscribers — GET /webpack/logs, which is what
|
|
392
|
+
// `lensmcp logs <service>` attaches to from any terminal (human or agent),
|
|
393
|
+
// • the optional LENSMCP_LOG_FILE tee (ANSI-stripped) for IDE Logs tabs.
|
|
394
|
+
const LOG_BUFFER_MAX = 2000;
|
|
395
|
+
const logBuffer = [];
|
|
396
|
+
const logSubscribers = new Set();
|
|
397
|
+
function emitLogLine(text) {
|
|
398
|
+
logBuffer.push(text);
|
|
399
|
+
if (logBuffer.length > LOG_BUFFER_MAX)
|
|
400
|
+
logBuffer.splice(0, logBuffer.length - LOG_BUFFER_MAX);
|
|
401
|
+
logFileStream?.write(text.replace(ANSI_RE, ''));
|
|
402
|
+
for (const sub of logSubscribers) {
|
|
403
|
+
try {
|
|
404
|
+
sub.res.write(sub.plain ? text.replace(ANSI_RE, '') : text);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
logSubscribers.delete(sub);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// The parent's own console lines (ready/respawn/Debugger listening/proxy errors)
|
|
412
|
+
// belong in the stream too — mirror console.* into the hub.
|
|
413
|
+
for (const level of ['log', 'info', 'warn', 'error']) {
|
|
414
|
+
const orig = console[level].bind(console);
|
|
415
|
+
console[level] = (...args) => {
|
|
416
|
+
orig(...args);
|
|
417
|
+
emitLogLine(args.map((a) => (typeof a === 'string' ? a : util.inspect(a))).join(' ') + '\n');
|
|
418
|
+
};
|
|
419
|
+
}
|
|
326
420
|
const colorFor = (id) => {
|
|
327
421
|
return COLORS[(id - 1) % COLORS.length];
|
|
328
422
|
};
|
|
@@ -361,6 +455,7 @@ else {
|
|
|
361
455
|
const dest = kind === 'stdout' ? process.stdout : process.stderr;
|
|
362
456
|
const log = isJsonLog(line) ? prettyLog(line) : `${line}\n`;
|
|
363
457
|
dest.write(`${tag}${log}`);
|
|
458
|
+
emitLogLine(`${tag}${log}`);
|
|
364
459
|
};
|
|
365
460
|
const attach = (stream, kind) => {
|
|
366
461
|
if (!stream)
|
|
@@ -381,6 +476,14 @@ else {
|
|
|
381
476
|
if (!publicPort)
|
|
382
477
|
publicPort = 9090;
|
|
383
478
|
const children = [];
|
|
479
|
+
const usedDebugSlots = new Set();
|
|
480
|
+
function allocDebugSlot() {
|
|
481
|
+
let slot = 0;
|
|
482
|
+
while (usedDebugSlots.has(slot))
|
|
483
|
+
slot++;
|
|
484
|
+
usedDebugSlots.add(slot);
|
|
485
|
+
return slot;
|
|
486
|
+
}
|
|
384
487
|
let nextId = 1;
|
|
385
488
|
let rrIndex = 0;
|
|
386
489
|
let shuttingDown = false; // set by the parent's signal handler
|
|
@@ -406,17 +509,21 @@ else {
|
|
|
406
509
|
if (__filename.endsWith('.ts')) {
|
|
407
510
|
childExecArgv.unshift('--require', '@swc-node/register');
|
|
408
511
|
}
|
|
512
|
+
// Stable inspector port: lowest free slot → debugBasePort+slot. Slots are
|
|
513
|
+
// released when a pod exits, so a crash-respawned pod reclaims the SAME port —
|
|
514
|
+
// an IDE "attach to 127.0.0.1:<port>" config survives respawns unedited.
|
|
515
|
+
const debugSlot = debugBasePort ? allocDebugSlot() : -1;
|
|
409
516
|
const proc = (0, node_child_process_1.fork)(__filename, {
|
|
410
517
|
env: {
|
|
411
518
|
...process.env,
|
|
412
519
|
APP_RUNNER: '1',
|
|
413
520
|
CHILD_SOCK_PATH: sockPath,
|
|
414
|
-
|
|
521
|
+
...(debugSlot >= 0 && { CHILD_DEBUG_PORT: String(debugBasePort + debugSlot) }),
|
|
415
522
|
},
|
|
416
523
|
stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
|
|
417
524
|
execArgv: childExecArgv,
|
|
418
525
|
});
|
|
419
|
-
const info = { id, proc, healthy: false, lastError: null };
|
|
526
|
+
const info = { id, proc, healthy: false, lastError: null, debugSlot };
|
|
420
527
|
proc.on('message', (msg) => {
|
|
421
528
|
if (!msg || typeof msg !== 'object')
|
|
422
529
|
return;
|
|
@@ -427,7 +534,10 @@ else {
|
|
|
427
534
|
console.log(`[parent] Child#${info.id} ready on socket ${info.socketPath}`);
|
|
428
535
|
}
|
|
429
536
|
else if (m.type === 'inspector-url') {
|
|
430
|
-
|
|
537
|
+
info.inspectorUrl = String(m.url);
|
|
538
|
+
// "Debugger listening on ws://…" is the exact phrase IDE run consoles
|
|
539
|
+
// (WebStorm, VS Code) detect and render as a click-to-attach link.
|
|
540
|
+
console.log(`[parent] Child#${info.id} Debugger listening on ${m.url}`);
|
|
431
541
|
}
|
|
432
542
|
else if (m.type === 'reloaded') {
|
|
433
543
|
info.socketPath = String(m.socketPath);
|
|
@@ -446,6 +556,10 @@ else {
|
|
|
446
556
|
}
|
|
447
557
|
});
|
|
448
558
|
proc.on('exit', (code, signal) => {
|
|
559
|
+
if (info.debugSlot >= 0) {
|
|
560
|
+
usedDebugSlots.delete(info.debugSlot);
|
|
561
|
+
info.debugSlot = -1;
|
|
562
|
+
}
|
|
449
563
|
const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
|
|
450
564
|
if (clean) {
|
|
451
565
|
console.log(`[parent] Child#${info.id} exited cleanly (code=${code}, signal=${signal ?? 'none'})`);
|
|
@@ -532,12 +646,21 @@ else {
|
|
|
532
646
|
workerExecArgv.push('--require', require.resolve('@lensmcp/node-instrumentation/register'));
|
|
533
647
|
}
|
|
534
648
|
catch { /* instrumentation package absent — workers run untapped */ }
|
|
649
|
+
// Workers get inspectors too, offset above the pod range: pods sit on
|
|
650
|
+
// base+slot, workers on base+20+i. The index comes from WORKER_NAMES order,
|
|
651
|
+
// so a worker keeps its port across restartWorkers(). With debugging off,
|
|
652
|
+
// --inspect-port only PRESETS the port (no listener) — SIGUSR1 from
|
|
653
|
+
// POST /webpack/debug activates it later without restarting the worker.
|
|
654
|
+
const workerDebugPort = (debugBasePort || 9229) + WORKER_DEBUG_PORT_OFFSET + Math.max(0, WORKER_NAMES.indexOf(name));
|
|
655
|
+
workerExecArgv.push(debugBasePort
|
|
656
|
+
? `--inspect=127.0.0.1:${workerDebugPort}`
|
|
657
|
+
: `--inspect-port=127.0.0.1:${workerDebugPort}`);
|
|
535
658
|
const proc = (0, node_child_process_1.fork)(workerBundle, {
|
|
536
659
|
env: process.env,
|
|
537
660
|
stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
|
|
538
661
|
execArgv: workerExecArgv,
|
|
539
662
|
});
|
|
540
|
-
const info = { name, proc };
|
|
663
|
+
const info = { name, proc, debugPort: workerDebugPort };
|
|
541
664
|
workerProcesses.push(info);
|
|
542
665
|
wireProcLogging(proc, `\x1b[35m[worker:${name}] \x1b[0m`);
|
|
543
666
|
proc.on('exit', (code, signal) => {
|
|
@@ -602,6 +725,66 @@ else {
|
|
|
602
725
|
});
|
|
603
726
|
return;
|
|
604
727
|
}
|
|
728
|
+
// Admin endpoint: the live log stream — history, then follow (chunked text).
|
|
729
|
+
// `lensmcp logs <service>` attaches a terminal here (human), or takes a
|
|
730
|
+
// one-shot snapshot with ?follow=0&tail=N (agent). ?plain=1 strips ANSI.
|
|
731
|
+
if (req.method === 'GET' && req.url?.startsWith('/webpack/logs')) {
|
|
732
|
+
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
733
|
+
const tail = Math.min(Math.max(Number(q.get('tail') ?? '100') || 0, 0), LOG_BUFFER_MAX);
|
|
734
|
+
const follow = q.get('follow') !== '0';
|
|
735
|
+
const plain = q.get('plain') === '1';
|
|
736
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' });
|
|
737
|
+
const history = tail === 0 ? '' : logBuffer.slice(-tail).join('');
|
|
738
|
+
res.write(plain ? history.replace(ANSI_RE, '') : history);
|
|
739
|
+
if (!follow) {
|
|
740
|
+
res.end();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const sub = { res, plain };
|
|
744
|
+
logSubscribers.add(sub);
|
|
745
|
+
req.on('close', () => logSubscribers.delete(sub));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Admin endpoint: attach a debugger to the RUNNING service — no restart, app
|
|
749
|
+
// state intact. POST /webpack/debug[?port=9339] opens (or re-reports) every
|
|
750
|
+
// pod's inspector and returns the ws URLs; the base becomes sticky so pods
|
|
751
|
+
// spawned later (respawn, scale) come up debuggable too. Workers are nudged
|
|
752
|
+
// via SIGUSR1 (their port was preset with --inspect-port at spawn).
|
|
753
|
+
if (req.method === 'POST' && req.url?.startsWith('/webpack/debug')) {
|
|
754
|
+
const requested = Number(new URL(req.url, 'http://localhost').searchParams.get('port') || '');
|
|
755
|
+
if (Number.isInteger(requested) && requested > 1024)
|
|
756
|
+
debugBasePort = requested;
|
|
757
|
+
if (!debugBasePort)
|
|
758
|
+
debugBasePort = 9229;
|
|
759
|
+
const targets = children.filter((c) => c.proc.exitCode === null && c.proc.signalCode === null);
|
|
760
|
+
for (const c of targets) {
|
|
761
|
+
if (c.debugSlot < 0)
|
|
762
|
+
c.debugSlot = allocDebugSlot();
|
|
763
|
+
try {
|
|
764
|
+
c.proc.send?.({ type: 'debug-open', port: debugBasePort + c.debugSlot });
|
|
765
|
+
}
|
|
766
|
+
catch { /* pod died mid-request */ }
|
|
767
|
+
}
|
|
768
|
+
for (const w of workerProcesses) {
|
|
769
|
+
try {
|
|
770
|
+
w.proc.kill('SIGUSR1');
|
|
771
|
+
}
|
|
772
|
+
catch { /* worker gone */ }
|
|
773
|
+
}
|
|
774
|
+
// inspector-url replies arrive async over IPC — wait briefly, then report
|
|
775
|
+
const deadline = Date.now() + 2000;
|
|
776
|
+
while (Date.now() < deadline && targets.some((c) => !c.inspectorUrl)) {
|
|
777
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
778
|
+
}
|
|
779
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
780
|
+
res.end(JSON.stringify({
|
|
781
|
+
ok: true,
|
|
782
|
+
basePort: debugBasePort,
|
|
783
|
+
pods: targets.map((c) => ({ id: c.id, inspectorUrl: c.inspectorUrl ?? null })),
|
|
784
|
+
workers: workerProcesses.map((w) => ({ name: w.name, port: w.debugPort })),
|
|
785
|
+
}, null, 2) + '\n');
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
605
788
|
// Front-gateway routes: host/prefix match → external target, or fall
|
|
606
789
|
// through to this service's own children when the route has no target.
|
|
607
790
|
const route = matchGatewayRoute(req);
|
|
@@ -675,7 +858,7 @@ else {
|
|
|
675
858
|
const certDomains = GATEWAY_ROUTES.map((r) => r.host).filter((h) => !!h);
|
|
676
859
|
const certCacheDir = path.join(process.cwd(), 'node_modules', '.cache', 'davnx-webpack');
|
|
677
860
|
gatewayPem = basicSsl.getCertificateSync(certCacheDir, SERVICE_NAME || 'lensmcp.dev', certDomains);
|
|
678
|
-
gatewayCaPath = basicSsl.caCertPath(
|
|
861
|
+
gatewayCaPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca)
|
|
679
862
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
680
863
|
const httpsMod = require('node:https');
|
|
681
864
|
server = httpsMod.createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler);
|
|
@@ -819,10 +1002,24 @@ else {
|
|
|
819
1002
|
});
|
|
820
1003
|
extraServers.push(extra);
|
|
821
1004
|
}
|
|
1005
|
+
// Control socket: the SAME admin surface (logs/debug/reload/scale), reachable
|
|
1006
|
+
// WITHOUT knowing the public port or scheme. `lensmcp logs <service>` finds it
|
|
1007
|
+
// by deriving $TMPDIR[/<wsKey>]/<service>-devserver/parent.sock — the exact
|
|
1008
|
+
// derivation the gateway uses for the pod pool dir. Plain HTTP: it's a
|
|
1009
|
+
// per-user unix socket, TLS adds nothing.
|
|
1010
|
+
const controlSockPath = path.join(SOCK_DIR, 'parent.sock');
|
|
1011
|
+
cleanupSock(controlSockPath); // stale socket from a crashed previous parent
|
|
1012
|
+
const controlServer = http.createServer(requestHandler);
|
|
1013
|
+
controlServer.on('error', (err) => {
|
|
1014
|
+
console.warn(`[parent] control socket unavailable (${err.code}) — lensmcp logs/debug attach disabled.`);
|
|
1015
|
+
});
|
|
1016
|
+
controlServer.listen(controlSockPath);
|
|
1017
|
+
extraServers.push(controlServer);
|
|
822
1018
|
// Graceful shutdown of parent (children get SIGTERM)
|
|
823
1019
|
const shutdown = async (sig) => {
|
|
824
1020
|
shuttingDown = true; // stop crash-respawn from fighting the teardown
|
|
825
1021
|
console.log(`[${sig}] [parent] shutting down…`);
|
|
1022
|
+
cleanupSock(controlSockPath);
|
|
826
1023
|
for (const extra of extraServers) {
|
|
827
1024
|
try {
|
|
828
1025
|
extra.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.25",
|
|
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.25",
|
|
69
|
+
"@lensmcp/nx-plugin": "1.16.25",
|
|
70
70
|
"fork-ts-checker-webpack-plugin": "^9.0.0",
|
|
71
71
|
"glob": "^11.0.0",
|
|
72
72
|
"http-proxy": "^1.18.0",
|