@lensmcp/cluster 1.18.4 → 1.18.7
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.js +1 -241
- package/build-scope-patterns.js +1 -40
- package/create-webpack-dev.js +1 -186
- package/create-webpack-prod.js +1 -169
- package/executors/build/build.impl.js +1 -98
- package/executors/gateway/gateway-errors.js +1 -43
- package/executors/gateway/gateway.impl.js +1 -53
- package/executors/gateway/gateway.lib.js +1 -29
- package/executors/gateway/health-check.js +1 -66
- package/executors/gateway/jwks-verify.js +1 -121
- package/executors/gateway/main.prod-gateway.js +2 -573
- package/executors/gateway/main.rollout.js +11 -117
- package/executors/gateway/manifest.js +1 -374
- package/executors/gateway/metrics.js +1 -56
- package/executors/gateway/otel-tracing.js +1 -74
- package/executors/gateway/prod-gateway.lib.js +1 -22
- package/executors/gateway/prod-runtime/access-log.js +1 -24
- package/executors/gateway/prod-runtime/app.js +1 -123
- package/executors/gateway/prod-runtime/auth.js +1 -51
- package/executors/gateway/prod-runtime/cors.js +1 -40
- package/executors/gateway/prod-runtime/edge.js +1 -65
- package/executors/gateway/prod-runtime/handler.js +1 -226
- package/executors/gateway/prod-runtime/hooks.js +1 -42
- package/executors/gateway/prod-runtime/observability.js +1 -125
- package/executors/gateway/prod-runtime/rollout.js +1 -103
- package/executors/gateway/prod-runtime/routing.js +1 -40
- package/executors/gateway/prod-runtime/server.js +1 -79
- package/executors/gateway/prod-runtime/trust.js +1 -32
- package/executors/gateway/prod-runtime/types.js +1 -2
- package/executors/gateway/prod-runtime/upgrade.js +4 -116
- package/executors/gateway/prod-runtime/upstream.js +1 -21
- package/executors/gateway/providers-prod.js +1 -232
- package/executors/gateway/rate-limit.js +2 -75
- package/executors/gateway/registry-source.js +1 -131
- package/executors/gateway/rollout-ops.js +2 -167
- package/executors/gateway/runtime/auth.js +1 -64
- package/executors/gateway/runtime/chooser.js +12 -45
- package/executors/gateway/runtime/control.js +1 -128
- package/executors/gateway/runtime/dev-auth.js +1 -108
- package/executors/gateway/runtime/discovery.js +1 -123
- package/executors/gateway/runtime/edge.js +1 -47
- package/executors/gateway/runtime/handler.js +1 -183
- package/executors/gateway/runtime/hooks.js +1 -55
- package/executors/gateway/runtime/lens-children.js +1 -651
- package/executors/gateway/runtime/lifecycle.js +3 -842
- package/executors/gateway/runtime/observability.js +2 -148
- package/executors/gateway/runtime/pod-env.js +2 -89
- package/executors/gateway/runtime/proxy.js +1 -457
- package/executors/gateway/runtime/route-registry.js +1 -72
- package/executors/gateway/runtime/scope.js +1 -117
- package/executors/gateway/runtime/server.js +3 -487
- package/executors/gateway/runtime/service-keys.js +1 -49
- package/executors/gateway/runtime/types.js +1 -151
- package/executors/gateway/runtime/upgrade.js +1 -71
- package/executors/gateway/runtime/workspace-registry.js +1 -99
- package/executors/gateway/ssrf-guard.js +1 -190
- package/executors/serve/serve.impl.js +1 -280
- package/executors/trust/trust.impl.js +4 -162
- package/gateway.js +1 -35
- package/index.js +1 -16
- package/main.devserver.js +10 -1117
- package/package.json +4 -3
- package/tsgo-check-plugin.js +4 -364
- package/typecheck-bus.js +4 -256
package/main.devserver.js
CHANGED
|
@@ -1,1117 +1,10 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// - If a child crashes, it is marked unhealthy; parent keeps serving via others.
|
|
12
|
-
// - If ALL are down, parent returns 503 with last errors. Parent only dies on user interrupt.
|
|
13
|
-
//
|
|
14
|
-
// Child (APP_RUNNER=1):
|
|
15
|
-
// - Loads webpack bundle (BUNDLE_PATH, default ./main.js) exporting global.createChildApp().
|
|
16
|
-
// - createChildApp() bootstraps NestJS+Fastify, returns a raw http.RequestListener handler.
|
|
17
|
-
// - Child wraps handler in http.createServer on a Unix socket; reports {type:'ready', socketPath} via IPC.
|
|
18
|
-
// - On 'reload' IPC or POST /webpack/reload, closes old app, re-imports bundle, swaps handler in-place.
|
|
19
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
const tslib_1 = require("tslib");
|
|
21
|
-
const path = tslib_1.__importStar(require("node:path"));
|
|
22
|
-
const fs = tslib_1.__importStar(require("node:fs"));
|
|
23
|
-
const os = tslib_1.__importStar(require("node:os"));
|
|
24
|
-
const node_child_process_1 = require("node:child_process");
|
|
25
|
-
const http = tslib_1.__importStar(require("node:http"));
|
|
26
|
-
const readline = tslib_1.__importStar(require("node:readline"));
|
|
27
|
-
const inspector = tslib_1.__importStar(require("node:inspector"));
|
|
28
|
-
const util = tslib_1.__importStar(require("node:util"));
|
|
29
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
|
-
const httpProxy = require('http-proxy');
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
-
const { prettyFactory } = require('pino-pretty');
|
|
33
|
-
// ----------------------------- Shared Config ----------------------------------
|
|
34
|
-
const BUNDLE_PATH = process.env.BUNDLE_PATH?.trim() || './main.js';
|
|
35
|
-
const SERVICE_NAME = process.env.SERVICE_NAME || '';
|
|
36
|
-
const SERVICE_PREFIX = process.env.SERVE_PREFIX ? `/${process.env.SERVE_PREFIX}` : '';
|
|
37
|
-
const GATEWAY_MIDDLEWARE_PATH = process.env.GATEWAY_MIDDLEWARE || '';
|
|
38
|
-
const GATEWAY_CONFIG = (() => {
|
|
39
|
-
const raw = process.env.GATEWAY_CONFIG;
|
|
40
|
-
if (!raw)
|
|
41
|
-
return {};
|
|
42
|
-
try {
|
|
43
|
-
return JSON.parse(raw);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return {};
|
|
47
|
-
}
|
|
48
|
-
})();
|
|
49
|
-
let gatewayMiddleware = null;
|
|
50
|
-
if (GATEWAY_MIDDLEWARE_PATH) {
|
|
51
|
-
try {
|
|
52
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
-
const mod = require(GATEWAY_MIDDLEWARE_PATH);
|
|
54
|
-
gatewayMiddleware = typeof mod === 'function' ? mod : (typeof mod.default === 'function' ? mod.default : null);
|
|
55
|
-
if (!gatewayMiddleware) {
|
|
56
|
-
console.warn(`[gateway] ${GATEWAY_MIDDLEWARE_PATH} does not export a function, gateway disabled`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
console.error(`[gateway] Failed to load middleware from ${GATEWAY_MIDDLEWARE_PATH}:`, err);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
function applyGatewayMiddleware(req) {
|
|
64
|
-
if (!gatewayMiddleware)
|
|
65
|
-
return;
|
|
66
|
-
try {
|
|
67
|
-
gatewayMiddleware(req, GATEWAY_CONFIG);
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
console.error('[gateway] Middleware error:', err);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const GATEWAY_ROUTES = (() => {
|
|
74
|
-
try {
|
|
75
|
-
return JSON.parse(process.env.GATEWAY_ROUTES || '[]');
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return [];
|
|
79
|
-
}
|
|
80
|
-
})();
|
|
81
|
-
const GATEWAY_HTTPS = process.env.GATEWAY_HTTPS === '1';
|
|
82
|
-
function hostMatches(pattern, host) {
|
|
83
|
-
if (pattern.startsWith('*.')) {
|
|
84
|
-
const suffix = pattern.slice(1); // ".tetros.localhost"
|
|
85
|
-
return host.endsWith(suffix) || host === pattern.slice(2);
|
|
86
|
-
}
|
|
87
|
-
return host === pattern;
|
|
88
|
-
}
|
|
89
|
-
function matchGatewayRoute(req) {
|
|
90
|
-
if (GATEWAY_ROUTES.length === 0)
|
|
91
|
-
return undefined;
|
|
92
|
-
const host = (req.headers.host || '').split(':')[0];
|
|
93
|
-
const url = req.url || '/';
|
|
94
|
-
for (const route of GATEWAY_ROUTES) {
|
|
95
|
-
if (route.host && !hostMatches(route.host, host))
|
|
96
|
-
continue;
|
|
97
|
-
if (route.prefix && !url.startsWith(route.prefix))
|
|
98
|
-
continue;
|
|
99
|
-
return route;
|
|
100
|
-
}
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
// ----------------------------- Worker Config ------------------------------------
|
|
104
|
-
const WORKER_NAMES = (() => {
|
|
105
|
-
try {
|
|
106
|
-
return JSON.parse(process.env.WORKERS || '[]');
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
112
|
-
// ----------------------------- Socket Helpers ---------------------------------
|
|
113
|
-
// The pod sockets live under a per-WORKSPACE dir when the gateway sets `LENSMCP_WS_KEY` (the shared
|
|
114
|
-
// multi-workspace gateway — planning/multi-workspace-gateway.md): `$TMPDIR/<wsKey>/<service>-devserver`.
|
|
115
|
-
// Without a key (standalone `serve`, or a single-workspace gateway that predates this) it's the legacy
|
|
116
|
-
// `$TMPDIR/<service>-devserver`. Namespacing is REQUIRED so two workspaces each running a service named
|
|
117
|
-
// `agent`/`auth` don't share `$TMPDIR/agent-devserver` — which would cross-route the gateway into the wrong
|
|
118
|
-
// workspace's pods and let one workspace's reap unlink the other's sockets. The gateway's `discovery.ts`
|
|
119
|
-
// mirror MUST derive the identical path.
|
|
120
|
-
const LENSMCP_WS_KEY = process.env.LENSMCP_WS_KEY || '';
|
|
121
|
-
const SOCK_DIR = LENSMCP_WS_KEY
|
|
122
|
-
? path.join(os.tmpdir(), LENSMCP_WS_KEY, `${SERVICE_NAME || 'lensmcp-cluster'}-devserver`)
|
|
123
|
-
: path.join(os.tmpdir(), `${SERVICE_NAME || 'lensmcp-cluster'}-devserver`);
|
|
124
|
-
function childSockPath(id) {
|
|
125
|
-
return path.join(SOCK_DIR, `child-${id}.sock`);
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* SOCKET OWNERSHIP (the generation race, foodguard 2026-07-26): when devserver generations
|
|
129
|
-
* overlap — a killed nx runner's orphaned tree dying while the daemon's fresh spawn binds —
|
|
130
|
-
* the DYING generation's cleanup used to unlink `child-1.sock` that the NEW generation had
|
|
131
|
-
* just bound. The file vanished from disk while the live child held the (now anonymous)
|
|
132
|
-
* inode: every gateway connect hit ENOENT/ghost, with no log and no evictable pod.
|
|
133
|
-
*
|
|
134
|
-
* The rule that closes it: **BIND steals, DEATH is guarded.** A binder writes a sidecar
|
|
135
|
-
* `<sock>.owner` pidfile FIRST (so any concurrently-dying process already sees the new
|
|
136
|
-
* owner), then unlinks the stale path and binds. Every death/cleanup path unlinks ONLY if
|
|
137
|
-
* the owner file still names the pid it is cleaning up for — a foreign live owner means a
|
|
138
|
-
* newer generation took the path, and the dying one must leave it alone.
|
|
139
|
-
*/
|
|
140
|
-
function sockOwnerPath(sockPath) { return `${sockPath}.owner`; }
|
|
141
|
-
/** Bind-time steal: claim ownership (pidfile first), then clear the stale path. */
|
|
142
|
-
function claimSock(sockPath, ownerPid = process.pid) {
|
|
143
|
-
try {
|
|
144
|
-
fs.mkdirSync(path.dirname(sockPath), { recursive: true });
|
|
145
|
-
}
|
|
146
|
-
catch { /* exists */ }
|
|
147
|
-
try {
|
|
148
|
-
fs.writeFileSync(sockOwnerPath(sockPath), String(ownerPid));
|
|
149
|
-
}
|
|
150
|
-
catch { /* best-effort */ }
|
|
151
|
-
try {
|
|
152
|
-
fs.unlinkSync(sockPath);
|
|
153
|
-
}
|
|
154
|
-
catch { /* no stale socket */ }
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Death-path cleanup: unlink the socket ONLY when `expectedOwnerPid` (default: this
|
|
158
|
-
* process) still owns it. A missing/unreadable owner file falls back to unlinking
|
|
159
|
-
* (legacy sockets from a pre-ownership build).
|
|
160
|
-
*/
|
|
161
|
-
function cleanupSock(sockPath, expectedOwnerPid = process.pid) {
|
|
162
|
-
if (!sockPath)
|
|
163
|
-
return;
|
|
164
|
-
try {
|
|
165
|
-
const owner = fs.readFileSync(sockOwnerPath(sockPath), 'utf8').trim();
|
|
166
|
-
if (owner && owner !== String(expectedOwnerPid))
|
|
167
|
-
return; // a newer generation owns the path
|
|
168
|
-
}
|
|
169
|
-
catch { /* no owner file → legacy behavior */ }
|
|
170
|
-
try {
|
|
171
|
-
fs.unlinkSync(sockPath);
|
|
172
|
-
}
|
|
173
|
-
catch { /* best-effort: socket already gone */ }
|
|
174
|
-
try {
|
|
175
|
-
fs.unlinkSync(sockOwnerPath(sockPath));
|
|
176
|
-
}
|
|
177
|
-
catch { /* best-effort */ }
|
|
178
|
-
}
|
|
179
|
-
// delete from CJS cache + require fresh
|
|
180
|
-
async function importFresh(spec) {
|
|
181
|
-
process.env.DEVSERVER_MODE = '1';
|
|
182
|
-
const resolved = path.isAbsolute(spec) ? spec : path.join(__dirname, spec);
|
|
183
|
-
eval(`delete require.cache["${resolved}"]`); // delete from CJS cache
|
|
184
|
-
return eval(`require("${resolved}")`);
|
|
185
|
-
}
|
|
186
|
-
function debounce(fn, ms) {
|
|
187
|
-
let t = null;
|
|
188
|
-
return (...args) => {
|
|
189
|
-
if (t)
|
|
190
|
-
clearTimeout(t);
|
|
191
|
-
t = setTimeout(() => fn(...args), ms);
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
// ===================================================================================
|
|
195
|
-
// CHILD MODE (APP_RUNNER=1) — loads bundle, bootstraps NestJS app, listens on socket
|
|
196
|
-
// ===================================================================================
|
|
197
|
-
if (process.env.APP_RUNNER === '1') {
|
|
198
|
-
// Debug: open the inspector on the port the parent allocated (serve `debugPort`
|
|
199
|
-
// option / LENSMCP_DEBUG_PORT base + pod slot) and report the ws URL up via IPC.
|
|
200
|
-
// inspector.open() itself prints the native "Debugger listening on ws://…" line,
|
|
201
|
-
// which WebStorm/VS Code run consoles turn into a one-click attach link. The
|
|
202
|
-
// matching inspector.close() on shutdown already lives below.
|
|
203
|
-
const openInspector = (port) => {
|
|
204
|
-
if (!inspector.url()) {
|
|
205
|
-
try {
|
|
206
|
-
inspector.open(port, '127.0.0.1', false);
|
|
207
|
-
}
|
|
208
|
-
catch (err) {
|
|
209
|
-
console.warn(`[child] inspector.open(${port}) failed (port taken by another devserver?) — falling back to a random port:`, err.message);
|
|
210
|
-
try {
|
|
211
|
-
inspector.open(0, '127.0.0.1', false);
|
|
212
|
-
}
|
|
213
|
-
catch { /* no inspector at all — pod still serves, just undebuggable */ }
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
const url = inspector.url();
|
|
217
|
-
if (url && process.send)
|
|
218
|
-
process.send({ type: 'inspector-url', url });
|
|
219
|
-
};
|
|
220
|
-
const CHILD_DEBUG_PORT = Number(process.env.CHILD_DEBUG_PORT || '');
|
|
221
|
-
if (Number.isInteger(CHILD_DEBUG_PORT) && CHILD_DEBUG_PORT > 1024)
|
|
222
|
-
openInspector(CHILD_DEBUG_PORT);
|
|
223
|
-
// Zero-touch instrumentation: load BEFORE the app bundle so the require
|
|
224
|
-
// hook sees pg/ioredis/bullmq/@nestjs/core on their first load, builtins
|
|
225
|
-
// (fs/net/exec) are tapped, NestFactory.create grafts the lens module, and
|
|
226
|
-
// the child-app bridge owns global.createChildApp — a completely plain
|
|
227
|
-
// main.ts (NestFactory.create + app.listen) becomes a pod with no
|
|
228
|
-
// devserver contract in host source. Legacy dual-mode bundles overwrite
|
|
229
|
-
// global.createChildApp at import and keep working unchanged.
|
|
230
|
-
try {
|
|
231
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
232
|
-
require('@lensmcp/node-instrumentation/register');
|
|
233
|
-
}
|
|
234
|
-
catch { /* instrumentation package absent — pods run untapped */ }
|
|
235
|
-
(async () => {
|
|
236
|
-
let current = null;
|
|
237
|
-
let swapping = false;
|
|
238
|
-
let pendingReload = false;
|
|
239
|
-
// Delegate starts as 503; swapped to the real handler after first boot
|
|
240
|
-
let delegate = (_req, res) => {
|
|
241
|
-
res.statusCode = 503;
|
|
242
|
-
res.end('starting');
|
|
243
|
-
};
|
|
244
|
-
async function swapNow() {
|
|
245
|
-
if (swapping) {
|
|
246
|
-
pendingReload = true;
|
|
247
|
-
console.log('[child] Reload deferred — will re-swap after current swap completes');
|
|
248
|
-
return current;
|
|
249
|
-
}
|
|
250
|
-
swapping = true;
|
|
251
|
-
// ── Stale-handler cleanup ──
|
|
252
|
-
// @frontegg/nestjs-common/app-builder registers process.on('uncaughtException')
|
|
253
|
-
// during build() and never removes it on close. After swap, the OLD handler
|
|
254
|
-
// fires during background teardown and crashes because its AsyncLocalStorage
|
|
255
|
-
// context is destroyed. Fix: wipe all handlers before re-import, then add
|
|
256
|
-
// a safe catch-all. The fresh bundle will register its own valid handlers.
|
|
257
|
-
process.removeAllListeners('uncaughtException');
|
|
258
|
-
process.removeAllListeners('unhandledRejection');
|
|
259
|
-
process.on('uncaughtException', (err, origin) => {
|
|
260
|
-
console.error(`[child] uncaughtException (${origin}):`, err);
|
|
261
|
-
});
|
|
262
|
-
process.on('unhandledRejection', (reason) => {
|
|
263
|
-
// Suppress known stale-context rejections from previous app's background teardown.
|
|
264
|
-
const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
|
|
265
|
-
if (msg.includes('FronteggContextScope') || msg.includes('populateLoggerMetadata')) {
|
|
266
|
-
return; // swallow — old app context is gone, nothing to do
|
|
267
|
-
}
|
|
268
|
-
console.error('[child] unhandledRejection:', reason);
|
|
269
|
-
});
|
|
270
|
-
await importFresh(BUNDLE_PATH);
|
|
271
|
-
if (typeof global.createChildApp !== 'function') {
|
|
272
|
-
swapping = false;
|
|
273
|
-
throw new Error(`Bundle '${BUNDLE_PATH}' does not export createChildApp() and the zero-touch ` +
|
|
274
|
-
`bridge is not active. Either install @lensmcp/node-instrumentation (a plain ` +
|
|
275
|
-
`NestFactory.create + app.listen main.ts then just works) or set ` +
|
|
276
|
-
`global.createChildApp in main.ts when DEVSERVER_MODE === '1'.`);
|
|
277
|
-
}
|
|
278
|
-
const next = await global.createChildApp();
|
|
279
|
-
const prev = current;
|
|
280
|
-
current = next;
|
|
281
|
-
delegate = next.handler;
|
|
282
|
-
console.log(`[child] Swapped to fresh app (previous closing in background)`);
|
|
283
|
-
if (prev)
|
|
284
|
-
prev.close().catch(err => console.error('[child] background close error:', err));
|
|
285
|
-
swapping = false;
|
|
286
|
-
if (pendingReload) {
|
|
287
|
-
pendingReload = false;
|
|
288
|
-
return swapNow();
|
|
289
|
-
}
|
|
290
|
-
return next;
|
|
291
|
-
}
|
|
292
|
-
// Create ONE debounced swapper that lives across requests
|
|
293
|
-
const debouncedSwap = debounce(async () => {
|
|
294
|
-
try {
|
|
295
|
-
console.log('[child] /webpack/reload');
|
|
296
|
-
await swapNow();
|
|
297
|
-
}
|
|
298
|
-
catch (e) {
|
|
299
|
-
console.error('[child] reload failed:', e);
|
|
300
|
-
}
|
|
301
|
-
}, 250);
|
|
302
|
-
// Raw HTTP server: admin reload endpoint + delegate all else to NestJS handler
|
|
303
|
-
const server = http.createServer((req, res) => {
|
|
304
|
-
if (req.method === 'POST' && req.url === '/webpack/reload') {
|
|
305
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
306
|
-
res.end(JSON.stringify({ ok: true }));
|
|
307
|
-
debouncedSwap();
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
delegate(req, res);
|
|
311
|
-
});
|
|
312
|
-
const sockPath = process.env.CHILD_SOCK_PATH;
|
|
313
|
-
// ORPHAN GUARD: if the parent devserver dies (idle-kill, crash, tree
|
|
314
|
-
// SIGTERM that missed us), the IPC channel closes — exit immediately and
|
|
315
|
-
// remove our socket so no ghost pod (live process + stale sock) survives.
|
|
316
|
-
process.on('disconnect', () => {
|
|
317
|
-
cleanupSock(sockPath); // owner-guarded: never unlink a newer generation's socket
|
|
318
|
-
process.exit(0);
|
|
319
|
-
});
|
|
320
|
-
// boot once, then listen on Unix socket
|
|
321
|
-
try {
|
|
322
|
-
await swapNow();
|
|
323
|
-
const resolvedBundle = path.isAbsolute(BUNDLE_PATH) ? BUNDLE_PATH : path.join(__dirname, BUNDLE_PATH);
|
|
324
|
-
console.log(`[child] Using bundle: ${resolvedBundle}`);
|
|
325
|
-
claimSock(sockPath); // bind steals: claim ownership, then clear the stale path
|
|
326
|
-
server.listen(sockPath, () => {
|
|
327
|
-
console.log(`[child] Listening on ${sockPath}`);
|
|
328
|
-
if (process.send)
|
|
329
|
-
process.send({ type: 'ready', socketPath: sockPath });
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
console.error('[child] Startup error:', err);
|
|
334
|
-
if (process.send)
|
|
335
|
-
process.send({ type: 'boot-error', error: String(err?.stack || err) });
|
|
336
|
-
process.exitCode = 1;
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
// IPC reload handler (parent-initiated reloads)
|
|
340
|
-
process.on('message', async (msg) => {
|
|
341
|
-
if (!msg || typeof msg !== 'object')
|
|
342
|
-
return;
|
|
343
|
-
// On-demand debugging (POST /webpack/debug): open the inspector NOW, in the
|
|
344
|
-
// running pod — no restart, app state intact. Already open → just re-report.
|
|
345
|
-
if (msg.type === 'debug-open') {
|
|
346
|
-
const port = Number(msg.port);
|
|
347
|
-
openInspector(Number.isInteger(port) && port > 1024 ? port : 0);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
if (msg.type === 'reload') {
|
|
351
|
-
try {
|
|
352
|
-
await swapNow();
|
|
353
|
-
if (process.send)
|
|
354
|
-
process.send({ type: 'reloaded', socketPath: sockPath });
|
|
355
|
-
}
|
|
356
|
-
catch (e) {
|
|
357
|
-
console.error('[child] reload error:', e);
|
|
358
|
-
if (process.send)
|
|
359
|
-
process.send({ type: 'reload-error', error: String(e?.stack || e) });
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
process.on('beforeExit', () => {
|
|
364
|
-
try {
|
|
365
|
-
inspector?.close?.();
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
/* inspector may already be detached */
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
const shutdown = async (sig) => {
|
|
372
|
-
console.log(`[child ${sig}] shutting down…`);
|
|
373
|
-
const closingApp = current?.close().catch(err => console.error('[child] close error:', err));
|
|
374
|
-
const closingServer = new Promise(resolve => server.close(() => resolve()));
|
|
375
|
-
cleanupSock(sockPath);
|
|
376
|
-
setTimeout(() => process.exit(0), 700).unref();
|
|
377
|
-
await Promise.all([closingApp, closingServer]);
|
|
378
|
-
process.exit(0);
|
|
379
|
-
};
|
|
380
|
-
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => {
|
|
381
|
-
process.on(sig, () => {
|
|
382
|
-
inspector?.close?.();
|
|
383
|
-
void shutdown(sig);
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
})();
|
|
387
|
-
}
|
|
388
|
-
else {
|
|
389
|
-
// ---------- Colored prefix helpers (no deps) ----------
|
|
390
|
-
const COLORS = [
|
|
391
|
-
'\x1b[36m', // cyan
|
|
392
|
-
'\x1b[33m', // yellow
|
|
393
|
-
'\x1b[35m', // magenta
|
|
394
|
-
'\x1b[32m', // green
|
|
395
|
-
'\x1b[34m', // blue
|
|
396
|
-
'\x1b[31m' // red
|
|
397
|
-
];
|
|
398
|
-
const RESET = '\x1b[0m';
|
|
399
|
-
const CHILD_COUNT = Math.max(1, Number(process.env.CHILD_COUNT || 1));
|
|
400
|
-
// Debug base port (serve `debugPort` option / LENSMCP_DEBUG_PORT env): pod at
|
|
401
|
-
// slot S opens its inspector on base+S, workers on base+WORKER_DEBUG_PORT_OFFSET+i.
|
|
402
|
-
// Unset/0 → no inspectors at boot; POST /webpack/debug can still turn debugging on
|
|
403
|
-
// at runtime (mutating this base — sticky, so respawned pods stay debuggable).
|
|
404
|
-
// Each service needs its OWN base — two services sharing one base would race for
|
|
405
|
-
// the same ports (loser warns and runs undebuggable).
|
|
406
|
-
let debugBasePort = (() => {
|
|
407
|
-
const n = Number(process.env.LENSMCP_DEBUG_PORT || '');
|
|
408
|
-
return Number.isInteger(n) && n > 1024 ? n : 0;
|
|
409
|
-
})();
|
|
410
|
-
const WORKER_DEBUG_PORT_OFFSET = 20;
|
|
411
|
-
// Log tee (serve `logFile` option / LENSMCP_LOG_FILE env): every pod/worker line
|
|
412
|
-
// is ALSO appended, ANSI-stripped, to this file — so an IDE can tail it (WebStorm:
|
|
413
|
-
// run config → Logs tab) while the service itself runs anywhere (terminal, gateway).
|
|
414
|
-
const LOG_FILE = process.env.LENSMCP_LOG_FILE || '';
|
|
415
|
-
const logFileStream = LOG_FILE
|
|
416
|
-
? (() => {
|
|
417
|
-
try {
|
|
418
|
-
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
419
|
-
return fs.createWriteStream(LOG_FILE, { flags: 'a' });
|
|
420
|
-
}
|
|
421
|
-
catch (err) {
|
|
422
|
-
console.warn(`[parent] cannot open log file ${LOG_FILE}:`, err.message);
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
})()
|
|
426
|
-
: null;
|
|
427
|
-
// eslint-disable-next-line no-control-regex
|
|
428
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
429
|
-
// ---------- Log hub ----------
|
|
430
|
-
// Every pod/worker line (and the parent's own lifecycle lines) fans out to:
|
|
431
|
-
// • the terminal (unchanged),
|
|
432
|
-
// • a ring buffer + live HTTP subscribers — GET /webpack/logs, which is what
|
|
433
|
-
// `lensmcp logs <service>` attaches to from any terminal (human or agent),
|
|
434
|
-
// • the optional LENSMCP_LOG_FILE tee (ANSI-stripped) for IDE Logs tabs.
|
|
435
|
-
const LOG_BUFFER_MAX = 2000;
|
|
436
|
-
const logBuffer = [];
|
|
437
|
-
const logSubscribers = new Set();
|
|
438
|
-
function emitLogLine(text) {
|
|
439
|
-
logBuffer.push(text);
|
|
440
|
-
if (logBuffer.length > LOG_BUFFER_MAX)
|
|
441
|
-
logBuffer.splice(0, logBuffer.length - LOG_BUFFER_MAX);
|
|
442
|
-
logFileStream?.write(text.replace(ANSI_RE, ''));
|
|
443
|
-
for (const sub of logSubscribers) {
|
|
444
|
-
try {
|
|
445
|
-
sub.res.write(sub.plain ? text.replace(ANSI_RE, '') : text);
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
logSubscribers.delete(sub);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
// The parent's own console lines (ready/respawn/Debugger listening/proxy errors)
|
|
453
|
-
// belong in the stream too — mirror console.* into the hub.
|
|
454
|
-
for (const level of ['log', 'info', 'warn', 'error']) {
|
|
455
|
-
const orig = console[level].bind(console);
|
|
456
|
-
console[level] = (...args) => {
|
|
457
|
-
orig(...args);
|
|
458
|
-
emitLogLine(args.map((a) => (typeof a === 'string' ? a : util.inspect(a))).join(' ') + '\n');
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
const colorFor = (id) => {
|
|
462
|
-
return COLORS[(id - 1) % COLORS.length];
|
|
463
|
-
};
|
|
464
|
-
const tagFor = (id) => {
|
|
465
|
-
if (CHILD_COUNT === 1) {
|
|
466
|
-
return '';
|
|
467
|
-
}
|
|
468
|
-
const base = `[child#${id}] `;
|
|
469
|
-
return `${colorFor(id)}${base}${RESET}`;
|
|
470
|
-
};
|
|
471
|
-
const isJsonLog = (log) => {
|
|
472
|
-
return log.startsWith('{') && log.endsWith('}');
|
|
473
|
-
};
|
|
474
|
-
const wireProcLogging = (proc, tag) => {
|
|
475
|
-
const prettyLog = prettyFactory({
|
|
476
|
-
sync: true,
|
|
477
|
-
colorize: true,
|
|
478
|
-
crlf: true,
|
|
479
|
-
messageKey: 'message',
|
|
480
|
-
errorLikeObjectKeys: ['err', 'error'],
|
|
481
|
-
errorProps: 'type,message,stack',
|
|
482
|
-
ignore: [
|
|
483
|
-
'logContext', 'context', 'hostname', 'req', 'res', 'err.driverError',
|
|
484
|
-
'module', 'cloudEnvironment',
|
|
485
|
-
'frontegg-application-id', 'frontegg-tenant-id',
|
|
486
|
-
'frontegg-trace-id', 'frontegg-vendor-id',
|
|
487
|
-
'host', 'service', 'version',
|
|
488
|
-
'err', 'error',
|
|
489
|
-
].join(','),
|
|
490
|
-
messageFormat: '{if module}[{module}] {end}{if context}[{context}] {end}{if logContext}[{logContext}] {end}{message}',
|
|
491
|
-
customPrettifiers: {
|
|
492
|
-
stack: (value) => '\n' + String(value),
|
|
493
|
-
},
|
|
494
|
-
});
|
|
495
|
-
const write = (kind, line) => {
|
|
496
|
-
const dest = kind === 'stdout' ? process.stdout : process.stderr;
|
|
497
|
-
const log = isJsonLog(line) ? prettyLog(line) : `${line}\n`;
|
|
498
|
-
dest.write(`${tag}${log}`);
|
|
499
|
-
emitLogLine(`${tag}${log}`);
|
|
500
|
-
};
|
|
501
|
-
const attach = (stream, kind) => {
|
|
502
|
-
if (!stream)
|
|
503
|
-
return;
|
|
504
|
-
const rl = readline.createInterface({ input: stream });
|
|
505
|
-
rl.on('line', (line) => write(kind, line));
|
|
506
|
-
rl.on('close', () => { });
|
|
507
|
-
};
|
|
508
|
-
attach(proc.stdout, 'stdout');
|
|
509
|
-
attach(proc.stderr, 'stderr');
|
|
510
|
-
};
|
|
511
|
-
const wireChildLogging = (info) => wireProcLogging(info.proc, tagFor(info.id));
|
|
512
|
-
(async () => {
|
|
513
|
-
// Ensure socket directory exists
|
|
514
|
-
fs.mkdirSync(SOCK_DIR, { recursive: true });
|
|
515
|
-
const proxy = httpProxy.createProxyServer({});
|
|
516
|
-
let publicPort = Number(process.env.PORT) || 0;
|
|
517
|
-
if (!publicPort)
|
|
518
|
-
publicPort = 9090;
|
|
519
|
-
const children = [];
|
|
520
|
-
const usedDebugSlots = new Set();
|
|
521
|
-
function allocDebugSlot() {
|
|
522
|
-
let slot = 0;
|
|
523
|
-
while (usedDebugSlots.has(slot))
|
|
524
|
-
slot++;
|
|
525
|
-
usedDebugSlots.add(slot);
|
|
526
|
-
return slot;
|
|
527
|
-
}
|
|
528
|
-
let nextId = 1;
|
|
529
|
-
let rrIndex = 0;
|
|
530
|
-
let shuttingDown = false; // set by the parent's signal handler
|
|
531
|
-
let desiredPods = CHILD_COUNT; // pool target; grown by /webpack/scale
|
|
532
|
-
let recentCrashRespawns = []; // timestamps — crash-loop backstop
|
|
533
|
-
function healthyChildren() {
|
|
534
|
-
return children.filter(c => c.healthy && typeof c.socketPath === 'string');
|
|
535
|
-
}
|
|
536
|
-
function pickChild() {
|
|
537
|
-
const healthy = healthyChildren();
|
|
538
|
-
if (healthy.length === 0)
|
|
539
|
-
return null;
|
|
540
|
-
const idx = rrIndex % healthy.length;
|
|
541
|
-
rrIndex = (rrIndex + 1) % healthy.length;
|
|
542
|
-
return healthy[idx];
|
|
543
|
-
}
|
|
544
|
-
async function spawnChild() {
|
|
545
|
-
const id = nextId++;
|
|
546
|
-
const sockPath = childSockPath(id);
|
|
547
|
-
cleanupSock(sockPath); // remove stale socket
|
|
548
|
-
// When running from TS source (local dev), children need @swc-node/register too
|
|
549
|
-
const childExecArgv = ['--enable-source-maps'];
|
|
550
|
-
if (__filename.endsWith('.ts')) {
|
|
551
|
-
childExecArgv.unshift('--require', '@swc-node/register');
|
|
552
|
-
}
|
|
553
|
-
// Stable inspector port: lowest free slot → debugBasePort+slot. Slots are
|
|
554
|
-
// released when a pod exits, so a crash-respawned pod reclaims the SAME port —
|
|
555
|
-
// an IDE "attach to 127.0.0.1:<port>" config survives respawns unedited.
|
|
556
|
-
const debugSlot = debugBasePort ? allocDebugSlot() : -1;
|
|
557
|
-
const proc = (0, node_child_process_1.fork)(__filename, {
|
|
558
|
-
env: {
|
|
559
|
-
...process.env,
|
|
560
|
-
APP_RUNNER: '1',
|
|
561
|
-
CHILD_SOCK_PATH: sockPath,
|
|
562
|
-
...(debugSlot >= 0 && { CHILD_DEBUG_PORT: String(debugBasePort + debugSlot) }),
|
|
563
|
-
},
|
|
564
|
-
stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
|
|
565
|
-
execArgv: childExecArgv,
|
|
566
|
-
});
|
|
567
|
-
const info = { id, proc, healthy: false, lastError: null, debugSlot };
|
|
568
|
-
proc.on('message', (msg) => {
|
|
569
|
-
if (!msg || typeof msg !== 'object')
|
|
570
|
-
return;
|
|
571
|
-
const m = msg;
|
|
572
|
-
if (m.type === 'ready') {
|
|
573
|
-
info.socketPath = String(m.socketPath);
|
|
574
|
-
info.healthy = true;
|
|
575
|
-
console.log(`[parent] Child#${info.id} ready on socket ${info.socketPath}`);
|
|
576
|
-
}
|
|
577
|
-
else if (m.type === 'inspector-url') {
|
|
578
|
-
info.inspectorUrl = String(m.url);
|
|
579
|
-
// "Debugger listening on ws://…" is the exact phrase IDE run consoles
|
|
580
|
-
// (WebStorm, VS Code) detect and render as a click-to-attach link.
|
|
581
|
-
console.log(`[parent] Child#${info.id} Debugger listening on ${m.url}`);
|
|
582
|
-
}
|
|
583
|
-
else if (m.type === 'reloaded') {
|
|
584
|
-
info.socketPath = String(m.socketPath);
|
|
585
|
-
info.healthy = true;
|
|
586
|
-
console.log(`[parent] Child#${info.id} hot-swapped on socket ${info.socketPath}`);
|
|
587
|
-
}
|
|
588
|
-
else if (m.type === 'reload-error') {
|
|
589
|
-
info.lastError = String(m.error || 'unknown reload error');
|
|
590
|
-
console.error(`[parent] Child#${info.id} reload error: ${info.lastError}`);
|
|
591
|
-
info.healthy = false;
|
|
592
|
-
}
|
|
593
|
-
else if (m.type === 'boot-error') {
|
|
594
|
-
info.lastError = String(m.error || 'unknown boot error');
|
|
595
|
-
console.error(`[parent] Child#${info.id} boot error: ${info.lastError}`);
|
|
596
|
-
info.healthy = false;
|
|
597
|
-
}
|
|
598
|
-
});
|
|
599
|
-
proc.on('exit', (code, signal) => {
|
|
600
|
-
if (info.debugSlot >= 0) {
|
|
601
|
-
usedDebugSlots.delete(info.debugSlot);
|
|
602
|
-
info.debugSlot = -1;
|
|
603
|
-
}
|
|
604
|
-
const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
|
|
605
|
-
if (clean) {
|
|
606
|
-
console.log(`[parent] Child#${info.id} exited cleanly (code=${code}, signal=${signal ?? 'none'})`);
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
console.error(`[parent] Child#${info.id} crashed (code=${code}, signal=${signal ?? 'none'})`);
|
|
610
|
-
if (!info.lastError)
|
|
611
|
-
info.lastError = `Child exited abnormally (code=${code}, signal=${signal ?? 'none'})`;
|
|
612
|
-
}
|
|
613
|
-
info.healthy = false;
|
|
614
|
-
cleanupSock(info.socketPath, info.proc.pid); // guarded by the DEAD child's claim
|
|
615
|
-
info.socketPath = undefined;
|
|
616
|
-
// Supervise the pool: a CRASHED pod (not a clean shutdown, not a
|
|
617
|
-
// reload/idle-kill) must be replaced or the pool silently shrinks to
|
|
618
|
-
// zero (with CHILD_COUNT=1, the service dies until a code edit). Drop
|
|
619
|
-
// the corpse and respawn after a short backoff — with a crash-loop
|
|
620
|
-
// backstop so a bundle that dies on boot doesn't busy-spin forever.
|
|
621
|
-
if (!clean && !shuttingDown) {
|
|
622
|
-
const i = children.indexOf(info);
|
|
623
|
-
if (i >= 0)
|
|
624
|
-
children.splice(i, 1);
|
|
625
|
-
const now = Date.now();
|
|
626
|
-
recentCrashRespawns = recentCrashRespawns.filter((t) => now - t < 30_000);
|
|
627
|
-
if (recentCrashRespawns.length >= 5) {
|
|
628
|
-
console.error(`[parent] crash-loop detected (${recentCrashRespawns.length} respawns/30s) — pausing auto-respawn; POST /webpack/reload to retry`);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
recentCrashRespawns.push(now);
|
|
632
|
-
setTimeout(() => {
|
|
633
|
-
const live = children.filter((c) => c.proc.killed === false).length;
|
|
634
|
-
if (!shuttingDown && live < desiredPods) {
|
|
635
|
-
console.log(`[parent] respawning a crashed pod (${live}/${desiredPods} live)`);
|
|
636
|
-
void spawnChild().catch(() => undefined); // spawnChild pushes to children + wires logging itself
|
|
637
|
-
}
|
|
638
|
-
}, 1500).unref?.();
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
children.push(info);
|
|
642
|
-
// connect stdout/stderr now that it's piped
|
|
643
|
-
wireChildLogging(info);
|
|
644
|
-
return info;
|
|
645
|
-
}
|
|
646
|
-
async function ensurePoolSize(n) {
|
|
647
|
-
desiredPods = Math.max(desiredPods, n); // remember the target so crash-respawn restores it
|
|
648
|
-
const live = children.filter(c => c.proc.killed === false);
|
|
649
|
-
const need = n - live.length;
|
|
650
|
-
for (let i = 0; i < need; i++) {
|
|
651
|
-
await spawnChild();
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
async function respawnUnhealthy() {
|
|
655
|
-
// Kill & replace children that are unhealthy or missing socketPaths
|
|
656
|
-
const toReplace = children.filter(c => !c.healthy || typeof c.socketPath !== 'string');
|
|
657
|
-
await Promise.all(toReplace.map(async (c) => {
|
|
658
|
-
try {
|
|
659
|
-
c.proc.kill('SIGTERM');
|
|
660
|
-
}
|
|
661
|
-
catch { /* ignore */
|
|
662
|
-
}
|
|
663
|
-
cleanupSock(c.socketPath, c.proc.pid); // guarded by the replaced child's claim
|
|
664
|
-
c.socketPath = undefined;
|
|
665
|
-
// Remove from array
|
|
666
|
-
const idx = children.indexOf(c);
|
|
667
|
-
if (idx >= 0)
|
|
668
|
-
children.splice(idx, 1);
|
|
669
|
-
// Spawn a fresh one
|
|
670
|
-
await spawnChild();
|
|
671
|
-
}));
|
|
672
|
-
}
|
|
673
|
-
const workerProcesses = [];
|
|
674
|
-
function spawnWorkerProcess(name) {
|
|
675
|
-
const bundleDir = path.dirname(path.isAbsolute(BUNDLE_PATH) ? BUNDLE_PATH : path.resolve(BUNDLE_PATH));
|
|
676
|
-
const workerBundle = path.join(bundleDir, `${name}.js`);
|
|
677
|
-
if (!fs.existsSync(workerBundle)) {
|
|
678
|
-
console.error(`[parent] Worker "${name}" bundle not found: ${workerBundle}`);
|
|
679
|
-
return null;
|
|
680
|
-
}
|
|
681
|
-
const workerExecArgv = ['--enable-source-maps'];
|
|
682
|
-
if (__filename.endsWith('.ts')) {
|
|
683
|
-
workerExecArgv.unshift('--require', '@swc-node/register');
|
|
684
|
-
}
|
|
685
|
-
// Workers get the same zero-touch taps as pods (fs/net/exec/db/redis/queue).
|
|
686
|
-
try {
|
|
687
|
-
workerExecArgv.push('--require', require.resolve('@lensmcp/node-instrumentation/register'));
|
|
688
|
-
}
|
|
689
|
-
catch { /* instrumentation package absent — workers run untapped */ }
|
|
690
|
-
// Workers get inspectors too, offset above the pod range: pods sit on
|
|
691
|
-
// base+slot, workers on base+20+i. The index comes from WORKER_NAMES order,
|
|
692
|
-
// so a worker keeps its port across restartWorkers(). With debugging off,
|
|
693
|
-
// --inspect-port only PRESETS the port (no listener) — SIGUSR1 from
|
|
694
|
-
// POST /webpack/debug activates it later without restarting the worker.
|
|
695
|
-
const workerDebugPort = (debugBasePort || 9229) + WORKER_DEBUG_PORT_OFFSET + Math.max(0, WORKER_NAMES.indexOf(name));
|
|
696
|
-
workerExecArgv.push(debugBasePort
|
|
697
|
-
? `--inspect=127.0.0.1:${workerDebugPort}`
|
|
698
|
-
: `--inspect-port=127.0.0.1:${workerDebugPort}`);
|
|
699
|
-
const proc = (0, node_child_process_1.fork)(workerBundle, {
|
|
700
|
-
env: process.env,
|
|
701
|
-
stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
|
|
702
|
-
execArgv: workerExecArgv,
|
|
703
|
-
});
|
|
704
|
-
const info = { name, proc, debugPort: workerDebugPort };
|
|
705
|
-
workerProcesses.push(info);
|
|
706
|
-
wireProcLogging(proc, `\x1b[35m[worker:${name}] \x1b[0m`);
|
|
707
|
-
proc.on('exit', (code, signal) => {
|
|
708
|
-
const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
|
|
709
|
-
if (!clean) {
|
|
710
|
-
console.error(`[parent] Worker "${name}" crashed (code=${code}, signal=${signal ?? 'none'})`);
|
|
711
|
-
}
|
|
712
|
-
const idx = workerProcesses.indexOf(info);
|
|
713
|
-
if (idx >= 0)
|
|
714
|
-
workerProcesses.splice(idx, 1);
|
|
715
|
-
});
|
|
716
|
-
console.log(`[parent] Worker "${name}" started (pid=${proc.pid})`);
|
|
717
|
-
return info;
|
|
718
|
-
}
|
|
719
|
-
function restartWorkers() {
|
|
720
|
-
for (const w of [...workerProcesses]) {
|
|
721
|
-
try {
|
|
722
|
-
w.proc.kill('SIGTERM');
|
|
723
|
-
}
|
|
724
|
-
catch { /* worker already exited */ }
|
|
725
|
-
}
|
|
726
|
-
workerProcesses.length = 0;
|
|
727
|
-
for (const name of WORKER_NAMES) {
|
|
728
|
-
spawnWorkerProcess(name);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
// Create ONE debounced forwarder that lives across requests
|
|
732
|
-
const debouncedForwardReload = debounce(async () => {
|
|
733
|
-
const healthies = healthyChildren();
|
|
734
|
-
if (healthies.length > 0) {
|
|
735
|
-
console.log(`[parent] Forwarding reload to ${healthies.length} child(ren)`);
|
|
736
|
-
for (const c of healthies)
|
|
737
|
-
c.proc.send?.({ type: 'reload' });
|
|
738
|
-
}
|
|
739
|
-
await respawnUnhealthy();
|
|
740
|
-
if (WORKER_NAMES.length > 0)
|
|
741
|
-
restartWorkers();
|
|
742
|
-
}, 200);
|
|
743
|
-
// Start parent server (stable port)
|
|
744
|
-
const requestHandler = async (req, res) => {
|
|
745
|
-
// Admin endpoint: trigger rolling hot-reload & respawn crashed
|
|
746
|
-
if (req.method === 'POST' && req.url === '/webpack/reload') {
|
|
747
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
748
|
-
res.end(JSON.stringify({ ok: true }));
|
|
749
|
-
debouncedForwardReload();
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
// Admin endpoint: grow the pod pool at runtime (gateway autoscaling).
|
|
753
|
-
if (req.method === 'POST' && req.url === '/webpack/scale') {
|
|
754
|
-
let body = '';
|
|
755
|
-
req.on('data', (c) => { body += c; });
|
|
756
|
-
req.on('end', async () => {
|
|
757
|
-
let pods = 0;
|
|
758
|
-
try {
|
|
759
|
-
pods = Number(JSON.parse(body || '{}').pods) || 0;
|
|
760
|
-
}
|
|
761
|
-
catch { /* bad json */ }
|
|
762
|
-
pods = Math.max(1, Math.min(pods, 16));
|
|
763
|
-
await ensurePoolSize(pods);
|
|
764
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
765
|
-
res.end(JSON.stringify({ ok: true, pods }));
|
|
766
|
-
});
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
// Admin endpoint: the live log stream — history, then follow (chunked text).
|
|
770
|
-
// `lensmcp logs <service>` attaches a terminal here (human), or takes a
|
|
771
|
-
// one-shot snapshot with ?follow=0&tail=N (agent). ?plain=1 strips ANSI.
|
|
772
|
-
if (req.method === 'GET' && req.url?.startsWith('/webpack/logs')) {
|
|
773
|
-
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
774
|
-
const tail = Math.min(Math.max(Number(q.get('tail') ?? '100') || 0, 0), LOG_BUFFER_MAX);
|
|
775
|
-
const follow = q.get('follow') !== '0';
|
|
776
|
-
const plain = q.get('plain') === '1';
|
|
777
|
-
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' });
|
|
778
|
-
const history = tail === 0 ? '' : logBuffer.slice(-tail).join('');
|
|
779
|
-
res.write(plain ? history.replace(ANSI_RE, '') : history);
|
|
780
|
-
if (!follow) {
|
|
781
|
-
res.end();
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
const sub = { res, plain };
|
|
785
|
-
logSubscribers.add(sub);
|
|
786
|
-
req.on('close', () => logSubscribers.delete(sub));
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
// Admin endpoint: attach a debugger to the RUNNING service — no restart, app
|
|
790
|
-
// state intact. POST /webpack/debug[?port=9339] opens (or re-reports) every
|
|
791
|
-
// pod's inspector and returns the ws URLs; the base becomes sticky so pods
|
|
792
|
-
// spawned later (respawn, scale) come up debuggable too. Workers are nudged
|
|
793
|
-
// via SIGUSR1 (their port was preset with --inspect-port at spawn).
|
|
794
|
-
if (req.method === 'POST' && req.url?.startsWith('/webpack/debug')) {
|
|
795
|
-
const requested = Number(new URL(req.url, 'http://localhost').searchParams.get('port') || '');
|
|
796
|
-
if (Number.isInteger(requested) && requested > 1024)
|
|
797
|
-
debugBasePort = requested;
|
|
798
|
-
if (!debugBasePort)
|
|
799
|
-
debugBasePort = 9229;
|
|
800
|
-
const targets = children.filter((c) => c.proc.exitCode === null && c.proc.signalCode === null);
|
|
801
|
-
for (const c of targets) {
|
|
802
|
-
if (c.debugSlot < 0)
|
|
803
|
-
c.debugSlot = allocDebugSlot();
|
|
804
|
-
try {
|
|
805
|
-
c.proc.send?.({ type: 'debug-open', port: debugBasePort + c.debugSlot });
|
|
806
|
-
}
|
|
807
|
-
catch { /* pod died mid-request */ }
|
|
808
|
-
}
|
|
809
|
-
for (const w of workerProcesses) {
|
|
810
|
-
try {
|
|
811
|
-
w.proc.kill('SIGUSR1');
|
|
812
|
-
}
|
|
813
|
-
catch { /* worker gone */ }
|
|
814
|
-
}
|
|
815
|
-
// inspector-url replies arrive async over IPC — wait briefly, then report
|
|
816
|
-
const deadline = Date.now() + 2000;
|
|
817
|
-
while (Date.now() < deadline && targets.some((c) => !c.inspectorUrl)) {
|
|
818
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
819
|
-
}
|
|
820
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
821
|
-
res.end(JSON.stringify({
|
|
822
|
-
ok: true,
|
|
823
|
-
basePort: debugBasePort,
|
|
824
|
-
pods: targets.map((c) => ({ id: c.id, inspectorUrl: c.inspectorUrl ?? null })),
|
|
825
|
-
workers: workerProcesses.map((w) => ({ name: w.name, port: w.debugPort })),
|
|
826
|
-
}, null, 2) + '\n');
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
// Front-gateway routes: host/prefix match → external target, or fall
|
|
830
|
-
// through to this service's own children when the route has no target.
|
|
831
|
-
const route = matchGatewayRoute(req);
|
|
832
|
-
if (route?.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
833
|
-
req.url = route.prependPrefix + (req.url ?? '/');
|
|
834
|
-
}
|
|
835
|
-
if (route?.target) {
|
|
836
|
-
applyGatewayMiddleware(req);
|
|
837
|
-
// autoRewrite ONLY for a string (external URL) target: a route may target a child socketPath
|
|
838
|
-
// OBJECT, and http-proxy's setRedirectHostRewrite does url.parse(options.target) on ANY 3xx —
|
|
839
|
-
// which throws on an object and crashes the gateway (e.g. proxying the auth IdP's /authorize → login).
|
|
840
|
-
proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true }, (err) => {
|
|
841
|
-
console.error(`[parent] proxy error to ${route.target}:`, err);
|
|
842
|
-
res.statusCode = 502;
|
|
843
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
844
|
-
res.end(`Upstream error from ${route.target}.`);
|
|
845
|
-
});
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
// Enforce service prefix (mimics production gateway)
|
|
849
|
-
if (SERVICE_PREFIX) {
|
|
850
|
-
if (!req.url?.startsWith(SERVICE_PREFIX)) {
|
|
851
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
852
|
-
res.end(`Not found. This service is mounted at ${SERVICE_PREFIX}/`);
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
req.url = req.url.slice(SERVICE_PREFIX.length) || '/';
|
|
856
|
-
}
|
|
857
|
-
// Gateway middleware: user-provided plugin modifies headers before proxying
|
|
858
|
-
applyGatewayMiddleware(req);
|
|
859
|
-
// Proxy all other traffic using round-robin among healthy children
|
|
860
|
-
const targetChild = pickChild();
|
|
861
|
-
if (!targetChild) {
|
|
862
|
-
res.statusCode = 503;
|
|
863
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
864
|
-
const errs = children.map(c => `#${c.id}: ${c.lastError ?? 'no error recorded'}`).join('\n');
|
|
865
|
-
res.end(`No healthy children available. POST /webpack/reload to recover.\n\nLast errors:\n${errs}`);
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
proxy.web(req, res, {
|
|
869
|
-
target: { socketPath: targetChild.socketPath },
|
|
870
|
-
// NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's
|
|
871
|
-
// setRedirectHostRewrite does url.parse(options.target) on ANY 3xx response — which throws
|
|
872
|
-
// ("url must be a string, received Object") and CRASHES the gateway the moment a child
|
|
873
|
-
// returns a redirect (e.g. the auth IdP's /authorize → login). Host-rewrite is meaningless
|
|
874
|
-
// for a socket target anyway (no host to match), and our services emit ABSOLUTE external
|
|
875
|
-
// redirect URLs, so no rewrite is needed. (autoRewrite stays on the string-target route above.)
|
|
876
|
-
headers: {
|
|
877
|
-
'x-proxy-child-id': String(targetChild.id) // for logging/debugging
|
|
878
|
-
}
|
|
879
|
-
}, (err) => {
|
|
880
|
-
console.error(`[parent] proxy error to Child#${targetChild.id}:`, err);
|
|
881
|
-
// Mark this child unhealthy so next request won't pick it
|
|
882
|
-
targetChild.healthy = false;
|
|
883
|
-
targetChild.lastError = String(err?.stack || err);
|
|
884
|
-
res.statusCode = 502;
|
|
885
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
886
|
-
res.end(`Upstream error from Child#${targetChild.id}. Try /webpack/reload.\n${targetChild.lastError}`);
|
|
887
|
-
});
|
|
888
|
-
};
|
|
889
|
-
// Optional HTTPS, @vitejs/plugin-basic-ssl style: a cached self-signed cert
|
|
890
|
-
// (SANs: localhost + loopbacks + every configured route hostname) so
|
|
891
|
-
// https://*.localhost dev "just works" after a one-time browser trust.
|
|
892
|
-
let server;
|
|
893
|
-
let gatewayPem;
|
|
894
|
-
let gatewayCaPath;
|
|
895
|
-
const extraServers = [];
|
|
896
|
-
if (GATEWAY_HTTPS) {
|
|
897
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
898
|
-
const basicSsl = require('./basic-ssl');
|
|
899
|
-
const certDomains = GATEWAY_ROUTES.map((r) => r.host).filter((h) => !!h);
|
|
900
|
-
const certCacheDir = path.join(process.cwd(), 'node_modules', '.cache', 'davnx-webpack');
|
|
901
|
-
gatewayPem = basicSsl.getCertificateSync(certCacheDir, SERVICE_NAME || 'lensmcp.dev', certDomains);
|
|
902
|
-
gatewayCaPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca)
|
|
903
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
904
|
-
const httpsMod = require('node:https');
|
|
905
|
-
server = httpsMod.createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler);
|
|
906
|
-
}
|
|
907
|
-
else {
|
|
908
|
-
server = http.createServer(requestHandler);
|
|
909
|
-
}
|
|
910
|
-
// WebSocket upgrades (vite HMR, app sockets): same routing as requests —
|
|
911
|
-
// external target when a route matches, otherwise a healthy child socket.
|
|
912
|
-
const upgradeHandler = (req, socket, head) => {
|
|
913
|
-
const route = matchGatewayRoute(req);
|
|
914
|
-
if (route?.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
915
|
-
req.url = route.prependPrefix + (req.url ?? '/');
|
|
916
|
-
}
|
|
917
|
-
if (route?.target) {
|
|
918
|
-
proxy.ws(req, socket, head, { target: route.target, changeOrigin: true }, () => socket.destroy());
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
const targetChild = pickChild();
|
|
922
|
-
if (!targetChild) {
|
|
923
|
-
socket.destroy();
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
proxy.ws(req, socket, head, { target: { socketPath: targetChild.socketPath } }, () => socket.destroy());
|
|
927
|
-
};
|
|
928
|
-
server.on('upgrade', upgradeHandler);
|
|
929
|
-
// The public TCP port is VESTIGIAL under the gateway: the gateway routes to this devserver over the
|
|
930
|
-
// unix-socket POOL ($TMPDIR/<service>-devserver/child-*.sock), never the TCP port. So a port clash must
|
|
931
|
-
// NOT crash the devserver — without this handler an EADDRINUSE (two services falling back to the same
|
|
932
|
-
// default port, or a not-yet-released port from a prior instance) throws an UNHANDLED 'error' that kills
|
|
933
|
-
// the parent → the supervisors respawn it → the old port is still held → EADDRINUSE again → a zombie
|
|
934
|
-
// devserver cascade. Handle it: log once and keep serving on the socket pool (gateway routing is
|
|
935
|
-
// unaffected). A distinct `cluster.port` / `config.<env>.yaml` port silences the warning.
|
|
936
|
-
server.on('error', (err) => {
|
|
937
|
-
if (err.code === 'EADDRINUSE') {
|
|
938
|
-
console.warn(`[parent] public port ${publicPort} already in use — continuing on the unix-socket pool only ` +
|
|
939
|
-
`(gateway routing is unaffected). Give this service a distinct cluster.port to silence this.`);
|
|
940
|
-
return; // do NOT rethrow: the socket pool is the real routing path under the gateway.
|
|
941
|
-
}
|
|
942
|
-
console.error(`[parent] public server error:`, err);
|
|
943
|
-
});
|
|
944
|
-
// The child pods — which serve on the unix sockets the gateway routes to — are the REAL serving path.
|
|
945
|
-
// Fork them UNCONDITIONALLY, BEFORE (and independent of) the vestigial public TCP port. This block used
|
|
946
|
-
// to live in the public-`listen` SUCCESS callback, so a public-port EADDRINUSE (a cross-workspace clash —
|
|
947
|
-
// e.g. two workspaces' services on the same default port — or a not-yet-released prior instance) meant
|
|
948
|
-
// the callback never fired → NO pods were forked → with no TCP server bound the parent's event loop
|
|
949
|
-
// emptied and it exited code=0 → the supervisor respawned it → a zombie respawn storm (the "loser of a
|
|
950
|
-
// public-port race gets stuck with 0 pods" bug). Under the gateway EVERYTHING reaches this service
|
|
951
|
-
// through the socket pool, so pod creation must NEVER be gated on the vestigial TCP port. The pods'
|
|
952
|
-
// IPC channels keep the parent alive even when no TCP port ever binds.
|
|
953
|
-
await ensurePoolSize(CHILD_COUNT);
|
|
954
|
-
for (const name of WORKER_NAMES) {
|
|
955
|
-
spawnWorkerProcess(name);
|
|
956
|
-
}
|
|
957
|
-
server.listen(publicPort, () => {
|
|
958
|
-
const scheme = GATEWAY_HTTPS ? 'https' : 'http';
|
|
959
|
-
console.log(`[parent] Listening on ${scheme}://localhost:${publicPort}`);
|
|
960
|
-
if (SERVICE_PREFIX) {
|
|
961
|
-
console.log(`[parent] Service prefix: "${SERVICE_PREFIX}" (enforced — requests without it will get 404)`);
|
|
962
|
-
}
|
|
963
|
-
if (gatewayMiddleware) {
|
|
964
|
-
console.log(`[parent] Gateway middleware: ACTIVE (${GATEWAY_MIDDLEWARE_PATH})`);
|
|
965
|
-
}
|
|
966
|
-
if (GATEWAY_ROUTES.length > 0) {
|
|
967
|
-
console.log(`[parent] Gateway routes: ${GATEWAY_ROUTES.map((r) => `${r.host ?? '*'}${r.prefix ?? '/'}→${r.target ?? 'children'}`).join(' ')}`);
|
|
968
|
-
}
|
|
969
|
-
if (gatewayCaPath) {
|
|
970
|
-
console.log(`[parent] HTTPS dev CA (trust ONCE, then rotations/new hostnames stay green):`);
|
|
971
|
-
console.log(`[parent] sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${gatewayCaPath}`);
|
|
972
|
-
}
|
|
973
|
-
console.log(`POST ${scheme}://localhost:${publicPort}/webpack/reload to trigger rolling swap/respawn`);
|
|
974
|
-
});
|
|
975
|
-
// Internal service-to-service port: plain http, straight to the children —
|
|
976
|
-
// NO gateway routes, NO gateway middleware (JWT etc.). Peers call this
|
|
977
|
-
// directly (http://localhost:<internalPort>/...) instead of going through
|
|
978
|
-
// the public front door, mirroring cluster-internal networking in prod.
|
|
979
|
-
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT || 0);
|
|
980
|
-
if (INTERNAL_PORT > 0) {
|
|
981
|
-
const internalHandler = (req, res) => {
|
|
982
|
-
const targetChild = pickChild();
|
|
983
|
-
if (!targetChild) {
|
|
984
|
-
res.statusCode = 503;
|
|
985
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
986
|
-
res.end('No healthy children available.');
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
proxy.web(req, res, {
|
|
990
|
-
target: { socketPath: targetChild.socketPath },
|
|
991
|
-
// NO autoRewrite — a socketPath target crashes http-proxy's setRedirectHostRewrite on a 3xx
|
|
992
|
-
// (url.parse of the target OBJECT). See the public-port handler above.
|
|
993
|
-
}, (err) => {
|
|
994
|
-
targetChild.healthy = false;
|
|
995
|
-
targetChild.lastError = String(err?.stack || err);
|
|
996
|
-
res.statusCode = 502;
|
|
997
|
-
res.end(`Upstream error from Child#${targetChild.id}.`);
|
|
998
|
-
});
|
|
999
|
-
};
|
|
1000
|
-
const internal = http.createServer(internalHandler);
|
|
1001
|
-
internal.on('upgrade', (req, socket, head) => {
|
|
1002
|
-
const targetChild = pickChild();
|
|
1003
|
-
if (!targetChild) {
|
|
1004
|
-
socket.destroy();
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
proxy.ws(req, socket, head, { target: { socketPath: targetChild.socketPath } }, () => socket.destroy());
|
|
1008
|
-
});
|
|
1009
|
-
internal.on('error', (err) => {
|
|
1010
|
-
console.warn(`[parent] internal port ${INTERNAL_PORT} unavailable (${err.code}).`);
|
|
1011
|
-
});
|
|
1012
|
-
internal.listen(INTERNAL_PORT, () => {
|
|
1013
|
-
console.log(`[parent] Internal (service-to-service, no gateway middleware) on http://localhost:${INTERNAL_PORT}`);
|
|
1014
|
-
});
|
|
1015
|
-
extraServers.push(internal);
|
|
1016
|
-
}
|
|
1017
|
-
// Extra listener ports sharing the SAME handler + upgrade routing. The
|
|
1018
|
-
// point is pretty dev domains: with `gateway.https` + extraPorts [443],
|
|
1019
|
-
// https://tetros.ai.local/ works with no :port in the URL (macOS lets
|
|
1020
|
-
// unprivileged processes bind <1024; elsewhere we warn and carry on).
|
|
1021
|
-
const EXTRA_PORTS = (() => {
|
|
1022
|
-
try {
|
|
1023
|
-
return JSON.parse(process.env.GATEWAY_EXTRA_PORTS || '[]');
|
|
1024
|
-
}
|
|
1025
|
-
catch {
|
|
1026
|
-
return [];
|
|
1027
|
-
}
|
|
1028
|
-
})();
|
|
1029
|
-
for (const extraPort of EXTRA_PORTS) {
|
|
1030
|
-
if (!Number.isInteger(extraPort) || extraPort === Number(publicPort))
|
|
1031
|
-
continue;
|
|
1032
|
-
const extra = GATEWAY_HTTPS
|
|
1033
|
-
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1034
|
-
require('node:https').createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler)
|
|
1035
|
-
: http.createServer(requestHandler);
|
|
1036
|
-
extra.on('upgrade', upgradeHandler);
|
|
1037
|
-
extra.on('error', (err) => {
|
|
1038
|
-
console.warn(`[parent] extra port ${extraPort} unavailable (${err.code}) — main port still up.`);
|
|
1039
|
-
});
|
|
1040
|
-
extra.listen(extraPort, () => {
|
|
1041
|
-
const scheme = GATEWAY_HTTPS ? 'https' : 'http';
|
|
1042
|
-
console.log(`[parent] Also listening on ${scheme}://localhost:${extraPort}`);
|
|
1043
|
-
});
|
|
1044
|
-
extraServers.push(extra);
|
|
1045
|
-
}
|
|
1046
|
-
// Control socket: the SAME admin surface (logs/debug/reload/scale), reachable
|
|
1047
|
-
// WITHOUT knowing the public port or scheme. `lensmcp logs <service>` finds it
|
|
1048
|
-
// by deriving $TMPDIR[/<wsKey>]/<service>-devserver/parent.sock — the exact
|
|
1049
|
-
// derivation the gateway uses for the pod pool dir. Plain HTTP: it's a
|
|
1050
|
-
// per-user unix socket, TLS adds nothing.
|
|
1051
|
-
const controlSockPath = path.join(SOCK_DIR, 'parent.sock');
|
|
1052
|
-
claimSock(controlSockPath); // bind steals: claim + clear a crashed previous parent's leftover
|
|
1053
|
-
const controlServer = http.createServer(requestHandler);
|
|
1054
|
-
controlServer.on('error', (err) => {
|
|
1055
|
-
console.warn(`[parent] control socket unavailable (${err.code}) — lensmcp logs/debug attach disabled.`);
|
|
1056
|
-
});
|
|
1057
|
-
controlServer.listen(controlSockPath);
|
|
1058
|
-
extraServers.push(controlServer);
|
|
1059
|
-
// Graceful shutdown of parent (children get SIGTERM)
|
|
1060
|
-
const shutdown = async (sig) => {
|
|
1061
|
-
shuttingDown = true; // stop crash-respawn from fighting the teardown
|
|
1062
|
-
console.log(`[${sig}] [parent] shutting down…`);
|
|
1063
|
-
cleanupSock(controlSockPath);
|
|
1064
|
-
for (const extra of extraServers) {
|
|
1065
|
-
try {
|
|
1066
|
-
extra.close();
|
|
1067
|
-
}
|
|
1068
|
-
catch { /* ignore */ }
|
|
1069
|
-
}
|
|
1070
|
-
for (const w of workerProcesses) {
|
|
1071
|
-
try {
|
|
1072
|
-
w.proc.kill('SIGTERM');
|
|
1073
|
-
}
|
|
1074
|
-
catch { /* worker already exited */ }
|
|
1075
|
-
}
|
|
1076
|
-
for (const c of children) {
|
|
1077
|
-
try {
|
|
1078
|
-
c.proc.kill('SIGTERM');
|
|
1079
|
-
}
|
|
1080
|
-
catch { /* ignore */
|
|
1081
|
-
}
|
|
1082
|
-
if (c.socketPath)
|
|
1083
|
-
cleanupSock(c.socketPath, c.proc.pid);
|
|
1084
|
-
}
|
|
1085
|
-
// Clean up socket directory
|
|
1086
|
-
try {
|
|
1087
|
-
fs.rmdirSync(SOCK_DIR);
|
|
1088
|
-
}
|
|
1089
|
-
catch { /* best-effort: dir missing or non-empty */ }
|
|
1090
|
-
setTimeout(() => process.exit(0), 700).unref();
|
|
1091
|
-
};
|
|
1092
|
-
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => process.on(sig, () => void shutdown(sig)));
|
|
1093
|
-
// Gateway watchdog: the gateway kills pods via its spawned process group, but an UNGRACEFUL
|
|
1094
|
-
// gateway death (crash/SIGKILL) never runs that kill — the whole nx run → run-executor →
|
|
1095
|
-
// devserver chain reparents to launchd and squats its ports/sockets forever (the 2026-07-28
|
|
1096
|
-
// day-old orphan tree). We are layers below the gateway, so a ppid check can't see it — poll
|
|
1097
|
-
// the stamped pid instead and tear down through the SAME graceful path when it's gone. The
|
|
1098
|
-
// chain above us unwinds as our exit propagates. A pod run OUTSIDE a gateway (`nx serve`
|
|
1099
|
-
// directly) has no LENSMCP_GATEWAY_PID and is untouched.
|
|
1100
|
-
const gatewayPid = Number(process.env.LENSMCP_GATEWAY_PID ?? 0);
|
|
1101
|
-
if (Number.isInteger(gatewayPid) && gatewayPid > 0) {
|
|
1102
|
-
const watchdog = setInterval(() => {
|
|
1103
|
-
try {
|
|
1104
|
-
process.kill(gatewayPid, 0);
|
|
1105
|
-
}
|
|
1106
|
-
catch (e) {
|
|
1107
|
-
if (e.code === 'EPERM')
|
|
1108
|
-
return; // exists, just not signalable
|
|
1109
|
-
clearInterval(watchdog);
|
|
1110
|
-
console.error(`[parent] gateway (pid ${gatewayPid}) is gone — shutting the pod down to avoid an orphan tree.`);
|
|
1111
|
-
void shutdown('gateway-gone');
|
|
1112
|
-
}
|
|
1113
|
-
}, 5_000);
|
|
1114
|
-
watchdog.unref(); // never keeps an otherwise-done parent alive
|
|
1115
|
-
}
|
|
1116
|
-
})();
|
|
1117
|
-
}
|
|
1
|
+
"use strict";var pe=Object.defineProperty;var h=(o,d)=>pe(o,"name",{value:d,configurable:!0});var ie=Object.defineProperty,a=h((o,d)=>ie(o,"name",{value:d,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0});const tslib_1=require("tslib"),path=tslib_1.__importStar(require("node:path")),fs=tslib_1.__importStar(require("node:fs")),os=tslib_1.__importStar(require("node:os")),node_child_process_1=require("node:child_process"),http=tslib_1.__importStar(require("node:http")),readline=tslib_1.__importStar(require("node:readline")),inspector=tslib_1.__importStar(require("node:inspector")),util=tslib_1.__importStar(require("node:util")),httpProxy=require("http-proxy"),{prettyFactory}=require("pino-pretty"),BUNDLE_PATH=process.env.BUNDLE_PATH?.trim()||"./main.js",SERVICE_NAME=process.env.SERVICE_NAME||"",SERVICE_PREFIX=process.env.SERVE_PREFIX?`/${process.env.SERVE_PREFIX}`:"",GATEWAY_MIDDLEWARE_PATH=process.env.GATEWAY_MIDDLEWARE||"",GATEWAY_CONFIG=(()=>{const o=process.env.GATEWAY_CONFIG;if(!o)return{};try{return JSON.parse(o)}catch{return{}}})();let gatewayMiddleware=null;if(GATEWAY_MIDDLEWARE_PATH)try{const o=require(GATEWAY_MIDDLEWARE_PATH);gatewayMiddleware=typeof o=="function"?o:typeof o.default=="function"?o.default:null,gatewayMiddleware||console.warn(`[gateway] ${GATEWAY_MIDDLEWARE_PATH} does not export a function, gateway disabled`)}catch(o){console.error(`[gateway] Failed to load middleware from ${GATEWAY_MIDDLEWARE_PATH}:`,o)}function applyGatewayMiddleware(o){if(gatewayMiddleware)try{gatewayMiddleware(o,GATEWAY_CONFIG)}catch(d){console.error("[gateway] Middleware error:",d)}}h(applyGatewayMiddleware,"applyGatewayMiddleware"),a(applyGatewayMiddleware,"applyGatewayMiddleware");const GATEWAY_ROUTES=(()=>{try{return JSON.parse(process.env.GATEWAY_ROUTES||"[]")}catch{return[]}})(),GATEWAY_HTTPS=process.env.GATEWAY_HTTPS==="1";function hostMatches(o,d){if(o.startsWith("*.")){const g=o.slice(1);return d.endsWith(g)||d===o.slice(2)}return d===o}h(hostMatches,"hostMatches"),a(hostMatches,"hostMatches");function matchGatewayRoute(o){if(GATEWAY_ROUTES.length===0)return;const d=(o.headers.host||"").split(":")[0],g=o.url||"/";for(const f of GATEWAY_ROUTES)if(!(f.host&&!hostMatches(f.host,d))&&!(f.prefix&&!g.startsWith(f.prefix)))return f}h(matchGatewayRoute,"matchGatewayRoute"),a(matchGatewayRoute,"matchGatewayRoute");const WORKER_NAMES=(()=>{try{return JSON.parse(process.env.WORKERS||"[]")}catch{return[]}})(),LENSMCP_WS_KEY=process.env.LENSMCP_WS_KEY||"",SOCK_DIR=LENSMCP_WS_KEY?path.join(os.tmpdir(),LENSMCP_WS_KEY,`${SERVICE_NAME||"lensmcp-cluster"}-devserver`):path.join(os.tmpdir(),`${SERVICE_NAME||"lensmcp-cluster"}-devserver`);function childSockPath(o){return path.join(SOCK_DIR,`child-${o}.sock`)}h(childSockPath,"childSockPath"),a(childSockPath,"childSockPath");function sockOwnerPath(o){return`${o}.owner`}h(sockOwnerPath,"sockOwnerPath"),a(sockOwnerPath,"sockOwnerPath");function claimSock(o,d=process.pid){try{fs.mkdirSync(path.dirname(o),{recursive:!0})}catch{}try{fs.writeFileSync(sockOwnerPath(o),String(d))}catch{}try{fs.unlinkSync(o)}catch{}}h(claimSock,"claimSock"),a(claimSock,"claimSock");function cleanupSock(o,d=process.pid){if(o){try{const g=fs.readFileSync(sockOwnerPath(o),"utf8").trim();if(g&&g!==String(d))return}catch{}try{fs.unlinkSync(o)}catch{}try{fs.unlinkSync(sockOwnerPath(o))}catch{}}}h(cleanupSock,"cleanupSock"),a(cleanupSock,"cleanupSock");async function importFresh(spec){process.env.DEVSERVER_MODE="1";const resolved=path.isAbsolute(spec)?spec:path.join(__dirname,spec);return eval(`delete require.cache["${resolved}"]`),eval(`require("${resolved}")`)}h(importFresh,"importFresh"),a(importFresh,"importFresh");function debounce(o,d){let g=null;return(...f)=>{g&&clearTimeout(g),g=setTimeout(()=>o(...f),d)}}if(h(debounce,"debounce"),a(debounce,"debounce"),process.env.APP_RUNNER==="1"){const o=a(g=>{if(!inspector.url())try{inspector.open(g,"127.0.0.1",!1)}catch(m){console.warn(`[child] inspector.open(${g}) failed (port taken by another devserver?) \u2014 falling back to a random port:`,m.message);try{inspector.open(0,"127.0.0.1",!1)}catch{}}const f=inspector.url();f&&process.send&&process.send({type:"inspector-url",url:f})},"openInspector"),d=Number(process.env.CHILD_DEBUG_PORT||"");Number.isInteger(d)&&d>1024&&o(d);try{require("@lensmcp/node-instrumentation/register")}catch{}(async()=>{let g=null,f=!1,m=!1,H=a((p,u)=>{u.statusCode=503,u.end("starting")},"delegate");async function P(){if(f)return m=!0,console.log("[child] Reload deferred \u2014 will re-swap after current swap completes"),g;if(f=!0,process.removeAllListeners("uncaughtException"),process.removeAllListeners("unhandledRejection"),process.on("uncaughtException",(w,k)=>{console.error(`[child] uncaughtException (${k}):`,w)}),process.on("unhandledRejection",w=>{const k=w instanceof Error?w.stack||w.message:String(w);k.includes("FronteggContextScope")||k.includes("populateLoggerMetadata")||console.error("[child] unhandledRejection:",w)}),await importFresh(BUNDLE_PATH),typeof global.createChildApp!="function")throw f=!1,new Error(`Bundle '${BUNDLE_PATH}' does not export createChildApp() and the zero-touch bridge is not active. Either install @lensmcp/node-instrumentation (a plain NestFactory.create + app.listen main.ts then just works) or set global.createChildApp in main.ts when DEVSERVER_MODE === '1'.`);const p=await global.createChildApp(),u=g;return g=p,H=p.handler,console.log("[child] Swapped to fresh app (previous closing in background)"),u&&u.close().catch(w=>console.error("[child] background close error:",w)),f=!1,m?(m=!1,P()):p}h(P,"I"),a(P,"swapNow");const B=debounce(async()=>{try{console.log("[child] /webpack/reload"),await P()}catch(p){console.error("[child] reload failed:",p)}},250),C=http.createServer((p,u)=>{if(p.method==="POST"&&p.url==="/webpack/reload"){u.writeHead(200,{"content-type":"application/json"}),u.end(JSON.stringify({ok:!0})),B();return}H(p,u)}),_=process.env.CHILD_SOCK_PATH;process.on("disconnect",()=>{cleanupSock(_),process.exit(0)});try{await P();const p=path.isAbsolute(BUNDLE_PATH)?BUNDLE_PATH:path.join(__dirname,BUNDLE_PATH);console.log(`[child] Using bundle: ${p}`),claimSock(_),C.listen(_,()=>{console.log(`[child] Listening on ${_}`),process.send&&process.send({type:"ready",socketPath:_})})}catch(p){console.error("[child] Startup error:",p),process.send&&process.send({type:"boot-error",error:String(p?.stack||p)}),process.exitCode=1;return}process.on("message",async p=>{if(!(!p||typeof p!="object")){if(p.type==="debug-open"){const u=Number(p.port);o(Number.isInteger(u)&&u>1024?u:0);return}if(p.type==="reload")try{await P(),process.send&&process.send({type:"reloaded",socketPath:_})}catch(u){console.error("[child] reload error:",u),process.send&&process.send({type:"reload-error",error:String(u?.stack||u)})}}}),process.on("beforeExit",()=>{try{inspector?.close?.()}catch{}});const x=a(async p=>{console.log(`[child ${p}] shutting down\u2026`);const u=g?.close().catch(k=>console.error("[child] close error:",k)),w=new Promise(k=>C.close(()=>k()));cleanupSock(_),setTimeout(()=>process.exit(0),700).unref(),await Promise.all([u,w]),process.exit(0)},"shutdown");["SIGINT","SIGTERM","SIGHUP","SIGQUIT","SIGUSR2"].forEach(p=>{process.on(p,()=>{inspector?.close?.(),x(p)})})})()}else{let o=h(function(i){x.push(i),x.length>_&&x.splice(0,x.length-_),B?.write(i.replace(C,""));for(const y of p)try{y.res.write(y.plain?i.replace(C,""):i)}catch{p.delete(y)}},"p");a(o,"emitLogLine");const d=["\x1B[36m","\x1B[33m","\x1B[35m","\x1B[32m","\x1B[34m","\x1B[31m"],g="\x1B[0m",f=Math.max(1,Number(process.env.CHILD_COUNT||1));let m=(()=>{const i=Number(process.env.LENSMCP_DEBUG_PORT||"");return Number.isInteger(i)&&i>1024?i:0})();const H=20,P=process.env.LENSMCP_LOG_FILE||"",B=P?(()=>{try{return fs.mkdirSync(path.dirname(P),{recursive:!0}),fs.createWriteStream(P,{flags:"a"})}catch(i){return console.warn(`[parent] cannot open log file ${P}:`,i.message),null}})():null,C=/\x1b\[[0-9;]*m/g,_=2e3,x=[],p=new Set;for(const i of["log","info","warn","error"]){const y=console[i].bind(console);console[i]=(...E)=>{y(...E),o(E.map(A=>typeof A=="string"?A:util.inspect(A)).join(" ")+`
|
|
2
|
+
`)}}const u=a(i=>d[(i-1)%d.length],"colorFor"),w=a(i=>{if(f===1)return"";const y=`[child#${i}] `;return`${u(i)}${y}${g}`},"tagFor"),k=a(i=>i.startsWith("{")&&i.endsWith("}"),"isJsonLog"),Z=a((i,y)=>{const E=prettyFactory({sync:!0,colorize:!0,crlf:!0,messageKey:"message",errorLikeObjectKeys:["err","error"],errorProps:"type,message,stack",ignore:["logContext","context","hostname","req","res","err.driverError","module","cloudEnvironment","frontegg-application-id","frontegg-tenant-id","frontegg-trace-id","frontegg-vendor-id","host","service","version","err","error"].join(","),messageFormat:"{if module}[{module}] {end}{if context}[{context}] {end}{if logContext}[{logContext}] {end}{message}",customPrettifiers:{stack:a($=>`
|
|
3
|
+
`+String($),"stack")}}),A=a(($,T)=>{const v=$==="stdout"?process.stdout:process.stderr,b=k(T)?E(T):`${T}
|
|
4
|
+
`;v.write(`${y}${b}`),o(`${y}${b}`)},"write"),G=a(($,T)=>{if(!$)return;const v=readline.createInterface({input:$});v.on("line",b=>A(T,b)),v.on("close",()=>{})},"attach");G(i.stdout,"stdout"),G(i.stderr,"stderr")},"wireProcLogging"),se=a(i=>Z(i.proc,w(i.id)),"wireChildLogging");(async()=>{fs.mkdirSync(SOCK_DIR,{recursive:!0});const i=httpProxy.createProxyServer({});let y=Number(process.env.PORT)||0;y||(y=9090);const E=[],A=new Set;function G(){let e=0;for(;A.has(e);)e++;return A.add(e),e}h(G,"G"),a(G,"allocDebugSlot");let $=1,T=0,v=!1,b=f,O=[];function K(){return E.filter(e=>e.healthy&&typeof e.socketPath=="string")}h(K,"Q"),a(K,"healthyChildren");function M(){const e=K();if(e.length===0)return null;const t=T%e.length;return T=(T+1)%e.length,e[t]}h(M,"U"),a(M,"pickChild");async function U(){const e=$++,t=childSockPath(e);cleanupSock(t);const l=["--enable-source-maps"];__filename.endsWith(".ts")&&l.unshift("--require","@swc-node/register");const n=m?G():-1,c=(0,node_child_process_1.fork)(__filename,{env:{...process.env,APP_RUNNER:"1",CHILD_SOCK_PATH:t,...n>=0&&{CHILD_DEBUG_PORT:String(m+n)}},stdio:["inherit","pipe","pipe","ipc"],execArgv:l}),r={id:e,proc:c,healthy:!1,lastError:null,debugSlot:n};return c.on("message",S=>{if(!S||typeof S!="object")return;const s=S;s.type==="ready"?(r.socketPath=String(s.socketPath),r.healthy=!0,console.log(`[parent] Child#${r.id} ready on socket ${r.socketPath}`)):s.type==="inspector-url"?(r.inspectorUrl=String(s.url),console.log(`[parent] Child#${r.id} Debugger listening on ${s.url}`)):s.type==="reloaded"?(r.socketPath=String(s.socketPath),r.healthy=!0,console.log(`[parent] Child#${r.id} hot-swapped on socket ${r.socketPath}`)):s.type==="reload-error"?(r.lastError=String(s.error||"unknown reload error"),console.error(`[parent] Child#${r.id} reload error: ${r.lastError}`),r.healthy=!1):s.type==="boot-error"&&(r.lastError=String(s.error||"unknown boot error"),console.error(`[parent] Child#${r.id} boot error: ${r.lastError}`),r.healthy=!1)}),c.on("exit",(S,s)=>{r.debugSlot>=0&&(A.delete(r.debugSlot),r.debugSlot=-1);const R=s==="SIGINT"||s==="SIGTERM"||S===0;if(R?console.log(`[parent] Child#${r.id} exited cleanly (code=${S}, signal=${s??"none"})`):(console.error(`[parent] Child#${r.id} crashed (code=${S}, signal=${s??"none"})`),r.lastError||(r.lastError=`Child exited abnormally (code=${S}, signal=${s??"none"})`)),r.healthy=!1,cleanupSock(r.socketPath,r.proc.pid),r.socketPath=void 0,!R&&!v){const N=E.indexOf(r);N>=0&&E.splice(N,1);const ne=Date.now();if(O=O.filter(q=>ne-q<3e4),O.length>=5){console.error(`[parent] crash-loop detected (${O.length} respawns/30s) \u2014 pausing auto-respawn; POST /webpack/reload to retry`);return}O.push(ne),setTimeout(()=>{const q=E.filter(le=>le.proc.killed===!1).length;!v&&q<b&&(console.log(`[parent] respawning a crashed pod (${q}/${b} live)`),U().catch(()=>{}))},1500).unref?.()}}),E.push(r),se(r),r}h(U,"K"),a(U,"spawnChild");async function V(e){b=Math.max(b,e);const t=E.filter(n=>n.proc.killed===!1),l=e-t.length;for(let n=0;n<l;n++)await U()}h(V,"Z"),a(V,"ensurePoolSize");async function ee(){const e=E.filter(t=>!t.healthy||typeof t.socketPath!="string");await Promise.all(e.map(async t=>{try{t.proc.kill("SIGTERM")}catch{}cleanupSock(t.socketPath,t.proc.pid),t.socketPath=void 0;const l=E.indexOf(t);l>=0&&E.splice(l,1),await U()}))}h(ee,"oe"),a(ee,"respawnUnhealthy");const I=[];function J(e){const t=path.dirname(path.isAbsolute(BUNDLE_PATH)?BUNDLE_PATH:path.resolve(BUNDLE_PATH)),l=path.join(t,`${e}.js`);if(!fs.existsSync(l))return console.error(`[parent] Worker "${e}" bundle not found: ${l}`),null;const n=["--enable-source-maps"];__filename.endsWith(".ts")&&n.unshift("--require","@swc-node/register");try{n.push("--require",require.resolve("@lensmcp/node-instrumentation/register"))}catch{}const c=(m||9229)+H+Math.max(0,WORKER_NAMES.indexOf(e));n.push(m?`--inspect=127.0.0.1:${c}`:`--inspect-port=127.0.0.1:${c}`);const r=(0,node_child_process_1.fork)(l,{env:process.env,stdio:["inherit","pipe","pipe","ipc"],execArgv:n}),S={name:e,proc:r,debugPort:c};return I.push(S),Z(r,`\x1B[35m[worker:${e}] \x1B[0m`),r.on("exit",(s,R)=>{R==="SIGINT"||R==="SIGTERM"||s===0||console.error(`[parent] Worker "${e}" crashed (code=${s}, signal=${R??"none"})`);const N=I.indexOf(S);N>=0&&I.splice(N,1)}),console.log(`[parent] Worker "${e}" started (pid=${r.pid})`),S}h(J,"q"),a(J,"spawnWorkerProcess");function te(){for(const e of[...I])try{e.proc.kill("SIGTERM")}catch{}I.length=0;for(const e of WORKER_NAMES)J(e)}h(te,"ne"),a(te,"restartWorkers");const ae=debounce(async()=>{const e=K();if(e.length>0){console.log(`[parent] Forwarding reload to ${e.length} child(ren)`);for(const t of e)t.proc.send?.({type:"reload"})}await ee(),WORKER_NAMES.length>0&&te()},200),W=a(async(e,t)=>{if(e.method==="POST"&&e.url==="/webpack/reload"){t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0})),ae();return}if(e.method==="POST"&&e.url==="/webpack/scale"){let c="";e.on("data",r=>{c+=r}),e.on("end",async()=>{let r=0;try{r=Number(JSON.parse(c||"{}").pods)||0}catch{}r=Math.max(1,Math.min(r,16)),await V(r),t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0,pods:r}))});return}if(e.method==="GET"&&e.url?.startsWith("/webpack/logs")){const c=new URL(e.url,"http://localhost").searchParams,r=Math.min(Math.max(Number(c.get("tail")??"100")||0,0),_),S=c.get("follow")!=="0",s=c.get("plain")==="1";t.writeHead(200,{"content-type":"text/plain; charset=utf-8","cache-control":"no-cache"});const R=r===0?"":x.slice(-r).join("");if(t.write(s?R.replace(C,""):R),!S){t.end();return}const N={res:t,plain:s};p.add(N),e.on("close",()=>p.delete(N));return}if(e.method==="POST"&&e.url?.startsWith("/webpack/debug")){const c=Number(new URL(e.url,"http://localhost").searchParams.get("port")||"");Number.isInteger(c)&&c>1024&&(m=c),m||(m=9229);const r=E.filter(s=>s.proc.exitCode===null&&s.proc.signalCode===null);for(const s of r){s.debugSlot<0&&(s.debugSlot=G());try{s.proc.send?.({type:"debug-open",port:m+s.debugSlot})}catch{}}for(const s of I)try{s.proc.kill("SIGUSR1")}catch{}const S=Date.now()+2e3;for(;Date.now()<S&&r.some(s=>!s.inspectorUrl);)await new Promise(s=>setTimeout(s,50));t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0,basePort:m,pods:r.map(s=>({id:s.id,inspectorUrl:s.inspectorUrl??null})),workers:I.map(s=>({name:s.name,port:s.debugPort}))},null,2)+`
|
|
5
|
+
`);return}const l=matchGatewayRoute(e);if(l?.prependPrefix&&!e.url?.startsWith(l.prependPrefix)&&(e.url=l.prependPrefix+(e.url??"/")),l?.target){applyGatewayMiddleware(e),i.web(e,t,{target:l.target,autoRewrite:typeof l.target=="string",changeOrigin:!0},c=>{console.error(`[parent] proxy error to ${l.target}:`,c),t.statusCode=502,t.setHeader("content-type","text/plain; charset=utf-8"),t.end(`Upstream error from ${l.target}.`)});return}if(SERVICE_PREFIX){if(!e.url?.startsWith(SERVICE_PREFIX)){t.writeHead(404,{"content-type":"text/plain; charset=utf-8"}),t.end(`Not found. This service is mounted at ${SERVICE_PREFIX}/`);return}e.url=e.url.slice(SERVICE_PREFIX.length)||"/"}applyGatewayMiddleware(e);const n=M();if(!n){t.statusCode=503,t.setHeader("content-type","text/plain; charset=utf-8");const c=E.map(r=>`#${r.id}: ${r.lastError??"no error recorded"}`).join(`
|
|
6
|
+
`);t.end(`No healthy children available. POST /webpack/reload to recover.
|
|
7
|
+
|
|
8
|
+
Last errors:
|
|
9
|
+
${c}`);return}i.web(e,t,{target:{socketPath:n.socketPath},headers:{"x-proxy-child-id":String(n.id)}},c=>{console.error(`[parent] proxy error to Child#${n.id}:`,c),n.healthy=!1,n.lastError=String(c?.stack||c),t.statusCode=502,t.setHeader("content-type","text/plain; charset=utf-8"),t.end(`Upstream error from Child#${n.id}. Try /webpack/reload.
|
|
10
|
+
${n.lastError}`)})},"requestHandler");let D,L,X;const Y=[];if(GATEWAY_HTTPS){const e=require("./basic-ssl"),t=GATEWAY_ROUTES.map(n=>n.host).filter(n=>!!n),l=path.join(process.cwd(),"node_modules",".cache","davnx-webpack");L=e.getCertificateSync(l,SERVICE_NAME||"lensmcp.dev",t),X=e.caCertPath(),D=require("node:https").createServer({key:L,cert:L},W)}else D=http.createServer(W);const re=a((e,t,l)=>{const n=matchGatewayRoute(e);if(n?.prependPrefix&&!e.url?.startsWith(n.prependPrefix)&&(e.url=n.prependPrefix+(e.url??"/")),n?.target){i.ws(e,t,l,{target:n.target,changeOrigin:!0},()=>t.destroy());return}const c=M();if(!c){t.destroy();return}i.ws(e,t,l,{target:{socketPath:c.socketPath}},()=>t.destroy())},"upgradeHandler");D.on("upgrade",re),D.on("error",e=>{if(e.code==="EADDRINUSE"){console.warn(`[parent] public port ${y} already in use \u2014 continuing on the unix-socket pool only (gateway routing is unaffected). Give this service a distinct cluster.port to silence this.`);return}console.error("[parent] public server error:",e)}),await V(f);for(const e of WORKER_NAMES)J(e);D.listen(y,()=>{const e=GATEWAY_HTTPS?"https":"http";console.log(`[parent] Listening on ${e}://localhost:${y}`),SERVICE_PREFIX&&console.log(`[parent] Service prefix: "${SERVICE_PREFIX}" (enforced \u2014 requests without it will get 404)`),gatewayMiddleware&&console.log(`[parent] Gateway middleware: ACTIVE (${GATEWAY_MIDDLEWARE_PATH})`),GATEWAY_ROUTES.length>0&&console.log(`[parent] Gateway routes: ${GATEWAY_ROUTES.map(t=>`${t.host??"*"}${t.prefix??"/"}\u2192${t.target??"children"}`).join(" ")}`),X&&(console.log("[parent] HTTPS dev CA (trust ONCE, then rotations/new hostnames stay green):"),console.log(`[parent] sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${X}`)),console.log(`POST ${e}://localhost:${y}/webpack/reload to trigger rolling swap/respawn`)});const j=Number(process.env.INTERNAL_PORT||0);if(j>0){const e=a((l,n)=>{const c=M();if(!c){n.statusCode=503,n.setHeader("content-type","text/plain; charset=utf-8"),n.end("No healthy children available.");return}i.web(l,n,{target:{socketPath:c.socketPath}},r=>{c.healthy=!1,c.lastError=String(r?.stack||r),n.statusCode=502,n.end(`Upstream error from Child#${c.id}.`)})},"internalHandler"),t=http.createServer(e);t.on("upgrade",(l,n,c)=>{const r=M();if(!r){n.destroy();return}i.ws(l,n,c,{target:{socketPath:r.socketPath}},()=>n.destroy())}),t.on("error",l=>{console.warn(`[parent] internal port ${j} unavailable (${l.code}).`)}),t.listen(j,()=>{console.log(`[parent] Internal (service-to-service, no gateway middleware) on http://localhost:${j}`)}),Y.push(t)}const ce=(()=>{try{return JSON.parse(process.env.GATEWAY_EXTRA_PORTS||"[]")}catch{return[]}})();for(const e of ce){if(!Number.isInteger(e)||e===Number(y))continue;const t=GATEWAY_HTTPS?require("node:https").createServer({key:L,cert:L},W):http.createServer(W);t.on("upgrade",re),t.on("error",l=>{console.warn(`[parent] extra port ${e} unavailable (${l.code}) \u2014 main port still up.`)}),t.listen(e,()=>{console.log(`[parent] Also listening on ${GATEWAY_HTTPS?"https":"http"}://localhost:${e}`)}),Y.push(t)}const z=path.join(SOCK_DIR,"parent.sock");claimSock(z);const Q=http.createServer(W);Q.on("error",e=>{console.warn(`[parent] control socket unavailable (${e.code}) \u2014 lensmcp logs/debug attach disabled.`)}),Q.listen(z),Y.push(Q);const oe=a(async e=>{v=!0,console.log(`[${e}] [parent] shutting down\u2026`),cleanupSock(z);for(const t of Y)try{t.close()}catch{}for(const t of I)try{t.proc.kill("SIGTERM")}catch{}for(const t of E){try{t.proc.kill("SIGTERM")}catch{}t.socketPath&&cleanupSock(t.socketPath,t.proc.pid)}try{fs.rmdirSync(SOCK_DIR)}catch{}setTimeout(()=>process.exit(0),700).unref()},"shutdown");["SIGINT","SIGTERM","SIGHUP","SIGQUIT","SIGUSR2"].forEach(e=>process.on(e,()=>{oe(e)}));const F=Number(process.env.LENSMCP_GATEWAY_PID??0);if(Number.isInteger(F)&&F>0){const e=setInterval(()=>{try{process.kill(F,0)}catch(t){if(t.code==="EPERM")return;clearInterval(e),console.error(`[parent] gateway (pid ${F}) is gone \u2014 shutting the pod down to avoid an orphan tree.`),oe("gateway-gone")}},5e3);e.unref()}})()}
|