@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.
Files changed (64) hide show
  1. package/basic-ssl.js +1 -241
  2. package/build-scope-patterns.js +1 -40
  3. package/create-webpack-dev.js +1 -186
  4. package/create-webpack-prod.js +1 -169
  5. package/executors/build/build.impl.js +1 -98
  6. package/executors/gateway/gateway-errors.js +1 -43
  7. package/executors/gateway/gateway.impl.js +1 -53
  8. package/executors/gateway/gateway.lib.js +1 -29
  9. package/executors/gateway/health-check.js +1 -66
  10. package/executors/gateway/jwks-verify.js +1 -121
  11. package/executors/gateway/main.prod-gateway.js +2 -573
  12. package/executors/gateway/main.rollout.js +11 -117
  13. package/executors/gateway/manifest.js +1 -374
  14. package/executors/gateway/metrics.js +1 -56
  15. package/executors/gateway/otel-tracing.js +1 -74
  16. package/executors/gateway/prod-gateway.lib.js +1 -22
  17. package/executors/gateway/prod-runtime/access-log.js +1 -24
  18. package/executors/gateway/prod-runtime/app.js +1 -123
  19. package/executors/gateway/prod-runtime/auth.js +1 -51
  20. package/executors/gateway/prod-runtime/cors.js +1 -40
  21. package/executors/gateway/prod-runtime/edge.js +1 -65
  22. package/executors/gateway/prod-runtime/handler.js +1 -226
  23. package/executors/gateway/prod-runtime/hooks.js +1 -42
  24. package/executors/gateway/prod-runtime/observability.js +1 -125
  25. package/executors/gateway/prod-runtime/rollout.js +1 -103
  26. package/executors/gateway/prod-runtime/routing.js +1 -40
  27. package/executors/gateway/prod-runtime/server.js +1 -79
  28. package/executors/gateway/prod-runtime/trust.js +1 -32
  29. package/executors/gateway/prod-runtime/types.js +1 -2
  30. package/executors/gateway/prod-runtime/upgrade.js +4 -116
  31. package/executors/gateway/prod-runtime/upstream.js +1 -21
  32. package/executors/gateway/providers-prod.js +1 -232
  33. package/executors/gateway/rate-limit.js +2 -75
  34. package/executors/gateway/registry-source.js +1 -131
  35. package/executors/gateway/rollout-ops.js +2 -167
  36. package/executors/gateway/runtime/auth.js +1 -64
  37. package/executors/gateway/runtime/chooser.js +12 -45
  38. package/executors/gateway/runtime/control.js +1 -128
  39. package/executors/gateway/runtime/dev-auth.js +1 -108
  40. package/executors/gateway/runtime/discovery.js +1 -123
  41. package/executors/gateway/runtime/edge.js +1 -47
  42. package/executors/gateway/runtime/handler.js +1 -183
  43. package/executors/gateway/runtime/hooks.js +1 -55
  44. package/executors/gateway/runtime/lens-children.js +1 -651
  45. package/executors/gateway/runtime/lifecycle.js +3 -842
  46. package/executors/gateway/runtime/observability.js +2 -148
  47. package/executors/gateway/runtime/pod-env.js +2 -89
  48. package/executors/gateway/runtime/proxy.js +1 -457
  49. package/executors/gateway/runtime/route-registry.js +1 -72
  50. package/executors/gateway/runtime/scope.js +1 -117
  51. package/executors/gateway/runtime/server.js +3 -487
  52. package/executors/gateway/runtime/service-keys.js +1 -49
  53. package/executors/gateway/runtime/types.js +1 -151
  54. package/executors/gateway/runtime/upgrade.js +1 -71
  55. package/executors/gateway/runtime/workspace-registry.js +1 -99
  56. package/executors/gateway/ssrf-guard.js +1 -190
  57. package/executors/serve/serve.impl.js +1 -280
  58. package/executors/trust/trust.impl.js +4 -162
  59. package/gateway.js +1 -35
  60. package/index.js +1 -16
  61. package/main.devserver.js +10 -1117
  62. package/package.json +4 -3
  63. package/tsgo-check-plugin.js +4 -364
  64. package/typecheck-bus.js +4 -256
package/main.devserver.js CHANGED
@@ -1,1117 +1,10 @@
1
- "use strict";
2
- // Parent/Child dual-mode runner with multi-child round-robin + hot reload.
3
- //
4
- // Parent (default):
5
- // - Stable public port (env.PORT or first free >= 8100).
6
- // - Spawns N children (env.CHILD_COUNT, default 2), each runs this file with APP_RUNNER=1.
7
- // - Round-robin load balancing to healthy children.
8
- // - POST /webpack/reload:
9
- // * forwards 'reload' to all healthy children (they hot-swap in-process)
10
- // * respawns any unhealthy/crashed children
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()}})()}